Author SHA1 Message Date
AskaEth a0b4c941e4 fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)
两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
2026-10-27 22:00:18 +08:00
AskaEth c9da488749 refactor(loop): extract speak voice backfill into applyBatchVoice (M57) 2026-10-27 21:33:05 +08:00
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +08:00
AskaEth f84a358e30 feat(loop): add OutboundVoice result type for speak tool 2026-10-27 20:22:12 +08:00
AskaEth 07df06b86d Merge branch 'feat/b2a-browserexit-redact-browser-exception' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:27:21 +08:00
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00
AskaEth e8bc8727da Merge branch 'feat/b2c-hostredact-vision-hostof-and-tts-red' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:15:21 +08:00
AskaEth b626b3f5c0 Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:58 +08:00
AskaEth 50cc60d019 Merge branch 'feat/b2b-judgetext-redact-judge-reason-sensit' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:05 +08:00
AskaEth a1c9da6e8a test(voice/tts): pin fragment-before-query in redactURL fallback
Review r1 P2-01: the only fragment case had '?' before '#', so degrading
stripURLQuery to cut '?' alone stayed green and the '#'-first branch was
unpinned. Add a scheme-less '#access_token=...' case (OAuth implicit
flow shape) and correct the mutation comment to state which case each
mutation actually kills.
2026-10-26 22:11:01 +08:00
AskaEth e39cc5d096 fix(weixin_ilink): drop user file name from media failure log; fix TestLogin_LogRedaction data race 2026-10-26 22:00:09 +08:00
AskaEth 5a69d6e0ff fix(automations): redact reminder message before logging 2026-10-26 21:58:31 +08:00
AskaEth 8058660537 fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::`
  命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数;
- Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白
  (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见
  errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文);
- 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现
  仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。

测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本),
外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言
已验证「退回原样直吐即红」。
2026-10-26 21:57:48 +08:00
AskaEth e0793debc3 fix(restrict): redact sensitive param summary before it reaches audit/panel 2026-10-26 21:55:25 +08:00
AskaEth 28a6cd7e53 fix(voice/tts): strip query from scheme-less URLs in redactURL
redactURL's fallback returned the raw string when url.Parse found no host.
A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the
signed query reached the WARN log and the error chain verbatim. Strip at
? and # in that path, keeping the '@' full-redaction rule first.

Test pins the scheme-less query/fragment cases and goes red if the fix
is reverted.
2026-10-26 21:54:21 +08:00
AskaEth 5099c21520 fix(audit): redact judge reason before it reaches logs and the panel
判定理理由(verdict.Reason)是模型自由文本,判定器会把它在参数摘要里看到的
凭据原样抄进理由,而这条理由要进审计日志与前端小字两个读面。cleanReason
在收敛空白/截断之后统一过 trace.RedactText(80 rune 档),与其余出图文本
走同一套清洗;maxReasonRunes 退化为「进入清洗前」的上限,避免在无界文本上
跑凭据匹配。
2026-10-26 21:52:44 +08:00
AskaEth a5b3d40a74 fix(vision): report only URL origin when an image source is rejected
fetchURL echoed the whole raw URL (path, query, userinfo) in the
ErrUnsupportedSource message, so a rejected source carrying a signed
query leaked it into logs and tickets. Route the URL exit through
trace.HostOf and fall back to a placeholder instead of the raw string.

Test pins origin-only output and goes red if the fix is reverted.
2026-10-26 21:52:19 +08:00
AskaEth d47185feb8 docs(contract): v0.97 voice WS frames + TTS autolaunch + upstream error safety
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- voice 进 message_persisted/channel_persisted 首帧与握手回放(M44 fb55769,
  INSERT→写列→notify;两条实现前提进契约)
- emotionTTS 自启默认关(M47,voice-tts-launch.json,probeUrl 单向兜底)
- UpstreamError 构造即安全:code/type 固定令牌守卫、message 默认缺席
  (M49/M50,一处行为变更如实标注)
- U6 双凭据门明示(Bearer JWT 或 X-API-Key,既有能力非新增)
- 改正 14 处「WS 帧不带 voice」假话 + 旧横幅加注;尾注 v0.82→v0.97
2026-10-26 21:44:44 +08:00
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth 921938d084 Merge branch 'feat/m52-urlcausesweep-sweep-url-error-direct' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 20:03:29 +08:00
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00
AskaEth 969857682f fix(runtime): M52 把 *url.Error 直打的 19 处换成 trace.URLCause
model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进
错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走
trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据
一字不动。

清单内(17):
- model/openai.go: Chat / ChatStream 的 NewRequest、doRequest
- model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest
- multimodal/vision/adapter.go: ask 的 NewRequest、client.Do
- multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do
- multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处
- voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因,
  地址只由 redactURL(endpoint) 出现一次)
- embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest

清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉):
- voice/tts/client.go: speak 的 NewRequest 失败
- embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败

测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒
对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
2026-10-26 19:55:05 +08:00
AskaEth 604f9ebaa9 Merge branch 'feat/m50-envelopesafe-drop-envelope-message-o' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:51:33 +08:00
AskaEth bfbf5057bc refactor(weixin): runLogin 的守卫说明上移到函数头,回到 80 行以内
复审 r2:runLogin 70 → 82 行越过 AGENTS.md §10 的函数 ≤80 上限。代码行本来
只有 56,超的是 M50 加在循环体内的两段 🔴 说明。把 status/redirect_host 的
出镜口径整段上移到函数头注释(不参与计数),循环体内只留一行指向函数头;
**控制流与守卫代码零改动**。

改后 runLogin = 76 行(全 plugins/channels 最长 79,其余均 ≤80)。
2026-10-26 19:48:54 +08:00
AskaEth 7fbb8d6232 fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平
Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
2026-10-26 19:47:35 +08:00
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00
AskaEth 2702777950 Merge branch 'feat/m45-extracttemporal-extractor-temporal-m' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:33:37 +08:00
AskaEth 82e9213804 test(memory): M45 复看 P2 —— 补「temporal 不许被判定读」的源码级断言 + 注释纠正
P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时
**整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧
(applyVerdict / setIngestRetention)恰恰最容易被人随手接线。

新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里
所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条,
**片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报
「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。

变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口):
  --- FAIL: TestTemporalIsNotWiredIntoJudgement
      temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…:
          preinsert.go:476: if item.Temporal == TemporalOneOff {
  删掉接线后全绿。

P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention
(唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者
指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到
模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。

另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码
路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用
datetime() —— 那是定宽文本比较,抄进查询层会静默判错。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。
2026-10-26 19:29:16 +08:00
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +08:00
AskaEth 3a0cc64160 Merge branch 'feat/m49-upstreamsafe-move-token-guard-into-t' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:18:39 +08:00
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00
AskaEth 4224fc2b12 Merge branch 'feat/m47-ttsspawn-detect-and-auto-launch-emot' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:08:45 +08:00
AskaEth 4e9cd17fbd Merge branch 'feat/m48-urlcauseharden-urlcause-depth-cap-an' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:06:50 +08:00
AskaEth fb55769cd7 Merge branch 'feat/m44-voicerealtime-voice-key-on-realtime' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:03:16 +08:00
AskaEth 67d7e4bd1b fix(voice): M47 r2 复审两条 P2 —— 关闭期取消不拉起、非法探活地址不启用
复审 p2-2items(P2-1 跨进程去重按裁决只报不修):

(a) **关闭期 ctx 取消 ≠ 没起**:后端关闭时 ctx 被取消,探活返回
`context.Canceled`;原实现把它判成"没起" ⇒ **在关闭过程中拉起 TTS**。
现在按 `errors.Is(err, context.Canceled)` 判为跳过:不记 markAttempt(不消耗
冷却)、不拉、也不 WARN 成故障。探活**自身超时**(DeadlineExceeded)仍是"没起",
照常拉 —— 两者用 errors.Is 区分,不按文案。新增 OutcomeCanceled。

(b) **探活地址必须合法**:写成 `127.0.0.1:9880`(漏 scheme)会让 http.NewRequest
每次都失败 ⇒「没起」永远成立 ⇒ 每次启动都拉一次进程。新增
`ttslaunch.ValidateProbeURL`(url.Parse + scheme ∈ {http,https} + host 非空):
- `New` 里校验 ⇒ 不合法即 WARN + 不启用(兜底闸,覆盖任何调用方);
- 组合根 `validateResolvedProbeURL` 里同一把闸再校验一次,WARN 带
  `probe_url_source` 指明该改哪个文件 —— **baseUrl 兜底那条路也被覆盖**。

顺带按复审记录项 1 修正 `Proxy: nil` 的注释:net/http 本来就不给回环 IP 走代理,
这行真正保护的是 probeUrl 被配成主机名的情况(原注释高估了它)。

用例(均变异验证打红):
- TestCheck_ParentCanceled_SkipsLaunch:取消 ⇒ OutcomeCanceled + 0 次拉起 + 不进冷却
  (变异:跳过改回"没起" ⇒ Outcome=launched、marker 落地)
- TestCheck_ProbeOwnTimeout_StillLaunches:探活自身超时仍照常拉
- TestNew_InvalidProbeURL_Disables:7 种非法形状 ⇒ 不接单、0 次拉起、有 WARN
- TestStartVoiceTTSLaunch_{ShutdownCancelDoesNotLaunch,InvalidProbeURLStaysOff,
  InvalidBaseURLFallbackStaysOff}:组合根三条,均用真 .bat marker 观察"有没有拉起"
2026-10-26 19:03:12 +08:00
AskaEth 98e1ae6aa9 fix(model): providerUpstreamError 的 error.type 也过令牌守卫
同一份上游报文,兄弟出口 streamEventError 守的正是 type,而 providerUpstreamError
只守了 error.code —— 网关/兼容层把回显塞进 type 时会被原样渲染进错误与日志。
两个字段现在用同一道 trace.ErrorToken:不像令牌的变空串,status/code/body_bytes
等诊断面不受影响。
2026-10-26 19:01:16 +08:00
AskaEth 92b0f89492 fix(trace): URLCause 用层数上限终止,去掉不可比较的 error 比较
手写终止条件只认「长度 1 的自引用」,于是:
- 两节点环(a.Err=b / b.Err=a)⇒ 死循环,打挂当前请求的 goroutine;
- uerr.Err == err 在两侧动态类型相同且不可比较时 ⇒ panic
  (comparing uncomparable type),同样打挂当前请求。

改为唯一终止条件 maxURLCauseDepth=8:超限返回只含 Op 的替身,不返回当前层
(当前层是 *url.Error,Error() 里就是整条地址,不能为防死循环把地址漏回去)。
正常单层/双层路径逐字不变。用例:两节点环 1s 内返回、不可比较类型不 panic、
自引用仍退化成替身、超限路径无 URL 片段、上限内(8 层)仍剥到内层原因。
2026-10-26 19:01:15 +08:00
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00
AskaEth 0ec9cbe32b Merge branch 'feat/m46-urlcause-hoist-url-error-unwrapping' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 18:56:55 +08:00
AskaEth b955a27479 fix(channelbridge): M44 入站语音先写 voice 列再广播 ⇒ 首帧就带 voice
安卓 2026-10-26 回执关心的场景:新来一条语音消息时,**第一条**帧里有没有
`voice`。此前没有 —— `persistBatchUsers` 先 `persistToMain`/`persistIsolated`
(内部同步 notify 把帧发出去),之后才 `persistMessageVoice` 写列,帧发出那
一刻列还是空串。网关侧的帧投影(上一 commit)读的就是这一列,因此首帧必然
缺 voice,客户端要等重连或下一次 REST 拉取。

修法(A 路的唯一可行形态,理由见下):
- `persistBatchUsers` 的通知改成 `persistVoiceFirstNotify(ref)`:
  **INSERT → 写 voice 列 → notify**。语句数不变,只把 notify 从 INSERT 内部
  挪到最后;非语音消息(ref 零值)时该包装与 `notifyPersisted` 逐字等价。
- `persist.go` 抽出 `mainRow` / `isolatedRow`(字段清单只写一次)与
  `persistIsolatedWith`(可注入通知的变体),`persistToMain`/
  `persistIsolated` 的对外行为逐字不变。

为什么不走 B(给 `history.Message` 加 voice 随 INSERT 写):`persistMessageVoice`
是按 id 的 UPDATE,行还不存在时必然 `ErrMessageNotFound` ⇒ 写列**不可能**真正
挪到 INSERT 之前;可行的最小形态就是上面那次换序,无需动 domain 类型、列清单
与 7 条读语句。B 若要做,属于 design/24 §10.5 的独立切片(那时还应删掉这里的
两段式,避免同一事实两处写)。

整链验收(`inbound_voice_gateway_e2e_test.go`,真桥接器 + 真存储 + 真网关 WS):
- 主人私聊语音:第一帧就是 `message_persisted`,带 `voice`,且与
  `GET /sessions/{id}/messages` 同一行**逐字相等**;随后的非语音消息帧整键缺席。
- 群聊语音(被点名 ⇒ 走完整回合):第一帧 `channel_persisted` 带 `voice`,
  且与渠道日志读面同一行逐字相等。
- 变异实测(把顺序还原成「先广播、后写列」):两条用例都 FAIL,输出即报障症状
  (帧里 content 是转写文本,却没有 voice 键)。

顺带把网关侧两处描述旧时序的注释改成新事实。
2026-10-26 18:54:13 +08:00
AskaEth faf18afb91 feat(voice): M47 r1 探活地址单向回退到 voice-tts.json 的 baseUrl(组合根)
塔 2026-10-26 裁决:主人若把合成端口从 9880 改掉,不该还要记得两处一起改。
回退只做一向,且只发生在组合根(包内不互相读配置):

1. voice-tts-launch.json 显式写了 probeUrl ⇒ 以它为准,**永不**被别的键压过;
2. 没写 ⇒ 用 voice-tts.json 的 baseUrl(同一个服务);
3. 两者都空(或 voice-tts.json 坏掉)⇒ 默认 http://127.0.0.1:9880。

启动 Info 行同时打印 probe_url 与 probe_url_source(voice-tts-launch.json /
voice-tts.json / default),一眼看出用的是哪个源。反向(拿这里地址去改合成面)
一律不做。VoiceTTSTuning 坏掉时只回落默认地址并 WARN,**不**因此关掉自启。

新增三条用例(均已变异验证打红):
- ProbeURLInheritedFromVoiceTTS:真让探活打到自定义端口(hits>0),摘掉回退即红
- ProbeURLFallsBackToDefault:两者都空 ⇒ 9880 + source=default
- ExplicitProbeURLWins:显式压过 baseUrl,且 baseUrl 一次都不被探
2026-10-26 18:50:52 +08:00
AskaEth 5c6f4e50a0 refactor(voice): dashscopeErrorToken 收敛到 trace.ErrorToken
令牌守卫的最后一处副本:删掉 voice 本地的 dashscopeErrorToken 与
maxErrorTokenBytes,四个调用点(classifyDashscopeStatus 的 code / request_id、
dashscopeOKBodyError 的 code / type)改调 trace.ErrorToken。守卫实现与原先逐字
同形(TrimSpace、64 字节上限、字母数字与 _ . -),因此零语义变化。

同时修掉一处过期注释:原 dashscopeErrorToken 的文档指向「runtime/model 的
safeErrorToken」——那个符号在本切片里已被删除,注释指向不存在的符号会把下一个人
送去空目录。现在注释与调用点都指向 trace.ErrorToken。

顺带补一条变异可打红的用例:已有用例只断言「令牌形状的 code 被保留」(去掉守卫
照样通过,半张网),新用例断言 4xx 的 code 是散文时连 `code=` 这个键都不出现,
而 status=400 与 request_id 仍在。

验收:go build ./... + go vet ./... + go test -count=1
./internal/runtime/multimodal/voice/... 全绿;变异(code 改回不过守卫)⇒
TestDashscopeTTS_Non200DropsFreeTextCode 变红。
2026-10-26 18:48:01 +08:00
AskaEth 6906d274fc feat(voice): M47 后端启动后探活 emotionTTS,未起则异步拉起一键启动脚本
主人 2026-10-24:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便检测
一下如果TTS没起就顺带启一下」。本片只做「启动时顺带探一下」,**默认关**(主人
早前说过「开机自启先不急 后面做」),未开开关时连探活都不发。

- 新包 internal/runtime/voice/ttslaunch:探 http://127.0.0.1:9880(毫秒级超时),
  已在跑什么都不做;没起则异步起脚本(cmd.exe /c 传参,不拼命令行字符串)
- 防重复:in-flight 单飞 + 一次尝试后的冷却(失败也算一次,重启风暴不会变成重试风暴)
- 失败只 WARN:探活超时/脚本不存在/起进程失败都只记带 source 的日志,
  组合根那条路径**没有 error 通道**
- 配置走面板文件 $DATA_DIR/config/voice-tts-launch.json(零新 .env 键)
- 接线在 cmd/cyrene 的网关 Listen 之后,整段在后台 goroutine 里,不占启动时长
  (用例以「挂载耗时 < 150ms 而探活超时 800ms」证明)
- 不新开端点、不加帧族;不碰 gateway 包
2026-10-26 18:47:36 +08:00
AskaEth 5717735da9 feat(gateway): M44 实时帧与握手回放挂上 voice(纯增量)
修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放
此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条
面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。

- `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo`
  (Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面,
  未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。
- `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice,
  `broadcastPersisted` 改用它;两类帧因此天然同口径。
- `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。

成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次
(去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有
voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它
要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。

纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {});
帧壳、帧类型、REST DTO 形状一律未动。

测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对
「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
2026-10-26 18:46:06 +08:00
AskaEth 3001fcc716 fix(model): providerUpstreamError 的 error.code 过令牌守卫
error.code 与 error.type 一样是**自由文本面**:主流上游里是枚举,但网关、代理与
兼容层可以往里塞任何东西(包括回显的用户原文)。M41 已给 type 加了守卫,
providerUpstreamError 的 code 一直原样带出,是这一族里唯一还漏的字段。

守卫本色从 runtime/model(safeErrorToken)与 runtime/multimodal/voice
(dashscopeErrorToken)两份逐字同形的实现上提到 trace.ErrorToken,model 侧改为
调用它(voice 侧仍是自己那份,不在本切片文件面内,见随附 finding)。

唯一的行为变化就是「不像令牌的 code 变空串」:status / type / body 体积与 sha256、
错误文案模板、双 %w 哨兵链(ErrUpstream、trace.ErrUpstream)都不变。

验收:go build ./... + go vet ./... + trace/model 包 test 全绿;新增用例覆盖
「带空格标点的 code 变空串且 status/type 仍在」与「令牌形状的 code 保留」。
2026-10-26 18:45:38 +08:00
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00
AskaEth af61b8ccfe docs(contract): v0.96 语音发音人绑生效人格(sessionId/两个错误码)+ 管理面脱敏投影与 props.omitted
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:10:40 +08:00
AskaEth 6be76f1665 Merge branch 'feat/m42-gatewayhygiene-admin-memory-tier-log' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:08:57 +08:00
AskaEth f37fe05aee Merge branch 'feat/m42-logchannel-channel-and-tool-url-plus' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:03:12 +08:00
AskaEth abbd79a3b1 Merge branch 'feat/m43-logupstreamadopt-adopt-reviewed-comm' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 14:02:22 +08:00
AskaEth 4b4173dd42 fix(channel): M41 r1 复审三条 —— telegram 构造分支、QQ 改 MaskQuery、删两处死上限
P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的
err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll
failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。

P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成
trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo,
且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被
sanitizeConnectReason 使用,未成死代码。

P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText
内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为
直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。

用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵
(含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
2026-10-26 13:58:19 +08:00
AskaEth 3157abcf3c fix(gateway): 日志页脱敏改用「可带上限」变体(4096),摘要侧仍是 80
塔 2026-10-26 复审裁定:复用 trace.RedactText 让面板日志页每条 msg 都被切到
80 rune,是实质性 UX 回归(日志页就是排障面)。修法不是网关侧另写脱敏,而是
在 trace 包给上限加一个参数。

- trace/redact.go:新增导出 `RedactTextLimited(s, limit)`,语义 = 同一套凭据形状
  清洗 + 按调用方给的上限截断;`RedactText` 改为它的 `maxSummaryRunes`(80)
  调用形态,既有调用方零改动,清洗逻辑仍只有一份(redactSecrets)。
  limit <= 0 = 不截断,留给体积已自己界定的调用方。
- observability_logs.go:新增常量 `maxLogMsgRunes = 4096`(含注释说明为什么不是
  80、也不是无上限:单条日志可能是 %v 的 MiB 级对象,1000 条会撑爆 JSON),
  adminLogOf 改用 `RedactTextLimited(e.Msg, maxLogMsgRunes)`。
- span props / 摘要侧不动,仍是 80(一个要摘要,一个要全文,两边不该被统一)。

测试(均验过变异打红):
- TestAdminObservabilityLogs_LongLineKeepsFullText:547 rune 的日志行(行首凭据 +
  行尾哨兵)⇒ 凭据打码、末尾哨兵仍在、rune 数只差「凭据→[redacted]」那一处;
  变异(改回 RedactText 80)⇒ 红:拿到 81 rune、末尾哨兵消失。
- TestAdminObservabilityTrace_PropsStayAtSummaryBound:200 rune 的 args_summary
  ⇒ 81 rune(80 + 省略号);变异(把 maxSummaryRunes 提到 4096)⇒ 红。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... ./internal/runtime/trace/... 全绿。未 push。
2026-10-26 13:58:02 +08:00
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(M37/M38/M39)同落 internal/services/gateway:

① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
  默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
  时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
  亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
  (自动抽取路径不放宽)。

② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
  admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
  yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
  响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
  原样日志等于把上传内容写进 viewer 可读的日志面。

③ 读侧脱敏走既有 helper,不另写一套:
  - adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
  - adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
    推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
    omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
    status/耗时/时间/名字一律保住。

测试(每条都验过「删掉实现 ⇒ FAIL」):
  - 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
    内容走 Store.Add 仍判 temp;
  - 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
    来源四类);
  - 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
  - trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
2026-10-26 13:53:59 +08:00
AskaEth 9e88605cc8 fix(voice): CloneVoice 的 200-with-message 泄漏收口到 dashscopeOKBodyError
M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话
是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
URL,带凭据),上游报错常把它原样回显。

- ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要;
  message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜
- errors.Is(err, ErrProviderFailed) 不变(熔断口径不动)
- 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
2026-10-26 13:50:10 +08:00
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00
AskaEth 66dda084cd fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error
M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14):
非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step /
retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。

- model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的
  providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message
  (原本无上限)整段丢弃,只保留令牌形状的 type
- embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文)
- vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文
- voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做
  分类判据;错误体读取同样改为 4 KiB 有界
- 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
2026-10-26 13:44:32 +08:00
AskaEth a697fa0a1e Merge branch 'feat/m35-ttsemo-emotiontts-provider-with-emot' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:44:00 +08:00
AskaEth 48b3dae5ed Merge branch 'feat/m37-memtier-retention-tier-column-and-de' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:42:20 +08:00
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话
概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是
effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective =
defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。
照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。
改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到
default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。

顺手同根三处(不留第四轮):
- tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身
  没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是
  "会话未绑定"、也不是"出站口没有会话"。
- runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步;
- selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把
  「解析不到」统一改成「连全局默认人格都没有」;
- voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例
  没接人格库,所以确实落到 default —— 但成因写对了);
- wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。

纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。
2026-10-26 13:40:00 +08:00
AskaEth 46a6da13e0 Merge branch 'feat/m40-logtrace-span-write-side-redaction-p' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 13:36:31 +08:00
AskaEth 066319f9c6 fix(memory): 复审 r2 返工 —— 真实「记住 X」路径钉 long,断言改为能打红
复审 r2 = p2-2items 全在「RememberManual 钉 long」那半,两条指向同一件事:
**我以为修好的人工路径实际没修**。逐条:

P2-1 上一版断言是**空断言**:夹具正文「记住她喜欢喝咖啡」含「记住」⇒
extract_retention.go 的护栏① 直接放行 ⇒ 默认落点本来就是 long,因此删掉
store.go 的 pin 仍然绿。
  → extract_test.go 的 TestStore_RememberManual_正常 改为一对语料:
    「我正在测通道」(N7,规则真会判 temp)+「记住她喜欢喝咖啡」(正文保留契约),
    并同时断言档位、`ExpiresAt` 零值、以及**落库列** `(retention_tier, expires_at)`。

P2-2 pin 钉在零生产调用方的方法上:真实路径是 ManualExtractor.Extract(无可用
LLM 出口时的生产回退)→ persistMemories → AddScoped → Add,而 rememberDirective
**先剥掉「记住」前缀** ⇒ 护栏① 永远看不到它。改前实测:记住今天下雨了→temp/n6、
记住我正在测通道→temp/n7、记住现在几点了→temp/n4(均 +7 天)。
  → extract.go 的 ManualExtractor.Extract 在**产出侧**显式置
    `RetentionTier: RetentionLong`(并在注释里写清「不能靠护栏①」的原因)。
    store.go 的 RememberManual pin 保留(两条独立的契约点)。
  → 新增 TestManualExtractorItemsStayLongTerm:四条正文过 Extract →
    复刻 persistMemories 的溯源覆盖 → AddScoped 落库 → 断言**落库列**
    `(long, 空串)`。

**变异证据(删掉两处 pin 后实测)**:
  --- FAIL: TestManualExtractorItemsStayLongTerm
      extract_test.go:167: 「记住今天下雨了」全链落库 =
        (tier="temp", expires_at="2026-11-02T05:34:20.844624200Z"), want (long, 空串)
  --- FAIL: TestStore_RememberManual_正常
      extract_test.go:99: RememberManual("我正在测通道") 的档位 = "temp", want "long"
恢复 pin 后两条均 PASS。

回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/...
./internal/db/... ./cmd/cyrene/ 全绿;go test -race ./internal/runtime/memory/ 绿。
2026-10-26 13:36:15 +08:00
AskaEth 1c2944fcf4 docs(voice): 收回 r2 修掉的旧语义注释 + 让关闭态变异真能打红(r3 P2-1/P2-2)
P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去):
- voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」
  ⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。
  改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出
  它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。
- voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」
  ⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。
- 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、
  cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。
- nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。

P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿):
根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。
按塔选的第二种收口(把测试做强):
- audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona
  voice"替身),并在注释里说明它存在的理由;
- 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false),
  于是 PrimaryActive 成为关闭态唯一的闸;
- 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**:
  「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。

未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。
go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/
cmd/cyrene 全绿。
2026-10-26 13:32:04 +08:00
AskaEth 542bac677d fix(trace): UpstreamError 默认不带 error.message,要带必须显式 opt-in
复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。

改法(默认安全,放宽必须写出来):

- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
  最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
  BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
  (凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
  「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
  status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
  model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
  报文在调用点上下文里还在。

测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
  error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
  仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
  且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
  opt-in 那条变红。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
2026-10-26 13:31:11 +08:00
AskaEth 765ef167bd fix(memory): 复审 p2 返工 —— SQL 空串字面量、两处过期注释、人工路径档位
复审 p2-1items(tip cc6904b)要求的三处,逐条:

1) SQL 双引号改单引号(唯一返工项):
   - preinsert_store.go 的 revertMergedSQL:`merged_into = ""` → `''`。今天功能等价,
     但靠的是 SQLite「双引号标识符解析不到 ⇒ 回退成字符串字面量」的 legacy 行为;
     这是一条**改数据**的语句,还被 RevertMerged 与整理侧回滚共用,不该依赖回退。
   - retention_migration_test.go 的 `WHERE retention_tier = ""` 一并统一成 `''`,
     并在原位留下「为什么」。
2) 两处过期注释:
   - retention_schema.go:「反过来(先自举、后迁移)也成立」是**假结论**,改为
     「生产顺序成立;反向顺序在 0019 处即失败(duplicate column name: status,既有问题)」。
   - store.go 的 ExpiresAt:「本字段目前只写不读」已半过期(Add 现在会把最终落库
     时刻回填到返回值),改成「读回语义是半开的」:Add 返回的条目有值,从库里读回的
     恒为零值。
3) 人工路径(塔裁定「人工动作默认应为 long」):包内的 `RememberManual`(「记住 X」)
   显式钉 `RetentionTier = long`,留存规则因此不许把人工明确要求记住的东西降成临时层
   (extract_test.go 加断言)。面板网关那条(adminAddMemory 直调 Add、不传档位)
   按塔的口径不在本片、留给后续切片 —— 本片对它的唯一影响见回报。

不动(按塔的裁定):preinsert.go 514 行(dev 既有 tech debt)、
design/03 §10.5 与 design/23 §18 的章节(塔的账)、gateway 人工路径。

回归:go build ./... + go vet ./... + go test -count=1 ./internal/runtime/memory/... ./internal/db/...
全绿,内存包 -race 亦绿。
2026-10-26 13:24:35 +08:00
AskaEth e684061e56 fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分
P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是
「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的
bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格"
⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人,
换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。

修法:
- turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""),
  默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它);
- ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的
  effective —— WS 语音回合为选音色不新增任何 I/O;
- REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。
因此 `default` 只剩"连默认人格都没有"这一种用途。
- service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底
  角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。

回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)——
未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音;
变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。

建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成
tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码;
用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。

P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。

go build ./... + go vet ./... + go test(含 -race)全绿。
2026-10-26 13:21:44 +08:00
AskaEth 6d0df6a825 fix(trace): 白名单放行 session —— 归属标识可读,正文边界不动
主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr
日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId
都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单
(safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。

「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 ——

- trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉
  两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的
  result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。
- loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵
  同时仍不可见。
- 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:20:45 +08:00
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
2026-10-26 12:57:35 +08:00
AskaEth 240b59708d docs(contract): 矩阵补 B28-B31 与计数改正(14+31+40=85,纠正 104 的重复计入)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 12:48:53 +08:00
AskaEth 3a2711a7e0 feat(voice): emotionTTS 接成一级发音人(UTF-8 请求体 + 失败回落既有链)
侦察结论(真机 + 上游源码):
- POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集
  实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。
- POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename
  且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。
- POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以
  [EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响
  语气,但由服务端从待念文本自推**,我方没有可映射的参数。
- 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端
  speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。

实现:
- internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8
  请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS
  在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。
- internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json
  (enabled/baseUrl/character/format/timeoutSec),零新 .env 键。
- cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口
  (POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。
- 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。

测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、
降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射;
三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag
emotiontts_live)实测 278,036 字节 WAV。
2026-10-26 12:46:57 +08:00
AskaEth 4c79728553 docs(contract): v0.94 MCP 管理面与工具 origin + v0.95 渠道静默窗口,含写门不对称与呈现隔离口径
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 12:46:25 +08:00
AskaEth a895754d55 Merge branch 'feat/m34-memdedup-dedupe-group-action-plus-re' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-25 00:52:28 +08:00
AskaEth 01aff71bc1 fix(memory): dedupe_group 数值门改三态(归一后同值可合 / 同量纲不同值冲突 / 量纲不可比保守拒),修 r3 复审两项
r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写):

- 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了
  (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers`
  (只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。
- 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并,
  与「比不了单位就不许合并」相反。

修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据:

| 结果 | 含义 | 合并 |
|---|---|---|
| tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ |
| tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ |
| tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 |

配套:
- 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` /
  `半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位;
- **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024`
  这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」;
- 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改
  (judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。

回归用例(三态各一条 + 修前红/修后绿,均实测):
- TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比),
  同时断言判定与簇结果;
  · 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红;
  · 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。
- TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits /
  SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing +
  TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。

语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对):
三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出
`250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs
一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」
一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:49:44 +08:00
AskaEth 7700a7eda0 fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文
(一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」
「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。

修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go):
- 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、
  半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 /
  周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个;
- 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)`
  这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」;
- 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出
  的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并;
  值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并);
- 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动;
  `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius,
  已在回报里登记为后续建议)。

语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**,
其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、
「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条);
其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。
dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。

回归用例(去掉这道门即红,已实测):
- TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意)
- TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并)
- TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」)
- TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、
  一万两千行=12000、第二天/一起/一直 不产生数量)

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:41:16 +08:00
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**,
生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行,
而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。
修法(结构性,不是补丁):
- 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与
  **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少;
  方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。
  补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。
- tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高:
  语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109)
  的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。
  实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。
- 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、
  TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、
  TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度
  归零 ⇒ 第四条红。

r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map,
`temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核
(候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例:
TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。

r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、
「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内;
「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。

实测(生产库只读 mode=ro,active 3667):
- dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15;
  取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2
  (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。
- 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条
  逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。
- 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74%
  且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」
  10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:29:02 +08:00
AskaEth dfac06bbeb docs(agents): 记下 internal/webui/dist/.gitkeep 占位不可删(干净 checkout 可构建的前提)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-25 00:09:56 +08:00
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00
AskaEth 210c8b999f Merge branch 'feat/m33-testhealth-fix-test-double-races-and' into dev 2026-10-24 23:41:40 +08:00
AskaEth f56d74c363 test(channelbridge): 准入三条用例改按「这条消息续出的臂」断言(M33 r2 复审 p2)
TestCoalesceSystemNoticeDoesNotJoinBatch 仍在用位置索引 d[1]:waitTimerCalls 只保证
「至少 2 次请求」,不保证顺序 —— 加压下会拿到 m1/m2 那次 2s 的续臂(实测 -race
-count=200 有 2/200 假红,空载测不出来)。

改成与兄弟用例同一口径,但把相位做成确定的:新增 sendSettledAndDuration ——
投递后等 worker 把这条收进本批、**为它**续好臂(按请求序号认领),再返回那次续臂的
时长。仅用 latestDuration 仍有缝:worker 可能在帧/通知之后才补上一条消息的续臂,
最新那次就成了 2s。三条准入用例(action 帧 / 系统通知 / 聊天消息对照组)统一改用它。

断言口径未变:仍然断言「系统通知/action 帧不重置静默窗口(剩余 500ms)」、
「聊天消息重置成完整 2s」;waitTimerCalls 语义、请求次数均未改。

证据:-race -count=400 连跑 3 次(1200 次)全绿、DATA RACE=0;
兄弟两条 -count=200 全绿;整包 -race -count=3 全绿。
2026-10-24 23:36:41 +08:00
AskaEth 1ae9158250 test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)
P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态):
- coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d,
  推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并
  按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、
  绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。
- 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle):
  时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。
- sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker
  放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。
- fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致
  推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。
- 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库;
  准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设);
  stale 用例两条都投完再推时钟(车道不会在两条之间退休)。

P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器,
退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径
(b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑
(含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁
并补 -race 用例。

证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮
18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿;
全树 go build/vet/test -count=1 全绿。
2026-10-24 23:18:45 +08:00
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00
AskaEth 20cf3e0a93 Merge branch 'feat/m32-subagentbackfill-deliver-subagent-re' into dev 2026-10-24 22:11:16 +08:00
AskaEth da6a365222 tower: snapshot of uncommitted base checkout changes (worktree wt-33) 2026-10-24 22:03:13 +08:00
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成
WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮
看不到,于是「她拍了个子代理出去,但没有下文」。

回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言):
- 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅
  `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头
  并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在;
- 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`),
  不是「她说过的话」。

落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空
(注记不属于任何角色、也不属于任何一路回合)。

记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰
`memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker
同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM
提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。

可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与
「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者
顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。

测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文
有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线;
结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多
一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。

不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、
不注入人设、不改 Docs(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00
AskaEth bd49080bd1 tower: snapshot of uncommitted base checkout changes (worktree wt-32) 2026-10-24 21:51:07 +08:00
AskaEth 0fe88a44c0 Merge branch 'feat/m23-coalesce-silent-window-batching-and' into dev 2026-10-24 21:32:29 +08:00
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00
AskaEth ca8577f05b feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
2026-10-24 21:11:22 +08:00
AskaEth 679c8abf1d Merge branch 'feat/m30-mcporigin-stamp-tool-origin-and-grou' into dev 2026-10-24 20:36:47 +08:00
AskaEth c608dd4afb fix(gateway): 卸载拒绝话术按接入路径分流(.env 接入的 server 不再指向 MCP 页)
复审 M30-p2-1(r1):`plugin_uninstall_scope` 的 400 一律让主人「去『MCP 服务器』页
卸载」,但 `.env` 的 MCP_SERVERS_JSON 启动期接入的 server **不经**插件管理器、
不在 plugins.json 里,MCP 页(= Manager.Statuses()/List())根本看不到它 ⇒ 那条指引
对该路径是假指引。

- 新增 `plugin.(Manager) MCPServerName`:`mcp:<server>` 注册键 → server 名的**唯一**
  解析口(前缀只在 manager.go 出现一次),网关不再自己拼前缀。
- `serverScopedUninstall` 改为 `*Server` 方法,文案经 `uninstallPointer` 分流:
  · 管理器托管(落在 plugins.json,判据 `Manager.List()`)⇒ 保留「请在『MCP 服务器』
    页启停或卸载该 server」;
  · 否则 ⇒ 明说该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入、面板不管理它,
    并给出可操作步骤(改 .env 里那一项:删掉或设 enabled=false,改完需重启生效)。
  接入路径判据来自**管理器持久清单**,不是名字前缀/transport 推断。
- `mcp:<server>` 登记条目同样分流(此前也一律指向 MCP 页)。

测试(gateway):plugins.json 路径断言含「MCP 服务器」页字样且不提 MCP_SERVERS_JSON;
.env 路径(工具与登记条目各一条)断言含 MCP_SERVERS_JSON 且**不含**「MCP 服务器」
字样;三条均断言 400 + `plugin_uninstall_scope` 不变、被拒后条目仍在。
修前红:两条 .env 用例(见回报);修后绿。回归 go build/vet/test 全绿。
2026-10-24 20:32:36 +08:00
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」

归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。

后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
  OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
  MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
  重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
  transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
  (plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
  server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。

前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
  transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
  「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
  不再静默丢弃(design/09 §5.2.1 红线③)。

零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。

测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
2026-10-24 20:21:22 +08:00
AskaEth fd8a62edf5 Merge branch 'feat/lanequeue-decouple-inbound-turns-and-add' into dev 2026-10-24 20:17:17 +08:00
AskaEth e3e8878ead fix(channelbridge): 车道键与会话键同源 + 主人私聊溢出区保序(M22 复审)
复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键
(platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text
channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」
—— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。

- P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID
  与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改);
  `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键),
  **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session),
  出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。
- P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的
  `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。
- P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收
  最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。
- P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计
  计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。

断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、
telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、
TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、
internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
2026-10-24 20:08:28 +08:00
AskaEth 7ea2850f18 Merge branch 'feat/m24-flowgate-move-review-off-admission-p' into dev 2026-10-24 20:00:57 +08:00
AskaEth 187eb327d6 Merge branch 'feat/mcppanel-mcp-server-management-page-and' into dev 2026-10-24 19:58:37 +08:00
AskaEth cfb8700b61 Merge branch 'feat/m26-webuidist-make-go-build-work-in-clea' into dev 2026-10-24 19:54:10 +08:00
AskaEth 2142de92f2 fix(plugin): 拒收 env/args 的非字符串值 + 保留键收成内置 id 集合 + 启用落盘失败文案
r1 复审 P2-1 + 两条顺手件。

P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。

顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。

顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。

测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
2026-10-24 19:53:38 +08:00
AskaEth a25e0c8870 test(channelbridge): 锁住审查解析的接线(BOM 韧性必须在生产侧生效)
- 新增 TestParseKeepIndices_ToleratesBOMWiring:喂对端**原样字节**(\xef\xbb\xbf + JSON 数组)
  给生产路径的 parseKeepIndices,必须解析成功且升序;同时断言夹具真的以 BOM 开头、且裸
  json.Unmarshal 在它上面仍然失败 —— 把委派改回裸 json.Unmarshal 即红。
- 新增 TestParseKeepIndices_StillFiltersAfterDelegation:委派之后过滤口径不变(越界/负数/
  重复编号丢弃、结果升序)。
- 新增 TestNewLLMReviewer_ToleratesBOMOutput:走整条生产路径(NewLLMReviewer 产出的
  Reviewer),回带 BOM 时给出保留编号而不是退回 FIFO。
- 注释里写明 M24 已确认的语义代价:审查移出准入路径后 review_pruned 不再从门口丢消息
  (41–74 条/日 会进流水线),而 rate_limited 门口丢与 FIFO 上限修剪一字不改(由既有用例钉住)。

只加用例与必要注释(含一个为断言所需的 encoding/json import),未改动既有用例的任何断言。
2026-10-24 19:49:58 +08:00
AskaEth 960d7b46fd Merge branch 'feat/m25-proactiveweb-route-web-proactive-pus' into dev 2026-10-24 19:47:48 +08:00
AskaEth e91b868a5c feat(channelbridge): 入站投递与回水解耦 + 每会话回合车道(M22)
报障:「群聊消息速率限制似乎会让消息一直排队,导致后面才回复前面的话」。
病灶:`onMessage` 在适配器的读循环里**同步**抢全局 16 槽回合信号量,槽满即停止
读 socket(心跳不回、后续帧不解析),跑完一轮再按条重放。

- 解耦:`onMessage` 过完流控闸后只调用 `enqueueInbound` 就返回;全局回合槽
  (maxConcurrent=16)搬到车道 worker 里取,读循环不再等任何一个回合。
- 每会话一条车道(lane.go):键 = `<账号>|<platform>|<channel>|<会话桶>`
  (主人私聊恒为 `main|web`,因为它们都路由进同一个主会话);同会话串行 FIFO、
  不同会话并行;空闲车道退休,表大小只跟活跃会话数相关。
- 陈旧度闸:轮到时已等超 `LaneStaleAfter`(默认 30s,Options 可覆盖)⇒ 丢弃不补答,
  留 WARN + LaneStats 计数 + onRejected 审计。主人私聊与非聊天事件豁免
  (主人 2026-10-24 裁定:宁可晚回,不许不回)。
- 背压有界且诚实:群聊/陌生人满时在门前拒收这一条(队列内容不动,M23 的 takeAll
  依赖它);主人私聊永不拒收,溢到 spill 只排队,越过 ownerLaneBacklogWarn 记 WARN。
- 四条不变量(顺序/一致性/完整性/最新性)在 lane.go 头部写清,lane_test.go 逐条断言;
  TestLaneDeliveryNeverBlocksTheReadLoop 用「槽全满 + 心跳帧」复现旧病灶。

不改「回什么」的语义:flowgate 限流判据、engage/冷避/owner 闸、陌生人 AcquireTurn
全部原样保留;不做静默窗口合并与回合中续跑(M23)。
2026-10-24 19:46:20 +08:00
AskaEth ec6c98d26a fix(web): keep the webui dist placeholder across frontend builds
copy-dist.mjs rm -rf'd internal/webui/dist before copying the SPA in, so
every npm run build deleted the committed .gitkeep and a fresh checkout
went back to failing go:embed. Restore the placeholder after the copy, and
also on a failed copy (an empty dist/ still compiles; a missing dir does
not), so the wipe only ever clears build output.
2026-10-24 19:44:34 +08:00
AskaEth 76d972f0f2 fix(channel/flowgate): 审查移出准入路径、节流覆盖失败路径、BOM 解析韧性
- Admit 不再同步调用审查模型:触发时只把审查**派发**到后台 goroutine,准入只读
  当前已有结论。三重上界:同一适配器至多一次在飞(reviewInFlight)、同一会话每窗口
  至多一次(lastReview)、整个适配器每窗口至多 defaultReviewPerWindow=6 次(reviewHits,
  写成可测断言)。
- st.lastReview 改为在**派发时**置位 ⇒ 成功与失败两条路径同样被节流。修复前它只在
  err == nil 分支置位,而实测 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满后每条入站帧
  都付一次 ~6s 的模型调用(qq-ws/kook-ws/weixin-poll 三路单读 goroutine 全被钉住)。
- 新增 adapter.ParseReviewIndices:容忍 UTF-8 BOM、首尾空白/换行、Markdown 代码围栏、
  数组外解说文字;越界/重复编号照旧丢弃。channelbridge.parseKeepIndices 改为委派它
  (仅函数体 + 注释,未动限流/丢弃/审查触发语义)。
- 语义差异(已写进 flowgate_review.go 文件头):rate_limited 的门口丢与 FIFO 上限
  一字不改;审查修剪降级为后台积压去噪,只修剪它当时看到的那批历史条目 ——
  review_pruned 不再从门口丢消息(实测 41–74 条/日),完整性↑、噪声↑,是否开口仍由
  engage_judge/冷避/owner 闸判(三处判据未动)。
- cmd/cyrene:逐账号启动日志如实报出生效的入口流控(塔裁决 A:零行为改动、不翻默认),
  让「没配流控键的账号闸门仍开着」一眼可见。
- 测试:4s 慢审查替身证明入站帧不被阻塞(变异回同步 ⇒ 红)、节流在失败路径生效
  (摘掉派发时置位 ⇒ 红)、每适配器窗口预算上界(摘掉预算 ⇒ 红)、同适配器单飞
  (摘掉 in-flight ⇒ 红)、在飞期间到达的消息不被连带修剪、带 BOM 字节的夹具
  (夹具去掉 BOM ⇒ 红)。闸门用例 -race 通过。
2026-10-24 19:43:57 +08:00
AskaEth a1d162febf fix(webui): commit dist placeholder so go:embed compiles in clean checkouts
internal/webui/webui.go uses //go:embed all:dist, which fails to compile
when the directory holds zero files. dist/ is gitignored (it is npm build
output), so every fresh clone or worktree could not run go build ./...
until the frontend had been built once.

Track an empty internal/webui/dist/.gitkeep so the embed always has one
matching file. The all: prefix is what makes a dotfile count. Real build
output (index.html, assets/**) stays ignored and is never committed.

The placeholder is forced into the index because .gitignore's dist/ rule
excludes the parent directory with no way to re-include a child.
2026-10-24 19:42:58 +08:00
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00
AskaEth 2421a8dac9 feat(plugin): 组合根装配「MCP 服务器」页(声明 + 动作处理器成对)
- wirePanelHub 在 AttachPanels / declareBuiltinPanels 之后调 AttachMCPPanel:
  一处完成「声明页面」+「登记 7 个动作处理器」,缺任一步就是面板在、动作全 501。
- wire_panels_test 的两条「内置面板恰好 1 条」断言改成按 pluginID 定位
  (web-search/providers),并加钉区块形状(form+table;viewer 只剩 table)。
  接线落地后目录里确实是两条(mcp/10 + web-search/20),旧断言是事实过期而非回归。
2026-10-24 19:38:38 +08:00
AskaEth 4518385f44 Merge branch 'feat/literalnewline-render-literal-backslash' into dev 2026-10-24 19:38:27 +08:00
AskaEth 6b0af81136 fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)
复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
2026-10-24 19:33:33 +08:00
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00
AskaEth fa573663c1 fix(turnreply): 字面 \n 渲染成真换行(Web 镜像与 IM 出站同一份)
主人 2026-10-24 报障:QQ 群最新一条 assistant 消息里 6 处字面 `\n`
(反斜杠 + n,不是真换行)原样发出去,用户看到「F\n多利亚」。

落点选在**写入侧共享层** `turnreply.NormalizeParagraphs`(gateway 的
persistTurnReply 与 channelbridge 的 turnVisibleReply 都调它):
IM 的 deliverReply 拿同一个字符串既落库(= Web 主会话镜像)又发气泡,
Web chat 气泡是 whitespace-pre-wrap ⇒ 在共享层渲染,落库/WS/REST/IM
出站与历史一次性对齐;只在 channelbridge 清洗层改会留下「面板上仍是字面 \n」。

- 新增 internal/runtime/turnreply/literal_breaks.go 的 RenderLiteralLineBreaks:
  逐字节扫描,围栏(```)与行内代码(`…` / ``…``)内原样保留,`\n` 与
  `\t`/`\r`/`\uXXXX`、行尾孤立 `\` 一律不动;未闭合围栏保守保护到末尾;幂等。
- paragraph.go 在**围栏 skip 之前**调用它 ⇒ 含围栏的消息里围栏外的 `\n` 也渲染。
- 🔴 未改既有语义:只产出换行字符,绝不产出 <br>/<p> —— <br> 仍是气泡分隔符,
  字面 \n 只做气泡内换行(一条列表不会被拆成 7 条消息)。
- channelbridge 源码零改动。

验证:真机原文(6 处 \n / 5 个 <p>)修前红(摘掉钩子 3 个测试变红)→ 修后绿;
三处变异(去掉围栏保护 / 去掉 \ 配对 / 把渲染挪到围栏 skip 之后)各自变红后还原复绿。
全树 go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包)。
2026-10-24 19:21:50 +08:00
AskaEth 4ed5925296 Merge branch 'feat/memberquiet-cold-shoulder-applies-before' into dev 2026-10-15 19:14:00 +08:00
AskaEth 28967d3435 refactor(channelbridge): 冷避例外②收窄为「真故障/求救」严格子集
塔 2026-10-15 改裁定:例外给了命中词「穿透 30 分钟冷避」的权力,噪声不再只是多回
一句话,而是直接削弱冷避本身 —— 而实测全表 14 条在 598 条真实 user 消息里只命中
1 次,且那次是假阳性(QQ 机器人卡片文案里的 `bug`,而该群本来就在放这种卡片)。

- 新增 coldShoulderPierceKeywords(具名常量,注释写明为什么比全表严、依据即那条
  卡片假阳性)与 hasColdShoulderPierceKeyword:保留 报错/出错/故障/紧急/救命/求助/
  帮帮我/urgent,剔出 ASCII `bug`/`error`/`help`(会命中结构化载荷/URL/卡片)与
  情绪歧义的 `崩溃`/`坏了`/`坏掉`。
- 例外② 改用该子集;hasHighValueKeyword(基线,ruleLightJudge 用)**一字未改**,
  两张表的分工写进注释,并有断言钉住「基线不许被这次收窄波及」。
- 例外矩阵新增三行:机器人卡片含 `bug` ⇒ 不穿透;英文 error/help ⇒ 不穿透;
  情绪「我崩溃了」⇒ 不穿透(三行对上一提交均为红)。第 1/2 行继续用保留词 报错,
  第 3/4/5/6 行原样保留;另加子集单元护栏(正例六条 / 反例六条)。
- 按项目「文件 ≤500 行」把冷避规则簇拆到 engage_coldshoulder.go(engage.go 528→376)。

红前绿证据:把例外判据临时还原成上一提交的 hasHighValueKeyword 全表(与该提交逐字
等价)⇒ 恰好上述三行变红、其余行与护栏保持绿;已还原(无残留)。
已知代价如实登记在案:用英文写的真求救不再穿透 —— 塔裁定以「宁可少穿透」为准;
全表(highValueKeywords)本身的收窄由塔另排切片,不在本片。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(73 包);
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:04:48 +08:00
AskaEth 13a0519115 feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)
塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:01:02 +08:00
AskaEth fe53e029ea fix(channelbridge): 冷避优先于「@她必回」——群成员也能让她少说话
M18(主人 2026-10-14 裁决「给」)。病灶是**顺序**:`shouldRespond` 把
`ruleLightJudge`(指向她本人 / 高价值关键词)排在参与度引擎之前,@她的消息
在规则层就 return true,永远走不到 `engageDecision` 的冷避判定——群成员说
「少说点」只换来一句口头答应,行为零变化(2026-10-14 00:27 群 531082168 报障)。

- light.go:新增冷避闸位次 —— 非主人**群**消息在廉价规则之前先过
  `coldShoulderGate`,命中负面态度即进 COOLDOWN、冷却期内一律静默(仍登记)。
  边界:私聊不受影响(冷避是群机制)、owner 车道更早分岔不受影响、
  与是否装配参与度引擎无关。shouldRespond 的判定顺序写进函数注释。
- engage.go:把冷避判据抽成唯一实现 `coldShoulderLocked`(engageDecision 仍调用
  它,作纵深防御),命中时补一条带 source 的 INFO(群 / 发送者 / 冷却到何时)。
- engage.go 词表:依真机原话补「减少文本输出」(覆盖「减少文本输出量」)与
  「文本输出调到最少」;刻意不取「减少 / 输出 / 最少」等短泛词(子串匹配会误伤)。
- 测试:新增 engage_coldshoulder_test.go(主断言修前红/修后绿、登记进上下文的
  端到端守卫、私聊与 owner 边界护栏、词表误伤护栏、INFO 可见性);
  engage_test.go 更新旧口径「@ 打断冷却」为「@ 不得作废冷避」。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿(windows 本机
与 darwin/arm64 交叉编译均通过);channelbridge / channel / cmd/cyrene 单独跑亦绿。
2026-10-15 18:55:35 +08:00
AskaEth 6432088586 Merge branch 'feat/addrmember-member-lane-mention-judgment' into dev 2026-10-14 07:26:34 +08:00
AskaEth 5969e700e1 fix(channelbridge): 成员车道「指向她」判据改为 AddressedToMe(@ 别人不再必回)
ruleLightJudge 原判据是 len(msg.Mentions) > 0 —— 只要有任意 @ 就必回,
@ 的是谁不论。2026-10-14 真机病灶:群里 @3889001246、@867587435 两条
都不是 @ 她,她仍逐条回。

改为复用 owner 车道已验证的同一份判据 ownerAddressedToMe
(msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)):
- 已接线渠道(qq-obv11 填了 SelfID/MentionAll/AddressedToMe):只有 @ 她本人 /
  @全体 才必回,@ 别人降级为无结论 → 参与度触发/概率那一档(未触发只登记);
- 未接线渠道(kook/webhook/telegram/weixin_ilink 未填 SelfID):逐字不变,
  仍按旧判据必回(「没有信号」≠「没在对我说话」);
- 判据全部来自结构化字段,绝不匹配正文里的 @<数字>。

口径依据 design/18 §2 触发① 的 2026-10-13 修订(消费点在 M13 只接了 owner 车道)。
2026-10-14 07:21:07 +08:00
331 changed files with 35790 additions and 1785 deletions

No files matched your search

+1 -1
View File
@@ -81,6 +81,6 @@
- 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。 - 🔴 **等待子代理:不许用长 `WaitFor` 阻塞回合(主人 2026-10-04 反馈)**:主会话在 `WaitFor` 里等子代理时,**主人无法给任何补充输入** —— 等于把主人锁在门外。规矩:**派完就结束回合**(子代理的完成通知会自动唤醒主会话);确需同回合内等,**单次 `WaitFor` ≤ 60s**,且只用于「马上就要用它的结果」;等待期间若有并行工作,**先做工作再结束回合**,不要空等。
- 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。 - 🔴 **遇到开发文档冲突 / 意义不明 / 规格矛盾时**:**阻塞会话**,用 `AskUserQuestion` 询问主人,**不得自行猜测**;文档清、不冲突则自主推进。
- 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。 - 🔴 阶段验证:后端 `go build ./...` + `go vet ./...` + `go test ./...` 全绿 + windows/darwin 交叉编译;前端(M9+)`tsc --noEmit` + `vite build` 通过。
- 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。 - 🔴 **换二进制前先确认前端是否变更(2026-10-05 登记)**:🔴 **`internal/webui/dist/.gitkeep` 是 `git add -f` 追踪的占位文件,不要删、不要 `git rm --cached`** —— 它让**干净 checkout / 新 worktree** 能直接 `go build ./...`(`//go:embed all:dist` 在空目录上会编译失败);真实产物仍被 `.gitignore` 的 `dist/` 忽略,`frontend/web/copy-dist.mjs` 已在构建末尾保证把它写回来。删掉它会同时打坏编译与前端拷贝链。`scripts\ctrl.bat build` **只跑 `go build`,不含前端**。若本次改动触及 `frontend/web/**`,必须先在 `frontend/web` 跑 `npm run build`(会把 `dist` 拷到 `internal/webui/dist`),**再** `ctrl.bat build` + **一次** `restart`;否则后端产物里带的是旧前端。反过来:**纯前端改动不需要重启后端**(运行时优先读磁盘 `frontend/web/dist`),刷新浏览器即可。
- 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。 - 🔴 **裁定必须带完成状态(2026-10-05 登记)**:凡是给主人 / 给安卓端的裁定、承诺、回执,**每一条都必须标注「已完成 / 在办 / 未开始」**;标「已完成」须附 commit 号或验收证据,且**完成后回原条目处补记「已完成 + commit」**——不得只在别处口头称完成。**禁止把「未验证」表述成「已修好」**。
- 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。 - 🔴 **提交一律逐文件 `git add <显式路径>`(2026-10-05 实测事故)**:**禁止**在「同一目录下有多路在途切片」时用 `git add -A -- <目录>` —— 实测它会**把别的子代理的在途文件一起吞进提交**(本次误把 237 的 `sessionenv.go` 提交并推了)。修复套路:`git reset --soft HEAD~1` → `git restore --staged <误入文件>` → 重新 commit → `git push --force-with-lease origin dev`(工作树内容不丢)。
+121 -29
View File
@@ -1,12 +1,12 @@
# Cyrene v2 客户端 API 契约 # Cyrene v2 客户端 API 契约
> **契约版本:`v0.91`**(上一版 `v0.90`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。 > **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。 > **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。 > ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。 > **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。 > **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。 > **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
@@ -37,9 +37,9 @@
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。 > **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。 > **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。 > **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。 > **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
@@ -123,7 +123,7 @@
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 | | U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 | | U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 | | U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/<format>`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 | | U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 | | U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 | | U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 | | U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
@@ -155,11 +155,15 @@
| 档位 | 中间件 | 条数 | 说明 | | 档位 | 中间件 | 条数 | 说明 |
|---|:--:|:--:|---| |---|:--:|:--:|---|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) | | **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **27** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)** | | **owner/admin 读档** | `writeAuth`(owner/admin) | **31** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)**、**渠道非消息事件与静默窗口四条(v0.95 补登,B28–B31)** |
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 | | **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
| **合计** | — | **81** | `14 + 27 + 40`(v0.92) | | **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
**管理路由总数 = 14 + 27 + 40 = 81 条。**(v0.92 起;`14 + 25 + 40 = 79` 是 v0.85–v0.91 的读数) **管理路由总数 = 14 + 31 + 40 = 85 条(`routes.go` 逐条枚举;补齐 B28–B31 后与代码逐条相等)。**
> 🔴 **本节此前的「18 + 43 + 43 = 104」是一个口径错误,已改正**(2026-10-26 由切片 CONTRACTMATRIX 逐条复算):那三个数**不是本表三段的分组计数**,而是**整张表按中间件用量全文统计**的结果 —— `writeAuth` 全文 **43** = 本表 **31** + 用户面 12(`chat` / `upload` / `documents` / `media/{name}` / `history` 等);`ownerAuth` 全文 **43** = 本表 **40** + `audio/*` **3**;而「`viewerAuth` 18」**根本不是 viewerAuth**(它全文就是 **14**,且全部是本表路由),18 是「用户面被中间件包住」的条数(12 + 3 + 3)。把 18 与两个 43 相加 ⇒ 用户面那 18 条被**重复计入一次**。正确口径:被四种中间件包住的路由 = **85(管理)+ 18(用户面)= 103**,另有 6 条未包(4 条公开 + `GET /api/v1/files/{name}` + `/ws/chat`),字面量注册合计 109,再加 `mountWebhooks` 一条动态注册 ⇒ `grep -c` 得 110。
> ⚠️ 同理,此前「差 23 条遗漏行」也是巧合:**23 恰好是用户面路由总数**(§1.1 的 P1–P4 共 4 条 + §1.2 的 U1–U19 共 19 条)—— 用户面本来就一条不缺。真实缺口是 **4 条**(全在 `writeAuth`),已补为 B28–B31。
> ✅ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的形状级条目已登记**,见 §6 v0.94 / v0.95。
> ✅ **本表现与 `routes.go` 逐条相等**(A14 / B31 / C40 = 85),此前的「已知文档债」警告已失效、已删除。反向也核过:81 行旧条目里**没有一条**是幽灵行,且**零条分档错误**。⚠️ 唯一一处**文档已自行标注**的差异是 B17(代码 `writeAuth`,`design/23 §11` 对同族标 owner-only)—— 那是对**设计文档**的差异,不在本节范围。
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。 > **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
@@ -199,7 +203,7 @@
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。 > **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
### 2.2 owner/admin 读(19 条,`writeAuth`) ### 2.2 owner/admin 读(31 条,`writeAuth`)
| # | 方法 | 路径 | 要点 | | # | 方法 | 路径 | 要点 |
|---|---|---|---| |---|---|---|---|
@@ -233,6 +237,11 @@
| B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** | | B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** |
| B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** | | B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** |
| B28 | GET | `/api/v1/admin/channels/events` | **渠道非消息事件列表(倒序)**,只读、**不审计**。此前只登记在 §2.4(a) D1,本条是它的矩阵行。query:`platform`(闭集 `qq`/`kook`/`telegram`/`weixin-ilink`/`webhook`,未知 ⇒ `400`,**绝不降级成「不过滤」**)/ `tier`(`t1\|t2\|t3`,其它 ⇒ `400`)/ `category`(🔴 **不做白名单**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 `400`)/ `channelId` / `since` / `until`(RFC3339 或 Unix 毫秒;跨度 > **90 天** ⇒ `400`;**两个都不给就不注入任何时间条件** —— 端点绝不替客户端造下界)/ `limit`(`≤0` 或缺省 ⇒ 存储层默认 **100**;> **500** 钳到 500 **不报错**;非整数 ⇒ `400`)/ `offset`(非法整数 ⇒ `400`,负值归 0)。响应 `{data:{events:[…],total}}`,`events` **恒为 `[]` 不是 `null`**。元素键见 §2.4(a)。**不含正文、不含媒体 URL、不含凭据**。未接线或读库失败 ⇒ `500 channel_events_unavailable`(**绝不回全 0 假汇总**)。⚠️ `weixin-ilink` 事实上无此类事件 ⇒ 对它查询恒为空,**不得用别平台事件顶替** |
| B29 | GET | `/api/v1/admin/channels/events/counters` | **渠道事件逐类计数(近似值)**,只读、**不审计**。此前只登记在 §2.4(a) D2。query 仅 `platform`(空 = 全部平台;未知 ⇒ `400`,与 B28 同一把校验)。响应 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}` —— `approx` 与 `counters` **同级**。🔴 **`approx` 恒为 `true` 且不是装饰**:计数是**内存聚合 + 每 30s flush**,读到的表**不含待 flush 部分** ⇒ 只少不多、是个**下界**;面板必须如实标注,别画成精确账。未接线或读库失败 ⇒ `500 channel_events_unavailable` |
| B30 | GET | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的读(v0.95)**,只读、**不审计**。此前只登记在 §6 v0.95,本条是它的矩阵行。**无参数**。`200 {data:{path,present,hot,default{…六键…},patch{},conversations{},rules{…},ownerPrivate:"never"}}`。🔴 **三块分工(客户端按此编程)**:`default` = **生效值**(代码默认 + 盘上 `default` 段,**已 clamp**);`patch` = **盘上原值**,**键缺位 ⇒ 该键不出现**(= 跟随默认,**不是 0**);`conversations` 的键是 `platform\|channel`(可读形态,NUL 已换成 `\|`),值是**生效值**。`hot` 恒为 `true`(桥每次发车前重读同一个 store ⇒ **这是事实,不是承诺**)。🔴 `ownerPrivate` 恒为 `"never"` = **只读事实**:主人私聊的车道类型由 actor 元数据判定,**没有任何请求键能翻转它**,面板只显示一行说明、不给开关。取值区间:`windowMs` **300–15000**、`maxWaitMs` 下界**跟随窗口**且 ≤ **30000**、`maxBatchMessages` **1–50**;越界是 **clamp + WARN,不是 `400`**。`path` 是 `$DATA_DIR/config/coalesce.json` 的落点(**零新 `.env` 键**)。未接线 ⇒ `500 coalesce_unavailable` |
| B31 | PUT | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的写(v0.95)**,门同 B30(`writeAuth` = owner/admin)。请求体 `{default?:{enabled?,windowMs?,maxWaitMs?,maxBatchMessages?,applyToStrangers?,strangerWindowMs?}, conversations?:{"<platform>\|<channel>": {…} 或 null}}`。🔴 **三态(硬要求)**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**(校验全部发生在落盘**之前**,是结构性的、不靠回滚)。**严格解码**:未知键(含拼错)与「一个以上的 JSON 对象」都归 `400 invalid_payload`,**绝不静默忽略**;**负数**同样 `400 invalid_payload`(越界的合法数字**不是** 400 —— 由运行期 clamp + WARN)。`default` 缺位 ⇒ 全局不动;`conversations` 里值为 `null` ⇒ **删掉该会话覆盖**(回到全局 default),没提到的会话一概不动。**值一个都没变时不写盘**(连 mtime 都不动)但仍回 `200`。`200` 响应与 B30 **同形状**,是**写完重新读盘**的真值。错误码:`400 invalid_payload`(未知键/负数/体形态错)|`400 bad_request`(读体失败)|`500 coalesce_unavailable`|`500 coalesce_write_failed`(落盘失败,**盘上未改动**)。**落审计** `channels.coalesce`(`result` ∈ `ok\|error`;⚠️ **空补丁也记一条 `ok`**) |
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
### 2.3 owner-only(40 条,`ownerAuth`) ### 2.3 owner-only(40 条,`ownerAuth`)
@@ -295,7 +304,7 @@
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态** ### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。 > ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档)。✅ **已并入矩阵(v0.95 补登)**:B28 = `GET …/channels/events`、B29 = `GET …/channels/events/counters`。🔴 **形状级真源从此以 B28/B29 为准**,下面 D1/D2 两行**保留仅作 v0.67 冻结版对照**,改端点时只改 B28/B29,别在四处一起改。
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现** #### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
@@ -366,7 +375,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}} {"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
``` ```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。 - 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。 - **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。 - 回放是尽力而为:存储未接或读出错静默跳过。
@@ -480,7 +489,7 @@
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。 > **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。 > ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。 > 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。 > **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin) #### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -650,7 +659,7 @@
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。 - 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。 - **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。 - ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。 - ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。 - ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
**`documents` 数组语义(核心)**: **`documents` 数组语义(核心)**:
@@ -704,10 +713,11 @@
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。 - `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。 - WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。 - `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**: **入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。 > ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。 > **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
@@ -1528,7 +1538,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 | | `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 | | `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 | | `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | `POST /audio/speech` 当前遵循的服务端配置(请求体 `voice`/`format` 为前向兼容接受但暂不生效) | | `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** | | `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) | | `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) | | `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
@@ -1573,7 +1583,89 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.93(当前契约版本) ### v0.97(当前契约版本)
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
### v0.96
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
| `error.code` | 含义 |
| --- | --- |
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
| `tts_failed` | 其余合成失败(原口径) |
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
### v0.95
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
- **新增 `GET /api/v1/admin/channels/coalesce`(读)** 与 **`PUT` 同路径(写)**。门 = `writeAuth`(owner|admin;`viewer` → 403、未认证 → 401),与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面。GET 只读不审计;PUT 落审计 **`channels.coalesce`**(`result` ∈ `ok|error`)。
- **写面是「三态补丁」**(键缺位 ⇒ 不动;合法值 ⇒ 采用;非法值 ⇒ **`400 invalid_payload` 且盘上一个字节都不动** —— 校验全部发生在落盘之前):
| 键 | 含义 |
| --- | --- |
| `enabled` | 是否启用静默窗口合并(bool) |
| `windowMs` | 首条到达后再等多久收批(ms) |
| `maxWaitMs` | **硬上限**:从首条算起最多等多久,到点就发车 |
| `maxBatchMessages` | 一批最多攒几条,够数立即发车 |
| `applyToStrangers` | 是否对陌生人/成员车道也应用(bool) |
| `strangerWindowMs` | 陌生人/成员车道的独立窗口(ms) |
- **每会话覆盖**:请求/响应带 `conversations` 映射(会话 id → 同一组键的补丁),因此窗口是**按会话**算的,不是适配器顶层一个值。
- **热生效是事实不是承诺**:桥每次发车从**同一个** store 实例读生效值 ⇒ 保存后下一次发车即用新值,响应里 `hot:true`。
- 🔴 **主人私聊不受此设置影响,且没有任何键能翻转它**:车道类型由 actor 元数据决定(`!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**)。响应里下发 `ownerPrivate:"never"` 是**只读事实**,面板据此只显示一行说明、不给开关。
- 🔴 **一批 = 一个 `turnID` = 一次 `loop.Run` = 一条助手回复**,但批内 N 条仍是**各自一行 `user`**(同 `turnID`、顺序 = 到达顺序),助手回复与它们同 `turnID`。⇒ 客户端按 `turnID` 聚合时,一个回合会看到 N 条用户消息 + 1 条助手消息。
- **零新 `.env` 键**(design/13 §2.1):配置落 `$DATA_DIR/config/coalesce.json`。
### v0.94
**「MCP 管理面 + 工具来源」版 —— 新增一页声明式管理面板,并给能力条目加可选 `origin`(切片 M20 / M30)**
- **前端路由 `/admin/plugin/mcp/servers`**。它**不是新路由/新端点**:这是在既有面板目录里声明的一页(`id = mcp`,`panel = servers`),字段与动作都由 `pkg/plugins/sdk` 的 `Panel` 描述,前端用**既有通用渲染器**画。
- **所有动作走既有的通用分发端点 `POST /api/v1/admin/plugins/mcp/actions/<action>`**(插件只给动作名、不给 URL)。**7 个动作**:
| 动作名 | 档 | 作用 |
| --- | --- | --- |
| `plugin.mcpServers` | 读 | 全部 server 的状态快照(表格数据源) |
| `plugin.mcpTransports` | 读 | 传输形态候选(`stdio` / `sse` / `http`) |
| `plugin.installMcpServer` | 写 | 安装(或刷新)一台 server |
| `plugin.reconnectMcpServer` | 写 | 断开重连并重新发现工具 |
| `plugin.enableMcpServer` | 写 | 启用一台被停用的 server |
| `plugin.disableMcpServer` | 写 | 停用一台 server |
| `plugin.uninstallMcpServer` | 写 | 卸载一台 server |
- **鉴权在两处**:路由挂 `viewerAuth`(读动作 viewer 可用),**写动作由分发器再查 `callerHasWrite`(owner|admin)**,每次写动作落审计。失败码是**稳定闭集**(前端按码本地化,不解析文案):`invalid_config` / `install_failed` / `reconnect_failed` / `enable_failed` / `disable_failed` / `uninstall_failed` / `server_not_found` / `server_disabled`。
- 🔴 **安全提示(客户端/运维必读)**:`transport=stdio` 的 `command` + `args` 等于**在本机执行任意命令**,安装即生效。⚠️ **本版存在一处档位不对称(已 file finding,未在本版修)**:面板的写动作是 **owner|admin**,而 REST 的 `POST /api/v1/admin/plugins/install` 与 `/uninstall` 是 **owner-only**(`ownerAuth`)⇒ 一个非 owner 的 `admin` 可以通过面板动作装上一台 stdio server,却走不通 REST 安装口。单 admin 部署无实际差别,多账号部署请把 admin 视同 owner 收敛。
- **工具条目新增可选 `origin` 对象**:`{ "kind": "mcp", "server": "<server 名>", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:<server>` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
### v0.93
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**) **「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
@@ -1765,7 +1857,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。 - **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。 - **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。 - **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。 - ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
### v0.79(上一版) ### v0.79(上一版)
@@ -1773,11 +1865,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。 - **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。 - **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。 - **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。 - **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。 - **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。 - **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。 - ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
### v0.78(上一版) ### v0.78(上一版)
@@ -1788,10 +1880,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。 - **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。 - **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。 - **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。 - **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。 - **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。 - **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。 - ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
### v0.77(上一版) ### v0.77(上一版)
@@ -1921,11 +2013,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。 **三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。 > ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
- **纯增量**: - **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。 ① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(见 §3.2、§3.4.4、§4.4 覆盖率的「尚未填充」)。 ② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。 ③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。 ④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。 ⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
@@ -2020,7 +2112,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
### 7.1 在飞切片 ### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。 **v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
| 项 | 状态 | 冻结位置 | | 项 | 状态 | 冻结位置 |
|---|---|---| |---|---|---|
@@ -2045,7 +2137,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| 项 | 现状 | | 项 | 现状 |
|---|---| |---|---|
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` | | **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 | | **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 | | **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 | | **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
@@ -2078,7 +2170,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。 - **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。 - `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。 - `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。 - `POST /audio/speech` 的请求体 `voice`/`format` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。 - `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。 - 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。 - **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
@@ -2089,7 +2181,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。 - **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。 - **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。 - **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。 - **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。 - **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。 - **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。 - **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
@@ -2143,7 +2235,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。 28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。 29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。 30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
@@ -2151,4 +2243,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
--- ---
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated *本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
+63 -2
View File
@@ -90,11 +90,62 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr) log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
continue continue
} }
logFlowControl(log, built)
out = append(out, built) out = append(out, built)
} }
return out return out
} }
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
//
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
//
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
// 不能拿保护性默认值假装成「开着」。
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
if log == nil {
log = slog.Default()
}
log = logger.WithSource(log, "cmd/cyrene/channels")
n, ok := built.(*namedAdapter)
if !ok {
log.Info("channel account ingress flow control",
"account", built.Name(), "gate", "off",
"why", "adapter exposes no flow keys, every limit stays disabled")
return
}
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
log.Info("channel account ingress flow control",
"account", n.Name(), "gate", "on",
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
"msg_per_min", max(n.msgPerMin, 0),
"max_messages", pending, "max_messages_raw", n.maxMessages,
"review_at", adapter.ReviewAtFor(pending),
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
}
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
func flowLimitOrDefault(v, def int) int {
switch {
case v < 0:
return 0
case v == 0:
return def
default:
return v
}
}
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上 // channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给 // CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底 // runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
@@ -174,8 +225,18 @@ type namedAdapter struct {
denyGroups []string denyGroups []string
allowUsers []string allowUsers []string
denyUsers []string denyUsers []string
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号 // 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。 // 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
// 这种状态)。负值经桥映射为「关闭」。
//
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
groupMsgPerMin int groupMsgPerMin int
directMsgPerMin int directMsgPerMin int
msgPerMin int msgPerMin int
+7
View File
@@ -125,6 +125,13 @@ func run() error {
return fmt.Errorf("start gateway: %w", err) return fmt.Errorf("start gateway: %w", err)
} }
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
// 失败只 WARN,绝不影响后端启动与退出。
a.startVoiceTTSLaunch(ctx)
select { select {
case <-ctx.Done(): case <-ctx.Done():
a.log.Info("cyrene signal received, shutting down") a.log.Info("cyrene signal received, shutting down")
+154
View File
@@ -0,0 +1,154 @@
package main
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
//
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
// 主人早前说过「开机自启先不急 后面做」。
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
// 落不到后端的启动路径上。
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件
// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者
// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。
//
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
import (
"context"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
//
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
log := logger.WithSource(a.log, voiceTTSLaunchSource)
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
if err != nil {
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
return nil
}
if !tuning.EnabledOn() {
log.Debug("emotionTTS auto-launch not enabled in panel config",
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
return nil
}
resolved := a.resolveProbeURL(tuning.ProbeURL)
if err := validateResolvedProbeURL(resolved); err != nil {
// 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次
// 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。
log.Warn("emotionTTS auto-launch stays off: invalid probe url",
"probe_url", resolved.url,
"probe_url_source", resolved.probeSource,
"err", err)
return nil
}
launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log))
cfg := launcher.Config()
log.Info("emotionTTS auto-launch enabled",
"probe_url", cfg.ProbeURL,
// 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后
// 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。
"probe_url_source", resolved.probeSource,
"script", cfg.ScriptPath,
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
"cooldown_sec", int(cfg.Cooldown/time.Second),
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
launcher.Trigger(ctx)
return launcher
}
// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。
type probeResolution struct {
url string
probeSource string
}
// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。
const (
probeSourceLaunchConfig = "voice-tts-launch.json"
probeSourceVoiceTTS = "voice-tts.json"
probeSourceDefault = "default"
)
// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生:
//
// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过);
// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改
// 一处;这正是"两处一起改"的 footgun 的收口);
// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized`
// 补默认 `http://127.0.0.1:9880`。
//
// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它
// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。
func (a *app) resolveProbeURL(explicit string) probeResolution {
if url := strings.TrimSpace(explicit); url != "" {
return probeResolution{url: url, probeSource: probeSourceLaunchConfig}
}
log := logger.WithSource(a.log, voiceTTSLaunchSource)
tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)))
if err != nil {
// 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的
// 事),并且必须说清楚"我没读到 baseUrl"。
log.Warn("voice tts config unreadable, probe url falls back to default",
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
return probeResolution{probeSource: probeSourceDefault}
}
if base := strings.TrimSpace(tuning.BaseURL); base != "" {
log.Debug("probe url inherited from voice tts config",
"path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base)
return probeResolution{url: base, probeSource: probeSourceVoiceTTS}
}
return probeResolution{probeSource: probeSourceDefault}
}
// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。
func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config {
cfg.ProbeURL = r.url
return cfg
}
// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由
// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。
//
// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上
// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl,
// 还是 voice-tts.json 的 baseUrl)。
func validateResolvedProbeURL(r probeResolution) error {
if r.url == "" {
return nil
}
return ttslaunch.ValidateProbeURL(r.url)
}
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
//
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
return ttslaunch.Config{
Enabled: t.EnabledOn(),
ScriptPath: t.ScriptPath,
ProbeURL: t.ProbeURL,
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
Cooldown: time.Duration(t.CooldownSec) * time.Second,
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
//
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
// 都拉一次进程")。
//
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
// 进行中。
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
//
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
dir := t.TempDir()
blackhole, addr := blackholeListener(t)
defer blackhole.Close()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
"http://"+addr+"/", script))
ctx, cancel := context.WithCancel(context.Background())
launcher := a.startVoiceTTSLaunch(ctx)
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
// 模拟后端关闭:探活还挂在黑洞上时取消。
cancel()
launcher.Wait()
assertNotLaunched(t, marker)
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
//
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
// 拉起(marker 出现),两条断言都会红。
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
script, marker := markerBat(t, dir)
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher != nil {
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
}
assertNotLaunched(t, marker)
out := buf.String()
if !strings.Contains(out, "invalid probe url") {
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
}
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
}
}
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
t.Fatalf("空地址应交给默认值: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
t.Fatalf("默认地址必须合法: %v", err)
}
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
t.Fatal("漏 scheme 的地址被放过了")
}
}
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
func markerBat(t *testing.T, dir string) (script, marker string) {
t.Helper()
if runtime.GOOS != "windows" {
return "", ""
}
script = filepath.Join(dir, "一键启动.bat")
marker = filepath.Join(dir, "launched.txt")
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
t.Fatalf("write script: %v", err)
}
return script, marker
}
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
// 返回值;空 marker = 非 Windows,跳过观察)。
func assertNotLaunched(t *testing.T, marker string) {
t.Helper()
if marker == "" {
return
}
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
t.Fatalf("脚本被拉起了: %s", marker)
}
time.Sleep(50 * time.Millisecond)
}
}
+288
View File
@@ -0,0 +1,288 @@
package main
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
//
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
// 启动 Info 行必须同时给出地址与来源。
//
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
import (
"bytes"
"context"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
)
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
type captureBuf struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *captureBuf) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *captureBuf) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
t.Helper()
buf := &captureBuf{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
}
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
func writeLaunchPanel(t *testing.T, dataDir, body string) {
t.Helper()
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write panel: %v", err)
}
}
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
a, buf := launchTestApp(t, t.TempDir())
start := time.Now()
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
t.Fatal("没配过却启用了自启")
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("默认关也花了 %v", elapsed)
}
if !strings.Contains(buf.String(), "not enabled") {
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
}
}
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
dir := t.TempDir()
writeLaunchPanel(t, dir, `{"enabled":false}`)
a, _ := launchTestApp(t, dir)
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
t.Fatal("enabled:false 却启用了自启")
}
}
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
//
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
dir := t.TempDir()
blackhole, addr := blackholeListener(t)
defer blackhole.Close()
writeLaunchPanel(t, dir, fmt.Sprintf(
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
a, buf := launchTestApp(t, dir)
start := time.Now()
launcher := a.startVoiceTTSLaunch(context.Background())
elapsed := time.Since(start)
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if elapsed > 150*time.Millisecond {
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
}
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
launcher.Wait()
out := buf.String()
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
t.Fatalf("拉起失败没有只记 WARN: %s", out)
}
}
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
enabled := true
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
Enabled: &enabled,
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
ProbeURL: "http://127.0.0.1:9880",
ProbeTimeoutMs: 250,
CooldownSec: 90,
})
if !cfg.Enabled {
t.Fatal("enabled 没映射过来")
}
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
t.Fatalf("超时/冷却没映射: %+v", cfg)
}
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
t.Fatalf("路径/地址没映射: %+v", cfg)
}
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
t.Fatal("空配置不该启用")
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
}
}
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
// 超时,正好用来证明挂载动作没有同步等它。
func blackholeListener(t *testing.T) (net.Listener, string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var conns []net.Conn
var mu sync.Mutex
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
mu.Lock()
conns = append(conns, c)
mu.Unlock()
}
}()
t.Cleanup(func() {
mu.Lock()
defer mu.Unlock()
for _, c := range conns {
_ = c.Close()
}
})
return ln, ln.Addr().String()
}
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
// 改一处)。
//
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
// 默认 9880,本用例立刻红。
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
dir := t.TempDir()
voiceTTS, hits := countingServer(t)
a, buf := launchTestApp(t, dir)
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
}
launcher.Wait()
if n := atomic.LoadInt32(hits); n == 0 {
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
}
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
}
}
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
dir := t.TempDir()
a, buf := launchTestApp(t, dir)
// 只有自启文件,没有 voice-tts.json。
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
}
launcher.Wait()
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
}
}
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
dir := t.TempDir()
explicit, explicitHits := countingServer(t)
inherited, inheritedHits := countingServer(t)
a, buf := launchTestApp(t, dir)
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
launcher := a.startVoiceTTSLaunch(context.Background())
if launcher == nil {
t.Fatal("启用后应拿到 launcher")
}
if got := launcher.Config().ProbeURL; got != explicit.URL {
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
}
launcher.Wait()
if atomic.LoadInt32(explicitHits) == 0 {
t.Fatal("显式 probeUrl 没被用来探活")
}
if n := atomic.LoadInt32(inheritedHits); n != 0 {
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
}
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
}
}
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
// 某个地址,而不只是配置里写着那个字符串。
func countingServer(t *testing.T) (*httptest.Server, *int32) {
t.Helper()
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv, &hits
}
+61
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -114,6 +115,12 @@ func (a *app) wireChannels() error {
ownerLane := runtimeconfig.NewOwnerLaneStore( ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir), runtimeconfig.DefaultOwnerLanePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/ownerlane")) logger.WithSource(a.log, "services/channelbridge/ownerlane"))
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
coalesceStore := runtimeconfig.NewCoalesceStore(
runtimeconfig.DefaultCoalescePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/coalesce"))
bridge, berr := channelbridge.New(channelbridge.Options{ bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"), Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop, Loop: a.loop,
@@ -168,6 +175,15 @@ func (a *app) wireChannels() error {
MinReplyGapSec: p.MinReplyGapSec, MinReplyGapSec: p.MinReplyGapSec,
}) })
}, },
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
Coalesce: channelbridge.CoalesceOptions{
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
p := coalesceStore.PolicyFor(laneKey)
return &p
},
},
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory, Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线 // Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -207,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback, fallback: factory.fallback,
bridge: bridge, bridge: bridge,
owner: a.owner, owner: a.owner,
coalesce: coalesceStore,
log: a.log, log: a.log,
} }
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载, // 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -227,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员 // owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。 // 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile owner *user.OwnerProfile
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger log *slog.Logger
} }
func (r *runtimeChannels) Path() string { return r.path } func (r *runtimeChannels) Path() string { return r.path }
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
func (r *runtimeChannels) CoalescePath() string {
if r.coalesce == nil {
return ""
}
return r.coalesce.Path()
}
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
func (r *runtimeChannels) CoalescePresent() bool {
if r.coalesce == nil {
return false
}
return r.coalesce.Present()
}
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
if r.coalesce == nil {
return runtimeconfig.CoalesceFile{}
}
return r.coalesce.Snapshot()
}
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
if r.coalesce == nil {
return false, errors.New("channel coalesce config store not wired")
}
wrote, err := r.coalesce.Apply(patch)
if err != nil {
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
}
if wrote {
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
"path", r.CoalescePath())
}
return wrote, nil
}
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount { func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts() accounts := r.loadAccounts()
r.applyAdminIDs(accounts) r.applyAdminIDs(accounts)
+18 -1
View File
@@ -126,7 +126,11 @@ func (a *app) wireFrontend() error {
if err != nil { if err != nil {
return fmt.Errorf("gateway: %w", err) return fmt.Errorf("gateway: %w", err)
} }
gw.SetVoice(a.voice) // 语音面只在这**一处**注入:emotionTTS 已按面板配置启用时是「emotionTTS +
// 既有链」的门面(失败回落既有链),否则就是既有链本身(行为逐字不变)。
// 两条既有出站口(POST /api/v1/audio/speech 与 WS voice_output)都读这一份,
// 因此不新建端点、不加帧族(切片 M35 红线)。
gw.SetVoice(a.voiceForGateway())
a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic) a.gw = gw // 必须在下面接线 hook 之前赋值——那些 hook 捕获了 a.gw / gw(否则会 nil 接收者 panic)
// 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的 // 可观测性 trace 出口(design/19 §2):网关已存在,其环形缓冲必须由循环产出的
// span 喂养,否则管理端 trace/metrics 接口会一直是空的。 // span 喂养,否则管理端 trace/metrics 接口会一直是空的。
@@ -165,6 +169,12 @@ func (a *app) wireFrontend() error {
// backthink.New 时网关还没组装。 // backthink.New 时网关还没组装。
a.backthink.SetAuditSink(gw) a.backthink.SetAuditSink(gw)
} }
// 子代理结果回填(M32;design/05 §2/§5/§9):网关 **已存在**(回填的实时出口就是
// 它的 `message_persisted` 扇出)之后才能接线。它订阅 `subagent.result`,把后台
// 批次的结论写成父会话的一条 **system 注记**(不冒充 user/assistant、不带 turn_id、
// 绝不进记忆抽取),因此「她拍了个子代理出去,然后没有下文」这件事在历史与下一轮
// 上下文里都有落点。装配细节与降级口径见 wire_subagent_backfill.go。
wireSubagentBackfill(a, gw)
return nil return nil
} }
@@ -203,6 +213,13 @@ func wirePanelHub(a *app) {
}) })
a.plugins.AttachPanels(hub) a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log) declareBuiltinPanels(hub, log)
// 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后
// (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。
// 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此
// 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。
if err := a.plugins.AttachMCPPanel(); err != nil {
log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err)
}
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记 // browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。 // 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
// //
+28
View File
@@ -0,0 +1,28 @@
package main
// 本文件只有一件事:给循环的记忆提取器套上「剔除子代理结果注记」这一层,并把这条
// 口径写在一个可测的入口上(M32)。
//
// 为什么要单独一个函数,而不是在 `wire_runtime.go` 的 Options 字面量里就地调一次:
//
// - 它是**红线级**口径(子代理结论绝不进记忆库),值得有一个名字与一个测试,
// 而不是夹在一长串 Options 里的一行调用 —— 那一行被删掉时没有任何东西会红;
// - 组合根里所有「必须套上」的装饰都走这种小函数(同 `a.buildMemoryExtractor()`
// 之于提取器本身),语义在函数名下,接线在唯一一处。
//
// 判据与实现见 `internal/services/gateway/subagent_backfill_extract.go`:元数据
// (role=system + 注记标记),不是提示词。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// loopMemoryExtractor 返回循环实际使用的提取器(`wire_runtime.go` 的
// `loop.Options.Extractor` 就是它):内层是组合根按配置构建的那个(LLM 或确定性),
// 外层保证子代理结果注记**永远不进**抽取输入。
//
// base 为 nil 时返回 nil(未配置提取器的部署逐字不变:循环那边同样是 nil 即跳过)。
func loopMemoryExtractor(base memory.Extractor) memory.Extractor {
return gateway.WithoutSubagentNotes(base)
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层
// (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 ——
// 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。
//
// 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里
// 换成 `base` 直通也会红。
// noteCaptureStore 是回填落库面的替身:只留下注记正文。
type noteCaptureStore struct {
mu sync.Mutex
rows []history.Message
}
func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if msg.ID == "" {
msg.ID = "msg-note"
}
s.rows = append(s.rows, msg)
return msg, nil
}
// seenCaptureExtractor 记录内层提取器实际收到的对话。
type seenCaptureExtractor struct {
mu sync.Mutex
seen []model.Message
}
func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.seen = append([]model.Message(nil), conversation...)
return nil, nil
}
// TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 →
// 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人
// 那一句。
func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteCaptureStore{}
bf, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{Bus: b, History: store})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Handle(context.Background(), bus.Event{
Subject: orchestration.SubagentResultSubject,
Payload: &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行",
}},
},
},
}); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(store.rows) != 1 {
t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows))
}
note := transcript.Message(store.rows[0])
if note.Role != model.RoleSystem {
t.Fatalf("seeded note role = %q, want system", note.Role)
}
inner := &seenCaptureExtractor{}
extractor := loopMemoryExtractor(inner)
if extractor == nil {
t.Fatal("loopMemoryExtractor(non-nil) = nil")
}
if _, err := extractor.Extract(context.Background(), []model.Message{
note,
model.TextMessage(model.RoleUser, "接着说"),
}); err != nil {
t.Fatalf("Extract: %v", err)
}
inner.mu.Lock()
seen := inner.seen
inner.mu.Unlock()
if len(seen) != 1 {
t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen))
}
if !strings.Contains(messageTextOf(seen[0]), "接着说") {
t.Fatalf("inner extractor lost the real dialogue: %+v", seen)
}
}
// TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。
func TestLoopMemoryExtractorKeepsNil(t *testing.T) {
if got := loopMemoryExtractor(nil); got != nil {
t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got)
}
}
// messageTextOf 返回一条模型消息的正文(测试窄视图)。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
+29 -6
View File
@@ -8,6 +8,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
@@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID || // 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增
entries[0].Panel.ID != search.PanelIDProviders { // mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries) // 「web-search/providers 在目录里,且形状仍是声明的那一版」。
entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders)
if !ok {
t.Fatalf("内置面板里没有 web-search/providers:%+v", entries)
}
// 形状也钉住:一个 form(写面)+ 一个 table(读面)。
if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm ||
entry.Panel.Sections[1].Type != sdk.SectionTable {
t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections)
} }
for _, name := range search.ActionNames() { for _, name := range search.ActionNames() {
if _, ok := hub.Action(search.PanelPluginID, name); !ok { if _, ok := hub.Action(search.PanelPluginID, name); !ok {
t.Fatalf("内置插件声明的动作 %q 未进白名单", name) t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
} }
} }
// viewer 拿到的必须是裁掉 form 的只读投影。 // viewer 拿到的必须是裁掉 form 的只读投影(只剩那个表格区块)。
viewer, err := hub.List(auth.RoleViewer) viewer, err := hub.List(auth.RoleViewer)
if err != nil { if err != nil {
t.Fatalf("List(viewer): %v", err) t.Fatalf("List(viewer): %v", err)
} }
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 { viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders)
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer) if !ok {
t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer)
} }
if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable {
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections)
}
}
// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。
func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) {
for _, e := range entries {
if e.PluginID == pluginID && e.Panel.ID == panelID {
return e, true
}
}
return paneldecl.Entry{}, false
} }
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 // TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
+1 -1
View File
@@ -324,7 +324,7 @@ func (a *app) wireRuntime() error {
OnTurnEnd: a.onTurnEnd, OnTurnEnd: a.onTurnEnd,
Memory: store, Memory: store,
Retriever: a.retriever, Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(), Extractor: loopMemoryExtractor(a.buildMemoryExtractor()),
Checkpoint: checkpoint, Checkpoint: checkpoint,
Tracer: tracer, Tracer: tracer,
Flow: flowPub, Flow: flowPub,
+80
View File
@@ -0,0 +1,80 @@
package main
// 本文件把**子代理结果回填**接进组合根(M32;design/05 §2/§5/§9)。
//
// 为什么必须在组合根装配,而不是在网关 `New` 里顺手订阅:
//
// - 回填要写的是**父会话的聊天历史**,而网关 New 拿到的 History 与组合根
// `as.history` 是同一个 `*history.Store`,但它此刻还没被 hub 的广播出口包上 ——
// 落库之后必须用**同一个** `message_persisted` 扇出(`Server.BroadcastPersisted`)
// 才能让在线端与刷新/重连的端看到同一行;
// - 与 hub 的分工:hub 只把主题归一成 `subagent_result` 帧(投递),回填订阅者只把
// 结果写成父会话的一行系统注记(落库)。两者订阅**同一个主题**、互不相识,
// 因此以后改帧不会牵连落库,改落库也不会牵连帧。
//
// 订阅用的 ctx 是 `context.Background()`:组合根这一步没有任何长生命周期 ctx 可用
// (`newApp` 的 ctx 只用于构造期的库操作),而进程关停时 `app.close` 会
// `a.bus.Close()` —— 它取消所有订阅的处理器 ctx,因此这里不需要第二个停止开关。
// 这与 hub(`Server.Start` 里自建 ctx 并在 `Close` 取消)是同一取舍。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// backfillWireLogSource 是这条接线自己的日志来源:订阅成功/失败要能按来源定位,
// 而不是混在 `main` 或 `gateway` 的裸日志里(AGENTS §10 的日志来源铁律)。
const backfillWireLogSource = "cmd/cyrene/wire_subagent_backfill"
// wireSubagentBackfill 把「后台子代理结果 → 父会话系统注记」的订阅者接到组合根的
// 进程内总线上(与调度器、网关 hub **同一个** bus 实例)。
//
// 三条降级口径(都**必须可见**,绝不静默):
//
// - 没有 bus 或没有历史存储:这条回填此刻做不了(未接持久化的构建);
// - 订阅失败:总线已关闭或主题非法 —— 那意味着后台结果永远不会回填;
// - 广播出口缺失(网关为 nil):仍然落库(历史是唯一事实来源),只是不推帧。
func wireSubagentBackfill(a *app, gw *gateway.Server) {
log := logger.WithSource(a.log, backfillWireLogSource)
if a.bus == nil || a.history == nil {
log.Warn("subagent backfill not wired: needs both a bus and a history store",
"bus", a.bus != nil, "history", a.history != nil)
return
}
backfill, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{
Bus: a.bus,
History: a.history,
Broadcast: backfillSink(gw),
Log: a.log,
})
if err != nil {
log.Warn("subagent backfill not wired", "err", err)
return
}
if err := backfill.Start(context.Background()); err != nil {
log.Warn("subagent backfill subscribe failed; background results will not reach the parent session",
"err", err)
return
}
log.Info("subagent backfill wired",
"subject", "subagent.result", "session_role", history.RoleSystem)
}
// backfillSink 返回回填的下行出口。网关为 nil 时返回**真 nil 接口**(带类型的 nil
// 指针会让订阅者里的 `!= nil` 判断失效,落库之后去调一个空接收者)。
func backfillSink(gw *gateway.Server) gateway.SubagentNoteSink {
if gw == nil {
return nil
}
return gw
}
// 编译期锁住这条链路的两端:网关仍是回填的下行出口,历史存储仍是它的落库面。
// 任何一边改签名,这里先红,而不是等到运行期回填静默缺席。
var (
_ gateway.SubagentNoteSink = (*gateway.Server)(nil)
_ gateway.SubagentNoteStore = (*history.Store)(nil)
)
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件锁住 M32 的接线本身:**组合根真的把回填订阅者挂上了那条总线**。
//
// 为什么单独测这一步:这次缺陷的形状就是「所有零件都在、没有一根线」。回填订阅者
// 写对了、总线也发得对,只要 `wireFrontend` 里少掉那一行调用,主人在界面上看到的
// 仍然是「没有下文」—— 而且没有任何测试会红。因此这里断言的是**装配后的行为**:
// 组合根接线 → 真 bus 上发布编排层载荷 → 父会话真的多出一条系统注记行。
// waitForBackfillRow 轮询历史,直到父会话出现一条消息或到期。
func waitForBackfillRow(t *testing.T, st *history.Store, sessionID string) []history.Message {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rows, err := st.Recent(context.Background(), sessionID, 10)
if err != nil {
t.Fatalf("read recent: %v", err)
}
if len(rows) > 0 {
return rows
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the parent session never received the backfilled note: the composition root did not wire the subscriber")
return nil
}
// TestWireSubagentBackfillPersistsThroughBus 是 ④ 的正面证据:装配 → 发布 → 落行。
func TestWireSubagentBackfillPersistsThroughBus(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
a := &app{bus: b, history: newTestHistory(t), log: testLog()}
wireSubagentBackfill(a, nil)
payload := &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Status: "done", Summary: "结论:X 可行",
}},
},
}
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
t.Fatalf("publish: %v", err)
}
rows := waitForBackfillRow(t, a.history, history.MainSessionID)
if len(rows) != 1 {
t.Fatalf("parent session has %d messages, want exactly the one system note: %+v", len(rows), rows)
}
row := rows[0]
if row.Role != history.RoleSystem {
t.Fatalf("backfilled row role = %q, want %q (a sub-agent conclusion is not her own words)", row.Role, history.RoleSystem)
}
if row.TurnID != "" || row.PersonaID != "" {
t.Fatalf("backfilled row = %+v, want no turn_id and no persona_id", row)
}
}
// TestWireSubagentBackfillWithoutHistoryWarns 锁住降级口径:没有历史存储时不装配、
// 不 panic,而是留一条可见的告警(绝不假装回填成立)。
func TestWireSubagentBackfillWithoutHistoryWarns(t *testing.T) {
a := &app{bus: bus.New(), log: testLog()}
wireSubagentBackfill(a, nil) // 没有 history:只允许告警
if err := a.bus.Publish(context.Background(), orchestration.SubagentResultSubject,
&orchestration.SubagentResultEvent{SessionID: history.MainSessionID}); err != nil {
t.Fatalf("publish on an unwired root: %v", err)
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
// 本文件把**半云端 emotionTTS** 接成她的一级发音人(切片 M35),并保持三条红线:
//
// 1. **配置走面板文件** `$DATA_DIR/config/voice-tts.json`(零新 `.env` 键,design/13
// §2.1)。文件缺失/坏掉 ⇒ 这一级**不启用**,既有语音链逐字不变。
// 2. **文本优先**:合成失败只回落既有链 + 一条带来源的 WARN,绝不把"这条回复"
// 判成失败(聊天正文早已发出/落库)。
// 3. **不新建端点/帧族**:只换网关 `SetVoice` 的注入点,因此既有两条出站口
// (`POST /api/v1/audio/speech` 与 WS `voice_output`)同时用上它。
//
// 为什么包一层而不是插进链里:既有链由 `multimodal/voice.New` 按 env 组装,各级
// 列表不外露;`.env` 已冻结,不能为新能力加键。包装既不动那条链,也让"失败回落"
// 发生在**合成入口**这一处,而不是散在两条出站口里。
import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// voiceTTSSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
const voiceTTSSource = "cmd/cyrene/voice-tts"
// voiceForGateway 返回注入给网关的语音服务:emotionTTS 已按面板配置启用时是
// 「emotionTTS + 既有链」的门面,否则**就是既有链本身**(行为逐字不变)。
//
// 它只在 `wireFrontend` 接线时调用一次;每次调用都重新读一次面板文件(启动期
// 读一次的口径与 `backthink.json` 一致)。
func (a *app) voiceForGateway() gateway.VoiceService {
if a.voice == nil {
return nil
}
cfg, enabled := a.voiceTTSConfig()
if !enabled {
return a.voice
}
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, voiceTTSSource)))
service := voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, voiceTTSSource)))
logger.WithSource(a.log, voiceTTSSource).Info("emotionTTS voice enabled",
"base_url", client.RedactedBaseURL(),
"personas", len(cfg.Personas),
"default_set", cfg.Default != "",
"timeout_sec", int(cfg.Timeout/time.Second),
"config", runtimeconfig.VoiceTTSConfigRelPath)
return service
}
// voiceTTSConfig 读面板配置并映射成 provider 配置。第二个返回值报告"这一级是否
// 启用"——坏文件、缺文件、显式 false 都得出 false(并在坏文件时留一条 WARN)。
//
// 它**不**在这里做"缺省填什么"的裁决:默认值在 `voicetts.Config.Normalized` 一处。
// 🔴 它也**不**给 personas/default 补任何角色:发音人只能由面板显式给出(全局定死
// 一个 character 正是主人点名的病害)。
func (a *app) voiceTTSConfig() (voicetts.Config, bool) {
log := logger.WithSource(a.log, voiceTTSSource)
path := runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg))
tuning, err := runtimeconfig.LoadVoiceTTSTuning(path)
if err != nil {
// 坏文件 ⇒ **这一级不启用**:宁可没有它,也不要用一份读不懂的配置去驱动
// 一台外部服务。既有链照旧服务(文本优先)。
log.Warn("voice tts config unreadable, emotionTTS stays disabled",
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
return voicetts.Config{}, false
}
if !tuning.EnabledOn() {
log.Debug("emotionTTS not enabled in panel config",
"path", runtimeconfig.VoiceTTSConfigRelPath, "hint", "set {\"enabled\": true}")
return voicetts.Config{}, false
}
if legacy := tuning.LegacyGlobalCharacter(); legacy != "" {
// 旧版的全局 character:**忽略**,但绝不静默 —— 运维需要知道换成 personas。
log.Warn("voice tts config: global `character` is deprecated and ignored",
"character", legacy, "config", runtimeconfig.VoiceTTSConfigRelPath,
"hint", "use {\"personas\": {\"<personaId>\": \"<characterId>\"}} so the voice follows the persona")
}
return voicetts.Config{
Enabled: true,
BaseURL: tuning.BaseURL,
Personas: tuning.Personas,
Default: tuning.Default,
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
}, true
}
// voiceFallbackChain 声明 a.voice 满足 emotionTTS 门面要的底链面(编译期断言)。
// 它把"既有链少了一个方法"这种改动的代价,从**运行期 panic** 提前到**编译失败**。
var _ voicetts.Chain = (*voice.Chain)(nil)
+127
View File
@@ -0,0 +1,127 @@
package main
// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种):
//
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec`
// 按文件来(发音人只能来自 persona 映射;已废弃的全局 `character` 不生效);
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
//
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
// 因此也钉住"零新 .env 键":没有任何一处读环境变量。
import (
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。
func voiceTTSApp(t *testing.T) *app {
t.Helper()
dir := t.TempDir()
return &app{
cfg: &config.Config{DataDir: dir},
log: tidyTestLog(),
voice: mvoice.New(config.VoiceConfig{}),
}
}
// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。
func TestVoiceForGateway_DisabledByDefault(t *testing.T) {
a := voiceTTSApp(t)
got := a.voiceForGateway()
if got != any(a.voice) {
t.Fatalf("没配过时不该包装既有链: %T", got)
}
}
// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。
//
// 🔴 它同时钉住主人 2026-10-26 那条硬要求在**组合根**上的形态:配置里读出来的是
// `personas` 映射与 `default`,**没有**任何"全局一个 character"。
func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("面板写了 enabled:true,但组合根报未启用")
}
if len(cfg.Personas) != 2 || cfg.Personas["furina"] != "char_5314489e" {
t.Fatalf("personas 没有逐项读出: %+v", cfg.Personas)
}
if cfg.Default != "char_84fb88a3" {
t.Fatalf("default = %q, want char_84fb88a3", cfg.Default)
}
got := a.voiceForGateway()
if _, ok := got.(*voicetts.Service); !ok {
t.Fatalf("启用后应返回 *tts.Service, got %T", got)
}
}
// TestVoiceForGateway_LegacyCharacterIgnored 钉住已废弃的全局 character **不生效**:
// 只写旧键时,映射与 default 都是空的 ⇒ 任何请求都选不到发音人(不会悄悄回退到
// 那个全局角色,因为那正是主人担心的病害)。
func TestVoiceForGateway_LegacyCharacterIgnored(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":true,"character":"char_84fb88a3"}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("enabled:true 应让这一级启用")
}
if len(cfg.Personas) != 0 || cfg.Default != "" {
t.Fatalf("废弃的全局 character 被当成了发音人: personas=%v default=%q", cfg.Personas, cfg.Default)
}
}
// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。
func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) {
a := voiceTTSApp(t)
writeVoiceTTSPanel(t, a, `{"enabled":`)
got := a.voiceForGateway()
if _, ok := got.(*voicetts.Service); ok {
t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)")
}
if got != any(a.voice) {
t.Fatalf("坏文件时应原样用既有链, got %T", got)
}
}
// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值,
// 配置解析的结果只由面板文件决定(证明它根本没读环境)。
func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) {
a := voiceTTSApp(t)
t.Setenv("VOICE_TTS_PROVIDER", "emotiontts")
t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid")
writeVoiceTTSPanel(t, a, `{"enabled":true}`)
cfg, enabled := a.voiceTTSConfig()
if !enabled {
t.Fatal("面板启用却没启用")
}
if cfg.BaseURL != "" {
t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL)
}
}
// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。
func writeVoiceTTSPanel(t *testing.T, a *app, body string) {
t.Helper()
path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write panel: %v", err)
}
}
+15 -2
View File
@@ -2,14 +2,27 @@
// the Go package can //go:embed it into the single Cyrene binary. It runs as the // the Go package can //go:embed it into the single Cyrene binary. It runs as the
// last step of `npm run build`. The source dist is the canonical build output, // last step of `npm run build`. The source dist is the canonical build output,
// so it also serves as the external deploy-override directory. // so it also serves as the external deploy-override directory.
import { cp, rm } from "node:fs/promises"; //
import { dirname, resolve } from "node:path"; // The destination is wiped first so assets from an older build cannot linger in
// the binary. internal/webui/dist/.gitkeep is committed because //go:embed
// all:dist fails to compile when the directory holds no files at all — that is
// what makes `go build ./...` work in a clean checkout. The wipe may clear build
// output but never the placeholder, so it is restored on every run, including a
// failed copy: a bare placeholder still compiles, a missing one breaks the build.
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const src = resolve(here, "dist"); const src = resolve(here, "dist");
const dest = resolve(here, "..", "..", "internal", "webui", "dist"); const dest = resolve(here, "..", "..", "internal", "webui", "dist");
const placeholder = ".gitkeep";
await rm(dest, { recursive: true, force: true }); await rm(dest, { recursive: true, force: true });
try {
await cp(src, dest, { recursive: true }); await cp(src, dest, { recursive: true });
} finally {
await mkdir(dest, { recursive: true });
await writeFile(join(dest, placeholder), "");
}
console.log(`webui: copied ${src} -> ${dest}`); console.log(`webui: copied ${src} -> ${dest}`);
+188 -26
View File
@@ -7,6 +7,12 @@ import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives"; import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins"; import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
type CapKind = "tool" | "skill" | "channel"; type CapKind = "tool" | "skill" | "channel";
@@ -16,6 +22,9 @@ const KIND_LABELS: Record<CapKind, string> = {
channel: "渠道 Channel", channel: "渠道 Channel",
}; };
/** 指向「MCP 服务器」页的提示文案:server 级动作(启停/重连/卸载)都在那边。 */
const MCP_PAGE_HINT = "在「MCP 服务器」页卸载该 server";
// Channel adapters are not gated by the tool policy (group/stranger markers are a // Channel adapters are not gated by the tool policy (group/stranger markers are a
// tool concept; a channel is configured via its own 渠道管理 page), so the // tool concept; a channel is configured via its own 渠道管理 page), so the
// channel block shows identity + a pointer rather than the policy switches. // channel block shows identity + a pointer rather than the policy switches.
@@ -27,18 +36,17 @@ function channelColumns(): AdminColumn<AdminPluginRecord>[] {
]; ];
} }
/** Columns for a tool / skill row (has environment markers, owner can edit). */ /** The environment-marker switches, shared by tool and skill rows.
function registeredColumns( *
* 它对**工具名**生效(`tool_policies` 以 name 为键),因此来自 MCP server 的工具有同
* 一套开关;`mcp:<server>` 那条 KindMCP 条目拿不到开关(策略判定从不读它),所以在
* MCP 登记段里不给开关 —— 假开关比没有开关更糟。 */
function policyColumn(
isOwner: boolean, isOwner: boolean,
busy: string | null, busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void, setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
uninstall: (name: string) => void, ): AdminColumn<AdminPluginRecord> {
): AdminColumn<AdminPluginRecord>[] { return {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "policy", key: "policy",
title: "环境授权(owner)", title: "环境授权(owner)",
render: (p) => ( render: (p) => (
@@ -62,7 +70,21 @@ function registeredColumns(
{busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null} {busy === p.name ? <span className="text-xs text-[var(--mu-text-lo)]">保存中…</span> : null}
</span> </span>
), ),
}, };
}
/** Columns for a host-builtin tool / a skill row (uninstall = remove this capability). */
function registeredColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
uninstall: (name: string) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{(KIND_LABELS as Record<string, string>)[p.kind] ?? p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
policyColumn(isOwner, busy, setPolicy),
{ {
key: "actions", key: "actions",
title: "操作", title: "操作",
@@ -75,6 +97,61 @@ function registeredColumns(
]; ];
} }
/** Columns for a tool that belongs to an MCP server.
*
* 🔴 行内**没有**「卸载」:单把 MCP 工具不是独立安装单元 —— 卸载它只能绕过插件管理器
* 裸删注册表条目,而该 server 的 names 账本不同步(重连/refresh 后工具复活,用户以为
* 卸掉了)。服务端同样拒绝这条路径(`plugin_uninstall_scope`),这里给出该去哪里做。 */
function mcpToolColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{
key: "origin",
title: "来源",
render: (p) => <Tag tone="neutral" title="注册时由后端盖章的上游归属,不从工具名推断">{p.origin?.server || "未知 server"}</Tag>,
},
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
policyColumn(isOwner, busy, setPolicy),
{
key: "actions",
title: "操作",
render: () => <span className="text-xs text-[var(--mu-text-lo)]">{MCP_PAGE_HINT}</span>,
},
];
}
/** Columns for a `kind:"mcp"` entry: one registration row per server, server-scoped. */
function mcpServerColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "登记键", render: (p) => <span className="font-mono text-xs font-medium">{p.name}</span> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
{
key: "admin",
title: "管理",
render: () => (
<span className="text-xs text-[var(--mu-text-lo)]">
启停 / 重连 / 卸载 / 安装见「MCP 服务器」页;本行是注册表登记条目,不承载单独的工具策略
</span>
),
},
];
}
/** Columns for capability kinds the page does not otherwise model (e.g. iot).
*
* design/09 §5.2.1 红线③:已装但本页不认识的条目要**如实显示**,不得静默过滤。 */
function otherColumns(): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="neutral">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-md truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
];
}
export function PluginsModule(): React.ReactNode { export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []); const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000); useAutoReload(reload, 10000);
@@ -125,14 +202,22 @@ export function PluginsModule(): React.ReactNode {
return groups; return groups;
}, [data]); }, [data]);
const toolSkillColumns = registeredColumns(isOwner, busy, setPolicy, uninstall); // 工具段的两级分节:先按上游 server,再剩「宿主内置」。
const toolBuckets = useMemo(() => toolOriginBuckets(byKind.tool), [byKind.tool]);
const mcpServers = useMemo(() => mcpServerEntries(data ?? []), [data]);
const others = useMemo(() => otherCapabilityEntries(data ?? []), [data]);
const toolCols = registeredColumns(isOwner, busy, setPolicy, uninstall);
const mcpToolCols = mcpToolColumns(isOwner, busy, setPolicy);
const channelCols = channelColumns(); const channelCols = channelColumns();
const serverCols = mcpServerColumns();
const otherCols = otherColumns();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="插件与能力" title="插件与能力"
subtitle="已注册能力(按工具 / 技能 / 渠道分组)+ 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)" subtitle="已注册能力(工具按上游 MCP server 分节 / 技能 / 渠道)+ 运行时 MCP 安装 · POST /api/v1/admin/plugins/policy (owner-only)"
/> />
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null} {!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
@@ -144,28 +229,66 @@ export function PluginsModule(): React.ReactNode {
<Surface className="space-y-3"> <Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已注册能力</h3> <h3 className="text-sm font-semibold">已注册能力</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 按工具 / 技能 / 渠道分组</span> <span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins · 工具按上游 server 分节</span>
</div> </div>
{(["tool", "skill"] as CapKind[]).map((kind) => { <div className="space-y-1.5">
const rows = byKind[kind];
return (
<div key={kind} className="space-y-1.5">
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS[kind]}</Tag> <Tag tone="accent">{KIND_LABELS.tool}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{rows.length} 个</span> <span className="text-xs text-[var(--mu-text-lo)]">
{byKind.tool.length} 个 · {toolBuckets.mcp.length} 台 MCP server + 宿主内置
</span>
</div>
{toolBuckets.mcp.map((group) => (
<div key={group.key} className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral" title="来源由后端在注册时盖章,不从工具名解析">
{originGroupTitle(group)}
</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">server 级动作见「MCP 服务器」页</span>
</div> </div>
<AdminTable <AdminTable
columns={toolSkillColumns} columns={mcpToolCols}
rows={loading ? null : rows} rows={loading ? null : group.rows}
loading={loading} loading={loading}
error={error} error={error}
emptyTitle={`无${KIND_LABELS[kind]}`} emptyTitle="无工具"
rowKey={(p) => `${kind}:${p.name}`} rowKey={(p) => `mcp:${group.key}:${p.name}`}
/>
</div>
))}
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 pt-1">
<Tag tone="neutral">宿主内置工具</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{toolBuckets.builtin.length} 把</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : toolBuckets.builtin}
loading={loading}
error={error}
emptyTitle="无宿主内置工具"
rowKey={(p) => `tool:${p.name}`}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="accent">{KIND_LABELS.skill}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{byKind.skill.length} 个</span>
</div>
<AdminTable
columns={toolCols}
rows={loading ? null : byKind.skill}
loading={loading}
error={error}
emptyTitle={`无${KIND_LABELS.skill}`}
rowKey={(p) => `skill:${p.name}`}
/> />
</div> </div>
);
})}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
@@ -181,9 +304,48 @@ export function PluginsModule(): React.ReactNode {
rowKey={(p) => `channel:${p.name}`} rowKey={(p) => `channel:${p.name}`}
/> />
</div> </div>
{/* 🔴 `kind:"mcp"` 条目**必须如实显示**(design/09 §5.2.1 红线③):它是每台
server 的注册登记,不是可调用能力,因此这里给事实 + 指向,不给策略开关
(`tool_policies` 只按工具名判定,对这条永远是惰性的 = 假开关)。 */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">MCP server 登记</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{mcpServers.length} 台</span>
</div>
<AdminTable
columns={serverCols}
rows={loading ? null : mcpServers}
loading={loading}
error={error}
emptyTitle="无 MCP server 登记(可在「MCP 服务器」页添加)"
rowKey={(p) => `mcpentry:${p.name}`}
/>
</div>
{others.length > 0 ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2 pt-1">
<Tag tone="neutral">其它能力</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">{others.length} 个 · 本页暂无专用视图,如实列出</span>
</div>
<AdminTable
columns={otherCols}
rows={others}
loading={loading}
error={error}
emptyTitle="无其它能力"
rowKey={(p) => `other:${p.name}`}
/>
</div>
) : null}
</Surface> </Surface>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页按类型分组展示已注册能力。群聊/陌生人环境可见性标记(默认关闭)仅对工具/技能生效。</Notice> <Notice>
工具按「注册时」的来源分节(来自哪台 MCP server 由后端盖章,不解析工具名);这是「呈现」分组,权限仍由
tool_policies 的逐工具环境授权决定。MCP server 的启停/重连/卸载/安装都在「MCP 服务器」页 ——
单把 MCP 工具不能单独卸载(否则 server 侧的工具账本会不同步)。
</Notice>
</div> </div>
); );
} }
@@ -0,0 +1,343 @@
/**
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
*
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
* 独立的一条消息,绝不拼成一条)。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
*
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
*/
import { useCallback, useEffect, useState } from "react";
import { request } from "../../../lib/api/core";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { useAuditStore } from "../../audit";
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
export interface CoalescePolicyView {
enabled: boolean;
windowMs: number;
maxWaitMs: number;
maxBatchMessages: number;
applyToStrangers: boolean;
strangerWindowMs: number;
}
/** 取值规则(后端 AdminCoalesceRules)。 */
export interface CoalesceRules {
windowMs: [number, number];
maxWaitMs: [number, number];
maxBatchMessages: [number, number];
clamp: string;
flushReasons: string[];
}
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
export interface CoalesceView {
path: string;
present: boolean;
hot: boolean;
default: CoalescePolicyView;
patch: Record<string, unknown>;
conversations: Record<string, CoalescePolicyView>;
rules: CoalesceRules;
ownerPrivate: string;
}
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
export interface CoalesceDraft {
enabled: string;
windowMs: string;
maxWaitMs: string;
maxBatchMessages: string;
applyToStrangers: string;
strangerWindowMs: string;
}
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
export const OWNER_PRIVATE_NOTE =
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
/** 热生效文案。 */
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
export const COALESCE_FIELDS: ReadonlyArray<{
key: keyof CoalesceDraft;
label: string;
kind: "bool" | "int";
hint: string;
}> = [
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
];
/** draftFromView 把生效值铺成草稿。 */
export function draftFromView(view: CoalesceView): CoalesceDraft {
return {
enabled: String(view.default.enabled),
windowMs: String(view.default.windowMs),
maxWaitMs: String(view.default.maxWaitMs),
maxBatchMessages: String(view.default.maxBatchMessages),
applyToStrangers: String(view.default.applyToStrangers),
strangerWindowMs: String(view.default.strangerWindowMs),
};
}
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
export function parseCoalescePatch(
view: CoalesceView,
draft: CoalesceDraft
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
const errors: Record<string, string> = {};
const out: Record<string, number | boolean> = {};
const changed: string[] = [];
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
errors[key] = "要填整数(毫秒/条)";
return null;
}
const n = Number(trimmed);
if (n < lo || n > hi) {
errors[key] = `取值范围 ${lo}–${hi}`;
return null;
}
return n;
};
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
const maxWait = window === null
? null
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
const strangerWindow = intIn(
"strangerWindowMs",
draft.strangerWindowMs,
view.rules.windowMs[0],
view.rules.windowMs[1]
);
const enabled = draft.enabled === "true";
const applyToStrangers = draft.applyToStrangers === "true";
const put = (key: string, next: number | boolean, current: number | boolean): void => {
if (next !== current) {
out[key] = next;
changed.push(key);
}
};
if (window !== null) put("windowMs", window, view.default.windowMs);
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
put("enabled", enabled, view.default.enabled);
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
return { patch: { default: out }, errors, changed };
}
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
export function fetchCoalesce(): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce");
}
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface CoalesceBodyProps {
view: CoalesceView;
draft: CoalesceDraft;
errors: Record<string, string>;
busy: boolean;
receipt: string | null;
savedView: CoalesceView | null;
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
onSave: () => void;
}
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function CoalesceCardBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: CoalesceBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
const inputCls =
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
</span>
</div>
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
{COALESCE_FIELDS.map((field) => (
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
{field.kind === "bool" ? (
<select
value={draft[field.key]}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onDraftChange(field.key, e.target.value)}
>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={draft[field.key]}
inputMode="numeric"
className={inputCls}
onChange={(e) => onDraftChange(field.key, e.target.value)}
/>
)}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存窗口设置"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
<div className="font-mono text-[var(--mu-text-lo)]">
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
</div>
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
</div>
) : null}
{Object.keys(view.conversations).length > 0 ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
{Object.entries(view.conversations).map(([key, policy]) => (
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
</div>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
发车原因:{view.rules.flushReasons.join(" / ")}。
</p>
</div>
);
}
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
export function ChannelCoalesceCard(): React.ReactNode {
const [view, setView] = useState<CoalesceView | null>(null);
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return fetchCoalesce()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view || !draft) return;
const check = parseCoalescePatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
if (check.changed.length === 0) {
setReceipt("没有改动(一个键都没写)");
return;
}
setBusy(true);
setFail(null);
saveCoalesce(check.patch)
.then((next) => {
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
log("channels.coalesce", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
log("channels.coalesce", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null || draft === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<CoalesceCardBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils"; import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
import { channelListState, channelStatus } from "./channelStatus"; import { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView"; import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { ChannelCoalesceCard } from "./CoalesceCard";
import type { AdapterNav } from "./useAdapterNav"; import type { AdapterNav } from "./useAdapterNav";
/** Page ① — aggregate all adapters: connection state, message volume, health. /** Page ① — aggregate all adapters: connection state, message volume, health.
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
onOpenList={() => navigate({ kind: "page", page: "list" })} onOpenList={() => navigate({ kind: "page", page: "list" })}
/> />
) : null} ) : null}
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
<ChannelCoalesceCard />
</div> </div>
); );
} }
@@ -0,0 +1,140 @@
// 群聊静默窗口卡(M23)的**真实渲染**测试。
//
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
//
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
// && node /tmp/cyrene-coalesce-card.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
COALESCE_FIELDS,
COALESCE_HOT_LABEL,
OWNER_PRIVATE_NOTE,
CoalesceCardBody,
draftFromView,
parseCoalescePatch,
type CoalesceView,
} from "./CoalesceCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
return {
path: "/data/config/coalesce.json",
present: true,
hot: true,
default: {
enabled: true,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
patch: {},
conversations: {
"telegram|group:-20": {
enabled: false,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
},
rules: {
windowMs: [300, 15000],
maxWaitMs: [300, 30000],
maxBatchMessages: [1, 50],
clamp: "clamp",
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
},
ownerPrivate: "never",
...overrides,
};
}
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
const check = parseCoalescePatch(view, draft);
return renderToStaticMarkup(
createElement(CoalesceCardBody, {
view,
draft,
errors: check.errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => undefined,
onSave: () => undefined,
})
);
}
function main(): void {
const view = fixtureView();
const html = render(view);
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
for (const field of COALESCE_FIELDS) {
ok(has(html, field.label), `field label missing: ${field.label}`);
ok(has(html, field.hint), `field hint missing for ${field.key}`);
ok(has(html, field.key), `field key not shown for ${field.key}`);
}
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
ok(has(html, "300–15000ms"), "window rules not shown");
ok(has(html, "quiet"), "flush reasons not shown");
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
const unchanged = parseCoalescePatch(view, draftFromView(view));
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
const badHtml = render(view, { windowMs: "10" });
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
}
main();
@@ -0,0 +1,111 @@
// 「插件与能力」页按上游 server 分组的**纯逻辑**测试(DevDocs/audit/MCP工具按上游分组-2026-10-24.md)。
//
// 锁四件事:
// ① **归属只看 origin,不看名字**:MCP 工具名里没有任何前缀(`mcp:` 是 KindMCP
// 条目的注册键),所以判据必须是后端盖的章 —— 本测试给工具起一个带 `mcp:` 字样的
// 名字但**无 origin**,它必须落进「宿主内置」;
// ② **transport 不编**:后端没报告 transport 时标题省略括号,而不是假设 stdio;
// ③ **空 server 不冒充内置**:报告了 mcp 来源却没说哪台 server 的工具,仍归 MCP 段
// (标题写「未知 MCP server」),不能被并进「宿主内置」—— 那是不同的结论;
// ④ **不得静默丢弃**:`kind:"mcp"` 登记条目与未知 kind(如 iot)都有专门的取值口。
//
// 项目没有前端测试框架,所以这是一个零依赖的 plain-node 检查(同目录既有手法):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/pluginOrigin.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-plugin-origin.test.cjs \
// && node /tmp/cyrene-plugin-origin.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import type { AdminPluginRecord } from "../../lib/types";
import {
mcpServerEntries,
originGroupTitle,
otherCapabilityEntries,
toolOriginBuckets,
} from "./pluginOrigin";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(g === w, `${what} — got ${g}, want ${w}`);
}
/** 造一条能力记录(origin 只在给定时出现,模拟后端 omitempty)。 */
function rec(
name: string,
kind: string,
origin?: { kind: string; server?: string; transport?: string },
): AdminPluginRecord {
const base: AdminPluginRecord = { name, kind, description: "", allowInGroup: false, allowInStranger: false };
return origin ? { ...base, origin } : base;
}
// ① 两个 server 各两把工具 + 两把内置工具(其中一把名字里带 mcp: 字样的假前缀)。
const rows: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("write_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("search", "tool", { kind: "mcp", server: "github", transport: "http" }),
rec("mcp:read_file", "tool"), // 名字像 MCP,但没有来源章 ⇒ 内置
rec("web_search", "tool"),
];
const buckets = toolOriginBuckets(rows);
// ① 归属只看 origin:两个 MCP 分节(按 server 名排序),其余归内置。
same(
buckets.mcp.map((g) => [g.server, g.transport, g.rows.map((r) => r.name)]),
[
["fs", "stdio", ["read_file", "write_file"]],
["github", "http", ["search"]],
],
"MCP 工具按 origin.server 分节(按 server 名排序)",
);
same(
buckets.builtin.map((r) => r.name),
["mcp:read_file", "web_search"],
"无 origin 的归宿主内置 —— 名字里带 mcp: 不算来源",
);
// ② 标题:transport 有则带括号,无则省略(不编 stdio);计数是「把工具」。
same(originGroupTitle(buckets.mcp[0]), "来自 fs(stdio)· 2 把工具", "带 transport 的组标题");
same(
originGroupTitle({ key: "mcp:x", server: "x", transport: "", rows: [rec("a", "tool")] }),
"来自 x · 1 把工具",
"未报告 transport 时省略括号,不编默认值",
);
// ③ 报告了 mcp 却没说哪台 server:仍属 MCP 段,不得冒充内置。
const anonymous = toolOriginBuckets([rec("orphan_tool", "tool", { kind: "mcp" }), rec("web_search", "tool")]);
same(anonymous.mcp.length, 1, "缺 server 名的 MCP 工具仍是 MCP 段的一员");
same(anonymous.mcp[0].server, "", "缺 server 名时 server 留空,不编一个名字");
same(originGroupTitle(anonymous.mcp[0]), "来自 未知 MCP server · 1 把工具", "缺 server 名的标题如实写未知");
same(anonymous.builtin.map((r) => r.name), ["web_search"], "内置桶只装真的没有 mcp 来源的工具");
// ④ 不得静默丢弃:KindMCP 登记条目与未知 kind 各有取值口。
const mixed: AdminPluginRecord[] = [
rec("read_file", "tool", { kind: "mcp", server: "fs", transport: "stdio" }),
rec("mcp:fs", "mcp"),
rec("kb_search", "skill"),
rec("lamp", "iot"),
rec("kook", "channel"),
];
same(mcpServerEntries(mixed).map((r) => r.name), ["mcp:fs"], "kind:mcp 登记条目要能被页面拿到");
same(otherCapabilityEntries(mixed).map((r) => r.name), ["lamp"], "未知 kind(iot)不得被静默过滤");
same(toolOriginBuckets(mixed).builtin.length, 0, "只有工具行进工具桶(技能/渠道/登记条目都不进)");
// 空输入不炸,且三个出口都是空数组而不是 undefined。
const empty = toolOriginBuckets([]);
same([empty.mcp.length, empty.builtin.length], [0, 0], "空清单得到空结果");
console.log(`pluginOrigin: ${checks} checks passed`);
@@ -0,0 +1,93 @@
// 「插件与能力」页的**来源分组**逻辑(纯函数、零 React 依赖)。
//
// 主人 2026-10-24 的要求:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器
// 分类并与其他插件相对隔离?」结论(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 归属**只能来自注册时的元数据**(`origin`),不能解析工具名 —— MCP 工具名由 server
// 自己给,宿主不加任何前缀(`mcp:` 是 KindMCP 条目的注册键,不是工具的命名空间)。
//
// 因此本模块只做三件事:
// ① 把 `kind:"tool"` 的行按 `origin.server` 切成分节,无 MCP 来源的归「宿主内置」;
// ② 生成分节标题(`来自 fs(stdio)· 12 把工具`)——transport 未报告时省略括号,
// 绝不编一个默认值;
// ③ 把不再是「工具/技能/渠道」行的事实摆出来(`mcp` 登记条目、其它未知 kind),
// 供页面**如实显示**而不是静默丢弃(design/09 §5.2.1 红线③)。
//
// 这里判的是「呈现归属」,不涉及权限:工具能不能被调用仍只由 tool_policies 的逐工具
// 布尔 + actor 元数据决定,分组既不收窄也不放宽任何权限。
import type { AdminPluginRecord } from "../../lib/types";
/** 一个上游 MCP server 承载的工具分节。 */
export interface ToolOriginGroup {
/** React key:同一 server 的所有工具共用一个。 */
key: string;
/** server 名(plugins.json / MCP_SERVERS_JSON 的 name);空串 = 报告了 MCP 却没说哪台。 */
server: string;
/** stdio / sse / http;空串 = 后端未报告。 */
transport: string;
rows: AdminPluginRecord[];
}
/** 工具段的两级分节结果。 */
export interface ToolOriginBuckets {
/** 按上游 server 分节(按 server 名排序);只含来自 MCP 的工具。 */
mcp: ToolOriginGroup[];
/** 没有 MCP 来源的工具:宿主内置 / 来源未知。 */
builtin: AdminPluginRecord[];
}
/** 页面自己成段渲染的 kind;其余 kind 必须落到「其它能力」段,不得静默丢弃。 */
export const RENDERED_KINDS = ["tool", "skill", "channel", "mcp"] as const;
/** record 是否来自一台 MCP server(判据是后端盖的来源章,不是名字)。 */
export function isMCPOrigin(rec: AdminPluginRecord): boolean {
return rec.origin?.kind === "mcp";
}
/** 把工具行按上游 server 分节;无 MCP 来源的归「宿主内置」桶。
*
* 只收 `kind === "tool"` 的行:技能/渠道/MCP 登记条目由页面自己成段渲染(见
* `RENDERED_KINDS` 与另两个取值口),它们**不是**工具、也不该被算进任何一个工具分节。
* 传整份清单进来是安全的 —— 忽略 ≠ 丢弃,其余 kind 在页面上各有位置。 */
export function toolOriginBuckets(rows: AdminPluginRecord[]): ToolOriginBuckets {
const groups = new Map<string, ToolOriginGroup>();
const builtin: AdminPluginRecord[] = [];
for (const rec of rows) {
if (rec.kind !== "tool") continue;
if (!isMCPOrigin(rec)) {
builtin.push(rec);
continue;
}
const server = (rec.origin?.server ?? "").trim();
const transport = (rec.origin?.transport ?? "").trim();
const key = `mcp:${server}`;
const group = groups.get(key);
if (group) {
group.rows.push(rec);
// 同一 server 的传输形态应当一致;万一不一致,保留先到的那个而不是拼接,
// 免得标题读成「stdio/sse」(拼接会让人以为一台 server 同时两种传输)。
continue;
}
groups.set(key, { key, server, transport, rows: [rec] });
}
const mcp = [...groups.values()].sort((a, b) => (a.server < b.server ? -1 : a.server > b.server ? 1 : 0));
builtin.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
return { mcp, builtin };
}
/** 分节标题:`来自 fs(stdio)· 12 把工具`;transport / server 缺失时如实省略。 */
export function originGroupTitle(group: ToolOriginGroup): string {
const count = `${group.rows.length} 把工具`;
const server = group.server || "未知 MCP server";
return group.transport ? `来自 ${server}(${group.transport})· ${count}` : `来自 ${server} · ${count}`;
}
/** `kind:"mcp"` 的登记条目(每台 server 一条,名字是 `mcp:<server>`)。 */
export function mcpServerEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => rec.kind === "mcp");
}
/** 既不是工具/技能/渠道、也不是 MCP 登记条目的能力(如 iot):必须如实显示。 */
export function otherCapabilityEntries(rows: AdminPluginRecord[]): AdminPluginRecord[] {
return rows.filter((rec) => !(RENDERED_KINDS as readonly string[]).includes(rec.kind));
}
+13
View File
@@ -8,6 +8,17 @@ export interface MemoryRecord {
text: string; text: string;
} }
/** 一条能力的上游来源(GET /api/v1/admin/plugins 的可选 `origin` 键,契约 v0.83)。
*
* 它回答「这条能力来自哪台上游 server」,来自**注册时的元数据**——绝不是从
* `name` 里解析前缀得来的(MCP 工具名里没有任何前缀)。宿主内置能力的该键
* **整个缺席**(不是 `{}`),前端据此把它们归「宿主内置」。 */
export interface CapabilityOrigin {
kind: string;
server?: string;
transport?: string;
}
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */ /** A capability/plugin as returned by GET /api/v1/admin/plugins. */
export interface AdminPluginRecord { export interface AdminPluginRecord {
name: string; name: string;
@@ -15,6 +26,8 @@ export interface AdminPluginRecord {
description: string; description: string;
allowInGroup: boolean; allowInGroup: boolean;
allowInStranger: boolean; allowInStranger: boolean;
/** 上游来源;缺席 = 宿主内置 / 来源未知(老后端同样缺席)。 */
origin?: CapabilityOrigin;
} }
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28). /** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
+12
View File
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测 // 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。 // (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。 // 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
//
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
//
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
// 路径,adopt_test 钉着它必须可见);
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
//
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
// 拼接处再洗一遍。
func withChromeOutput(err error, preview string) error { func withChromeOutput(err error, preview string) error {
if preview == "" { if preview == "" {
return fmt.Errorf("%w;浏览器没有输出任何内容", err) return fmt.Errorf("%w;浏览器没有输出任何内容", err)
+25 -2
View File
@@ -5,10 +5,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被 // cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
@@ -30,8 +33,28 @@ type cdpError struct {
Message string `json:"message"` Message string `json:"message"`
} }
// Error 实现 error 接口。 // Error 渲染协议错误(实现 error 接口)。
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) } //
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
// 可见视图,因此默认**不出镜**:
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
//
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
func (e *cdpError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "CDP %d", e.Code)
switch token := trace.ErrorToken(e.Message); {
case token != "":
b.WriteString(": ")
b.WriteString(token)
case e.Message != "":
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
}
return b.String()
}
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。 // cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
// //
+52 -6
View File
@@ -3,6 +3,7 @@ package browser
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
} }
} }
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此 // TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
// 判定「连接级问题 ⇒ 丢会话重起」)。 // (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) { //
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
// (trace.ErrorToken)才保留,其余只留字节数。
//
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
// 守卫 —— 用例不该拿它当「自由文本」。
//
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
// ⇒ 哨兵文本出现在错误串里,本用例红。
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"} return nil, &cdpError{Code: -32000, Message: message}
}) })
wsURL, _ := pageTargetWS(context.Background(), f.base) wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL) c, err := newCDPClient(context.Background(), wsURL)
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
} }
defer func() { _ = c.Close() }() defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil) err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") { if err == nil {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err) t.Fatal("协议错误应当返回 error")
}
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "CDP -32000") {
t.Errorf("错误没带上协议码: %v", err)
}
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
}
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
if !strings.Contains(err.Error(), "Runtime.evaluate") {
t.Errorf("错误丢了方法名: %v", err)
} }
if !strings.Contains(err.Error(), "CDP 调用失败") { if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err) t.Errorf("错误没带上 ErrCDP 文案: %v", err)
} }
} }
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
//
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。 // TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) { func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
@@ -0,0 +1,154 @@
package browser
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
//
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
// 的凭据断言(以及日志读面那条)会全红。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
// 普通原话一个字都不许少。
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
t.Parallel()
const (
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
)
var out chromeOutput
for _, line := range []string{complaint, credURL, bearer} {
if _, err := out.Write([]byte(line + "\n")); err != nil {
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
}
}
got := out.preview()
t.Logf("实测预览=%q", got)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(got, secret) {
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
}
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
}
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
if !strings.Contains(got, want) {
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
}
}
}
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
// 量级)。没有它,读者会把半截输出误当成完整输出。
func TestChromeOutputPreviewIsBounded(t *testing.T) {
t.Parallel()
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
t.Parallel()
var out chromeOutput
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.HasSuffix(got, "…") {
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
}
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
}
})
t.Run("字节截断时仍报总量", func(t *testing.T) {
t.Parallel()
var out chromeOutput
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.Contains(got, "已截断,共 5000 字节") {
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
}
})
}
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
t.Parallel()
const (
complaint = "不支持的标记: --remote-allow-origins=*"
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
)
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
msg := err.Error()
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(msg, secret) {
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
}
if strings.Contains(logs, secret) {
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("打码应当看得见([redacted]): %v", err)
}
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
// 浏览器原话那半是这条出口存在的理由。
for _, want := range []string{
"绝不杀别人的浏览器", l.profileDir,
complaint, "载入失败",
} {
if !strings.Contains(msg, want) {
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
}
}
if !strings.Contains(logs, complaint) {
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
}
}
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}
+124
View File
@@ -0,0 +1,124 @@
package browser
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
//
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
// 的「按字段类别判定」):
//
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
// 像枚举就保留,散文一律丢弃;
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
//
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
//
// 为什么不复用既有的两个档位:
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
// 而这条错误串同时进日志与模型可见视图。
//
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
const maxExceptionTextRunes = 200
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
//
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
const netErrorNamespace = "net::"
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
const netErrorCodePrefix = "ERR_"
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
func netErrorCode(raw string) string {
text := strings.TrimSpace(raw)
namespace := ""
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
namespace, text = netErrorNamespace, rest
}
if !strings.HasPrefix(text, netErrorCodePrefix) {
return ""
}
token := trace.ErrorToken(text)
if token == "" {
return ""
}
return namespace + token
}
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
//
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
// 不带那句话(与 cdpError.Error 同一口径)。
func navigateCause(raw string) string {
if code := netErrorCode(raw); code != "" {
return code
}
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
}
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
func pageExceptionText(raw string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
}
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
func evaluateCause(raw string) string {
if text := pageExceptionText(raw); text != "" {
return text
}
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
return "页面异常(对端未给文本)"
}
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
// (= 12 行 × 50 rune)。
//
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
//
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
//
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
const chromeOutputPreviewRunes = 600
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
// preview() 里,两个读面不会判得不一致。
func chromeOutputPreview(text string) string {
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
}
+200
View File
@@ -0,0 +1,200 @@
package browser
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": errorText}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
}
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
//
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
t.Parallel()
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("err = %v, want ErrNavigate", err)
}
msg := err.Error()
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "已隐去") {
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
}
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
}
}
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
t.Parallel()
for _, code := range []string{
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_TIMED_OUT",
"ERR_ABORTED",
} {
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
}
if !strings.Contains(err.Error(), code) {
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
}
}
}
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
// 文本的处置口径是「带上限地带」,见 errtext.go)。
//
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
t.Parallel()
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
}
if !strings.Contains(msg, "TypeError") {
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
}
}
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
// 就能在按行聚合的日志里伪造出另一条记录)。
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
t.Parallel()
const tail = "TAILMARKER"
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
if strings.Contains(msg, tail) {
t.Errorf("超出上限的尾部不该出镜: %q", msg)
}
if !strings.HasSuffix(msg, "…") {
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
}
if strings.ContainsAny(msg, "\r\n") {
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
}
}
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
// rune 加一个省略号,一个不多。
func TestPageExceptionTextLimitsRunes(t *testing.T) {
t.Parallel()
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("截断应补省略号: %q", got)
}
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
short := pageExceptionText("TypeError: body is gone")
if short != "TypeError: body is gone" {
t.Errorf("未超限不该改动文本: %q", short)
}
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
t.Errorf("空白异常文本的处理 = %q", got)
}
}
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
{"ERR_FAILED", "ERR_FAILED"},
{"hunter2", ""},
{"Failed loading https://example.com/", ""},
{"net::ERR_NOT_A_CODE because spaces", ""},
{"net::hunter2", ""},
{"", ""},
}
for _, c := range cases {
if got := netErrorCode(c.in); got != c.want {
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+13 -2
View File
@@ -20,10 +20,12 @@ const (
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上 // 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。 // (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
// //
// 两条口径: // 三条口径:
// //
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规 // - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。 // 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由 // - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。 // `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct { type chromeOutput struct {
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时 // preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串, // 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。 // 调用方据此判断「浏览器一个字都没说」。
//
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
// 这份预算,因此截断与量级都照实可见。
func (o *chromeOutput) preview() string { func (o *chromeOutput) preview() string {
o.mu.Lock() o.mu.Lock()
raw := truncateRune(o.head) raw := truncateRune(o.head)
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
break break
} }
} }
text := strings.Join(kept, " | ") // 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
// 只有拼起来之后才看得见。
text := chromeOutputPreview(strings.Join(kept, " | "))
if truncated && text != "" { if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)" text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
} }
+5 -2
View File
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
return pageState{}, err return pageState{}, err
} }
if text := strings.TrimSpace(res.ErrorText); text != "" { if text := strings.TrimSpace(res.ErrorText); text != "" {
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text) // errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
} }
if err := s.waitReady(ctx); err != nil { if err := s.waitReady(ctx); err != nil {
return pageState{}, err return pageState{}, err
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
return err return err
} }
if res.ExceptionDetails != nil { if res.ExceptionDetails != nil {
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text) // text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
} }
if out == nil || len(res.Result.Value) == 0 { if out == nil || len(res.Result.Value) == 0 {
return nil return nil
+13 -7
View File
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err)) return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
} }
info := serverInfoFrom(session, c.hostFeatures()) info := serverInfoFrom(session, c.hostFeatures())
c.mu.Lock() c.mu.Lock()
c.session = session c.session = session
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
if session != nil { if session != nil {
if err := session.Close(); err != nil { if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err) return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
} }
} }
return nil return nil
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
out := make([]ToolInfo, 0, 8) out := make([]ToolInfo, 0, 8)
for tool, err := range session.Tools(ctx, nil) { for tool, err := range session.Tools(ctx, nil) {
if err != nil { if err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err) return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
} }
out = append(out, toolInfoFrom(tool)) out = append(out, toolInfoFrom(tool))
} }
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
} }
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared}) res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil { if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err) return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
} }
return ToolResult{ return ToolResult{
Content: toContentBlocks(res.Content), Content: toContentBlocks(res.Content),
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。 // newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client { func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{ client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
Logger: c.log, // 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(), Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(), CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(), ElicitationHandler: c.wrapElicitation(),
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
} }
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。 // wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
//
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
func wrapConnectErr(err error) error { func wrapConnectErr(err error) error {
safe := upstreamError(err)
var wire *sdkjsonrpc.Error var wire *sdkjsonrpc.Error
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion { if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err) return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
} }
return err return safe
} }
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。 // normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
//
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
// **默认不带自由文本**,只留可分类的骨架。
//
// 留下的骨架(够定位,不够回显):
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
// 不是错误文本);
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
//
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
type upstreamFailure struct {
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
code int64
hasCode bool
// length 是被扣下的自由文本的字节数。
length int
// cause 只用于分类,绝不参与 Error()。
cause error
}
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
func (f *upstreamFailure) Error() string {
if f.hasCode {
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
}
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
}
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
// 少了它,取消会被上层当成普通工具失败继续跑。
func (f *upstreamFailure) Unwrap() error { return f.cause }
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
//
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
// JSON-RPC 信封),所以不能只断言最外层。
func upstreamError(err error) error {
if err == nil {
return nil
}
failure := &upstreamFailure{cause: err, length: len(err.Error())}
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
failure.code = wire.Code
failure.hasCode = true
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
failure.length = len(wire.Message)
}
return failure
}
+94
View File
@@ -0,0 +1,94 @@
package mcp
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
//
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
import (
"context"
"errors"
"fmt"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
func sentinelServer() *mcpsdk.Server {
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
srv.AddTool(&mcpsdk.Tool{
Name: "boom",
InputSchema: map[string]any{"type": "object"},
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
})
return srv
}
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, sentinelServer())
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
if err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
}
for _, want := range []string{"boom", "fake", "code=-32603"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
}
}
}
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
err := upstreamError(cause)
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "code=-32601") {
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
}
// 分类面:WireError 的 Is 比的是码,链不能断。
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
}
var wire *sdkjsonrpc.Error
if !errors.As(err, &wire) || wire.Code != -32601 {
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
}
}
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
// 收敛文本时绝不能把这条链切断。
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
t.Parallel()
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
if !errors.Is(err, context.Canceled) {
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
}
if strings.Contains(err.Error(), "context canceled") {
t.Errorf("取消的原文也被带进了错误串:%v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package mcp
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住 MCP 工具的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// 每把注册进注册表的工具都要带「哪台 server + 什么传输」,供面板按 server 分节。
// TestRegistryRegisterStampsToolOrigin 覆盖**两条接入路径**的共同汇点:
// - 运行时热装:plugin.Manager.start → mcp.NewRegistry → **Register**;
// - `.env` 的 MCP_SERVERS_JSON 启动期装:cmd/cyrene.registerMCPServer →
// mcp.NewRegistry → **同一个 Register**(不经插件管理器)。
//
// 两条路都在这里进注册表,因此这一处的断言就同时钉住了它们;也从反面证明
// 「从插件管理器现场反推归属」会漏掉 env 路径。
func TestRegistryRegisterStampsToolOrigin(t *testing.T) {
reg := registry.New()
cfg := MCPConfig{Name: "calc", TransportType: "stdio"}
r := NewRegistry(reg, cfg, WithTransport(startFakeServer(t, newFakeServer())))
if err := r.Register(context.Background()); err != nil {
t.Fatalf("Register: %v", err)
}
t.Cleanup(func() { _ = r.Unregister(context.Background()) })
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "calc", Transport: "stdio"}
tools := reg.List(registry.KindTool)
if len(tools) != 2 {
t.Fatalf("注册进注册表的工具数 = %d, want 2(echo/add)", len(tools))
}
for _, cap := range tools {
if cap.Origin != want {
t.Fatalf("工具 %q 的来源章 = %+v, want %+v", cap.Name, cap.Origin, want)
}
}
// 来源是 server 名,不是注册键 —— 与 `mcp:calc` 那条 KindMCP 条目无关。
if err := r.Unregister(context.Background()); err != nil {
t.Fatalf("Unregister: %v", err)
}
if left := reg.List(registry.KindTool); len(left) != 0 {
t.Fatalf("停用 server 后还剩 %d 把工具:条目必须连同来源章一起消失,不留孤儿", len(left))
}
}
// TestTransportNameOf 钉住来源章里 transport 的取值:它必须报**实际会用的**传输,
// 而不是原样下发可能为空的 TransportType(配置常见留空,由推断决定)。
// 判据与 newTransport 的推断同源,因此面板显示的传输永远是真在用的那个。
func TestTransportNameOf(t *testing.T) {
cases := []struct {
name string
cfg MCPConfig
want string
}{
{"显式 stdio", MCPConfig{TransportType: "stdio"}, "stdio"},
{"显式 sse", MCPConfig{TransportType: "sse"}, "sse"},
{"显式 http", MCPConfig{TransportType: "http"}, "http"},
{"streamable 归一为 http", MCPConfig{TransportType: "streamable"}, "http"},
{"留空 + 命令 ⇒ stdio", MCPConfig{Command: "npx"}, "stdio"},
{"留空 + 地址 ⇒ sse", MCPConfig{URL: "https://example.test/sse"}, "sse"},
{"命令优先于地址(与 newTransport 同序)", MCPConfig{Command: "npx", URL: "https://example.test"}, "stdio"},
{"都没有 ⇒ 空串(不编一个)", MCPConfig{}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := transportNameOf(tc.cfg); got != tc.want {
t.Fatalf("transportNameOf(%+v) = %q, want %q", tc.cfg, got, tc.want)
}
})
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
//
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
//
// logger.Error("keepalive ping failed; closing session", "error", err, …)
//
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
// 了对端回显的整句话。
//
// 收敛口径(与 client.go 同一刻画,不做第二套):
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
// `upstreamError` 渲染的 code + message_bytes;
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
//
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
import (
"context"
"errors"
"log/slog"
"strings"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
//
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
const maxSDKLogRunes = 300
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
func redactingLogger(l *slog.Logger) *slog.Logger {
return slog.New(&redactingHandler{inner: l.Handler()})
}
// redactingHandler 是 redactingLogger 的 handler 形态。
type redactingHandler struct {
inner slog.Handler
}
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool {
out.AddAttrs(scrubSDKAttr(a))
return true
})
return h.inner.Handle(ctx, out)
}
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
// 记录会绕过本文件。
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
scrubbed := make([]slog.Attr, len(attrs))
for i, a := range attrs {
scrubbed[i] = scrubSDKAttr(a)
}
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
}
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
func (h *redactingHandler) WithGroup(name string) slog.Handler {
return &redactingHandler{inner: h.inner.WithGroup(name)}
}
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
func scrubSDKText(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
}
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
func scrubSDKAttr(a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindString:
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok && err != nil {
a.Value = slog.StringValue(scrubSDKError(err))
}
case slog.KindGroup:
group := a.Value.Group()
scrubbed := make([]slog.Attr, len(group))
for i, item := range group {
scrubbed[i] = scrubSDKAttr(item)
}
a.Value = slog.GroupValue(scrubbed...)
}
return a
}
// scrubSDKError 区分两类错误来源(口径见文件头):
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
// message_bytes),一个字节的自由文本都不留;
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
func scrubSDKError(err error) string {
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
return upstreamError(err).Error()
}
return scrubSDKText(err.Error())
}
@@ -0,0 +1,175 @@
package mcp
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
//
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
//
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
//
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
// 说的那句话」被对端回显)。
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
// keepalive 日志」这条路径。
//
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
type pingErrorTransport struct {
inner mcpsdk.Transport
}
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
conn, err := t.inner.Connect(ctx)
if err != nil {
return nil, err
}
return newPingErrorConn(conn), nil
}
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
type pingErrorConn struct {
inner mcpsdk.Connection
msgs chan sdkjsonrpc.Message
done chan struct{}
once sync.Once
}
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
c := &pingErrorConn{
inner: inner,
msgs: make(chan sdkjsonrpc.Message, 16),
done: make(chan struct{}),
}
go c.pump()
return c
}
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
func (c *pingErrorConn) pump() {
defer close(c.msgs)
for {
msg, err := c.inner.Read(context.Background())
if err != nil {
return
}
select {
case c.msgs <- msg:
case <-c.done:
return
}
}
}
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
req, ok := msg.(*sdkjsonrpc.Request)
if !ok || req.Method != "ping" {
return c.inner.Write(ctx, msg)
}
select {
case c.msgs <- &sdkjsonrpc.Response{
ID: req.ID,
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
}:
return nil
case <-c.done:
return context.Canceled
}
}
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
select {
case msg, ok := <-c.msgs:
if !ok {
return nil, context.Canceled
}
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *pingErrorConn) Close() error {
c.once.Do(func() { close(c.done) })
return c.inner.Close()
}
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
c := New(MCPConfig{Name: "fake"},
WithTransport(startFakeServer(t, sentinelServer())),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if got := buf.String(); strings.Contains(got, errSentinel) {
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
}
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
}
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
c := New(MCPConfig{Name: "fake"},
WithTransport(tr),
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
WithProtocolVersion("2025-11-25"),
WithKeepAlive(20*time.Millisecond, 1),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
return strings.Contains(buf.String(), "keepalive ping failed")
})
logged := buf.String()
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
if !strings.Contains(logged, "keepalive ping failed") {
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
}
if strings.Contains(logged, sdkLogSentinel) {
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
}
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
}
+59
View File
@@ -0,0 +1,59 @@
package mcp
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
// 用真 server 证明这条日志面确实走过收敛。
//
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
t.Parallel()
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
wire := fmt.Errorf("calling %q: %w", "ping",
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
got := scrubSDKError(wire)
if strings.Contains(got, "SENTINEL-MCPLOG") {
t.Fatalf("线上错误的自由文本进了日志:%q", got)
}
if !strings.Contains(got, "code=-32603") {
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
}
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
t.Fatalf("本地错误的原因被吞掉了:%q", got)
}
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
got = scrubSDKError(leaky)
if strings.Contains(got, "sk-live") {
t.Fatalf("本地错误里的凭据没被打码:%q", got)
}
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
}
}
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
// 状态,不该被字符串化或截断。
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
t.Parallel()
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
}
}
+56
View File
@@ -0,0 +1,56 @@
package mcp
// 本文件是 MCP 客户端 registry 的**状态读面**(切片 M20:MCP 服务器的管理页)。
//
// 为什么放在 mcp 包而不是插件管理器:工具名与描述的唯一真源是**已注册进能力注册表
// 的那些条目**(`Register`/`refresh` 写进去、`Unregister` 摘掉的正是它们)。管理器只
// 知道「这个 server 活没活」,它没有工具描述;让它自己去猜工具清单就会出现「面板上的
// 工具列表」与「模型真正能调的工具」两份事实。这里只读,不改任何注册/刷新逻辑。
// ToolSummary 是一把工具在状态快照里的形状:名字 + 截断后的描述。
//
// 描述必然要截断:MCP 服务器的描述可以是一整段 Markdown(实测有几百字的),而状态页是
// 给人扫一眼的排障面,把原文全塞进表格只会把有用的列挤没。
type ToolSummary struct {
// Name 是工具名(模型调用时用的标识,不截断:它是排障时要对的那个字符串)。
Name string `json:"name"`
// Description 是截断后的描述(原始描述见 mcp 客户端的 tools/list)。
Description string `json:"description,omitempty"`
}
// ToolSummaries 返回本 registry 当前注册的工具摘要(按注册顺序)。
//
// 「当前注册」= `ToolNames` 里还活着的名字;被 `refresh` 摘掉的工具不会留下影子。
// descriptionLimit ≤ 0 表示不截断。描述取自能力注册表的条目:**注册失败的工具不在
// 其中**,因此清单与模型可见的工具集合同源。
func (r *Registry) ToolSummaries(descriptionLimit int) []ToolSummary {
if r == nil {
return nil
}
names := r.ToolNames()
out := make([]ToolSummary, 0, len(names))
for _, name := range names {
desc := ""
if r.reg != nil {
if cap, err := r.reg.Get(name); err == nil {
desc = cap.Description
}
}
out = append(out, ToolSummary{Name: name, Description: TruncateRunes(desc, descriptionLimit)})
}
return out
}
// TruncateRunes 把 s 截到最多 limit 个字符(按 rune 计,不切坏多字节字符);被截断时
// 末尾补一个省略号,让「这里还有」在界面上可见(静默截断会被读成「原文就这么长」)。
// limit ≤ 0 表示不截断。
func TruncateRunes(s string, limit int) string {
if limit <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "…"
}
+13
View File
@@ -7,6 +7,8 @@ import (
"strings" "strings"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// ToolInfo 是 MCP 服务器暴露的一把工具。 // ToolInfo 是 MCP 服务器暴露的一把工具。
@@ -60,6 +62,17 @@ func (t *mcpTool) Name() string { return t.info.Name }
// Description 返回 MCP 工具描述。 // Description 返回 MCP 工具描述。
func (t *mcpTool) Description() string { return t.info.Description } func (t *mcpTool) Description() string { return t.info.Description }
// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**,
// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。
func (t *mcpTool) Origin() sdk.Origin {
return sdk.Origin{
Kind: sdk.OriginKindMCP,
Server: t.client.cfg.Name,
Transport: transportNameOf(t.client.cfg),
}
}
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。 // Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
func (t *mcpTool) Parameters() (map[string]any, error) { func (t *mcpTool) Parameters() (map[string]any, error) {
if t.info.InputSchema == nil { if t.info.InputSchema == nil {
+24
View File
@@ -33,6 +33,30 @@ func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
} }
} }
// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与
// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http),
// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立,
// 上游来源里该键缺席,而不是编一个 stdio。
//
// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」,
// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。
func transportNameOf(cfg MCPConfig) string {
switch cfg.TransportType {
case "http", "streamable":
return "http"
case "sse", "stdio":
return cfg.TransportType
}
switch {
case cfg.Command != "":
return "stdio"
case cfg.URL != "":
return "sse"
default:
return ""
}
}
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25 // httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
// 规范首选),否则走旧式 SSE。 // 规范首选),否则走旧式 SSE。
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) { func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
+195
View File
@@ -0,0 +1,195 @@
package plugin
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
//
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
//
// 刻意不做的事:
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json
// 还是旧值 —— 因此**重启后会退回旧状态**。
//
// 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server
// 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。
var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted")
// notFound 构造一条带名字的「找不到」。
func notFound(name string) error {
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
// SetEnabled 启用或停用一台已安装的 server。
//
// 语义(design/09 §5.2.1 生命周期一栏):
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
//
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
// 找不到该 server 时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return notFound("")
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
//
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
// 变成一个查不出的谜)。
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
name = strings.TrimSpace(name)
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
cfg, ok = srv.cfg, true
}
}
if !ok {
return Installed{}, notFound(name)
}
if !cfg.IsEnabled() {
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
}
return m.install(ctx, cfg, false, PanelChangeInstalled)
}
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
//
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.liveServer(name)
cfg, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return notFound(name)
}
if !persisted {
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
// 否则「停用」重启后会变成「不存在」。
cfg = srv.cfg
}
if err := m.persistEnabled(cfg, false); err != nil {
return fmt.Errorf("plugin %q: %w", name, err)
}
if srv != nil {
m.take(srv.cfg.Name)
m.stop(ctx, srv)
}
m.noteDisabled(cfg)
m.setPanelsEnabled(cfg.Name, false)
m.log.Info("plugin disabled", "name", cfg.Name)
return nil
}
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
return m.persistEnabled(srv.cfg, true)
}
return notFound(name)
}
if srv := m.liveServer(name); srv != nil {
if cfg.IsEnabled() {
return nil
}
return m.persistEnabled(cfg, true)
}
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
if err != nil {
return err
}
return m.persistEnabled(res.Config, true)
}
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
//
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
// 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败
// 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, cfg.Name, err)
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
+223
View File
@@ -0,0 +1,223 @@
package plugin
// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20):
// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来;
// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且
// **一次都不去连**(不是「连了一下再判定停用」);
// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。
import (
"context"
"errors"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。
func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) {
t.Helper()
reg := registry.New()
mgr := NewManager(reg, path, nil)
t.Cleanup(func() { mgr.Close(context.Background()) })
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
}); err != nil {
t.Fatalf("Install: %v", err)
}
return mgr, reg
}
// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。
func TestDisableUnregistersToolsAndPersists(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("安装后工具 = %v,期望 add+mul", names)
}
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 工具必须真的不在注册表里:模型的面就是这样来的。
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用后工具仍可被模型看到 = %v", names)
}
if _, err := reg.Get("mcp:calc"); err == nil {
t.Fatal("停用后 mcp:calc 能力仍在")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs)
}
if cfgs[0].Enabled == nil {
t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)")
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected {
t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st)
}
if st.ToolCount != nil {
t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount)
}
}
// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。
func TestEnableBringsToolsBack(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add", "mul")
mgr, reg := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if err := mgr.SetEnabled(ctx, "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if names := toolNames(reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(cfgs) != 1 || !cfgs[0].IsEnabled() {
t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs)
}
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 {
t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st)
}
if len(st.Tools) != 2 {
t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools)
}
}
// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。
func TestRestoreHonoursDisabledFlag(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
mgr.Close(context.Background())
before := srv.Config.Handler.(*fakeMCP).hitCount()
reg2 := registry.New()
mgr2 := NewManager(reg2, path, nil)
defer mgr2.Close(context.Background())
mgr2.Restore(context.Background())
if names := toolNames(reg2); len(names) != 0 {
t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names)
}
if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before {
t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after)
}
st := statusOfOne(t, mgr2, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st)
}
}
// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。
func TestInstallDisabledDoesNotConnect(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
reg := registry.New()
mgr := NewManager(reg, path, nil)
defer mgr.Close(context.Background())
got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false),
})
if err != nil {
t.Fatalf("Install(disabled): %v", err)
}
if got.Live {
t.Fatalf("停用安装不该有活 server:%+v", got)
}
if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 {
t.Fatalf("停用安装发起了 %d 次连接", hits)
}
if names := toolNames(reg); len(names) != 0 {
t.Fatalf("停用安装注册了工具 = %v", names)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() {
t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err)
}
}
// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。
func TestEnableFailureKeepsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
// 让 server 连不上:关掉进程内的假 server。
srv.Close()
if err := mgr.SetEnabled(ctx, "calc", true); err == nil {
t.Fatal("启用一台连不上的 server 应当报错")
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v)", cfgs, err)
}
if cfgs[0].IsEnabled() {
t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0])
}
// 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 ——
// 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。
st := statusOfOne(t, mgr, "calc")
if st.State != ServerStateDisabled || st.Enabled {
t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled)
}
if st.Connected == nil || *st.Connected {
t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected)
}
if st.LastError == "" {
t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了")
}
if st.LastErrorAt == "" {
t.Fatal("启用失败后 lastErrorAt 为空")
}
}
// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。
func TestReconnectRejectsDisabled(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
ctx := context.Background()
if err := mgr.SetEnabled(ctx, "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) {
t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err)
}
if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) {
t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err)
}
}
// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。
func enabledFlag(v bool) *bool { return &v }
+50 -108
View File
@@ -33,6 +33,19 @@ const (
mcpCapabilityPrefix = "mcp:" mcpCapabilityPrefix = "mcp:"
) )
// MCPServerName 从 `mcp:<server>` 登记条目的注册键里取出 server 名。
// 第二个返回值为 false 表示这不是一条 KindMCP 登记条目的键(或者 server 名为空)。
//
// 它是**注册键格式**的唯一真相(`mcpCapabilityPrefix` 只在这里出现),因此网关在需要
// 「这条登记条目是哪台 server」时应当调它,而不是自己在别处再拼一遍前缀。
func MCPServerName(capName string) (string, bool) {
server, ok := strings.CutPrefix(capName, mcpCapabilityPrefix)
if !ok || server == "" {
return "", false
}
return server, true
}
// Installed is one plugin as reported to the admin API: its persisted config, // Installed is one plugin as reported to the admin API: its persisted config,
// whether a live server currently backs it, and how many tools it registered. // whether a live server currently backs it, and how many tools it registered.
type Installed struct { type Installed struct {
@@ -61,6 +74,10 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
servers map[string]*server servers map[string]*server
// observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败
// 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在
// servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。
observed map[string]observed
// panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。
// 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把
// 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。
@@ -80,6 +97,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager
path: path, path: path,
log: log, log: log,
servers: make(map[string]*server), servers: make(map[string]*server),
observed: make(map[string]observed),
panelKeys: make(map[string]string), panelKeys: make(map[string]string),
} }
} }
@@ -116,6 +134,11 @@ func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (
// install is Install with the persist step optional (Restore reuses it without // install is Install with the persist step optional (Restore reuses it without
// rewriting the file it just read). changeReason labels the panel change pushed // rewriting the file it just read). changeReason labels the panel change pushed
// when the plugin becomes live (install vs enable). // when the plugin becomes live (install vs enable).
//
// It respects cfg.Enabled: a config that says enabled=false is **persisted but not
// connected** (no live server, no tools), which is how "installed but switched off"
// survives a restart. Restore goes through this same path, so startup honours the
// flag too.
func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) { func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) {
cfg = cfg.Normalize() cfg = cfg.Normalize()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -129,8 +152,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
if prev != nil { if prev != nil {
m.stop(ctx, prev) m.stop(ctx, prev)
} }
if !cfg.IsEnabled() {
return m.installDisabled(cfg, persist, prev)
}
srv, err := m.start(ctx, cfg) srv, err := m.start(ctx, cfg)
if err != nil { if err != nil {
// 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。
m.noteFailure(cfg, err)
m.restore(prev) m.restore(prev)
return Installed{}, err return Installed{}, err
} }
@@ -142,11 +170,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p
} }
} }
m.put(srv) m.put(srv)
m.noteSeen(cfg)
m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount) m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount)
m.markInstalled(cfg, changeReason) m.markInstalled(cfg, changeReason)
return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil
} }
// installDisabled 走「已安装但停用」这条路:只落配置,不连接、不发现、不注册任何工具,
// 并把面板入口隐藏(`when:{enabled:true}` 的入口不出现)。
//
// 调用前 prev(同名活 server)已经被停掉:配置现在说的是「停用」,让旧 server 继续挂着
// 就成了两份互相矛盾的事实。落盘失败时把 prev 恢复回来(回滚到「启用且活着」那个状态)。
func (m *Manager) installDisabled(cfg runtimeconfig.PluginConfig, persist bool, prev *server) (Installed, error) {
if persist {
if err := m.persistUpsert(cfg); err != nil {
m.restore(prev)
return Installed{}, err
}
}
m.noteDisabled(cfg)
m.markDisabled(cfg)
m.log.Info("plugin installed disabled", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport)
return Installed{Config: cfg}, nil
}
// Uninstall removes the plugin matching name: it unregisters the live server's // Uninstall removes the plugin matching name: it unregisters the live server's
// tools and closes it, then deletes the persisted config. It reports whether // tools and closes it, then deletes the persisted config. It reports whether
// anything matched, so the caller can fall back to a plain capability removal. // anything matched, so the caller can fall back to a plain capability removal.
@@ -166,6 +213,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) {
panelName = srv.cfg.Name panelName = srv.cfg.Name
} }
m.forgetPanels(panelName) m.forgetPanels(panelName)
m.forgetObserved(panelName)
m.log.Info("plugin uninstalled", "name", name) m.log.Info("plugin uninstalled", "name", name)
} }
return removed, nil return removed, nil
@@ -231,114 +279,6 @@ func (m *Manager) Close(ctx context.Context) {
} }
} }
// SetEnabled 启用或停用一个已安装插件。
//
// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的
// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json**
// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并
// 让入口回来。两条路都会推一次 `panels_changed`。
//
// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该
// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此
// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给
// plugins.json 加 `enabled` 字段(并让 Restore 读它)。
//
// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。
//
// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置
// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.takeAny(name)
_, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if srv != nil {
m.stop(ctx, srv)
}
m.setPanelsEnabled(name, false)
m.log.Info("plugin disabled", "name", name)
return nil
}
// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。
// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
if m.liveServer(name) != nil {
return nil
}
cfg, ok := m.persistedConfig(name)
if !ok {
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil {
return err
}
return nil
}
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
func (m *Manager) liveServer(name string) *server {
m.mu.Lock()
defer m.mu.Unlock()
if srv := m.servers[name]; srv != nil {
return srv
}
for _, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
return srv
}
}
return nil
}
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
func (m *Manager) takeAny(name string) *server {
if srv := m.take(name); srv != nil {
return srv
}
m.mu.Lock()
defer m.mu.Unlock()
for key, srv := range m.servers {
if srv.cfg.ID != "" && srv.cfg.ID == name {
delete(m.servers, key)
return srv
}
}
return nil
}
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
return runtimeconfig.PluginConfig{}, false
}
for _, cfg := range cfgs {
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
return cfg, true
}
}
return runtimeconfig.PluginConfig{}, false
}
// start connects cfg, registers its tools and surfaces its KindMCP capability. // start connects cfg, registers its tools and surfaces its KindMCP capability.
// On a registration failure it cleans up and returns the error. // On a registration failure it cleans up and returns the error.
func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) { func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) {
@@ -389,6 +329,8 @@ func (m *Manager) restore(prev *server) {
return return
} }
m.put(srv) m.put(srv)
// 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。
m.noteSeen(prev.cfg)
} }
// take removes and returns the live server named name, or nil. // take removes and returns the live server named name, or nil.
@@ -62,6 +62,15 @@ type fakeMCP struct {
mu sync.Mutex mu sync.Mutex
auth string auth string
hits int
}
// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server
// 在重启后不该被连一次)。
func (f *fakeMCP) hitCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.hits
} }
func (f *fakeMCP) setTools(tools ...string) { func (f *fakeMCP) setTools(tools ...string) {
@@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
f.mu.Lock() f.mu.Lock()
f.auth = r.Header.Get("Authorization") f.auth = r.Header.Get("Authorization")
f.hits++
tools := append([]string(nil), f.tools...) tools := append([]string(nil), f.tools...)
f.mu.Unlock() f.mu.Unlock()
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth { if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
+203
View File
@@ -0,0 +1,203 @@
package plugin
// 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即
// `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。
//
// 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** ——
// 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/
// Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。
//
// 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL
// (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点
// `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。
//
// 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`):
// - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search
// 页的 `state` 列同一口径),工具栏退化成一列截断文本;
// - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发;
// - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染**
// (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`;
// kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。
import (
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。
//
// 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以
// 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目
// (见 isReservedPanelKey)。
const MCPPanelPluginID = "mcp"
// MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。
const MCPPanelID = "servers"
// 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的
// `POST /api/v1/admin/plugins/mcp/actions/<name>`。
const (
// ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。
ActionListMCPServers = "plugin.mcpServers"
// ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。
ActionInstallMCPServer = "plugin.installMcpServer"
// ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。
ActionReconnectMCPServer = "plugin.reconnectMcpServer"
// ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。
ActionEnableMCPServer = "plugin.enableMcpServer"
// ActionDisableMCPServer 停用一台 server(行内动作,写面)。
ActionDisableMCPServer = "plugin.disableMcpServer"
// ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。
ActionUninstallMCPServer = "plugin.uninstallMcpServer"
// ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为
// OptionsSourceTransports 时前端按此动作名取候选。
ActionMCPTransports = "plugin.mcpTransports"
)
// OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。
const OptionsSourceTransports = "plugin:mcpTransports"
// 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名),
// 因此集中在这里,处理器引用常量而不另写字面量。
const (
// FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:<name>`)。
FieldServerName = "name"
// FieldTransport 是传输形态(stdio / http / sse)。
FieldTransport = "transport"
// FieldCommand 是 stdio 的启动命令。
FieldCommand = "command"
// FieldArgs 是 stdio 的命令参数(字符串列表)。
FieldArgs = "args"
// FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。
FieldURL = "url"
// FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。
FieldAPIKey = "apiKey"
// FieldEnv 是 stdio 子进程的额外环境变量(键值对)。
FieldEnv = "env"
)
// MCPAdminPanels 返回「MCP 服务器」页的声明。
func MCPAdminPanels() []sdk.Panel {
return []sdk.Panel{{
ID: MCPPanelID,
Title: "MCP 服务器",
Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" +
"卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" +
"配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," +
"无需重启)。",
Group: sdk.GroupPlugins,
Order: 10,
Icon: "server",
// 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源),
// 而不是由声明自己假设。
When: &sdk.When{Enabled: flagBool(true)},
Sections: []sdk.Section{
{
Type: sdk.SectionTable,
ID: "list",
Title: "已接入的服务器",
Source: &sdk.Source{Action: ActionListMCPServers},
// 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的
// 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是
// `endpoint`/`command`/`tools[]`。
Columns: []sdk.Column{
{Key: "name", Label: "名称"},
{Key: "transport", Label: "传输"},
{Key: "target", Label: "地址 / 命令"},
{Key: "state", Label: "状态"},
{Key: "toolsText", Label: "工具"},
{Key: "lastError", Label: "最近一次错误"},
{Key: "lastSeenAt", Label: "最近连接"},
},
RowActions: []sdk.RowAction{
{ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer},
{ID: "enable", Label: "启用", Action: ActionEnableMCPServer},
{ID: "disable", Label: "停用", Action: ActionDisableMCPServer},
{ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer},
},
},
{
Type: sdk.SectionForm,
ID: "install",
Title: "安装 / 刷新服务器",
Fields: []sdk.Field{
{Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true,
Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"},
{Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect,
OptionsFrom: OptionsSourceTransports,
Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"},
{Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText,
Placeholder: "npx / uvx / 可执行文件路径"},
{Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect,
Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"},
{Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText,
Placeholder: "https://..."},
{Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret,
Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"},
{Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV,
Placeholder: `{"TOKEN":"..."}`},
},
Submit: &sdk.Submit{Action: ActionInstallMCPServer},
},
},
}}
}
// DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。
//
// 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions`
// 登记(声明与登记是两个事实,见 actions.go 的口径)。
func DeclareMCPPanel(hub *Panels) ([]string, error) {
if hub == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
return hub.Declare(MCPPanelPluginID, MCPAdminPanels())
}
// AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作
// 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。
//
// 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501;
// 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。
func (m *Manager) AttachMCPPanel() error {
hub := m.Panels()
if hub == nil {
err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)",
ErrInvalidActionRegistration, MCPPanelPluginID)
m.log.Error("mcp panel not declared; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
if _, err := DeclareMCPPanel(hub); err != nil {
m.log.Error("mcp panel declaration rejected; its actions will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return fmt.Errorf("plugin: declare mcp panel: %w", err)
}
if err := m.RegisterMCPPanelActions(); err != nil {
m.log.Error("mcp panel actions not registered; they will answer 501",
"pluginId", MCPPanelPluginID, "error", err)
return err
}
m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID)
return nil
}
// isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。
//
// 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很
// 实在 —— 内置提供者的 id 是给人看的短名字(mcp / web-search / browser),而 server 的
// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页
// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。
//
// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与
// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、
// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。
func isReservedPanelKey(key string) bool {
switch key {
case MCPPanelPluginID, "web-search", "browser":
return true
default:
return false
}
}
@@ -0,0 +1,405 @@
package plugin
// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。
//
// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向):
// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计);
// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」;
// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文
// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。
//
// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` /
// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发
// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。
const (
// mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。
mcpCodeInvalidConfig = "invalid_config"
// mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。
mcpCodeInstallFailed = "install_failed"
// mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。
mcpCodeReconnectFailed = "reconnect_failed"
// mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。
mcpCodeEnableFailed = "enable_failed"
// mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。
mcpCodeDisableFailed = "disable_failed"
// mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。
mcpCodeUninstallFailed = "uninstall_failed"
// mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。
mcpCodeServerNotFound = "server_not_found"
// mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。
mcpCodeServerDisabled = "server_disabled"
)
// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。
//
// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的
// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。
func (m *Manager) RegisterMCPPanelActions() error {
return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{
ActionListMCPServers: m.actListServers,
ActionInstallMCPServer: m.actInstallServer,
ActionReconnectMCPServer: m.actReconnectServer,
ActionEnableMCPServer: m.actEnableServer,
ActionDisableMCPServer: m.actDisableServer,
ActionUninstallMCPServer: m.actUninstallServer,
ActionMCPTransports: m.actTransports,
})
}
// actListServers 返回全部 server 的状态快照(表格数据源)。
//
// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。
func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) {
statuses := m.Statuses()
rows := make([]ServerRow, 0, len(statuses))
for _, st := range statuses {
rows = append(rows, serverRowOf(st))
}
return map[string]any{"rows": rows}, nil
}
// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。
//
// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看
// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。
func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) {
return map[string]any{"options": []map[string]string{
{"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"},
{"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"},
{"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"},
}}, nil
}
// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。
func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) {
values, err := actionValues(req.Payload)
if err != nil {
return nil, err
}
cfg, err := installConfigOf(values)
if err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
}
res, err := m.Install(ctx, cfg)
if err != nil {
m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err)
return nil, NewActionError(mcpCodeInstallFailed,
fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err)
}
return map[string]any{
"installed": true,
"name": res.Config.Name,
"transport": res.Config.Transport,
"toolCount": res.ToolCount,
}, nil
}
// installConfigOf 把表单值翻译成插件配置。
//
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
// 同理 `args` 里的非字符串元素也不许悄悄消失。
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
args, err := actionStringList(values[FieldArgs])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
env, err := actionStringMap(values[FieldEnv])
if err != nil {
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
}
return runtimeconfig.PluginConfig{
Name: actionText(values, FieldServerName),
Kind: runtimeconfig.PluginKindMCP,
Transport: actionText(values, FieldTransport),
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
Command: actionText(values, FieldCommand),
Args: args,
APIKey: actionText(values, FieldAPIKey),
Env: env,
}.Normalize(), nil
}
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
//
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
func invalidConfigError(err error) error {
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
}
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
res, err := m.Reconnect(ctx, name)
switch {
case errors.Is(err, ErrPluginNotFound):
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
case errors.Is(err, ErrPluginDisabled):
return nil, NewActionError(mcpCodeServerDisabled,
fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err)
case err != nil:
m.log.Warn("mcp reconnect failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeReconnectFailed,
fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil
}
// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。
func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, true); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp enable failed", "name", name, "error", err)
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
if errors.Is(err, ErrPluginStatePersist) {
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
}
return nil, NewActionError(mcpCodeEnableFailed,
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
}
return map[string]any{"name": name, "enabled": true, "connected": true}, nil
}
// actDisableServer 停用一台 server(工具随之下线,配置保留)。
func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
if err := m.SetEnabled(ctx, name, false); err != nil {
if errors.Is(err, ErrPluginNotFound) {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
}
m.log.Warn("mcp disable failed", "name", name, "error", err)
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
return nil, NewActionError(mcpCodeDisableFailed,
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
}
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
}
// actUninstallServer 卸载一台 server(摘工具 + 删配置)。
func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) {
name, err := actionRowName(req.Payload)
if err != nil {
return nil, err
}
removed, err := m.Uninstall(ctx, name)
if err != nil {
m.log.Warn("mcp uninstall failed", "name", name, "error", err)
return nil, NewActionError(mcpCodeUninstallFailed,
fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err)
}
if !removed {
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil)
}
return map[string]any{"name": name, "uninstalled": true}, nil
}
// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。
//
// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列
// 只为表格排版而存在(一列只能显示一个标量)。
type ServerRow struct {
Status
// Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。
Target string `json:"target,omitempty"`
// ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。
ToolsText string `json:"toolsText,omitempty"`
}
// serverRowOf 把一条快照翻成表格行。
func serverRowOf(st Status) ServerRow {
target := st.Endpoint
if target == "" {
target = st.Command
}
return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)}
}
// --- 载荷解析 -----------------------------------------------------------------
// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。
func actionValues(payload any) (map[string]any, error) {
obj, ok := payload.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
raw, ok := obj["values"]
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
}
values, ok := raw.(map[string]any)
if !ok {
return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil)
}
return values, nil
}
// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。
func actionRowName(payload any) (string, error) {
obj, ok := payload.(map[string]any)
if !ok {
return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil)
}
row, ok := obj["row"].(map[string]any)
if !ok {
// 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。
row = obj
}
name := actionText(row, FieldServerName)
if name == "" {
return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil)
}
return name, nil
}
// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。
func actionText(values map[string]any, key string) string {
raw, ok := values[key]
if !ok {
return ""
}
s, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
// `[]any`)。
//
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
func actionStringList(raw any) ([]string, error) {
switch items := raw.(type) {
case nil:
return nil, nil
case []string:
return compactStrings(items), nil
case []any:
out := make([]string, 0, len(items))
for i, item := range items {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
}
out = append(out, s)
}
return compactStrings(out), nil
default:
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
}
}
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
//
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
func actionStringMap(raw any) (map[string]string, error) {
var pairs map[string]any
switch obj := raw.(type) {
case nil:
return nil, nil
case map[string]any:
pairs = obj
case map[string]string:
pairs = make(map[string]any, len(obj))
for k, v := range obj {
pairs[k] = v
}
default:
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
}
out := make(map[string]string, len(pairs))
for k, v := range pairs {
key := strings.TrimSpace(k)
if key == "" {
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
}
out[key] = s
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
//
// 它刻意不打印值本身:env 的值可能就是凭据。
func jsonKindOf(v any) string {
switch v.(type) {
case nil:
return "null"
case string:
return "字符串"
case bool:
return "布尔"
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return "数字"
case []any:
return "数组"
case map[string]any:
return "对象"
default:
return fmt.Sprintf("%T", v)
}
}
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
func compactStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if s := strings.TrimSpace(item); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+430
View File
@@ -0,0 +1,430 @@
package plugin
// 本文件锁定「MCP 服务器」页(切片 M20)的三件事:
// ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染);
// ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501);
// ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed,
// 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。
import (
"context"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。
func TestMCPPanelDeclarationIsValid(t *testing.T) {
t.Parallel()
if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil {
t.Fatalf("「MCP 服务器」页声明非法:%v", err)
}
}
// TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。
//
// 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个
// 处理器,本测试直接红。
func TestMCPPanelActionsWired(t *testing.T) {
t.Parallel()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
declared := hub.DeclaredActions(MCPPanelPluginID)
handled := hub.ActionHandlerNames(MCPPanelPluginID)
if len(declared) == 0 || len(handled) == 0 {
t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled)
}
set := make(map[string]bool, len(handled))
for _, name := range handled {
set[name] = true
}
for _, name := range declared {
if !set[name] {
t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name)
}
}
// 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名,
// 否则前端的传输下拉会静默降级成文本框。
if name := OptionsActionName(OptionsSourceTransports); !set[name] {
t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name)
}
}
// TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。
func TestMCPPanelInstallAndList(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
assertInstalledConfig(t, mgr, "calc")
row := mustRows(t, hub, ctx)[0]
if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 {
t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row)
}
if row.ToolsText != "add、mul" {
t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText)
}
if row.Target != srv.URL {
t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL)
}
}
// TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载,
// 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。
func TestMCPPanelToggleUninstallAndFrames(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add", "mul")
mgr, hub, rec := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil {
t.Fatalf("reconnect 动作: %v", err)
}
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable 动作: %v", err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("停用后工具仍在注册表 = %v", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled {
t.Fatalf("停用后行 = %+v", row)
}
_, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict {
t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled)
}
if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil {
t.Fatalf("enable 动作: %v", err)
}
if names := toolNames(mgr.reg); !names["add"] || !names["mul"] {
t.Fatalf("启用后工具 = %v,期望 add+mul", names)
}
if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil {
t.Fatalf("uninstall 动作: %v", err)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("卸载后仍有行:%+v", rows)
}
reasons := changeReasons(rec.all())
for _, want := range []string{
PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled,
} {
if !containsString(reasons, want) {
t.Fatalf("变更原因 = %v,缺 %q", reasons, want)
}
}
}
// TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。
func TestMCPPanelInstallFailureIsClassified(t *testing.T) {
t.Parallel()
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed)
}
// 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" {
t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row)
}
// 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。
_, err = hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{"transport": "stdio"}},
})
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig)
}
// 卸载一个不存在的名字 ⇒ server_not_found / 404。
_, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost")
if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound {
t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound)
}
}
// TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用
// 不许把内置「MCP 服务器」页整页摘掉。
func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
installFake(t, mgr, MCPPanelPluginID, srv.URL)
if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil {
t.Fatalf("Uninstall: %v", err)
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID {
t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries)
}
}
// mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。
func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow {
t.Helper()
res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers})
if err != nil {
t.Fatalf("list 动作: %v", err)
}
body, ok := res.(map[string]any)
if !ok {
t.Fatalf("list 结果 = %T,期望对象", res)
}
rows, ok := body["rows"].([]ServerRow)
if !ok {
t.Fatalf("list 结果的 rows 类型 = %T", body["rows"])
}
return rows
}
// dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。
func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) {
t.Helper()
return hub.Dispatch(context.Background(), ActionRequest{
PluginID: MCPPanelPluginID, Action: action,
Payload: map[string]any{"row": map[string]any{FieldServerName: name}},
})
}
// installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。
func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) {
t.Helper()
if _, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": map[string]any{
"name": name, "transport": "http", "url": url,
"args": []any{"--flag", "value"},
"env": map[string]any{"TOKEN": "t0ken"},
}},
}); err != nil {
t.Fatalf("install 动作: %v", err)
}
}
// assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值
// 最容易在「前端提交了、后端读错键」这一步静默丢失)。
func assertInstalledConfig(t *testing.T, mgr *Manager, name string) {
t.Helper()
cfgs, err := runtimeconfig.LoadPlugins(mgr.path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err)
}
if cfgs[0].Name != name {
t.Fatalf("落盘名字 = %q", cfgs[0].Name)
}
if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" {
t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args)
}
if cfgs[0].Env["TOKEN"] != "t0ken" {
t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env)
}
}
// containsString 报告 items 里是否有 want。
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须
// 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、
// server 连上了、env 却没生效 —— 比直接失败更坏)。
func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
cases := []struct {
name string
mutate func(values map[string]any)
wantKey string
}{
{"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"},
{"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"},
{"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"},
}
for _, tc := range cases {
values := map[string]any{
"name": "calc", "transport": "http", "url": srv.URL,
"env": map[string]any{"TOKEN": "abc"},
"args": []any{"--flag"},
}
tc.mutate(values)
_, err := hub.Dispatch(ctx, ActionRequest{
PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer,
Payload: map[string]any{"values": values},
})
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest {
t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig)
}
if !strings.Contains(ae.Message, tc.wantKey) {
t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey)
}
// 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的
// 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。
if strings.Contains(ae.Message, "9182") {
t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message)
}
}
// 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。
if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 {
t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err)
}
if names := toolNames(mgr.reg); len(names) != 0 {
t.Fatalf("注册表里有工具 = %v", names)
}
if rows := mustRows(t, hub, ctx); len(rows) != 0 {
t.Fatalf("快照里有行 = %+v", rows)
}
}
// TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间
// 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。
func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) {
t.Parallel()
for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} {
id := id
t.Run(id, func(t *testing.T) {
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
ctx := context.Background()
// 模拟该内置提供者在装配期声明的那一页。
if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare(%s): %v", id, err)
}
installFake(t, mgr, id, srv.URL)
assertBuiltinPageAlive(t, hub, id, "安装")
if err := mgr.SetEnabled(ctx, id, false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
assertBuiltinPageAlive(t, hub, id, "停用")
if err := mgr.SetEnabled(ctx, id, true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if _, err := mgr.Uninstall(ctx, id); err != nil {
t.Fatalf("Uninstall: %v", err)
}
assertBuiltinPageAlive(t, hub, id, "卸载")
})
}
}
// assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。
func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) {
t.Helper()
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
for _, e := range entries {
if e.PluginID == id {
return
}
}
t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries)
}
// TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但
// 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。
//
// 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限
// 决定,同法不成立)。
func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("只读位只在 windows 的 rename 上生效")
}
t.Parallel()
srv := newFakeMCP(t, "", "add")
mgr, hub, _ := newPanelManager(t)
defer mgr.Close(context.Background())
if err := mgr.AttachMCPPanel(); err != nil {
t.Fatalf("AttachMCPPanel: %v", err)
}
ctx := context.Background()
installViaAction(t, hub, ctx, "calc", srv.URL)
if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil {
t.Fatalf("disable: %v", err)
}
if err := os.Chmod(mgr.path, 0o444); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) })
_, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc")
ae, ok := AsActionError(err)
if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway {
t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed)
}
// 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。
if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") {
t.Fatalf("文案与事实不符:%q", ae.Message)
}
// 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。
if names := toolNames(mgr.reg); !names["add"] {
t.Fatalf("启用后工具 = %v,期望已连上并注册", names)
}
if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled {
t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row)
}
}
+54 -7
View File
@@ -287,15 +287,11 @@ func PanelIDValid(id string) bool {
// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给 // markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。 // 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) { func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
key := PanelPluginID(cfg) key := m.rememberPanelKey(cfg)
if key == "" { if key == "" || isReservedPanelKey(key) {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return return
} }
m.mu.Lock() hub := m.panelsHub()
m.panelKeys[cfg.Name] = key
hub := m.panels
m.mu.Unlock()
if hub == nil { if hub == nil {
return return
} }
@@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str
hub.MarkInstalled(key) hub.MarkInstalled(key)
} }
// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的
// 入口(声明保留,重新启用即原样回来)。
//
// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」);
// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。
func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) {
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
if hub := m.panelsHub(); hub != nil {
hub.SetEnabled(key, false)
}
}
// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。
//
// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下,
// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。
func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string {
key := PanelPluginID(cfg)
if key == "" {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return ""
}
if isReservedPanelKey(key) {
// 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动
// 「MCP 服务器」页本身(见 isReservedPanelKey)。
m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched",
"name", cfg.Name, "panelId", key)
return ""
}
m.mu.Lock()
m.panelKeys[cfg.Name] = key
m.mu.Unlock()
return key
}
// panelsHub 返回已接线中枢(未接线时 nil)。
func (m *Manager) panelsHub() *Panels {
m.mu.Lock()
defer m.mu.Unlock()
return m.panels
}
// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 // forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 // (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
func (m *Manager) forgetPanels(name string) { func (m *Manager) forgetPanels(name string) {
@@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.MarkUninstalled(key) hub.MarkUninstalled(key)
} }
@@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) {
return return
} }
} }
if isReservedPanelKey(key) {
return
}
hub.SetEnabled(key, on) hub.SetEnabled(key, on)
} }
+273
View File
@@ -0,0 +1,273 @@
package plugin
// 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次
// 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。
//
// 三条口径:
//
// 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见
// 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着;
// present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理
// `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server
// 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。
// 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 ——
// stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`)
// 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意
// 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点),
// 把安全押在「每个调用方都记得抹」上迟早漏一次。
// 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」
// 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。
// 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。
import (
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案:
// 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。
const (
// ServerStateConnected 表示 server 已连接、工具已注册。
ServerStateConnected = "connected"
// ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。
ServerStateError = "error"
// ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。
ServerStateDisabled = "disabled"
// ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录
// (例如 plugins.json 被手工改过、启动期还没轮到它)。
ServerStateUnknown = "unknown"
)
// 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。
const (
// toolDescriptionLimit 是单把工具描述的字符上限。
toolDescriptionLimit = 200
// lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。
lastErrorLimit = 400
// toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。
toolsTextLimit = 240
)
// Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。
type Status struct {
// Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。
Name string `json:"name"`
// ID 是 server 的稳定标识(缺省省略)。
ID string `json:"id,omitempty"`
// Kind 是插件种类(本期恒为 mcp)。
Kind string `json:"kind,omitempty"`
// Transport 是连接形态(stdio / http / sse)。
Transport string `json:"transport,omitempty"`
// Endpoint 是 http/sse 的地址(已抹凭据)。
Endpoint string `json:"endpoint,omitempty"`
// Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。
Command string `json:"command,omitempty"`
// Enabled 是启用态(来自 plugins.json;缺省视为 true)。
Enabled bool `json:"enabled"`
// State 是状态闭集码(见 ServerState*)。
State string `json:"state"`
// Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。
Connected *bool `json:"connected,omitempty"`
// ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」,
// 而是「不知道它有哪些工具」。
ToolCount *int `json:"toolCount,omitempty"`
// Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时
// `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。
Tools []mcp.ToolSummary `json:"tools,omitempty"`
// LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。
LastError string `json:"lastError,omitempty"`
// LastErrorAt 是那次失败的时刻(RFC3339)。
LastErrorAt string `json:"lastErrorAt,omitempty"`
// LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。
LastSeenAt string `json:"lastSeenAt,omitempty"`
}
// observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。
//
// 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与
// 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。
type observed struct {
cfg runtimeconfig.PluginConfig
lastError string
lastErrorAt time.Time
lastSeenAt time.Time
}
// Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」
// 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序
// (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。
func (m *Manager) Statuses() []Status {
cfgs, err := runtimeconfig.LoadPlugins(m.path)
if err != nil {
m.log.Warn("plugins.json load skipped", "path", m.path, "error", err)
cfgs = nil
}
live, obs := m.snapshotState()
out := make([]Status, 0, len(cfgs)+len(obs))
seen := make(map[string]bool, len(cfgs)+len(obs))
for _, cfg := range cfgs {
out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name]))
seen[cfg.Name] = true
}
for _, name := range sortedKeys(obs) {
if seen[name] {
continue
}
out = append(out, statusOf(obs[name].cfg, live[name], obs[name]))
seen[name] = true
}
for _, name := range sortedKeys(live) {
if seen[name] {
continue
}
out = append(out, statusOf(live[name].cfg, live[name], obs[name]))
seen[name] = true
}
return out
}
// snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表,
// 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。
func (m *Manager) snapshotState() (map[string]*server, map[string]observed) {
m.mu.Lock()
defer m.mu.Unlock()
live := make(map[string]*server, len(m.servers))
for name, srv := range m.servers {
live[name] = srv
}
obs := make(map[string]observed, len(m.observed))
for name, rec := range m.observed {
obs[name] = rec
}
return live, obs
}
// statusOf 把「配置 + 活 server + 观察记录」推成一条快照。
func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status {
st := Status{
Name: cfg.Name,
ID: cfg.ID,
Kind: cfg.Kind,
Transport: cfg.Transport,
Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint),
Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command),
Enabled: cfg.IsEnabled(),
LastError: rec.lastError,
LastErrorAt: formatTime(rec.lastErrorAt),
}
switch {
case srv != nil:
st.State = ServerStateConnected
st.Connected = flagBool(true)
count := srv.toolCount
st.ToolCount = &count
st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case !cfg.IsEnabled():
// 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。
//
// 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时
// `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个
// 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用
// 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。
st.State = ServerStateDisabled
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
case rec.lastError != "":
st.State = ServerStateError
st.Connected = flagBool(false)
st.LastSeenAt = formatTime(rec.lastSeenAt)
default:
// 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。
st.State = ServerStateUnknown
}
return st
}
// noteSeen 记录一次成功连接(tools 已注册)。
func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() })
}
// noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留
// 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。
func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) {
if err == nil {
return
}
text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit)
m.observe(cfg, func(rec *observed) {
rec.lastError = text
rec.lastErrorAt = time.Now().UTC()
})
}
// noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。
func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) {
m.observe(cfg, func(*observed) {})
}
// observe 在锁内更新一台 server 的观察记录(不存在则新建)。
func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) {
name := cfg.Name
if name == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.observed == nil {
m.observed = make(map[string]observed, 1)
}
rec := m.observed[name]
rec.cfg = cfg
apply(&rec)
m.observed[name] = rec
}
// forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。
func (m *Manager) forgetObserved(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.observed, name)
}
// formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。
func flagBool(v bool) *bool { return &v }
// sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。
//
// 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里
// 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。
func toolsText(tools []mcp.ToolSummary) string {
if len(tools) == 0 {
return ""
}
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit)
}
+185
View File
@@ -0,0 +1,185 @@
package plugin
// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20):
// ① 三态:缺席 = 未知,不用零值冒充「未连接」;
// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证;
// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里
// (否则「为什么挂了」在面板上根本无处可看)。
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。
func statusOfOne(t *testing.T, mgr *Manager, name string) Status {
t.Helper()
for _, st := range mgr.Statuses() {
if st.Name == name {
return st
}
}
t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses())
return Status{}
}
// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。
func TestStatusesTriState(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, "", "add")
mgr, _ := newEnabledManager(t, path, srv.URL)
// 连着:connected=true、工具数/明细/最近连接都在。
live := statusOfOne(t, mgr, "calc")
if live.State != ServerStateConnected {
t.Fatalf("state = %q,期望 connected", live.State)
}
if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 {
t.Fatalf("连接态快照不对 = %+v", live)
}
if live.LastSeenAt == "" || live.LastError != "" {
t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live)
}
if live.Endpoint != srv.URL {
t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL)
}
// 停用:connected 是**确认的 false**(有意没连),工具则是未知。
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
off := statusOfOne(t, mgr, "calc")
if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected {
t.Fatalf("停用快照 = %+v", off)
}
if off.ToolCount != nil || off.Tools != nil {
t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools)
}
// 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。
handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"}
if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
unknown := statusOfOne(t, mgr, "manual")
if unknown.State != ServerStateUnknown {
t.Fatalf("state = %q,期望 unknown", unknown.State)
}
if unknown.Connected != nil {
t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected)
}
if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" {
t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown)
}
}
// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。
func TestStatusesFailureIsVisible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
dead := httptest.NewServer(http.NotFoundHandler())
deadURL := dead.URL
dead.Close()
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL,
}); err == nil {
t.Fatal("对关掉的 server 安装应当失败")
}
st := statusOfOne(t, mgr, "down")
if st.State != ServerStateError || st.Connected == nil || *st.Connected {
t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st)
}
if st.LastError == "" {
t.Fatal("失败快照没有 lastError:面板说不出病因")
}
if st.Enabled != true {
t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st)
}
}
// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。
//
// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把
// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。
func TestStatusesNeverLeakAPIKey(t *testing.T) {
t.Parallel()
const key = "sk-live-SECRET-9f3c2b1d"
path := filepath.Join(t.TempDir(), "plugins.json")
srv := newFakeMCP(t, key, "add")
mgr := NewManager(registry.New(), path, nil)
defer mgr.Close(context.Background())
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key,
}); err != nil {
t.Fatalf("Install: %v", err)
}
cfgs, err := runtimeconfig.LoadPlugins(path)
if err != nil || len(cfgs) != 1 {
t.Fatalf("LoadPlugins = %+v, %v", cfgs, err)
}
cfgWithKey := cfgs[0]
// 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。
mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3"))
mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed"))
raw, err := json.Marshal(mgr.Statuses())
if err != nil {
t.Fatalf("marshal statuses: %v", err)
}
body := string(raw)
assertNoSecret(t, body, key)
if !strings.Contains(body, "failed") {
t.Fatalf("抹凭据不该把病因整条抹掉:%s", body)
}
}
// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。
func TestRedactPluginSecrets(t *testing.T) {
t.Parallel()
const key = "tok_ABCDEF123456"
cfg := runtimeconfig.PluginConfig{APIKey: key,
Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}}
cases := []string{
"authorization: Bearer " + key,
"prefix " + key[:8] + " suffix",
"middle ABCDEF123456 and more",
"env dump GITHUB_TOKEN=ghp_ZZZZZZ9999",
}
for _, in := range cases {
out := runtimeconfig.RedactPluginSecrets(cfg, in)
assertNoSecret(t, out, key)
assertNoSecret(t, out, "ghp_ZZZZZZ9999")
}
// 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。
if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") {
t.Fatalf("非敏感值被误抹:%q", out)
}
}
// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。
func assertNoSecret(t *testing.T, text, secret string) {
t.Helper()
if strings.Contains(text, secret) {
t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text)
}
const frag = 8
for i := 0; i+frag <= len(secret); i++ {
if part := secret[i : i+frag]; strings.Contains(text, part) {
t.Fatalf("文本里出现了凭据片段 %q:%s", part, text)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package registry
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「来源章」(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// 归属只能来自注册时的元数据,因此盖章必须发生在**注册汇点**上,且热替换不能把它抹掉。
//
// 三条判据各自**能变红**:
// - 去掉 RegisterTool 里的盖章 → 第一条红;
// - 去掉 ReplaceTool 里的盖章(它逐字段重建条目)→ 第二条红;
// - 给所有工具都编一个章(比如按名字猜)→ 第三条红。
// originTool 是自报来源的最小工具(宿主内置工具不实现 sdk.OriginAware)。
type originTool struct {
name string
origin sdk.Origin
}
func (t originTool) Name() string { return t.name }
func (t originTool) Description() string { return "来自 MCP" }
func (t originTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t originTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
func (t originTool) Origin() sdk.Origin { return t.origin }
var _ sdk.OriginAware = originTool{}
// TestRegisterToolStampsOrigin:注册汇点把工具自报的来源盖在能力条目上。
func TestRegisterToolStampsOrigin(t *testing.T) {
r := New()
want := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: want}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != want {
t.Fatalf("条目上的来源章 = %+v, want %+v(RegisterTool 是唯一汇点,必须在这里盖章)", cap.Origin, want)
}
}
// TestReplaceToolKeepsOrigin:热替换必须重新盖章 —— 它逐字段重建 Capability,
// 漏一位就等于「面板保存一次,这把工具从它的 server 分节里掉进内置」。
func TestReplaceToolKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "http"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
// 热替换成同名的新实现,来源相同(换实现不换 server)。
if err := r.ReplaceTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("ReplaceTool: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Origin != origin {
t.Fatalf("热替换后来源章 = %+v, want %+v(ReplaceTool 重建条目,必须重新盖章)", cap.Origin, origin)
}
}
// TestRegisterToolLeavesBuiltinOriginZero:宿主内置工具(没实现 OriginAware)
// 的条目**没有**来源章。它是「键缺席」的根据 —— 读面据此省略 origin,而不是编一个。
func TestRegisterToolLeavesBuiltinOriginZero(t *testing.T) {
r := New()
if err := r.RegisterTool(&stubTool{name: "web_search", desc: "内置"}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
cap, err := r.Get("web_search")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Origin.IsZero() {
t.Fatalf("内置工具却带来源章 %+v:没有 OriginAware 就不得编一个来源", cap.Origin)
}
}
// TestSetPolicyKeepsOrigin:策略写路径不受来源章影响,也不许把它覆盖掉。
// 这是「tool_policies / 权限闸零影响」的断言 —— 策略仍按**名字**存,
// 来源章只在条目上,两者互不干涉。
func TestSetPolicyKeepsOrigin(t *testing.T) {
r := New()
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := r.RegisterTool(originTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
if err := r.SetAllowInGroup("read_file_mcp", true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
cap, err := r.Get("read_file_mcp")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Fatalf("策略 = %+v, want 只有 AllowInGroup=true(按名字写的策略行)", cap.Policy)
}
if cap.Origin != origin {
t.Fatalf("改策略把来源章冲掉了:%+v, want %+v", cap.Origin, origin)
}
}
+28
View File
@@ -58,6 +58,12 @@ type Capability struct {
// Policy is the editable reachability markers for this capability. It // Policy is the editable reachability markers for this capability. It
// defaults to zero, which the policy engine reads as owner-only. // defaults to zero, which the policy engine reads as owner-only.
Policy ToolPolicy Policy ToolPolicy
// Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点
// RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 ——
// 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面
// (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。
Origin sdk.Origin
// Value is the underlying capability object. For KindTool/KindSkill/ // Value is the underlying capability object. For KindTool/KindSkill/
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and // KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
// KindIoT it is owned by the relevant subsystem. // KindIoT it is owned by the relevant subsystem.
@@ -225,6 +231,13 @@ func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) e
} }
// RegisterTool registers t as a KindTool capability. // RegisterTool registers t as a KindTool capability.
//
// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装
// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的
// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章
// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。
// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。
func (r *Registry) RegisterTool(t sdk.Tool) error { func (r *Registry) RegisterTool(t sdk.Tool) error {
if t == nil { if t == nil {
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument) return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
@@ -233,10 +246,21 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
Name: t.Name(), Name: t.Name(),
Kind: KindTool, Kind: KindTool,
Description: t.Description(), Description: t.Description(),
Origin: originOf(t),
Value: t, Value: t,
}) })
} }
// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时
// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。
func originOf(t sdk.Tool) sdk.Origin {
origin, ok := sdk.OriginOf(t)
if !ok {
return sdk.Origin{}
}
return origin
}
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5: // ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
// 面板保存后下一轮即用新接线,不必重启)。 // 面板保存后下一轮即用新接线,不必重启)。
// //
@@ -270,6 +294,10 @@ func (r *Registry) ReplaceTool(t sdk.Tool) error {
Description: t.Description(), Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。 // Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy, Policy: cur.Policy,
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
Origin: originOf(t),
Value: t, Value: t,
} }
return nil return nil
+11 -2
View File
@@ -3,6 +3,8 @@ package restrict
import ( import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端 // SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
} }
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」, // sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。 // 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
const sensitiveSummaryRunes = 80 const sensitiveSummaryRunes = 80
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。 // SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开 // summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
// 多字节字符);目标为空时只返回动作。 // 多字节字符);目标为空时只返回动作。
//
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
func summarize(action, target string) string { func summarize(action, target string) string {
target = strings.TrimSpace(target) target = trace.RedactText(strings.TrimSpace(target))
if target == "" { if target == "" {
return action return action
} }
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail) t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
} }
} }
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "sk-live-9f8e7d6c5b4a3210"
cases := []struct {
name string
args map[string]any
start string // 摘要应保留的开头(打码不能把摘要打没)
}{
{
name: "terminal",
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
start: "terminal ",
},
{
name: "http",
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
start: "http POST ",
},
}
for _, tc := range cases {
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
if !sensitive {
t.Fatalf("%s 应判定为敏感", tc.name)
}
if strings.Contains(detail, secret) {
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
}
if !strings.Contains(detail, "[redacted]") {
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
}
if !strings.HasPrefix(detail, tc.start) {
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
}
}
}
+110 -95
View File
@@ -13,21 +13,32 @@ import (
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must // It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
// never decide identity: who the sender is was already resolved structurally by the // never decide identity: who the sender is was already resolved structurally by the
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap. // channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
//
// The gate calls it **off the admission path** (see FlowGate.Admit), so an
// implementation may take as long as the model takes; it is bounded by
// reviewTimeout and by the per-window budget, never by the caller's patience.
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error) type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
// GateVerdict reports how the gate classified one inbound message. // GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct { type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream. // Proceed reports whether the message should be delivered downstream.
Proceed bool Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned" // Reason is a short token describing a drop. After M24 it is always
// or "capped". It is empty when Proceed is true. // "rate_limited" (over the sliding window — dropped at the door, the message
// never enters the pending set), because nothing else on the admission path can
// refuse a message: the pending cap evicts the **oldest** entries and the review
// model runs in the background, so neither can reject the message that just
// arrived. The audit hook still reports their drops with "capped" /
// "review_pruned"; those are retained-message bookkeeping, not admissions.
// Reason is empty when Proceed is true.
Reason string Reason string
// DroppedMsg is the message that failed admission (nil when Proceed is true). // DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the // Pruned lists every message the gate dropped during this admission: on the cap
// review path it is the noise the reviewer rejected; on the cap path it is the // path it is the oldest messages evicted to honor the total cap. Each is recorded
// oldest messages evicted to honor the total cap. Each is recorded to the // to the OnPruned audit hook before being dropped, so nothing is lost silently.
// OnPruned audit hook before being dropped, so nothing is lost silently. // Backlog drops made by a background review reach the audit hook with
// "review_pruned" when that review completes, not through this field.
Pruned []UnifiedMessage Pruned []UnifiedMessage
} }
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
MsgPerMin int MsgPerMin int
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6 // MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer // 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
// is invoked to prune noise; with no Reviewer the oldest messages are evicted // is invoked — in the background — to prune noise; with no Reviewer the oldest
// FIFO. Zero disables the cap. // messages are evicted FIFO. Zero disables the cap.
MaxMessages int MaxMessages int
// Window is the sliding window over which the per-minute limits are counted. // Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow. // Zero uses defaultRateWindow.
Window time.Duration Window time.Duration
// Review is the optional review model. When nil the gate enforces the cap with // Review is the optional review model. When nil the gate enforces the cap with
// FIFO eviction instead of model judgment. // FIFO eviction instead of model judgment. It is always called from a background
// goroutine, at most once per conversation per Window, with at most one call in
// flight per gate.
Review Reviewer Review Reviewer
// ReviewPerMin is the hard per-account ceiling on Reviewer calls started per
// Window: the budget that bounds review cost no matter how many conversations
// cross their own threshold in the same window. Zero (or negative) uses
// defaultReviewPerWindow. It bounds calls started, not calls finished, so the
// bound holds independently of model latency.
ReviewPerMin int
// OnPruned is called once per pruned/dropped message with a short reason token // OnPruned is called once per pruned/dropped message with a short reason token
// ("review_pruned" or "capped") so the caller can record an audit trail. // ("review_pruned" or "capped") so the caller can record an audit trail.
// It may be nil (then the caller only sees Pruned on the verdict). // It may be nil (then the caller only sees Pruned on the verdict).
@@ -80,6 +99,13 @@ const (
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard // runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message. // that keeps review from firing on every message.
defaultReviewPct = 80 defaultReviewPct = 80
// defaultReviewPerWindow is the hard per-adapter ceiling on reviewer calls per
// window (6/minute, the same order of magnitude as FlowLimitDefault). Before it
// existed the only bound was "one per conversation per window", which an account
// with many conversations could multiply without limit. At p50 5.3 s per call
// this still allows ~half a minute of review work per minute per account, so it
// bounds the tail without starving normal traffic.
defaultReviewPerWindow = 6
// FlowLimitDefault is the protective default of 6 group messages per minute // FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit. // (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6 FlowLimitDefault = 6
@@ -128,6 +154,10 @@ type flowSession struct {
groupHits []time.Time groupHits []time.Time
directHits []time.Time directHits []time.Time
buffer []queuedMsg buffer []queuedMsg
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
// 每条入站帧都付一次 ~6 s 的模型调用)。
lastReview time.Time lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道 // inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。 // 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
@@ -148,6 +178,14 @@ type flowSession struct {
// //
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。 // 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
// //
// **审查不在准入路径上**:Admit 只读待处理集合里已经存在的结论(限流计数、
// 上一批已完成审查留下的队列),触发条件满足时只把一次审查**交给后台**,因此
// 适配器的读 goroutine 永远不会等模型 —— 修复前它会同步等一次 `noise_review`
// (实测 p50 5.3 s / p95 6.7 s / max 9.2 s),把消息、心跳与探活回执全排在同一条
// 队上(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。后台审查的三重上界:
// 同一适配器至多一次在飞(reviewInFlight)、同一会话每 Window 至多一次
// (lastReview)、整个适配器每 Window 至多 defaultReviewPerWindow 次(reviewHits)。
//
// Owner-private messages are not routed through the gate (the caller bypasses it), // Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here. // so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn / // The gate also carries the per-conversation running-turn slot (AcquireTurn /
@@ -160,11 +198,18 @@ type FlowGate struct {
reviewAt int reviewAt int
window time.Duration window time.Duration
review Reviewer review Reviewer
reviewBudget int
onPruned func(UnifiedMessage, string) onPruned func(UnifiedMessage, string)
now func() time.Time now func() time.Time
mu sync.Mutex mu sync.Mutex
totalHits []time.Time totalHits []time.Time
// reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
// (defaultReviewPerWindow)。
reviewHits []time.Time
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
reviewInFlight bool
sessions map[string]*flowSession sessions map[string]*flowSession
admitted uint64 admitted uint64
seq uint64 seq uint64
@@ -173,11 +218,28 @@ type FlowGate struct {
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can // queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
// be audited and the fate of the just-admitted message be tracked across index // be audited and the fate of the just-admitted message be tracked across index
// shifts (value equality on UnifiedMessage is unsafe because it holds slices). // shifts (value equality on UnifiedMessage is unsafe because it holds slices).
// The sequence also lets a review verdict land on the entries the reviewer actually
// saw, even when the buffer has moved on while the call was in flight.
type queuedMsg struct { type queuedMsg struct {
seq uint64 seq uint64
msg UnifiedMessage msg UnifiedMessage
} }
// ReviewAtFor 返回给定待处理上限对应的审查触发水位(design/12 §6 策略四:
// maxMessages 的 80%,至少 1)。它是 NewFlowGate 内部同一处的口径,导出只为让调用方
// **如实报出生效值**(例如 cmd/cyrene 的逐账号入口流控启动日志),而不是在别处另写
// 一遍 80% 的算法。非正上限表示上限关闭,返回 0(永不触发审查)。
func ReviewAtFor(maxMessages int) int {
if maxMessages <= 0 {
return 0
}
at := maxMessages * defaultReviewPct / 100
if at < 1 {
return 1
}
return at
}
// NewFlowGate builds a gate from opts. Options that are zero disable their limit, // NewFlowGate builds a gate from opts. Options that are zero disable their limit,
// except Window which defaults to one minute when left zero. // except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate { func NewFlowGate(opts FlowGateOptions) *FlowGate {
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
maxMessages: opts.MaxMessages, maxMessages: opts.MaxMessages,
window: opts.Window, window: opts.Window,
review: opts.Review, review: opts.Review,
reviewBudget: opts.ReviewPerMin,
onPruned: opts.OnPruned, onPruned: opts.OnPruned,
now: opts.Now, now: opts.Now,
sessions: make(map[string]*flowSession), sessions: make(map[string]*flowSession),
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
if g.window <= 0 { if g.window <= 0 {
g.window = defaultRateWindow g.window = defaultRateWindow
} }
if g.maxMessages > 0 { if g.reviewBudget <= 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100 g.reviewBudget = defaultReviewPerWindow
if g.reviewAt < 1 {
g.reviewAt = 1
}
} }
g.reviewAt = ReviewAtFor(g.maxMessages)
if g.now == nil { if g.now == nil {
g.now = time.Now g.now = time.Now
} }
return g return g
} }
// Admit applies the rate limits then the per-conversation cap/review gate to one // Admit applies the rate limits then the per-conversation cap gate to one inbound
// inbound message. It returns a verdict telling the caller whether to proceed; // message. It returns a verdict telling the caller whether to proceed; messages
// messages that the gate drops are returned (and audited) so nothing is lost // that the gate drops are returned (and audited) so nothing is lost silently.
// silently. //
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
// //
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的 // 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。 // flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now() now := g.now()
key := ConversationKey(msg) key := ConversationKey(msg)
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock()
g.maybeGCLocked(now) g.maybeGCLocked(now)
verdict, job := g.admitLocked(msg, key, now)
g.mu.Unlock()
if job != nil {
// 派发在解锁之后:后台审查自己会加锁把结论落回待处理集合,绝不能让它
// 与这次准入抢同一把锁(也绝不能让准入等它)。
go g.runReview(ctx, job)
}
return verdict
}
// admitLocked runs the whole admission decision under g.mu and returns the verdict
// plus, when due, the background review job the caller must launch after unlocking.
// It never calls the Reviewer itself.
func (g *FlowGate) admitLocked(msg UnifiedMessage, key string, now time.Time) (GateVerdict, *reviewJob) {
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin // 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door // 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking. // (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup { if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
st := g.sessionLocked(key) st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now) st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) >= g.groupPerMin { if len(st.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
st.groupHits = append(st.groupHits, now) st.groupHits = append(st.groupHits, now)
} }
if g.msgPerMin > 0 { if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now) g.totalHits = g.trimHits(g.totalHits, now)
if len(g.totalHits) >= g.msgPerMin { if len(g.totalHits) >= g.msgPerMin {
return g.drop("rate_limited", msg) return g.drop("rate_limited", msg), nil
} }
g.totalHits = append(g.totalHits, now) g.totalHits = append(g.totalHits, now)
} }
if g.maxMessages <= 0 { if g.maxMessages <= 0 {
return GateVerdict{Proceed: true} return GateVerdict{Proceed: true}, nil
} }
// 策略四(会话级): add to this conversation's pending set, then prune when it // 策略四(会话级): add to this conversation's pending set, then enforce the cap
// nears the cap. // synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
// 后台落回 —— 准入读的是「当前已有结论」。
st := g.sessionLocked(key) st := g.sessionLocked(key)
g.seq++ g.seq++
seq := g.seq seq := g.seq
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg}) st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg var dropped []queuedMsg
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
keep, err := g.review(ctx, queueMessages(st.buffer))
if err == nil {
st.buffer, dropped = g.applyReview(st.buffer, keep)
st.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(st.buffer) > g.maxMessages { if len(st.buffer) > g.maxMessages {
n := len(st.buffer) - g.maxMessages n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg var evicted []queuedMsg
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
dropped = append(dropped, evicted...) dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped") g.auditDropped(evicted, "capped")
} }
job := g.scheduleReviewLocked(st, key, now)
if !bufferHasSeq(st.buffer, seq) { // 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
reason := "capped" // 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
if containsSeq(dropped, seq) { // (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
reason = "review_pruned" return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
} }
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct // AllowDirect enforces the per-sender sliding-window rate limit for a **direct
@@ -385,15 +451,6 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
} }
} }
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window. // trimHits drops event timestamps older than the sliding window.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time { func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window) cutoff := now.Add(-g.window)
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
return hits[:keep] return hits[:keep]
} }
// applyReview keeps only the entries at the returned indices (validated, de-duped,
// ascending) and returns the kept slice plus the dropped entries in original order.
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
seen := make(map[int]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(buffer) {
seen[i] = true
}
}
out := make([]queuedMsg, 0, len(buffer))
drop := make([]queuedMsg, 0)
for i, entry := range buffer {
if seen[i] {
out = append(out, entry)
} else {
drop = append(drop, entry)
}
}
return out, drop
}
// dropFront removes n entries from the front (oldest) and returns the rest plus the // dropFront removes n entries from the front (oldest) and returns the rest plus the
// dropped ones. // dropped ones.
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) { func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
@@ -437,27 +473,6 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...) return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
} }
// bufferHasSeq reports whether an entry with the given sequence is still buffered
// in the conversation's pending set.
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
for _, e := range buffer {
if e.seq == seq {
return true
}
}
return false
}
// containsSeq reports whether seq appears among the dropped entries.
func containsSeq(dropped []queuedMsg, seq uint64) bool {
for _, e := range dropped {
if e.seq == seq {
return true
}
}
return false
}
// auditDropped records every dropped entry to the OnPruned audit hook. // auditDropped records every dropped entry to the OnPruned audit hook.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) { func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
for _, e := range dropped { for _, e := range dropped {
@@ -0,0 +1,411 @@
package adapter
// 本文件覆盖 M24:把策略四的审查**移出准入路径**,并让节流在失败路径同样生效。
// 每条用例都对应一条可变异点(见 M24 回报里的「预期会红的用例名」)。
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
// auditRecorder 收集 OnPruned 的审计回调。审查结论来自后台 goroutine,因此这里
// 必须加锁(-race 会盯)。
type auditRecorder struct {
mu sync.Mutex
events []auditEvent
}
type auditEvent struct {
content string
reason string
}
func (r *auditRecorder) hook() func(UnifiedMessage, string) {
return func(m UnifiedMessage, reason string) {
r.mu.Lock()
r.events = append(r.events, auditEvent{content: m.Content, reason: reason})
r.mu.Unlock()
}
}
func (r *auditRecorder) snapshot() []auditEvent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]auditEvent(nil), r.events...)
}
func (r *auditRecorder) reasons() []string {
out := make([]string, 0)
for _, e := range r.snapshot() {
out = append(out, e.reason)
}
return out
}
// waitReviewIdle 等到闸门没有在飞的审查调用,使后台结论已经落回待处理集合。
// reviewInFlight 在**派发时**(Admit 持锁期间)就置位,因此 Admit 返回后立刻
// 调用它不会漏掉刚派发的调用。
func waitReviewIdle(t *testing.T, g *FlowGate) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
return
}
if time.Now().After(deadline) {
t.Fatal("reviewer call did not finish within 5s")
}
time.Sleep(time.Millisecond)
}
}
// pendingLen 返回某会话待处理集合的条目数。
func pendingLen(g *FlowGate, key string) int {
g.mu.Lock()
defer g.mu.Unlock()
if st := g.sessions[key]; st != nil {
return len(st.buffer)
}
return 0
}
// keepAll 是「审查者什么都不想丢」的替身:返回全部索引,使待处理集合恒在阈值上,
// 从而让节流/预算成为唯一的抑制因素(否则队列一被修剪就跌破阈值,测不出节流)。
func keepAll(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
keep := make([]int, len(msgs))
for i := range msgs {
keep[i] = i
}
return keep, nil
}
// TestFlowGate_AdmitNeverWaitsForSlowReview 是 M24 的核心证据:一个 3–6 s 量级的
// 慢审查替身压住闸门时,入站帧仍然按节奏被放行 —— 读循环不再被模型调用阻塞。
//
// 变异判据:把审查改回同步调用(在 Admit 里直接 g.review(...))⇒ 帧间隔出现一个
// ≈4 s 的空窗 ⇒ 本用例红。
func TestFlowGate_AdmitNeverWaitsForSlowReview(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
reviewer := func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
select {
case started <- struct{}{}:
default:
}
// 4 s 的慢审查替身:实测 noise_review 的 p50 5.3 s / max 9.2 s 同量级。
select {
case <-release:
case <-time.After(4 * time.Second):
}
return nil, fmt.Errorf("slow stand-in returned")
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 10, Window: time.Minute, Review: reviewer})
defer close(release)
const frames = 100
const cadence = 20 * time.Millisecond
var maxGap time.Duration
var slowestAdmit time.Duration
prev := time.Now()
for i := 0; i < frames; i++ {
admitStart := time.Now()
// 消息与心跳/探活回执在适配器侧走的是同一条 Admit 路,这里逐帧模拟。
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("frame %d dropped by the gate: %+v", i, v)
}
if d := time.Since(admitStart); d > slowestAdmit {
slowestAdmit = d
}
if gap := time.Since(prev); gap > maxGap {
maxGap = gap
}
prev = time.Now()
time.Sleep(cadence)
}
// 先断言「帧没有被拖住」——同步实现会在这里给出可读的红。
if maxGap > 500*time.Millisecond {
t.Fatalf("inbound frames stalled behind the reviewer: max gap %v (cadence %v)", maxGap, cadence)
}
if slowestAdmit > 100*time.Millisecond {
t.Fatalf("Admit waited on the reviewer: slowest admission %v", slowestAdmit)
}
// 审查确实被派发并且**仍在飞**:证明替身是慢的,上面测到的不是「审查根本没跑」。
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("reviewer was never called: the gate did not schedule the review")
}
g.mu.Lock()
busy := g.reviewInFlight
g.mu.Unlock()
if !busy {
t.Fatal("slow reviewer already returned; the stand-in was not slow enough to prove anything")
}
}
// TestFlowGate_ReviewThrottleHoldsOnFailure 固定「节流在失败路径也必须生效」:
// 审查者连续失败时,同一窗口内同一会话只允许一次调用 —— 这正是修复前失守的地方
// (lastReview 只在 err == nil 分支置位,而实测 67–85% 的审查因 BOM 解析失败)。
//
// 变异判据:把 scheduleReviewLocked 里的 `st.lastReview = now` 挪回「成功之后」
// (或删掉)⇒ 11 条消息各派发一次 ⇒ 本用例红。
func TestFlowGate_ReviewThrottleHoldsOnFailure(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return nil, fmt.Errorf("invalid character 'ï' looking for beginning of value")
},
OnPruned: audit.hook(),
})
for i := 0; i < 11; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d dropped although the reviewer only failed: %+v", i, v)
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got != 1 {
t.Fatalf("reviewer calls within one window = %d, want 1 (throttle must cover the failure path)", got)
}
// 失败方向:既不额外丢弃,也不给队列任何条目「保留」特权 —— 只有上限该有的
// "capped" 审计,绝不出现 "review_pruned"。
for _, r := range audit.reasons() {
if r == "review_pruned" {
t.Fatalf("a failed review must not prune anything, got audits %v", audit.reasons())
}
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 4 {
t.Fatalf("pending set = %d, want 4 (cap only, no review pruning)", n)
}
}
// TestFlowGate_ReviewBudgetBoundsCallsPerWindow 把「每适配器每分钟 LLM 审查次数」的
// 硬上界写成可测断言:40 个群在同一窗口内全部越阈,实际调用不超过
// defaultReviewPerWindow(6)。
//
// 变异判据:删掉 reviewBudgetLocked 的调用(或把 reviewBudget 置 0/极大)⇒ 40 次
// ⇒ 本用例红。
func TestFlowGate_ReviewBudgetBoundsCallsPerWindow(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
calls := 0
g := NewFlowGate(FlowGateOptions{
MaxMessages: 4, // reviewAt = 3
Window: time.Minute,
Now: now,
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
calls++
mu.Unlock()
return keepAll(ctx, msgs)
},
})
const groups = 40
for gi := 0; gi < groups; gi++ {
group := fmt.Sprintf("-20%d", gi)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn(group, i))
}
waitReviewIdle(t, g)
}
mu.Lock()
got := calls
mu.Unlock()
if got > defaultReviewPerWindow {
t.Fatalf("reviewer calls in one window = %d, want <= %d (per-adapter budget)", got, defaultReviewPerWindow)
}
if got != defaultReviewPerWindow {
t.Fatalf("reviewer calls = %d, want exactly %d (every conversation crossed, budget is the only cap)", got, defaultReviewPerWindow)
}
}
// TestFlowGate_ReviewSingleInFlightPerAdapter 固定「同一适配器至多一次在飞」:
// 两个群同时越阈,第二个必须让位,不并发起第二次模型调用。
//
// 变异判据:删掉 scheduleReviewLocked 里的 reviewInFlight 判断 ⇒ 第二个群也起一次
// 调用 ⇒ 本用例红。
func TestFlowGate_ReviewSingleInFlightPerAdapter(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan string, 4)
reviewer := func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
started <- msgs[0].Content
<-release
return keepAll(context.Background(), msgs)
}
g := NewFlowGate(FlowGateOptions{MaxMessages: 4, Window: time.Minute, Review: reviewer})
defer close(release)
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-100", i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first review never started")
}
for i := 0; i < 4; i++ {
g.Admit(context.Background(), groupMsgIn("-200", i))
}
select {
case extra := <-started:
t.Fatalf("second review started while one was in flight (%q)", extra)
case <-time.After(100 * time.Millisecond):
}
}
// TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall 固定后台审查的落点:结论只作用于
// 审查者**当时看到的那批**(用序号对齐)。审查在飞期间新到的消息没被审查过,绝不能被
// 这次结论连带修剪 —— 否则审计会记出「review_pruned」却都是已经放行的消息。
//
// 变异判据:把 applyReviewSeqs 写成「不在 keep 集合里就丢」(不做快照过滤)⇒ 在飞期间
// 到达的两条也被丢 ⇒ 本用例红。
func TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall(t *testing.T) {
t.Parallel()
release := make(chan struct{})
started := make(chan struct{}, 1)
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, // reviewAt = 8
Window: time.Minute,
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
started <- struct{}{}
<-release
return []int{0}, nil // 只保留快照里的第 0 条,其余视为噪声
},
OnPruned: audit.hook(),
})
for i := 0; i < 8; i++ {
g.Admit(context.Background(), groupMsg(i))
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("review never started")
}
// 审查在飞期间又到两条(seq 9、10):它们不在这次审查的快照里。
g.Admit(context.Background(), groupMsg(8))
g.Admit(context.Background(), groupMsg(9))
close(release)
waitReviewIdle(t, g)
// 快照第 0 条(msg 0)保留 + 在飞期间到达的两条 = 3。
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 3 {
t.Fatalf("pending set = %d, want 3 (msg0 + the two messages the reviewer never saw)", n)
}
for _, e := range audit.snapshot() {
if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
}
if e.content == "msg 8" || e.content == "msg 9" {
t.Fatalf("a message that arrived during the call was pruned: %q", e.content)
}
}
if got := len(audit.snapshot()); got != 7 {
t.Fatalf("review_pruned count = %d, want 7 (snapshot entries 1..7)", got)
}
}
// TestFlowGate_CapStillEvictsOnAdmission 固定上限那一半仍是**同步**的:队列超出
// maxMessages 时最旧的条目在准入当次就被挤掉并审计 "capped",不依赖审查。
//
// 变异判据:把上限修剪也挪进后台 ⇒ v.Pruned 为空 ⇒ 本用例红。
func TestFlowGate_CapStillEvictsOnAdmission(t *testing.T) {
t.Parallel()
audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
Review: keepAll,
OnPruned: audit.hook(),
})
for i := 0; i < 3; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should fit under the cap: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(3))
if !v.Proceed {
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
}
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
}
found := false
for _, r := range audit.reasons() {
if r == "capped" {
found = true
}
}
if !found {
t.Fatalf("cap eviction not audited as capped: %v", audit.reasons())
}
}
// TestFlowGate_RateLimitDropsAtTheDoor 固定 rate_limited 的「门口丢」语义一字不改:
// 被滑窗挡下的消息根本不进待处理集合,审查也绝不会把它「捞回来」。
func TestFlowGate_RateLimitDropsAtTheDoor(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
g := NewFlowGate(FlowGateOptions{
GroupMsgPerMin: 2,
MaxMessages: 3,
Window: time.Minute,
Now: now,
Review: keepAll,
})
for i := 0; i < 2; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should pass the group window: %+v", i, v)
}
}
v := g.Admit(context.Background(), groupMsg(2))
if v.Proceed {
t.Fatal("third message in the window must be rate limited")
}
if v.Reason != "rate_limited" {
t.Fatalf("reason = %q, want rate_limited", v.Reason)
}
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 2" {
t.Fatalf("dropped msg = %+v, want msg 2", v.DroppedMsg)
}
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2: a rate-limited message never enters it", n)
}
waitReviewIdle(t, g)
}
+100
View File
@@ -0,0 +1,100 @@
package adapter
import (
"encoding/json"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
// encoding/json 见到它报 `invalid character 'ï' looking for beginning of value` ——
// 实测 9 天 1,909 次审查里 1,288 次(67%)因此解析失败,每一次都白烧一次模型调用
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
const utf8BOM = "\ufeff"
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
// 消息内容带进日志。
//
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
const reviewExcerptLen = 40
// ParseReviewIndices decodes the keep-index array a review model returned, rising
// above the shapes real gateways produce instead of requiring a bare JSON array:
//
// - a UTF-8 BOM prefix (`\ufeff`) — the actual cause of the 67% failure rate;
// - leading/trailing whitespace and newlines;
// - a Markdown code fence (` ```json … ``` ` or ` ``` … ``` `);
// - surrounding prose ("好的,保留以下编号:[0, 2]") — the outermost `[ … ]`
// span is used.
//
// The decoded indices are validated the same way the previous strict parser did:
// out-of-range and duplicate values are dropped and the rest sorted ascending, so a
// hallucinated number can never evict a real message. An empty or absent array is
// NOT an error here — the caller decides; a missing/non-array answer is.
func ParseReviewIndices(s string, bound int) ([]int, error) {
body, ok := reviewArraySpan(s)
if !ok {
return nil, fmt.Errorf("no JSON array in reviewer output %q", reviewExcerpt(s))
}
var idx []int
if err := json.Unmarshal([]byte(body), &idx); err != nil {
return nil, fmt.Errorf("decode reviewer indices %q: %w", reviewExcerpt(body), err)
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
}
// reviewArraySpan strips the decorations a model puts around its answer (BOM,
// whitespace, code fence, prose) and returns the outermost JSON array span.
func reviewArraySpan(s string) (string, bool) {
s = strings.TrimSpace(strings.TrimPrefix(s, utf8BOM))
s = strings.TrimSpace(unwrapCodeFence(s))
start := strings.IndexByte(s, '[')
end := strings.LastIndexByte(s, ']')
if start < 0 || end < start {
return "", false
}
return s[start : end+1], true
}
// unwrapCodeFence drops a leading ``` fence line and the closing ``` so the fenced
// body is left for the array scan. Anything without a leading fence is returned
// unchanged; a fence with no body is returned as-is and fails the array scan.
func unwrapCodeFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
nl := strings.IndexByte(s, '\n')
if nl < 0 {
return s
}
s = s[nl+1:]
if close := strings.LastIndex(s, "```"); close >= 0 {
s = s[:close]
}
return s
}
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
//
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
// 半个,错误串因此变成非法 UTF-8;
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
func reviewExcerpt(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
}
@@ -0,0 +1,171 @@
package adapter
// 本文件用**对端原样输出**(含 UTF-8 BOM 字节)做夹具,固定审查输出的解析韧性。
// 生产症状(`~/cy.log`,9 天):`flow gate review output unparsable` 1,301 次,其中
// 1,288 次的错误串逐字是 `invalid character 'ï' looking for beginning of value` ——
// 对端在 JSON 数组前带了 BOM,而闸门用的 json.Unmarshal 不接受它。
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
// JSON 数组。写死字节而不是用 \ufeff,是为了让「夹具被谁悄悄去掉 BOM」本身就会红。
const bomReviewFixture = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
// TestReviewBOMFixtureStillReproducesTheFailure 先钉住夹具的真实性:它必须以 BOM
// 开头,且严格的 json.Unmarshal 必须在它上面失败,错误串必须就是生产日志里那条。
//
// 变异判据:把夹具里的 BOM 去掉 ⇒ 本用例红(同时也说明 BOM 相关用例已失去意义)。
func TestReviewBOMFixtureStillReproducesTheFailure(t *testing.T) {
t.Parallel()
if !strings.HasPrefix(bomReviewFixture, "\xef\xbb\xbf") {
t.Fatal("BOM fixture lost its BOM bytes: it no longer reproduces the production failure")
}
var idx []int
err := json.Unmarshal([]byte(bomReviewFixture), &idx)
if err == nil {
t.Fatal("strict json.Unmarshal accepted the BOM fixture: the regression it models is gone")
}
if !strings.Contains(err.Error(), "invalid character 'ï'") {
t.Fatalf("strict parse error = %v, want the production symptom \"invalid character 'ï'\"", err)
}
}
// TestParseReviewIndices_ToleratesBOM 是修复的正面证据:同一份带 BOM 的原样回包
// 现在能解析出编号。
//
// 变异判据:去掉 ParseReviewIndices 里的 reviewArraySpan(回到裸 json.Unmarshal)
// ⇒ 本用例红。
func TestParseReviewIndices_ToleratesBOM(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices(bomReviewFixture, 6)
if err != nil {
t.Fatalf("ParseReviewIndices(BOM fixture) error = %v, want nil", err)
}
want := []int{0, 1, 2, 3, 4, 5}
if len(keep) != len(want) {
t.Fatalf("keep = %v, want %v", keep, want)
}
for i := range want {
if keep[i] != want[i] {
t.Fatalf("keep = %v, want %v", keep, want)
}
}
}
// TestParseReviewIndices_ToleratesDecorations 覆盖模型常见的几种外壳:首尾空白与换行、
// Markdown 代码围栏(带或不带语言标签)、以及围在数组外面的解说文字。
func TestParseReviewIndices_ToleratesDecorations(t *testing.T) {
t.Parallel()
cases := map[string]string{
"newlines": "\n\n[0, 2]\n\n",
"fence": "```\n[0, 2]\n```",
"fence with lang": "```json\n[0, 2]\n```",
"prose prefix": "好的,保留以下编号:[0, 2]",
"prose suffix": "[0, 2] 以上是保留的编号。",
"bom plus fence": "\xef\xbb\xbf```json\n[0, 2]\n```",
"spaced values": "[ 0 , 2 ]",
}
for name, raw := range cases {
keep, err := ParseReviewIndices(raw, 3)
if err != nil {
t.Fatalf("%s: error = %v, want nil", name, err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("%s: keep = %v, want [0 2]", name, keep)
}
}
}
// TestParseReviewIndices_FiltersHallucinations 固定过滤口径与修复前一致:越界、负数、
// 重复的编号一律丢弃(模型幻觉出的数字绝不能淘汰真实消息),结果升序。
func TestParseReviewIndices_FiltersHallucinations(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[2, 2, 99, -1, 0]", 3)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
t.Fatalf("keep = %v, want [0 2]", keep)
}
}
// TestParseReviewIndices_EmptyArrayIsNotAnError:模型明确回空数组 = 「一条都不保留」,
// 这是有效结论(全部当噪声),不是解析失败 —— 失败方向必须留给真正的畸形输出。
func TestParseReviewIndices_EmptyArrayIsNotAnError(t *testing.T) {
t.Parallel()
keep, err := ParseReviewIndices("\xef\xbb\xbf[]", 5)
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
if len(keep) != 0 {
t.Fatalf("keep = %v, want empty", keep)
}
}
// TestParseReviewIndices_RejectsGarbage:真正畸形的输出仍然报错(调用方据此走
// 「不额外丢弃、也不提权」的安全方向),错误里带一小段原文便于定位下一次变种。
func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
t.Parallel()
for name, raw := range map[string]string{
"no array": "抱歉,我无法判断。",
"empty": "",
"array of str": `["a", "b"]`,
} {
if _, err := ParseReviewIndices(raw, 5); err == nil {
t.Fatalf("%s: error = nil, want a decode error", name)
}
}
}
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
//
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
// (utf8.ValidString 为 false)。
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
t.Parallel()
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
if !utf8.ValidString(got) {
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
}
body := strings.TrimSuffix(got, "…")
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("超限时应当带省略号:%q", got)
}
}
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
//
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
t.Parallel()
const secret = "sk-abcdefghijklmnop"
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
if strings.Contains(got, secret) {
t.Fatalf("凭据形状没被打码:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("打码标记缺失:%q", got)
}
if !strings.Contains(got, "[0, 1]") {
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
}
}
+171
View File
@@ -0,0 +1,171 @@
package adapter
// 本文件是策略四的「审查」半边:把它从准入路径上摘下来的派发器(reviewJob /
// scheduleReviewLocked / runReview)与三重上界(同适配器在飞、每会话窗口、每适配器
// 窗口预算)。解析韧性在 flowgate_parse.go,准入判定仍在 flowgate.go。
//
// 为什么必须是后台:`noise_review` 实测 p50 5,294 ms / p95 6,651 ms / max 9,204 ms
// (`~/cy.log`,9 天 1,900 次),而五路入站里 qq-ws、kook-ws、weixin 长轮询都是
// 「单读 goroutine + 同步派发」—— 同步跑在读循环上等于把消息、心跳、探活回执全排在
// 一次 6 秒的模型调用后面(DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
//
// # 与修复前的语义差异(哪一条变了、哪一条没变)
//
// - `rate_limited`(策略三,门口丢):**一字不改**。超窗消息在准入当次就被拒绝、
// 根本不进待处理集合,也不经过审查。
// - 上限修剪(策略四的硬部分):**一字不改**。待处理集合超过 maxMessages 时,最旧的
// 条目仍在**准入当次同步**被挤掉并审计为 "capped";触发水位仍是上限的 80%。
// - 审查修剪(策略四的模型部分):**判据不变,时机变了**。触发条件(≥80% 上限、
// 同一会话每窗口至多一次、返回保留编号、越界/重复编号丢弃)逐条沿用;变的是结论
// 落地时点 —— 它不再决定「触发它的那条消息」是否放行,而是修剪**它当时看到的**
// 那批历史条目(审计为 "review_pruned")。
// 直接后果:`review_pruned` 不再从门口丢消息(实测原本 41–74 条/日),那些消息
// 现在会进入流水线 —— 完整性↑、噪声↑,是否开口仍由 engage_judge / 冷避 / owner
// 闸判(这三处判据一行未动)。
//
// 为什么这个差异可以接受:design/12 §6 策略四给审查的职责是「保护 loop 不被不重要的
// 消息塞满」的历史积压去噪,不是逐条消息的准入判决;把一次 6 秒的模型调用钉在准入
// 路径上,代价(读循环阻塞 → 消息/心跳/探活全排队)远大于收益(少进 41–74 条噪声/日)。
// 真正决定「进不进 loop」的两道判据 —— 限流与上限 —— 都留在准入路径上,且未改口径。
import (
"context"
"time"
)
const (
// reviewTimeout bounds one background reviewer call. The in-flight slot is held
// for the whole call, so an unbounded call would also freeze every other
// conversation's review for that account. 上一版没有上界,最长一次实测 9.2 s;
// 30 s 留足余量而不至于让一次挂死的调用长期占位。
reviewTimeout = 30 * time.Second
)
// reviewJob is one scheduled reviewer call: the snapshot of the pending set the
// reviewer is asked about, plus the sequence numbers that identify those entries
// when the answer comes back (the buffer keeps moving while the call is in flight).
type reviewJob struct {
key string
msgs []UnifiedMessage
seqs []uint64
}
// scheduleReviewLocked reports whether this admission should start a background
// review and, when it should, marks that review as started and returns its job.
//
// 三重上界都在这里(调用方须持有 g.mu):
// 1. 同一适配器至多一次在飞(reviewInFlight)—— 后台审查有界,不会因为多个群
// 同时越阈而并发起 N 个模型调用;
// 2. 同一会话每 window 至多一次(lastReview)—— 修复前它只在成功分支置位,
// 于是 68% 解析失败时「每窗口一次」退化成「每条消息一次」;
// 3. 整个适配器每 window 至多 reviewBudget 次(reviewHits)—— 与模型耗时无关的
// 硬上界,会话再多也乘不上去。
//
// lastReview 在这里(**派发时**)写入,而不是等调用返回:成功与失败两条路径因此
// 同样被节流,这是「每条入站帧都付一次 6 秒」的正面修复。
func (g *FlowGate) scheduleReviewLocked(st *flowSession, key string, now time.Time) *reviewJob {
if g.review == nil || g.maxMessages <= 0 || len(st.buffer) < g.reviewAt {
return nil
}
if g.reviewInFlight || g.reviewRecently(st, now) || !g.reviewBudgetLocked(now) {
return nil
}
st.lastReview = now
g.reviewInFlight = true
g.reviewHits = append(g.reviewHits, now)
return &reviewJob{key: key, msgs: queueMessages(st.buffer), seqs: bufferSeqs(st.buffer)}
}
// runReview executes one scheduled review off the admission path and folds its
// verdict back into the conversation's pending set. It is the only place the
// Reviewer is called.
//
// 失败方向(安全侧,与修复前 `err != nil` 分支同口径):审查失败**既不额外丢弃**
// (失败绝不被解释成「低价值 ⇒ 丢掉」),**也不给任何条目保留特权**(失败绝不被
// 解释成「高价值 ⇒ 提权」);待处理集合原样保留,只由既有的 FIFO 上限兜底。
// 唯一的代价是这一批噪声没被修剪,而窗口节流保证下一次尝试要等到下个窗口。
func (g *FlowGate) runReview(ctx context.Context, job *reviewJob) {
rctx, cancel := reviewContext(ctx)
defer cancel()
keep, err := g.review(rctx, job.msgs)
g.mu.Lock()
g.reviewInFlight = false
var dropped []queuedMsg
if err == nil {
// 会话可能在审查期间被 GC 掉(空闲清理),那时结论无处可落,直接丢弃。
if st := g.sessions[job.key]; st != nil {
st.buffer, dropped = applyReviewSeqs(st.buffer, job.seqs, keep)
}
}
g.mu.Unlock()
// 审计在锁外:钩子由 channelbridge 接(落库/面板),不该占着闸门的锁跑 I/O。
g.auditDropped(dropped, "review_pruned")
}
// reviewContext detaches the reviewer call from the admission caller's cancellation
// — the inbound handler returns the moment the message is admitted, and on several
// adapters its ctx dies with the frame — while still bounding the call so a hung
// model cannot pin the single in-flight slot forever.
func reviewContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(context.WithoutCancel(ctx), reviewTimeout)
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(st.lastReview) < g.window
}
// reviewBudgetLocked enforces the per-adapter ceiling on reviewer calls started in
// the current window. 调用方须持有 g.mu。
func (g *FlowGate) reviewBudgetLocked(now time.Time) bool {
g.reviewHits = g.trimHits(g.reviewHits, now)
return len(g.reviewHits) < g.reviewBudget
}
// applyReviewSeqs keeps only the buffered entries the reviewer chose. Entries are
// matched by sequence number, so:
// - entries evicted by the cap while the call was in flight simply do not appear
// in the buffer any more (they were audited as "capped" at eviction time);
// - entries that arrived **after** the snapshot are never judged by this review
// and always survive (the reviewer never saw them).
func applyReviewSeqs(buffer []queuedMsg, seqs []uint64, keep []int) (kept, dropped []queuedMsg) {
reviewed := make(map[uint64]bool, len(seqs))
for _, s := range seqs {
reviewed[s] = true
}
keepSet := make(map[uint64]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(seqs) {
keepSet[seqs[i]] = true
}
}
kept = make([]queuedMsg, 0, len(buffer))
dropped = make([]queuedMsg, 0)
for _, e := range buffer {
if !reviewed[e.seq] || keepSet[e.seq] {
kept = append(kept, e)
continue
}
dropped = append(dropped, e)
}
return kept, dropped
}
// bufferSeqs extracts the sequence numbers of buffered entries in order, so a
// review verdict can be applied to exactly the entries it was asked about.
func bufferSeqs(buffer []queuedMsg) []uint64 {
out := make([]uint64, len(buffer))
for i, e := range buffer {
out[i] = e.seq
}
return out
}
+53 -30
View File
@@ -2,6 +2,7 @@ package adapter
import ( import (
"context" "context"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -118,10 +119,16 @@ func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
} }
} }
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) { // TestFlowGate_ReviewPrunesBacklogAfterAdmission 固定策略四的新口径:审查**不在准入
// 路径上**。越阈那一条消息照常放行(它的到达只负责派发审查),审查完成后只修剪
// **它当时看到的那批**待处理条目,并以 "review_pruned" 入审计。
//
// 变异判据:把审查改回在 Admit 里同步执行并按结论丢「刚到的那条」⇒ 本用例的
// `v.Proceed` 断言红。
func TestFlowGate_ReviewPrunesBacklogAfterAdmission(t *testing.T) {
t.Parallel() t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0)) now, _ := fixedClock(time.Unix(1000, 0))
var audited []string audit := &auditRecorder{}
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 10, MaxMessages: 10,
Window: time.Minute, Window: time.Minute,
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
// Keep only the first two messages; drop the rest as noise. // Keep only the first two messages; drop the rest as noise.
return []int{0, 1}, nil return []int{0, 1}, nil
}, },
OnPruned: func(_ UnifiedMessage, reason string) { OnPruned: audit.hook(),
audited = append(audited, reason)
},
}) })
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires. // Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
@@ -141,69 +146,87 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
t.Fatalf("message %d should be admitted before threshold: %+v", i, v) t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
} }
} }
if len(audited) != 0 { waitReviewIdle(t, g)
t.Fatalf("review fired below threshold: %v", audited) if n := len(audit.snapshot()); n != 0 {
t.Fatalf("review fired below threshold: %d audits", n)
} }
// The 8th message crosses the 80% threshold → review runs and drops msg2..7, // The 8th message crosses the 80% threshold → the review is dispatched in the
// including the just-admitted message 7. // background; the message itself is still admitted (admission never waits).
v := g.Admit(context.Background(), groupMsg(7)) v := g.Admit(context.Background(), groupMsg(7))
if v.Proceed { if !v.Proceed {
t.Fatal("just-admitted message 7 must be pruned by the reviewer") t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
} }
if v.Reason != "review_pruned" { if v.Reason != "" {
t.Fatalf("reason = %q, want review_pruned", v.Reason) t.Fatalf("reason = %q, want empty", v.Reason)
} }
if len(v.Pruned) != 6 { waitReviewIdle(t, g)
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
// The review keeps 0 and 1, so msg2..7 are pruned from the pending set and audited.
if n := len(audit.snapshot()); n != 6 {
t.Fatalf("audit count = %d, want 6 (msg2..msg7)", n)
} }
if len(audited) != 6 { for _, e := range audit.snapshot() {
t.Fatalf("audit count = %d, want 6", len(audited)) if e.reason != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
} }
for _, r := range audited {
if r != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", r)
} }
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
t.Fatalf("pending set = %d, want 2 (only the kept entries)", n)
} }
} }
// TestFlowGate_ReviewThrottledWithinWindow 固定「每会话每窗口至多一次审查」。
// 审查者保留全部条目,使待处理集合恒在阈值上 —— 于是节流(而不是队列长度)是唯一的
// 抑制因素;这正是修复前失守的那条判据(lastReview 只在成功分支置位)。
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) { func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
t.Parallel() t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0)) now, clock := fixedClock(time.Unix(1000, 0))
var mu sync.Mutex
reviews := 0 reviews := 0
g := NewFlowGate(FlowGateOptions{ g := NewFlowGate(FlowGateOptions{
MaxMessages: 3, MaxMessages: 3,
Window: time.Minute, Window: time.Minute,
Now: now, Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) { Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
mu.Lock()
reviews++ reviews++
return []int{0}, nil mu.Unlock()
return keepAll(ctx, msgs)
}, },
}) })
count := func() int {
mu.Lock()
defer mu.Unlock()
return reviews
}
// First threshold crossing triggers a review. // First threshold crossing triggers a review.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews) if got := count(); got != 1 {
t.Fatalf("reviews after crossing threshold = %d, want 1", got)
} }
// Within the same window the gate must not re-review even when it refills. // Within the same window the gate must not re-review even when it refills.
for i := 3; i < 6; i++ { for i := 3; i < 9; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 1 { waitReviewIdle(t, g)
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews) if got := count(); got != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
} }
// A fresh window allows a review again. // A fresh window allows a review again.
*clock = clock.Add(2 * time.Minute) *clock = clock.Add(2 * time.Minute)
for i := 6; i < 9; i++ { for i := 9; i < 12; i++ {
g.Admit(context.Background(), groupMsg(i)) g.Admit(context.Background(), groupMsg(i))
} }
if reviews != 2 { waitReviewIdle(t, g)
t.Fatalf("reviews after window roll = %d, want 2", reviews) if got := count(); got != 2 {
t.Fatalf("reviews after window roll = %d, want 2", got)
} }
} }
+81 -13
View File
@@ -5,9 +5,16 @@
// future platforms; existing adapters (Telegram, OneBot) that already carry // future platforms; existing adapters (Telegram, OneBot) that already carry
// their own transport are left untouched. // their own transport are left untouched.
// //
// It depends only on the standard library and internal/logger. The auth token is // It depends only on the standard library, internal/logger and internal/runtime/trace.
// never logged or surfaced in errors. The Authorization scheme is configurable // The auth token is never logged or surfaced in errors. The Authorization scheme is
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot"). // configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
//
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
// request body into it, and that body holds what she said. It is therefore **not**
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
// same rule — code/status stay, free text goes — applies to every channel adapter's own
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
// upstream_text.go.
package httpclient package httpclient
import ( import (
@@ -23,6 +30,7 @@ import (
"strings" "strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or // ErrAPI is the sentinel returned when the remote API reports a non-zero code or
@@ -39,6 +47,9 @@ type Client struct {
token string token string
authScheme string authScheme string
log *slog.Logger log *slog.Logger
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
keepEnvelopeMessage bool
} }
// Option customizes a Client at construction time. // Option customizes a Client at construction time.
@@ -80,6 +91,23 @@ func WithLogger(l *slog.Logger) Option {
} }
} }
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
//
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
//
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
// 「一屏摘要」的诉求,短到装不下一句正文。
//
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
func WithEnvelopeMessage() Option {
return func(c *Client) { c.keepEnvelopeMessage = true }
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via // New builds a Client. With no base URL the caller must pass absolute URLs (via
// the Do/Get helpers' path being an absolute URL). // the Do/Get helpers' path being an absolute URL).
func New(opts ...Option) *Client { func New(opts ...Option) *Client {
@@ -129,7 +157,13 @@ func (c *Client) resolve(path string) string {
} }
// do performs the request, injecting auth and decoding the envelope. // do performs the request, injecting auth and decoding the envelope.
//
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) { func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
host := trace.HostOf(target)
var buf bytes.Buffer var buf bytes.Buffer
if body != nil { if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil {
@@ -138,7 +172,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
} }
req, err := http.NewRequestWithContext(ctx, method, target, &buf) req, err := http.NewRequestWithContext(ctx, method, target, &buf)
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err) return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, trace.URLCause(err))
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
@@ -152,16 +186,16 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err) return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, trace.URLCause(err))
} }
defer resp.Body.Close() defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil { if err != nil {
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err) return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI) return nil, c.apiFailure(method, host, resp, raw)
} }
var envelope APIEnvelope var envelope APIEnvelope
@@ -169,15 +203,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
return raw, nil return raw, nil
} }
if envelope.Code != 0 { if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI) if detail := c.envelopeDetail(envelope.Message); detail != "" {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, detail, ErrAPI)
}
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
} }
return envelope.Data, nil return envelope.Data, nil
} }
// truncate caps a byte slice to 512 bytes for inclusion in an error message. // apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
func truncate(b []byte) string { // 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
if len(b) > 512 { // 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
return strings.TrimSpace(string(b[:512])) + "…" // 报文」这类相关性判断。
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err == nil {
detail := c.envelopeDetail(envelope.Message)
switch {
case envelope.Code != 0 && detail != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, detail, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case detail != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
} }
return strings.TrimSpace(string(b)) }
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
//
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
//
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
// + 截到 40 rune)。
func (c *Client) envelopeDetail(message string) string {
if !c.keepEnvelopeMessage {
return ""
}
return upstreamText(message)
} }
@@ -0,0 +1,234 @@
package httpclient
// 本文件钉住本客户端的出镜边界(M41 #10/#11 那一族,M50 补信封 message):
//
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
// 什么。现在只留状态码、来源主机,以及信封的 code(message 默认不带)。
// - **信封 message 默认不带**:上游会用它在被拒时回显请求正文(也就是她的话)。
// 要带必须显式 WithEnvelopeMessage(),那时仍过 upstreamText(凭据打码 + 40 rune)。
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
//
// 每条用例都是「把它改回去就变红」的形状:断言的是**错误文本**,也就是调用方实际
// 打进日志的那段字符串。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
// 是因为只有传输层失败才会让 net/http 包出 **带完整地址** 的 *url.Error ——
// 那正是要钉住的泄漏形态。
type failingTransport struct{}
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp: connection refused")
}
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
//
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留 code(message
// 默认不带),非信封体则一个字节都不留。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-4XX-BODY"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html><body>bad gateway " + sentinel + "</body></html>"))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
if strings.Contains(err.Error(), sentinel) {
t.Fatalf("响应体原文进了错误(会随调用方落日志):%v", err)
}
// 可诊断性不能一起丢:来源主机与状态码必须还在,且带得出「体有多大」。
host, _, ok := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if !ok || !strings.Contains(err.Error(), host) {
t.Fatalf("错误里没有来源主机 %q:%v", host, err)
}
if !strings.Contains(err.Error(), "status=502") {
t.Fatalf("错误里没有状态码:%v", err)
}
if !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("错误里没有响应体体积(相关性判断要用):%v", err)
}
}
// TestDoJSON_StatusErrorKeepsEnvelopeCode 非 2xx 的信封体仍要给出 code:这是本客户端
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。而 message 是上游自由文本面,
// **默认不带**(M50 口径)—— 上游会用它在被拒时回显她的话。
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("400 却报成功")
}
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "status=400") {
t.Fatalf("信封的 code / 上游 status 丢了:%v", err)
}
// 哨兵出现在 message 里 ⇒ 错误与日志都不得带它(变异:把 envelopeDetail 改回
// 无条件 upstreamText(message) ⇒ 本断言立刻红)。
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("send failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("信封 message(上游回显的她的原话)进了错误或日志:%s", surface)
}
}
// 哨兵链不变:分类靠 errors.Is,不靠文本。
if !errors.Is(err, ErrAPI) || !errors.Is(err, trace.ErrUpstream) {
t.Fatalf("err = %v, want both ErrAPI and trace.ErrUpstream", err)
}
}
// TestDoJSON_NonZeroCodeDropsEnvelopeMessage 是 2xx + `code != 0` 这**另一个分支**:
// 它此前把 message 直接拼进错误(M49 探针复现的原文里 `code 1: invalid content: …`
// 走的就是跨过 apiFailure 的这一支)。code 必须留下,message 必须不带。
func TestDoJSON_NonZeroCodeDropsEnvelopeMessage(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 200 + 非零 code:KOOK 一类平台的「业务失败也 200」形态。
_, _ = w.Write([]byte(`{"code":1,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"content": "她刚才说的那句话"})
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), "她刚才说的那句话") {
t.Fatalf("200 + code!=0 分支仍把 message 拼进错误:%v", err)
}
if !strings.Contains(err.Error(), "code 1") {
t.Fatalf("2xx 分支的 code 丢了(那一条没别的分类信息):%v", err)
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
}
// TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets opt-in(WithEnvelopeMessage)是
// 唯一能带 message 的通道:带上的那一刻仍必须清洗凭据形状。
func TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets(t *testing.T) {
t.Parallel()
const secret = "big-secret-value-123456"
body := `{"code":40101,"message":"access_token=` + secret + `","data":null}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithToken(secret), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("opt-in 的信封 message 没有清洗凭据形状:%v", err)
}
if !strings.Contains(err.Error(), "[redacted]") {
t.Fatalf("opt-in 生效后错误里没有 message(无法证明这条通道还活着):%v", err)
}
}
// TestDoJSON_EnvelopeMessageOptInIsBounded 钉住 opt-in 下 message 的长度口径:40 rune
// (maxUpstreamTextRunes)+ 省略号。这条让「到底哪一层在截、截到多少」变成可断言的事实 ——
// 上界刻意比 trace.RedactText 的 80 更短(错误里那句话是提示,不是摘要)。
func TestDoJSON_EnvelopeMessageOptInIsBounded(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes)+"…") {
t.Fatalf("信封 message 没有按 %d rune 截断:%v", maxUpstreamTextRunes, err)
}
if strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes+1)) {
t.Fatalf("信封 message 超过了 %d rune 上限:%v", maxUpstreamTextRunes, err)
}
}
// TestGetJSON_TransportErrorOmitsTargetCredentials 传输层失败时,错误里不得出现目标
// 地址(含 query 里的凭据)—— net/http 会把整条 URL 放在 *url.Error 里。
func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
t.Parallel()
const secret = "QUERY-SECRET-VALUE"
c := New(
WithBaseURL("http://api.example.com"),
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"access_token": {secret}})
if err == nil {
t.Fatal("传输层失败却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("目标地址的凭据进了错误:%v", err)
}
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
}
}
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
//
// 断言用户名而不只是密码:net/http 自己在 *url.Error 里就把密码打成 xxxxx,因此
// 「密码不出现」在剥壳与不剥壳两种实现下都成立(这样的用例是空跑);用户名才是
// 剥壳与不剥壳的分水岭。
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
t.Parallel()
c := New(
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
WithLogger(discardLogger()),
)
_, err := c.GetJSON(context.Background(), "https://leakuser:leakpass@api.example.com/x", nil)
if err == nil {
t.Fatal("传输层失败却报成功")
}
msg := err.Error()
if strings.Contains(msg, "leakuser") || strings.Contains(msg, "leakpass") {
t.Fatalf("user:pass@ 进了错误:%v", msg)
}
if !strings.Contains(msg, "api.example.com") {
t.Fatalf("剥掉地址时把来源一起丢了:%v", msg)
}
}
@@ -0,0 +1,48 @@
package httpclient
// 本文件是**渠道侧上游自由文本的唯一清洗入口**(M50)。它管的是同一件事在四个出口上
// 的形态:KOOK 一类 `{code,message}` 信封的 `message`、Telegram Bot API 的
// `description`、OneBot v11 信封的 `msg` / `wording`、iLink CDN 的 `x-error-message`
// 头。四者都是上游给的**人话**,都可能把被拒的请求内容回显出来 —— 而请求内容里就有
// 她的原话。
//
// 🔴 契约(改本文件前先读):
// - 上层口径是 **默认不带**:这些字段默认不进 error、不进日志(见 client.go 的
// WithEnvelopeMessage 与各渠道调用点的注释)。本文件的函数**只在调用方已经显式
// 选择保留**时才被调用 —— 它是「保留时的上限」,不是「可以带」的许可。
// - 清洗只有一份:`trace.RedactTextLimited`(凭据形状打码 + rune 截断)。本包**不**
// 另写一套正则或截断,另写一套必然漂移,而漂移的方向总是「其中一条出口开始原样
// 吐凭据」。
// - 压平空白也是必需的一步:一条诊断只许占一行,否则上游可以用换行伪造日志行
// (log injection)。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxUpstreamTextRunes 是上游自由文本允许保留的 rune 上限:**40**。
//
// 为什么比 `trace.RedactText` 的 80 更短:那一档服务的是**摘要素材**(出图、工具结果
// 摘要),读者只看这一条;本文件的产物进的是**错误链与日志**,同一条信息还会被调用方
// 再落一次,长度预算没有理由按摘要给。而 40 rune 已足够装下渠道侧真正有用的那类说明
// (`invalid token`、`参数错误`、`缺少 code`、`FLOOD_WAIT_30`),又短到装不下一句她
// 说的话 —— 上游回显 `invalid content: <正文>` 时,前半句就吃掉大半预算。
const maxUpstreamTextRunes = 40
// upstreamText 把一段上游自由文本收敛成可以出镜的短诊断:压平空白,再交给
// `trace.RedactTextLimited` 打码凭据形状并截到 maxUpstreamTextRunes(超出的带省略号)。
//
// 空串进 ⇒ 空串出;调用方据此判断「这一段有没有东西可显示」。
//
// 🔴 它**不**决定该不该带(默认不带)。把它的结果无条件拼进 error 仍然是把她的原话
// 送上日志 —— 调用点必须自己持有那个显式的开关(本包只有 WithEnvelopeMessage 一处)。
// 渠道插件当前**不带**任何自由文本,因此没有第二个调用点;将来若真要开,也要回到
// 这一条(`trace` 的唯一清洗 + 40 rune),不要在插件里另写一套截断。
func upstreamText(s string) string {
if s == "" {
return ""
}
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxUpstreamTextRunes)
}
@@ -0,0 +1,56 @@
package httpclient
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
// 不安全」,所以这一条故意不去调用点补。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
for _, want := range []string{"status=502", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
+3 -14
View File
@@ -39,6 +39,7 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
const ( const (
@@ -179,7 +180,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
resp, err := client.Do(httpReq) resp, err := client.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, sanitizeErr(err)) return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, trace.URLCause(err))
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
@@ -189,7 +190,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
// 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。 // 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, sanitizeErr(err)) return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, trace.URLCause(err))
} }
if int64(len(data)) > maxBytes { if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin) return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin)
@@ -239,18 +240,6 @@ func applyHeader(dst, src http.Header) {
} }
} }
// sanitizeErr 剥掉 *url.Error 的外壳:它会把**完整 URL**(含查询串里的签名凭据)
// 拼进错误串。剥壳同时保留内层哨兵链(`context.DeadlineExceeded` 等仍可用
// errors.Is 判定)。内层已是最终错误时**原样透传**(这里只做剥壳,不新增包装 ——
// 再包一层只会把同一个原因说两遍)。
func sanitizeErr(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// originOfURL 把一个已解析的 URL 归一成来源字符串。 // originOfURL 把一个已解析的 URL 归一成来源字符串。
func originOfURL(u *url.URL) (string, error) { func originOfURL(u *url.URL) (string, error) {
if u == nil { if u == nil {
+54
View File
@@ -0,0 +1,54 @@
package session
// 本文件把「消息 → 会话」的键派生**导出**给别的层复用(M22 复审 P1-1)。
//
// 为什么必须导出:会话的键一直是会话层自己算的(`channelID` + `platform \x00 channelID`),
// 而渠道桥接器的**每会话回合车道**需要「什么算同一个会话」这个判据与它**同源**。
// 各算各的会漂移,而且漂移的后果不是排序不好看,是**同一个 session 两条车道、两个回合
// 并发**——两条 run 共享 sessionID,checkpoint 行 id `{sessionID}:{step}` 互相覆盖、
// `loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
// `session_turn_fence` 兜底)。
//
// 两处真实反例(都来自复审实测):
// - kook:同一 guild 的两个 text channel —— `ChannelID` 不同(c1/c2)但 `GroupID` 相同,
// 会话层按 `group:<GroupID>` 判成**同一个** session;任何把 `ChannelID` 当会话判据的
// 派生都会把它们拆成两条车道。
// - 多账号:同一平台两个账号对同一对端 —— 账号名不同(`telegram:conf0`/`:conf1`)、
// `platform` 常量相同、`ChannelID` 相同 ⇒ 也是**同一个** session;任何把「账号」当
// 会话判据的派生都会把它们拆开。
//
// 因此本文件只做一件事:把既有的派生**原样**导出,语义一个字不改(`channelID` 仍是唯一
// 的实现,见 manager.go)。
import "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
// sessionKeySep 分隔 (platform, channel_id) 两个维度。用 NUL 是因为平台名与渠道 id 都
// 不可能包含它,拼接因此不会产生歧义(`a` + "b" 与 `ab` + "" 不会撞键)。
const sessionKeySep = "\x00"
// ChannelKey 返回一条入站消息所代表的**渠道**键(会话派生的第二个维度):
// 群消息按 `group:<GroupID>`(加前缀使群不会与同 id 的私聊渠道撞键),私聊按
// `ChannelID`,没有显式渠道 id 时退回 `SenderID`。
//
// 它就是 `SessionManager.GetOrCreate` 内部用的那一个函数(manager.go 的 `channelID`),
// 导出只为让别的层(渠道桥接器的回合车道)能与会话键同源,**不得**在别处另写一份。
// 返回空串表示这条消息没有可用的渠道(`GetOrCreate` 会以 `ErrInvalidChannel` 拒掉它)。
func ChannelKey(msg adapter.UnifiedMessage) string { return channelID(msg) }
// Key 返回一条入站消息的**会话键** —— 与 `SessionManager` 的缓存键和
// `channel_sessions` 表的唯一键**逐字相同**(`platform \x00 channelID`)。
//
// 它是本包对外唯一的「什么算同一个会话」判据:任何需要按会话串行/分桶的调用方都用它,
// 不要自己拼 `platform|channel` 之类的近似键(那正是 M22 复审里「车道键与会话键不同源」
// 的成因)。返回的键在渠道不可用时仍然非空(`platform\x00`),调用方若需要区分,
// 用 ChannelKey 判空。
func Key(msg adapter.UnifiedMessage) string {
return sessionKey(msg.Platform, channelID(msg))
}
// sessionKey 拼接 (platform, channel_id) 两维度。本包内所有「会话键」的**唯一**出处
// (GetOrCreate / List / 导出的 Key 都经这里),因此不可能各自漂移。
func sessionKey(platform, cid string) string {
return platform + sessionKeySep + cid
}
+89
View File
@@ -0,0 +1,89 @@
package session
// 本文件钉住 keys.go 导出的两个派生:`ChannelKey` 与会话层内部 `channelID` 是**同一个**
// 实现,`Key` 与会话缓存键**逐字相同**。它们是「什么算同一个会话」的唯一定义 —— 车道
// (internal/services/channelbridge/lane.go)按会话串行就靠它,两处一旦分叉,同一个
// session 就会出现两条车道、两个回合并发(M22 复审 P1-1)。
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestChannelKeyDerivations(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msg adapter.UnifiedMessage
want string
}{
{
name: "群:按 group:<GroupID>,与频道无关",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"},
want: "group:g1",
},
{
name: "群:缺 GroupID 时退回 ChannelID",
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1"},
want: "c1",
},
{
name: "私聊:按 ChannelID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, ChannelID: "200", SenderID: "200"},
want: "200",
},
{
name: "私聊:缺 ChannelID 时退回 SenderID",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "200"},
want: "200",
},
{
name: "私聊:两者都缺 ⇒ 空(调用方据此以 ErrInvalidChannel 拒掉)",
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect},
want: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := ChannelKey(c.msg); got != c.want {
t.Fatalf("ChannelKey = %q, want %q", got, c.want)
}
})
}
}
// TestKeyIsTheSessionManagerCacheKey 钉住「导出的 Key 就是会话缓存键」:
// 形状 `platform \x00 channelID`(NUL 分隔,杜绝拼接歧义),且两维度都参与 ——
// 同平台不同渠道、不同平台同渠道都必须是**不同**的键。
func TestKeyIsTheSessionManagerCacheKey(t *testing.T) {
t.Parallel()
group1 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
group1OtherChannel := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c2", GroupID: "g1"}
group2 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g2"}
otherPlatform := adapter.UnifiedMessage{Platform: "qq-obv11", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
if got, want := Key(group1), "kook\x00group:g1"; got != want {
t.Fatalf("Key = %q, want %q", got, want)
}
// 同一 guild 的两个 text channel 在会话层是**同一个**会话。
if Key(group1) != Key(group1OtherChannel) {
t.Fatalf("same guild channels must share one session key: %q vs %q", Key(group1), Key(group1OtherChannel))
}
// 不同群、不同平台必须是不同的会话。
if Key(group1) == Key(group2) {
t.Fatalf("different groups must not share a session key: %q", Key(group1))
}
if Key(group1) == Key(otherPlatform) {
t.Fatalf("different platforms must not share a session key: %q", Key(group1))
}
// 渠道缺失时键仍非空,但 ChannelKey 能为空(调用方据此区分不可用渠道)。
noChannel := adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect}
if Key(noChannel) == "" || ChannelKey(noChannel) != "" {
t.Fatalf("missing-channel case: Key=%q ChannelKey=%q", Key(noChannel), ChannelKey(noChannel))
}
}
+2 -2
View File
@@ -110,7 +110,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
if cid == "" { if cid == "" {
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel) return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
} }
key := msg.Platform + "\x00" + cid key := sessionKey(msg.Platform, cid)
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。 // 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role { if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil { if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err) return nil, fmt.Errorf("session: scan list: %w", err)
} }
key := platform + "\x00" + cid key := sessionKey(platform, cid)
if cached, ok := m.lookupCached(key); ok { if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached) sessions = append(sessions, cached)
continue continue
@@ -0,0 +1,475 @@
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
//
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
//
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
// config 子目录口径。
coalesceConfigSubdir = "config"
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
coalesceFileName = "coalesce.json"
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
coalesceLogSource = "config/runtimeconfig/coalesce"
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
CoalesceWindowMinMs = 300
CoalesceWindowMaxMs = 15000
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
CoalesceMaxWaitMaxMs = 30000
CoalesceBatchMin = 1
CoalesceBatchMax = 50
coalesceDefaultWindow = 2000
coalesceDefaultWait = 6000
coalesceDefaultBatch = 24
)
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
func DefaultCoalescePath(dataDir string) string {
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
}
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
//
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
func CoalesceConversationKey(laneKey string) string {
return strings.ReplaceAll(laneKey, "\x00", "|")
}
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
//
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
type CoalescePolicy struct {
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
Enabled bool `json:"enabled"`
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
WindowMs int `json:"windowMs"`
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
MaxWaitMs int `json:"maxWaitMs"`
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
MaxBatchMessages int `json:"maxBatchMessages"`
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
ApplyToStrangers bool `json:"applyToStrangers"`
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
StrangerWindowMs int `json:"strangerWindowMs"`
}
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
func DefaultCoalescePolicy() CoalescePolicy {
return CoalescePolicy{
Enabled: true,
WindowMs: coalesceDefaultWindow,
MaxWaitMs: coalesceDefaultWait,
MaxBatchMessages: coalesceDefaultBatch,
ApplyToStrangers: true,
StrangerWindowMs: coalesceDefaultWindow,
}
}
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
type CoalescePolicyPatch struct {
Enabled *bool `json:"enabled,omitempty"`
WindowMs *int `json:"windowMs,omitempty"`
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
}
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.WindowMs != nil {
p.WindowMs = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
p.MaxWaitMs = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
p.MaxBatchMessages = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
p.ApplyToStrangers = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
p.StrangerWindowMs = *patch.StrangerWindowMs
}
return p
}
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
out := patch
if next.Enabled != nil {
out.Enabled = next.Enabled
}
if next.WindowMs != nil {
out.WindowMs = next.WindowMs
}
if next.MaxWaitMs != nil {
out.MaxWaitMs = next.MaxWaitMs
}
if next.MaxBatchMessages != nil {
out.MaxBatchMessages = next.MaxBatchMessages
}
if next.ApplyToStrangers != nil {
out.ApplyToStrangers = next.ApplyToStrangers
}
if next.StrangerWindowMs != nil {
out.StrangerWindowMs = next.StrangerWindowMs
}
return out
}
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
func (patch CoalescePolicyPatch) Empty() bool {
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
patch.StrangerWindowMs == nil
}
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
// (键 = CoalesceConversationKey(车道键))。
type CoalesceFile struct {
Default CoalescePolicyPatch `json:"default"`
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
}
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
// 一个是运行期事实,一个是盘上原文。
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
return p
}
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
conv, ok := f.Conversations[key]
if !ok {
return CoalescePolicy{}, false
}
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
return p, true
}
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
// 如实回显。值是该键在盘上的原值(未经 clamp)。
func (patch CoalescePolicyPatch) Values() map[string]any {
out := map[string]any{}
if patch.Enabled != nil {
out["enabled"] = *patch.Enabled
}
if patch.WindowMs != nil {
out["windowMs"] = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
out["maxWaitMs"] = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
out["maxBatchMessages"] = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
out["applyToStrangers"] = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
out["strangerWindowMs"] = *patch.StrangerWindowMs
}
return out
}
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
// 一个没人知道的 300ms)。
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
var notes []string
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
p.WindowMs = CoalesceWindowMinMs
}
if p.WindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
p.WindowMs = CoalesceWindowMaxMs
}
if p.WindowMs == 0 {
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
notes = append(notes, "windowMs=0 视为关闭静默窗口")
p.Enabled = false
p.WindowMs = coalesceDefaultWindow
}
if p.StrangerWindowMs <= 0 {
p.StrangerWindowMs = p.WindowMs
}
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
p.StrangerWindowMs = p.WindowMs
}
if p.MaxWaitMs < p.WindowMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
p.MaxWaitMs = p.WindowMs
}
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
p.MaxWaitMs = CoalesceMaxWaitMaxMs
}
if p.MaxBatchMessages < CoalesceBatchMin {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
p.MaxBatchMessages = CoalesceBatchMin
}
if p.MaxBatchMessages > CoalesceBatchMax {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
p.MaxBatchMessages = CoalesceBatchMax
}
return p, notes
}
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
fields := []struct {
name string
v *int
}{
{"windowMs", p.WindowMs},
{"maxWaitMs", p.MaxWaitMs},
{"maxBatchMessages", p.MaxBatchMessages},
{"strangerWindowMs", p.StrangerWindowMs},
}
for _, f := range fields {
if f.v != nil && *f.v < 0 {
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
}
}
return nil
}
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
type CoalescePatch struct {
Default *CoalescePolicyPatch `json:"default,omitempty"`
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
}
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
func (p CoalescePatch) Empty() bool {
if p.Default != nil && !p.Default.Empty() {
return false
}
return len(p.Conversations) == 0
}
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
type CoalesceStore struct {
path string
log *slog.Logger
mu sync.RWMutex
cfg CoalesceFile
}
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
if log == nil {
log = slog.Default()
}
s := &CoalesceStore{
path: path,
log: logger.WithSource(log, coalesceLogSource),
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
}
s.load()
return s
}
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
func (s *CoalesceStore) Path() string { return s.path }
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
func (s *CoalesceStore) Present() bool {
if s.path == "" {
return false
}
_, err := os.Stat(s.path)
return err == nil
}
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
// 其它读/解析错误留 WARN 后仍走默认值。
func (s *CoalesceStore) load() {
if s.path == "" {
return
}
raw, err := os.ReadFile(s.path)
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
}
return
}
var f CoalesceFile
if err := json.Unmarshal(raw, &f); err != nil {
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
return
}
if f.Conversations == nil {
f.Conversations = map[string]CoalescePolicyPatch{}
}
s.mu.Lock()
s.cfg = f
s.mu.Unlock()
s.logClamp(f)
}
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
func (s *CoalesceStore) logClamp(f CoalesceFile) {
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
}
for key, patch := range f.Conversations {
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce conversation override clamped",
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
}
}
}
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
//
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
// 也与入站路径一样不会被一次落盘抖动卡住。
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
s.mu.RLock()
def := s.cfg.Default
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
s.mu.RUnlock()
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
if ok {
p, _ = ClampCoalescePolicy(conv.apply(p))
}
return p
}
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
// 而不是「运行期把它夹成了什么」。
func (s *CoalesceStore) Snapshot() CoalesceFile {
s.mu.RLock()
defer s.mu.RUnlock()
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
for k, v := range s.cfg.Conversations {
out.Conversations[k] = v
}
return out
}
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
//
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
before, _ := json.Marshal(s.cfg)
if patch.Default != nil {
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
}
if s.cfg.Conversations == nil {
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
}
for key, conv := range patch.Conversations {
if conv == nil {
delete(s.cfg.Conversations, key)
continue
}
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
}
after, _ := json.Marshal(s.cfg)
if string(before) == string(after) {
return false, nil
}
if err := s.saveLocked(); err != nil {
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
}
return true, nil
}
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
func (s *CoalesceStore) saveLocked() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("chmod %s: %w", tmpName, err)
}
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("write %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("close %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
+79
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
) )
@@ -61,10 +62,30 @@ type PluginConfig struct {
// Env is extra environment for a stdio subprocess. Values under a sensitive // Env is extra environment for a stdio subprocess. Values under a sensitive
// key (token/secret/password/key...) are redacted by MaskedPlugin. // key (token/secret/password/key...) are redacted by MaskedPlugin.
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
// Enabled is the per-server on/off switch. It is a pointer so that "the file
// has no enabled key" (every plugins.json written before this field existed)
// stays distinguishable from an explicit false: absent means enabled
// (IsEnabled), and only an explicit disable is persisted.
//
// Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives
// a restart as disabled) and only its live connection + tools go away.
Enabled *bool `json:"enabled,omitempty"`
// CreatedAt is the RFC3339 install time. // CreatedAt is the RFC3339 install time.
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
} }
// IsEnabled reports whether the server should be connected. A missing enabled key
// (older plugins.json / an install that never mentioned it) counts as enabled.
func (p PluginConfig) IsEnabled() bool {
return p.Enabled == nil || *p.Enabled
}
// WithEnabled returns a copy of p with an explicit enabled flag.
func (p PluginConfig) WithEnabled(on bool) PluginConfig {
p.Enabled = &on
return p
}
// DefaultPluginsPath returns the plugins.json path under dataDir. // DefaultPluginsPath returns the plugins.json path under dataDir.
func DefaultPluginsPath(dataDir string) string { func DefaultPluginsPath(dataDir string) string {
return filepath.Join(dataDir, pluginsFileName) return filepath.Join(dataDir, pluginsFileName)
@@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string {
return out return out
} }
// secretMask is what a redacted secret (or a fragment of one) collapses to inside
// free text. It is deliberately not maskToken: free text needs a marker that
// cannot be mistaken for a real tail of the credential.
const secretMask = "***"
// minRedactFragment is the shortest run of a secret that is still scrubbed from
// free text. Anything shorter is not evidence of that credential (a 2-3 char run
// of a hex key shows up in ordinary prose by chance), and keeping the floor low
// enough that a leaked *middle* of a key (an error printing a substring) is still
// caught is the point of scanning fragments at all.
const minRedactFragment = 6
// RedactPluginSecrets scrubs every configured credential out of text, returning a
// string that is safe to show in a status snapshot or a log line.
//
// Why this exists (and why MaskedPlugin is not enough): a connect failure's error
// text is written by the MCP client, the HTTP stack or the server itself, so it
// can carry the bearer token (a 401 echoing the Authorization header, a URL with
// `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that
// text as `lastError`, therefore the text — not just the config — has to be
// redacted. Replacement is by whole value first, then by every fragment of at
// least minRedactFragment characters, so a partially printed key is caught too.
func RedactPluginSecrets(p PluginConfig, text string) string {
if text == "" {
return ""
}
out := redactSecret(text, p.APIKey)
keys := make([]string, 0, len(p.Env))
for k := range p.Env {
if isSensitiveKey(k) {
keys = append(keys, k)
}
}
// 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。
sort.Strings(keys)
for _, k := range keys {
out = redactSecret(out, p.Env[k])
}
return out
}
// redactSecret replaces secret and every fragment of it (longest first, down to
// minRedactFragment runes-worth of bytes) with secretMask.
func redactSecret(text, secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" || text == "" {
return text
}
out := strings.ReplaceAll(text, secret, secretMask)
// 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。
for n := len(secret) - 1; n >= minRedactFragment; n-- {
for i := 0; i+n <= len(secret); i++ {
out = strings.ReplaceAll(out, secret[i:i+n], secretMask)
}
}
return out
}
// samePlugin reports whether cur is the entry p replaces: same name when p has // samePlugin reports whether cur is the entry p replaces: same name when p has
// one, otherwise same id. // one, otherwise same id.
func samePlugin(cur, p PluginConfig) bool { func samePlugin(cur, p PluginConfig) bool {
@@ -186,3 +186,64 @@ func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
t.Fatalf("masked JSON leaks a secret: %s", raw) t.Fatalf("masked JSON leaks a secret: %s", raw)
} }
} }
// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json
// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。
func TestPluginEnabled_BackwardCompatible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]`
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatalf("write legacy file: %v", err)
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 1 || !got[0].IsEnabled() {
t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got)
}
if got[0].Enabled != nil {
t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled)
}
off := got[0].WithEnabled(false)
if err := SavePlugins(path, []PluginConfig{off}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("显式停用必须落盘:%s", raw)
}
back, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if back[0].IsEnabled() {
t.Fatalf("重启后停用态丢失:%+v", back[0])
}
}
// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。
func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) {
t.Parallel()
const key = "sk-live-0123456789abcdef"
p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}}
for _, text := range []string{
"bearer " + key,
"echo " + key[8:20],
"tail " + key[len(key)-6:],
"env GH_TOKEN=ghp_secret_value",
} {
out := RedactPluginSecrets(p, text)
if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") {
t.Fatalf("整把凭据仍在:%q", out)
}
if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) {
t.Fatalf("凭据片段仍在:%q", out)
}
}
}
@@ -0,0 +1,123 @@
// voice_tts_launch.go 是 **emotionTTS 自启**(切片 M47)的可变源:
// `$DATA_DIR/config/voice-tts-launch.json`(与 `voice-tts.json` / `backthink.json`
// 同一家族口径,design/13 §2.1:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/` +
// 代码默认值;`.env` 已冻结,**零新 env 键**)。
//
// 为什么需要它:主人 2026-10-24 逐字「tts启动脚本在这
// `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,以后启动后端可以顺便检测
// 一下如果TTS没起就顺带启一下」。这是**一条运维便利**,不是后端的一部分:
//
// - **默认关**(nil / false 都等于不启用)。主人早前说过「开机自启先不急 后面做」
// ⇒ 未显式打开时,后端连一次探活都不发,更不会拉起任何进程。
// - 拉起动作整段在后台 goroutine 里(见 `internal/runtime/voice/ttslaunch`),
// **不占**后端启动时长;失败只 WARN,绝不阻塞或改坏后端启动路径。
//
// 与 `voice-tts.json` 分开而不是塞进同一份文件:那一份管「合成怎么调」(地址/
// 角色映射/超时),这一份管「服务没起时要不要顺手拉一下」。两件事的失败语义、
// 默认值、被谁读都不同(合成面读一次决定注入与否;本文件读一次决定是否起进程),
// 合在一份会让"只想开合成"的人被迫接触进程拉起这个更重的开关。
//
// 边界(与 voicetts.go 同源的手续):
//
// 1. 本文件只管**文件形状 + 读取**:不认识 `ttslaunch.Config`,也不裁决"缺省填
// 什么值"(那是 `ttslaunch.Config.Normalized` 的事)。
// 2. **没有"面板编辑"的写口**:改内容仍要人手改文件 + 重启(启动期读一次)。
// 3. **零新 env 键**,也不进面板的配置事实卡。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回分类错误,调用方记 WARN 并**回落"不启用"**(一份读不懂的配置绝不能变成
// 「随手拉起进程」)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// voiceTTSLaunchFileName 是文件名(家族命名 = 模块名 + .json)。
voiceTTSLaunchFileName = "voice-tts-launch.json"
// voiceTTSLaunchConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。
voiceTTSLaunchConfigSubdir = "config"
)
// VoiceTTSLaunchConfigRelPath 是落点相对 `$DATA_DIR` 的路径
// (`config/voice-tts-launch.json`)。导出它是为了让日志/文档能**如实**告诉主人
// 改哪个文件。
const VoiceTTSLaunchConfigRelPath = voiceTTSLaunchConfigSubdir + "/" + voiceTTSLaunchFileName
// ErrInvalidVoiceTTSLaunch 是 voice-tts-launch.json 结构不合法时的分类错误
// (errors.Is 可判类)。
var ErrInvalidVoiceTTSLaunch = errors.New("runtimeconfig: invalid voice tts launch config")
// VoiceTTSLaunchTuning 是 `voice-tts-launch.json` 的形状:启动期「探一下、没起
// 就顺手拉起」的几个旋钮。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type VoiceTTSLaunchTuning struct {
// Enabled 控制后端启动时是否顺带探活/拉起 emotionTTS。nil = 没配
// (**不启用**);true = 启用;false = 显式关掉(与"没配"同效,写它只是为了
// 自解释)。
//
// 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认
// 语义是**关**(它会**起进程**,是比"调个超时"重得多的副作用,不能靠"猜"打开)。
Enabled *bool `json:"enabled"`
// ScriptPath 是拉起用的启动脚本(主人的 `一键启动.bat`)。
// 空 = 代码默认(`ttslaunch.DefaultScriptPath`)。
//
// 脚本不存在只 WARN(这台机器上没装 emotionTTS 是正常形态),不是错误。
ScriptPath string `json:"scriptPath,omitempty"`
// ProbeURL 是探活地址。空 = 代码默认 `http://127.0.0.1:9880`。
//
// 它回答的问题是「这个端口上已经有人在应答吗」——**任何** HTTP 回应都算在跑
// (见 ttslaunch 的探活口径),因此填一个别的服务的地址只会让它永远"已在跑"。
ProbeURL string `json:"probeUrl,omitempty"`
// ProbeTimeoutMs 是单次探活超时(**毫秒**)。零/负 = 代码默认。
//
// 它决定的是「等多久才认定没起」:这是启动**之后**的后台动作,但过大的值只会让
// "没装 TTS"的机器白等,过小则可能把"正在初始化"误判成"没起"。
ProbeTimeoutMs int `json:"probeTimeoutMs,omitempty"`
// CooldownSec 是**一次拉起尝试之后的冷却**(**秒**)。零/负 = 代码默认。
//
// 它的用途是防「后端重启风暴」:主人连点重启时,每次都去拉一个已在启动中的
// TTS 只会堆出多个抢同一个端口的进程。
CooldownSec int `json:"cooldownSec,omitempty"`
}
// EnabledOn 报告自启是否被显式打开(nil = 没配 ⇒ 不启用)。
func (t VoiceTTSLaunchTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
// DefaultVoiceTTSLaunchPath 返回落点:`$DATA_DIR/config/voice-tts-launch.json`。
func DefaultVoiceTTSLaunchPath(dataDir string) string {
return filepath.Join(dataDir, voiceTTSLaunchConfigSubdir, voiceTTSLaunchFileName)
}
// LoadVoiceTTSLaunchTuning 读取 voice-tts-launch.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"没配过(不启用)",**不是**错误
// —— 绝大多数机器不装 emotionTTS,不该每次启动都刷 WARN。
//
// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTSLaunch 的错误。调用方应记一条带
// source 的 WARN 并**回落"不启用"**;**绝不得覆盖**那个文件(人工救得回来,
// 程序盖掉就丢了人写的意图)。
func LoadVoiceTTSLaunchTuning(path string) (VoiceTTSLaunchTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return VoiceTTSLaunchTuning{}, nil
}
return VoiceTTSLaunchTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return VoiceTTSLaunchTuning{}, nil
}
var t VoiceTTSLaunchTuning
if err := json.Unmarshal(raw, &t); err != nil {
return VoiceTTSLaunchTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTSLaunch, path, err)
}
return t, nil
}
@@ -0,0 +1,84 @@
package runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// TestLoadVoiceTTSLaunchTuning 锁定 voice-tts-launch.json 的四条读取口径:
// 缺失 = 没配过(不是错误、**不启用**)、全空白 = 没配过、合法 = 逐键读出、
// 坏文件 = 可分类错误(调用方据此回落"不启用")。
func TestLoadVoiceTTSLaunchTuning(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "voice-tts-launch.json")
// ① 缺失 ⇒ 零值 + nil(默认关:这把开关会起进程,不能靠"猜"打开)
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ② 全空白 ⇒ 同样当作没配过
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ③ 合法 ⇒ 逐键读出(脚本路径含中文,正是主人那台机器的形状)
body := `{"enabled":true,"scriptPath":"D:\\Project\\Service\\emotionTTS_v5.1.0\\一键启动.bat",
"probeUrl":"http://127.0.0.1:9880","probeTimeoutMs":250,"cooldownSec":90}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
tuning, err := LoadVoiceTTSLaunchTuning(path)
if err != nil {
t.Fatalf("合法文件: %v", err)
}
if !tuning.EnabledOn() {
t.Fatal("enabled:true 没被读出")
}
if tuning.ProbeTimeoutMs != 250 || tuning.CooldownSec != 90 {
t.Fatalf("超时/冷却没读出: %+v", tuning)
}
if tuning.ProbeURL != "http://127.0.0.1:9880" {
t.Fatalf("probeUrl = %q", tuning.ProbeURL)
}
if filepath.Base(tuning.ScriptPath) != "一键启动.bat" {
t.Fatalf("scriptPath 没读出: %q", tuning.ScriptPath)
}
// ④ 显式 false ⇒ 与"没配过"同效(EnabledOn 为 false)
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("显式 false: tuning=%+v err=%v, want 不启用", tuning, err)
}
// ⑤ 坏文件 ⇒ 分类错误(调用方据此 WARN + 不启用),且绝不覆盖原文件
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err = LoadVoiceTTSLaunchTuning(path); !errors.Is(err, ErrInvalidVoiceTTSLaunch) {
t.Fatalf("坏文件: err=%v, want ErrInvalidVoiceTTSLaunch", err)
}
if raw, rerr := os.ReadFile(path); rerr != nil || string(raw) != `{"enabled":` {
t.Fatalf("坏文件被改写了: %q err=%v", raw, rerr)
}
}
// TestDefaultVoiceTTSLaunchPath 钉住落点:`$DATA_DIR/config/voice-tts-launch.json`
// —— 它是"零新 .env 键"这一条的物理形态(开关只可能来自面板文件)。
func TestDefaultVoiceTTSLaunchPath(t *testing.T) {
got := DefaultVoiceTTSLaunchPath("/data")
want := filepath.Join("/data", "config", "voice-tts-launch.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if VoiceTTSLaunchConfigRelPath != "config/voice-tts-launch.json" {
t.Fatalf("相对路径 = %q", VoiceTTSLaunchConfigRelPath)
}
}
+149
View File
@@ -0,0 +1,149 @@
// voicetts.go 是 **emotionTTS 语音合成**(切片 M35)的可变源:
// `$DATA_DIR/config/voice-tts.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。
//
// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要几项运维旋钮 ——
// 开关、地址、**角色映射**、超时;而 `.env` 已被主人冻结(**零新 env 键**,
// design/13 §2.1),所以它们落在这里,与 `memory-tidy.json` / `backthink.json`
// 同一家族口径。
//
// 🔴 **发音人必须绑定人格**(主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的
// 角色的,不然会出现以后换了别的角色(人格配置),还是用老角色的 TTS 角色」):
// 因此本文件的核心是 `personas`(personaId → characterId)与 `default`(**仅**
// 服务"连全局默认人格都没有"这一种情形 —— 不是"会话未绑定")。
// 全局 `character` 键已废弃并被忽略(见字段注释)。
// 映射的**唯一真源**就是本文件 —— `configs/personas/*.yaml` 不参与。
//
// 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据):
// - 上游把下游载荷的 `speed` **硬编码 1.0**(`core/utils/llm_provider.py` 的
// `synthesize_audio`),客户端传的 speed 是空转 ⇒ 做一个键就是做一个**假开关**;
// - 情绪由上游**自己**推(LLM 分析待念文本 + 匹配参考音 + 注入情绪向量),我方
// 没有可映射的参数 ⇒ 不做"情绪 → 语气"映射表(有映射表也只是自嗨)。
//
// 边界(与 backthink.go / memorytidy.go 同源的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `tts.Config`,也不做"缺省填什么值"
// 的裁决(那是 `tts.Config.Normalized` 的事)。
// 2. **没有"面板编辑"的写口**:改内容仍要**人手改文件 + 重启**(与
// `backthink.json` 同口径:启动期读一次)。本切片不新增播种文件 —— 文件不存在
// 就是"没配过",该级不启用。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回错误,调用方记 WARN 并**回落到"不启用"**(一个坏文件绝不能把语音面变成
// 「半开」状态 —— 宁可没有这一级,也不要用一份读不懂的配置去调外部服务)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// voiceTTSFileName 是文件名(家族命名 = 服务/模块名 + .json)。
voiceTTSFileName = "voice-tts.json"
// voiceTTSConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与其它族
// 各自声明一份,避免某一族换位置时另一处静默跟着搬家。
voiceTTSConfigSubdir = "config"
)
// VoiceTTSConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/voice-tts.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件。
const VoiceTTSConfigRelPath = voiceTTSConfigSubdir + "/" + voiceTTSFileName
// ErrInvalidVoiceTTS 是 voice-tts.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidVoiceTTS = errors.New("runtimeconfig: invalid voice tts config")
// VoiceTTSTuning 是 `voice-tts.json` 的形状:emotionTTS 那个发音人的运维旋钮。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type VoiceTTSTuning struct {
// Enabled 控制这一级是否参与合成。nil = 没配(**不启用**);true = 启用;
// false = 显式关掉(与"没配"同效,写它只是为了自解释)。
//
// 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认
// 语义是**关**(它要连一台默认在本机回环上的外部服务,不能靠"猜"打开)。
// 这里指针带来的是"没写"与"写了 false"在日志里可区分,行为上二者都等于不启用。
Enabled *bool `json:"enabled"`
// BaseURL 是服务地址。空 = 代码默认 `http://127.0.0.1:9880`。
//
// 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给
// 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。
BaseURL string `json:"baseUrl,omitempty"`
// Personas 是 **personaId → 上游 characterId** 的唯一映射源(主人 2026-10-26:
// 「TTS 的人物选择是要绑定后端的角色的」)。
//
// - 这是**唯一真源**:`configs/personas/*.yaml` 不参与(启动期静态配置 + 面板
// 可热调,两处必然漂移)。
// - 有键但值为空串 = 该角色明确没有发音人 ⇒ 不合成(绝不借用别的角色)。
// - **没有这个键** ⇒ 同样不合成,且**不**回落到下面的 `default`。
Personas map[string]string `json:"personas,omitempty"`
// Default 是**仅当连生效人格都没有**(personaId 传空串)时使用的发音人;
// 空 = 没配 ⇒ 那种情况**不合成**。
//
// 🔴 空串的唯一合法成因是"连全局默认人格都没有"(人格库未接线 / 没有任何角色被
// 标为默认)—— **不是**"会话未绑定",也不是"这个出站口没有会话":那些情况下
// 调用方传的是**生效人格**(会话绑定 → 全局默认人格),非空。
//
// 它**不服务**「配了 personas 但没有这个 persona 的键」——那种情况一律不合成
// (见 `tts.Config.Resolve` 的拒绝分支)。用 default 去顶正是"换了人格还用老角色
// 声音"的病害本身。
Default string `json:"default,omitempty"`
// Character 是**已废弃**的全局发音人键(切片第一版)。
//
// 🔴 它被**忽略**:全局一个 character 正是主人点名的缺陷(换人格仍用老声音)。
// 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas",
// 而不是让它被 json 静默丢弃。新配置**不要**写它。
Character string `json:"character,omitempty"`
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
//
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
// 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。
TimeoutSec int `json:"timeoutSec,omitempty"`
}
// 本文件**刻意没有** `format` 键:上游只拿 `response_format` 改它自报的 media_type,
// 产物恒 `.wav` 且无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,
// 是假开关。请求体里它由 `tts.requestFormat` 固定为 wav。
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
// LegacyGlobalCharacter 返回已废弃的全局 `character` 键(未写时为空)。调用方应
// 记一条 WARN 说明"这一键不再生效,改用 personas",**绝不**把它当发音人使用。
func (t VoiceTTSTuning) LegacyGlobalCharacter() string { return strings.TrimSpace(t.Character) }
// DefaultVoiceTTSPath 返回落点:`$DATA_DIR/config/voice-tts.json`。
func DefaultVoiceTTSPath(dataDir string) string {
return filepath.Join(dataDir, voiceTTSConfigSubdir, voiceTTSFileName)
}
// LoadVoiceTTSTuning 读取 voice-tts.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"这一级没配过(不启用)",
// **不是**错误 —— 一台没装 emotionTTS 的机器不该每次启动都刷 WARN。
//
// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTS 的错误。调用方应记一条带 source 的
// WARN 并**回落"不启用"**(见文件头第 3 条);**绝不得覆盖**那个文件(损坏的文件
// 可以人工救回,程序盖掉它就丢了人工写的意图)。
func LoadVoiceTTSTuning(path string) (VoiceTTSTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return VoiceTTSTuning{}, nil
}
return VoiceTTSTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return VoiceTTSTuning{}, nil
}
var t VoiceTTSTuning
if err := json.Unmarshal(raw, &t); err != nil {
return VoiceTTSTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTS, path, err)
}
return t, nil
}
@@ -0,0 +1,107 @@
package runtimeconfig
import (
"errors"
"os"
"path/filepath"
"testing"
)
// TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径:
// 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。
func TestLoadVoiceTTSTuning(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "voice-tts.json")
// ① 缺失
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ② 全空白
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
}
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
"default":"char_84fb88a3","timeoutSec":45}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
tuning, err := LoadVoiceTTSTuning(path)
if err != nil {
t.Fatalf("合法文件: %v", err)
}
if !tuning.EnabledOn() || tuning.TimeoutSec != 45 {
t.Fatalf("读取结果不对: %+v", tuning)
}
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
t.Fatalf("personas 没有被读出: %+v", tuning.Personas)
}
if tuning.Default != "char_84fb88a3" {
t.Fatalf("default = %q", tuning.Default)
}
// ③c 旧文件里若还留着 `format`,它**不再有任何效果**(结构体里没有这个键,
// json 当作未知字段忽略):加载仍成功,且交付格式只由字节嗅探决定。
if err := os.WriteFile(path, []byte(`{"enabled":true,"format":"mp3","personas":{"cyrene":"char_84fb88a3"}}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
t.Fatalf("带废弃 format 键的文件不该解不开: %v", err)
}
if !tuning.EnabledOn() || tuning.Personas["cyrene"] != "char_84fb88a3" {
t.Fatalf("废弃键影响了其余字段: %+v", tuning)
}
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
// personas/default 的一部分 —— 组合根绝不把它当发音人。
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
t.Fatalf("旧键: %v", err)
}
if tuning.LegacyGlobalCharacter() != "char_old" {
t.Fatalf("LegacyGlobalCharacter = %q", tuning.LegacyGlobalCharacter())
}
if len(tuning.Personas) != 0 || tuning.Default != "" {
t.Fatalf("旧全局 character 混进了 personas/default: %+v", tuning)
}
// ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil {
t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err)
}
// ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件)
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) {
t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err)
}
if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` {
t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err)
}
}
// TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。
func TestDefaultVoiceTTSPath(t *testing.T) {
got := DefaultVoiceTTSPath(filepath.Join("data", "root"))
want := filepath.Join("data", "root", "config", "voice-tts.json")
if got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if VoiceTTSConfigRelPath != "config/voice-tts.json" {
t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath)
}
}
@@ -0,0 +1,17 @@
-- 0026 的回滚:先删索引、再删列(与 0019 同一顺序口径 —— SQLite 拒绝 DROP COLUMN
-- 删除一个仍被索引引用的列)。
--
-- 回滚的代价(如实登记):`retention_tier` 这一列的值**全部丢失**。它对本迁移之前
-- 写入的行本来就恒为 `''`(未分型),因此丢的只是「本迁移之后被判出来的档位」。
-- 真正不可逆的只有一条:**判成 temp 的行不会被自动改回长期层** —— 它们的
-- `expires_at` 是另一列(0019 就存在),本回滚**刻意不动它**:
--
-- - 若回滚时顺手清空 `expires_at`,一次 schema 回滚就会把已经判定的临时记忆
-- 全部升回永久(那是改数据,不是回滚结构);
-- - 若不清空,回滚后这些行仍是「有时效的行」,读侧时效闸(纯 SQL,不依赖本列)
-- 照旧隐藏它们,后台清扫照旧删除它们 —— 即**行为不变**。
--
-- 因此本回滚只撤结构、绝不改写任何一行的时效数据;要重判档位,重跑判定即可
-- (`retention_tier` 列会以默认值 `''` 重建,历史行回到「未分型」)。
DROP INDEX IF EXISTS idx_memory_items_retention_tier;
ALTER TABLE memory_items DROP COLUMN retention_tier;
@@ -0,0 +1,43 @@
-- 0026_memory_retention_tier:「留多久 / 干什么用」这根轴(主人 2026-10-26 裁定「两列」)。
--
-- 为什么是**新列**而不是复用 kind:`kind`(relationship / world_event)回答的是
-- 「关于什么」,`scope`(user / persona / project)回答的是「谁的」,本条新增的
-- `retention_tier` 回答的是「留多久 / 干什么用」。三根轴**正交、互不推导**:
-- 一条关系记忆可以是临时记忆,也可以是长期记忆;把「临时性」塞进 `kind` 会让
-- 「一条关系记忆又是临时的」无法表达(审计 `记忆存量体检-2026-10-26.md` 卷四的
-- 三条路对比,主人选了第 1 条)。
--
-- 🔴 默认值 `''` = **尚未分型**,不是第六档业务档位:
-- ① 本迁移之前的历史行全部落在 `''`(本迁移**刻意不做回填**:那是一次写全表的
-- 判定动作,属于整理侧的第二现场,不该藏在迁移里,也不该在启动路径上静默跑);
-- ② 受限来源(群聊 / 陌生人)的新行同样不分型(它们的留存由来源面决定,后台到期
-- 清扫也会跳过它们 —— 判成 temp 就是造一条「对读面隐藏却永不删除」的行)。
-- 读侧因此**必须**能区分「未分型」与「已分型」:缺省不过滤(历史行照常返回),
-- 显式按 `''` 过滤即「只看未分型的行」(回填分型的入口)。
--
-- 档位闭集(代码侧的定义是 internal/runtime/memory/retention.go 的 RetentionTier):
-- temp 临时层:唯一带时效的档位,`retention_tier='temp'` ⇔ `expires_at`
-- 非空且落在临时 TTL 内(两列由同一个判定、同一笔 INSERT 落下)
-- daily 日常层(一天之内的琐事 / 生活节奏;**不是** scope=persona 的「角色日常」)
-- work 工作层(任务推进 / 工单 / 项目过程)
-- long 长期层(稳定的偏好 / 关系 / 事实;入库判定的默认落点)
-- knowledge 知识库层(外部资料 / 规格 / 文档性质的记忆行)
-- '' 尚未分型(见上)
--
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
-- 迁移与 0019_memory_tidy 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
-- (ensureRetentionTierColumn)走到 "duplicate column name" 是预期路径。反过来
-- (先自举、后迁移)也只在同一条账本跳一次。列与索引的形状必须与自举 DDL
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
-- internal/runtime/memory/retention_migration_test.go 逐列/逐索引钉住。
--
-- 命名口径:表名复数、主键 `id`、索引 `idx_{table}_{col}`(援引 AGENTS §10 已登记
-- 例外,与既有迁移一致)。
ALTER TABLE memory_items ADD COLUMN retention_tier TEXT NOT NULL DEFAULT '';
-- 三条列表读路径的可选档位过滤(`retention_tier IN (...)`)与「只看未分型的行」
-- (回填入口)都吃这条索引。单列而不是复合列:这一轴的查询形状是
-- 「跨角色按档位聚合」(面板 / 回填),不绑定 persona。
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
ON memory_items (retention_tier);
@@ -0,0 +1,18 @@
-- 0027 的回滚:只撤结构(删列),**不改写任何一行的数据**。
--
-- 回滚的代价(如实登记):`temporal` 这一列的值全部丢失。它对本迁移之前写入的行
-- 本来就恒为 `''`(未分型),因此丢的只是「本迁移之后提取器标出的时间性位」。
--
-- 真正需要说明的是**判定行为**:`retention_tier` / `expires_at` 是另两列,本回滚
-- **刻意不动它们**。
-- - 已被判成 temp 的行(其中一部分是 `temporal=one_off` 带来的)保持原状:读侧
-- 时效闸与后台清扫都只认 `expires_at`(纯 SQL,不依赖本列),因此回滚后它们的
-- 行为**逐字不变**,到期照旧消失、照旧被清扫;
-- - 若回滚时顺手把那些行的 `expires_at` 清空,那是一次 schema 回滚**改数据**,
-- 会把已经判定的临时记忆全部升回永久,同时还留在 `retention_tier='temp'` 上
-- —— 那正是「temp ⇔ 非空 expires_at」这条不变量的反面。
-- 要重判档位就重跑判定:列会以默认值 `''` 重建,历史行回到「未分型」。
--
-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up),
-- 因此无需先删索引(0026 的 down 因为带索引才要以 DROP INDEX 开头)。
ALTER TABLE memory_items DROP COLUMN temporal;
@@ -0,0 +1,43 @@
-- 0027_memory_temporal:提取器产出侧的**时间性元数据位**(切片 M45)。
--
-- 为什么需要这一列(而不是再调一次词项阈值):M37 复审的量化结论是「defer 命中
-- 2/500 = 0.4%」不是阈值太严,而是**判定链缺信息位** —— 真正卡住上游的是提取器的
-- 改写:一次性事件被写成 30~80 字的第三人称长期陈述,规则闸只看落库正文,因此
-- 看不见「这是今天的一次性事件」这一位。实测反证:把 N6 的时点词窗口从 12 放宽到
-- 24 字,命中 2→14,多出的 11 条逐条都是**同一长期约定的重复陈述**(「一起晒太阳」
-- 暂时搁置 ×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 放宽阈值等于 7 天后
-- 把约定本身删掉,违背 fail-safe。所以补的是信息位,不是阈值。
--
-- 为什么是**列**而不是新增一种 kind:`kind`(关于什么)、`scope`(谁的)、
-- `retention_tier`(留多久)、本条 `temporal`(**说的是一次发生的事,还是反复/长期
-- 成立的事**)是四根正交的轴。时间性是**提取器对原始对话的判断结果**,它必须能被
-- 单独存下来(事后巡检「模型当时标了什么」、以及将来回填),塞进 kind 就丢了这条
-- 观测线;靠提示词里的自然语言描述则违反 AGENTS §9「判据靠元数据,不靠提示词」。
--
-- 列值闭集(代码侧定义:internal/runtime/memory/temporal.go 的 TemporalKind):
-- one_off 一次性事件(今天 / 这次 / 某一次)⇒ 判定为 temp + 非空 expires_at
-- recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
-- durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
-- unknown 模型拿不准或没给出合法值 ⇒ **没有结论**,交给既有 N1~N7 规则判
-- '' 尚未分型:本迁移之前的历史行,以及非提取器的写入路径(默认值)
--
-- 🔴 默认值 `''` 而**不是** `unknown`:两者在判定上走同一条路(都落到旧规则),但
-- 含义不同 —— `''` 是「没问过」,`unknown` 是「问过,模型拿不准」。把历史行写成
-- `unknown` 会让「模型答了什么」这个观测面从第一行起就掺假。本迁移**刻意不回填**:
-- 历史行没有对应的提取器输出,拿正文反推「它当时是不是一次性事件」正是本片要消灭
-- 的那种猜测。
--
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
-- 迁移与 0019 / 0026 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
-- (internal/runtime/memory/temporal_schema.go 的 ensureTemporalSchema)走到
-- "duplicate column name" 是预期路径。列的形状必须与自举 DDL
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
-- internal/runtime/memory/temporal_migration_test.go 钉住。
--
-- 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入时的判定输入 +
-- 事后巡检的留痕列)。将来整理侧真要按它筛,再补索引不迟(两条路径一起加)。
--
-- 命名口径:表名复数(AGENTS §10 已登记例外)、列名与代码的 JSON 键同名
-- (`temporal`),与 0026 的 `retention_tier` 同族。
ALTER TABLE memory_items ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
+13 -20
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// maxResponseBytes caps how much of an /embeddings response body is read, so an // maxResponseBytes caps how much of an /embeddings response body is read, so an
@@ -21,8 +22,9 @@ import (
// for a normal batch fall far below this; the cap is a guard, not a limit. // for a normal batch fall far below this; the cap is a guard, not a limit.
const maxResponseBytes = 16 << 20 const maxResponseBytes = 16 << 20
// maxErrorBodyBytes caps how much of a non-OK response body is read for the // maxErrorBodyBytes 限制错误路径**读多少字节**(只用来数长度、取摘要、抽
// error message. // code/type):正文一个字节都不进 error,因此它不再决定「错误里带多少原文」。
// 非 2xx 是攻击面而不是数据面,上界刻意小。
const maxErrorBodyBytes = 4096 const maxErrorBodyBytes = 4096
// HTTP client tuning values, matching the recommended pool in // HTTP client tuning values, matching the recommended pool in
@@ -229,13 +231,18 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector,
resp, err := e.client.Do(req) resp, err := e.client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("embedding: request: %w", err) return nil, fmt.Errorf("embedding: request: %w", trace.URLCause(err))
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
msg := readErrorBody(resp.Body) // 这一处是全片最敏感的一条:/embeddings 的 **input 就是她的记忆正文**,
return nil, fmt.Errorf("embedding: status %d: %s", resp.StatusCode, msg) // 而兼容层常把 input 原样回显在错误体里(`Invalid value: '<正文>'`)。
// 因此正文只用来数长度、取摘要、抽 code/type,一个字节都不进 error ——
// 口径由 trace.UpstreamError(M40 共享 helper)统一决定。
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, fmt.Errorf("embedding: %w", trace.UpstreamError(
trace.HostOf(e.cfg.APIURL), resp.StatusCode, resp.Header.Get("Content-Type"), body))
} }
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
@@ -291,7 +298,7 @@ func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil { if err != nil {
return nil, fmt.Errorf("embedding: build request: %w", err) return nil, fmt.Errorf("embedding: build request: %w", trace.URLCause(err))
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" { if e.cfg.APIKey != "" {
@@ -300,20 +307,6 @@ func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (
return req, nil return req, nil
} }
// readErrorBody drains a small prefix of a non-OK response body into a message,
// capped at maxErrorBodyBytes so an error response cannot exhaust memory.
func readErrorBody(r io.Reader) string {
raw, err := io.ReadAll(io.LimitReader(r, maxErrorBodyBytes))
if err != nil {
return err.Error()
}
msg := strings.TrimSpace(string(raw))
if msg == "" {
return "bad status"
}
return msg
}
// defaultHTTPClient builds the HTTP client configuration described in // defaultHTTPClient builds the HTTP client configuration described in
// DevDocs/02-development-constraints.md §2.3: tuned idle/TLS timeouts plus a // DevDocs/02-development-constraints.md §2.3: tuned idle/TLS timeouts plus a
// total request timeout. A fresh client is returned per call (never a package // total request timeout. A fresh client is returned per call (never a package
+36
View File
@@ -135,6 +135,42 @@ func TestAPIEmbedder_Non200Errors(t *testing.T) {
} }
} }
// TestAPIEmbedder_Non200DropsEchoedInput 覆盖 M41 里最敏感的一处:/embeddings 的
// **input 就是她的记忆正文**,而兼容层常把 input 原样回显在错误体里。旧实现读
// 4096 字节响应体、整个拼进 error,而 error 会被 retrieve_fallback 等位置打日志。
//
// 断言的是「文本里搜不到正文」,不是「error 非空」—— 后者在旧实现下同样成立,
// 打不红。
func TestAPIEmbedder_Non200DropsEchoedInput(t *testing.T) {
t.Parallel()
const memo = "SENTINEL-MEMORY-INPUT-4a7d"
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
body := `{"error":{"code":"invalid_request_error","param":"input",` +
`"message":"Invalid value: '` + memo + `'"}}`
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"},
WithRoundTripper(rt),
)
_, err := emb.Embed(context.Background(), []string{memo})
if err == nil {
t.Fatal("expected error on 400, got nil")
}
if strings.Contains(err.Error(), memo) {
t.Errorf("error text echoes the memory text: %v", err)
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("err = %v, want the status kept for triage", err)
}
}
func TestAPIEmbedder_MismatchedCountErrors(t *testing.T) { func TestAPIEmbedder_MismatchedCountErrors(t *testing.T) {
t.Parallel() t.Parallel()
+4 -1
View File
@@ -5,6 +5,8 @@ import (
"net" "net"
"net/url" "net/url"
"strings" "strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写 // 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写
@@ -39,7 +41,8 @@ const loopbackIPv4 = "127.0.0.1"
func normalizeEndpointURL(raw string) (string, error) { func normalizeEndpointURL(raw string) (string, error) {
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil { if err != nil {
return "", fmt.Errorf("embedding: parse api url: %w", err) // 不回原文:url.Parse 的 *url.Error 会把整条地址(query 里可能是 api_key)渲染进去。
return "", fmt.Errorf("embedding: parse api url: %w", trace.URLCause(err))
} }
if u.Scheme != "http" && u.Scheme != "https" { if u.Scheme != "http" && u.Scheme != "https" {
// 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题, // 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题,
@@ -0,0 +1,57 @@
package embedding
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// embedding 这一处一行没改也应当自动安全 —— 兼容层把散文塞进 error.code /
// error.type 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
// 这一处最敏感:/embeddings 的 input 就是她的记忆正文,回显它的散文不能进日志。
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestAPIEmbedder_Non200DropsFreeTextErrorCode 400 报文的 error.code / error.type
// 里带散文 ⇒ 两个字段整体消失,而状态码与体积摘要仍在。
func TestAPIEmbedder_Non200DropsFreeTextErrorCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"},
WithRoundTripper(rt),
)
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("400 却报成功")
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("embed call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
for _, want := range []string{"status=400", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
@@ -0,0 +1,89 @@
package embedding
// 本文件是 M52 的出口回归探针:embedding 客户端里「把 net/http 的 *url.Error 直接
// 打进错误串」的两处 —— `embedChunk` 的传输失败与 `normalizeEndpointURL` 的解析失败 ——
// 一律先过 trace.URLCause。
//
// 为什么这两处特别敏感:/embeddings 的端点常常配成带 `?api_key=` 的网关地址,而
// `client.Do` 失败时 net/http 会把**整条地址**渲染进错误串(密码被打码、用户名与 query
// 不打码)。原文进日志就等于把网关凭据交出去。
//
// 这一包与 model/vision 的差别:这里原本用的是 `%w`(不是 `%v`),因此除了「去地址」,
// 还必须钉住 `%w` 的链没被削弱 —— 把 URLCause 换成「新建一个错误」会让
// errors.Is(err, context.DeadlineExceeded) 变红。
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
const (
urlCauseQuerySecret = "SECRET-2f8a1c"
urlCauseUser = "svc-user"
urlCauseBadHost = "bad host.example.com"
)
func embedURLCauseGoodURL() string {
return "https://" + urlCauseUser + ":svc-pass@embed.example.com/v1/embeddings?api_key=" + urlCauseQuerySecret
}
func embedURLCauseBadURL() string {
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1/embeddings?api_key=" + urlCauseQuerySecret
}
// TestEmbedDropsURLFromTransportError 钉住 embedChunk 里 `e.client.Do` 失败那一处,
// 并钉住 `%w` 的哨兵链仍然成立(context.DeadlineExceeded 可 errors.Is)。
func TestEmbedDropsURLFromTransportError(t *testing.T) {
t.Parallel()
emb := NewAPI(
config.EmbeddingConfig{APIURL: embedURLCauseGoodURL()},
WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, fmt.Errorf("net/http: %w", context.DeadlineExceeded)
})),
)
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) {
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
}
if strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
}
if !strings.Contains(err.Error(), "deadline exceeded") {
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
}
// `%w` 链必须还在:URLCause 只剥 *url.Error 外壳,内层原因原样返回。
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want errors.Is(context.DeadlineExceeded)(哨兵链不许被带偏)", err)
}
}
// TestEmbedDropsURLFromParseError 钉住 normalizeEndpointURL 的解析失败:
// url.Parse 的 *url.Error 里就是整条 api URL。
func TestEmbedDropsURLFromParseError(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: embedURLCauseBadURL()})
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("解析失败的串带出了 api URL 里的凭据:%v", err)
}
if !strings.Contains(err.Error(), "invalid character") {
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
}
if !strings.Contains(err.Error(), "parse api url") {
t.Fatalf("错误串丢了来源标记:%v", err)
}
}
+14
View File
@@ -85,6 +85,11 @@ type Result struct {
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 / // 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。 // 读不到」—— 没有它,抽取失败对客户端就是不可见的。
Documents []history.DocumentRef Documents []history.DocumentRef
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
// 字段语义详见 OutboundVoice。
Voice *OutboundVoice
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次 // docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
// Result,绝不跨 Run 共享。 // Result,绝不跨 Run 共享。
docSeen map[string]int docSeen map[string]int
@@ -138,6 +143,12 @@ type Loop struct {
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部 // 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。 // 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace skillFace *SkillFace
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
// speakBudget 是超时预算的替换公式,nil 用内置公式。
voiceSynth VoiceSynth
voiceStore VoiceStore
speakBudget func(int) int
log *slog.Logger log *slog.Logger
} }
@@ -211,6 +222,9 @@ func New(opts Options) *Loop {
imageResolver: opts.Vision, imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver, documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars, documentInjectChars: docInjectChars,
voiceSynth: opts.VoiceSynth,
voiceStore: opts.VoiceStore,
speakBudget: opts.SpeakBudget,
log: sourcedLog(opts.Log), log: sourcedLog(opts.Log),
} }
} }
+9
View File
@@ -214,6 +214,15 @@ type Options struct {
// 或重排模型所见的消息。它是循环中与 // 或重排模型所见的消息。它是循环中与
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。 // OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// Log 是基础 logger。为 nil 时替换为标记来源 // Log 是基础 logger。为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。 // "runtime/loop" 的丢弃 logger。
Log *slog.Logger Log *slog.Logger
+2 -2
View File
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
} }
schemas := d.ToolSchemas() schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() { if l.capacity == nil || !l.capacity.Enabled() {
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)) return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
} }
names := make([]string, 0, len(schemas)) names := make([]string, 0, len(schemas))
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
out = append(out, s) out = append(out, s)
} }
} }
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)) return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
} }
// capacityTexts 为 capacity 判定器提取近期对话文本: // capacityTexts 为 capacity 判定器提取近期对话文本:
+1
View File
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
} }
msgs = batch.messages msgs = batch.messages
res.ToolCallsExecuted += batch.executed res.ToolCallsExecuted += batch.executed
applyBatchVoice(res, &batch)
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls) l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案 // 关键工具循环安全网(或终止钩子)不等真正的最终答案
@@ -0,0 +1,199 @@
package loop
import (
"bytes"
"context"
"encoding/json"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 M40 的工具侧写盘边界,分两层各钉一遍:
//
// - **循环层**:真跑一次循环,按 writer 收到的**原始 SpanRecord**断言 —— tool
// span 里根本没有 `result` 原文(曾经是原样落盘的工具结果全文,M38 排查出的
// 最严重一条),只有 result_summary(已脱敏、已截断,供回看面读)与
// result_digest(字节/rune/sha256 前缀);
// - **写侧**:同一份记录的 stderr JSONL 字节里搜不到正文。
//
// 两层分开断言是刻意的:只测 JSONL 的话,写侧白名单会替循环层的漏做遮挡,把
// 「span 里曾经存着正文」这件事测不出来(反过来也一样)。
//
// 口径:工具结果正文留在会话库,span 只留摘要与指纹 —— 于是「成功还是失败、
// 输出多大、两次是不是同一份」仍可分辨,脱敏不该把 span 变得没用。
// captureWriter 收下每个 span 的**原始**记录(未经写侧投影),供测试按 span 层断言。
type captureWriter struct {
mu sync.Mutex
recs []trace.SpanRecord
}
// WriteSpan 记录一份 span。
func (c *captureWriter) WriteSpan(rec trace.SpanRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
c.recs = append(c.recs, rec)
return nil
}
// snapshot 返回已收集记录的副本。
func (c *captureWriter) snapshot() []trace.SpanRecord {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]trace.SpanRecord, len(c.recs))
copy(out, c.recs)
return out
}
// teeWriter 把同一份 span 同时交给「原始记录捕获器」与 JSONL 记录器,使两层
// 断言落在同一次运行上。
type teeWriter []trace.Writer
// WriteSpan 依次写入每个出口,返回第一个错误。
func (t teeWriter) WriteSpan(rec trace.SpanRecord) error {
var firstErr error
for _, w := range t {
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// runToolTurnTraced 跑一个「一次工具调用 + 收尾」的回合,返回原始 span 记录与
// stderr JSONL 的原始字节。
func runToolTurnTraced(t *testing.T, toolResult any, toolErr error) ([]trace.SpanRecord, string) {
t.Helper()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: toolResult, err: toolErr})
a := newTestAgent(t, ad, td)
var buf bytes.Buffer
capture := &captureWriter{}
writer := teeWriter{capture, trace.NewJSONRecorder(&buf)}
l := New(Options{Agent: a, Tracer: trace.New(writer), MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
return capture.snapshot(), buf.String()
}
// TestRun_ToolResultNeverEntersSpan 是循环层的泄漏钉:工具结果里有一段哨兵正文,
// span 的 props 与 stderr JSONL 里都搜不到它。
//
// 哨兵刻意放在**摘要窗口之外**:结果摘要(result_summary,回看面要读的那份)按
// 设计就是结果的有界前缀(80 rune),它是摘要、不是正文 —— 本测试钉的是「整段
// 结果不再落盘」,因此不能把断言的前提设成「连摘要都不许有」。
func TestRun_ToolResultNeverEntersSpan(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-TOOL-BODY-3b7d-工具结果的后半段"
result := strings.Repeat("拿到的报表行:", 40) + sentinel
recs, raw := runToolTurnTraced(t, result, nil)
span := toolSpanOf(t, recs)
if _, ok := span.Props["result"]; ok {
t.Fatalf("tool span 仍带着 result 原文:%+v", span.Props)
}
if _, ok := span.Props["text"]; ok {
t.Fatalf("tool span 带着 text 原文:%+v", span.Props)
}
propsBlob := mustMarshal(t, span.Props)
if strings.Contains(propsBlob, sentinel) {
t.Fatalf("tool span 的 props 带着正文:%s", propsBlob)
}
digest, ok := span.Props["result_digest"].(trace.Digest)
if !ok {
t.Fatalf("tool span 没有 result_digest:%+v", span.Props)
}
if digest.Bytes != len(result) {
t.Fatalf("result_digest.Bytes = %d, want %d(输出大小必须仍可读)", digest.Bytes, len(result))
}
summary, _ := span.Props["result_summary"].(string)
if summary == "" {
t.Fatalf("result_summary 缺席:回看面会失去结果摘要:%+v", span.Props)
}
if n := len([]rune(summary)); n > 81 {
t.Fatalf("result_summary 长度 = %d rune,want 有界(摘要不是正文)", n)
}
// 写侧:同一份记录落盘后仍搜不到正文,也不含任何结果原文的长尾。
if strings.Contains(raw, sentinel) {
t.Fatalf("工具结果正文落进了 JSONL:%s", raw)
}
if strings.Contains(raw, strings.Repeat("拿到的报表行:", 16)) {
t.Fatalf("JSONL 里出现了超出一份摘要的结果原文:%s", raw)
}
// 归属标识反而必须原文可读(主人 2026-10-26 裁定:session 是业务标识,不是
// 正文;排障要把一条 stderr 日志对回某个会话)。它可读与正文不可见并不矛盾,
// 这一条与上一条同时成立才是这一片的验收口径。
if !strings.Contains(raw, `"session":"sess"`) {
t.Fatalf("JSONL 里 session 不可读:%s", raw)
}
}
// TestRun_ToolSpanStillTellsOKFromError 是「脱敏不能把 span 变得没用」的验收钉:
// 成功与失败在落盘上仍可分辨 —— 终态不同、失败的带已脱敏的 error、输出体积可读。
func TestRun_ToolSpanStillTellsOKFromError(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
small := "ok"
large := strings.Repeat("失", 500)
okRecs, okRaw := runToolTurnTraced(t, small, nil)
badRecs, badRaw := runToolTurnTraced(t, nil, &toolFailErr{msg: "boom: token=" + secret + " " + large})
okSpan, badSpan := toolSpanOf(t, okRecs), toolSpanOf(t, badRecs)
if okSpan.Status != trace.SpanStatusOK || badSpan.Status != trace.SpanStatusError {
t.Fatalf("status = %q / %q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDigest, _ := okSpan.Props["result_digest"].(trace.Digest)
if okDigest.Bytes != len(small) {
t.Fatalf("成功调用的 result_digest.Bytes = %d, want %d", okDigest.Bytes, len(small))
}
// 失败侧写盘面:错误原因仍可读("boom"),凭据已打码,长度有界。
for _, surface := range []string{okRaw, badRaw} {
if strings.Contains(surface, secret) {
t.Fatalf("JSONL 带着凭据原文:%s", surface)
}
}
if !strings.Contains(badRaw, "boom") {
t.Fatalf("失败落盘里没有可诊断的原因:%s", badRaw)
}
if strings.Contains(badRaw, large) {
t.Fatalf("失败落盘里带着整段错误文本:%s", badRaw)
}
}
// toolFailErr 是一个只提供 Error() 的工具失败(模拟工具自己拼的错误文本)。
type toolFailErr struct{ msg string }
func (e *toolFailErr) Error() string { return e.msg }
// toolSpanOf 在记录里取那条工具 span。
func toolSpanOf(t *testing.T, recs []trace.SpanRecord) trace.SpanRecord {
t.Helper()
for _, rec := range recs {
if rec.Name == "tool.echo" {
return rec
}
}
t.Fatalf("记录里没有 tool.echo span:%+v", recs)
return trace.SpanRecord{}
}
// mustMarshal 把一段东西序列化成 JSON 文本,用于「搜不到正文」的断言。
func mustMarshal(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}
+21
View File
@@ -126,6 +126,27 @@ func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutco
_ = span.Record(trace.Event{Type: EventToolDone, Props: props}) _ = span.Record(trace.Event{Type: EventToolDone, Props: props})
} }
// recordToolResult 把一次**成功**调用的结果落成两样东西,都不是正文:
//
// - result_summary:与 `tool_execution.end` 帧**逐字同源**的已脱敏、已截断
// 摘要(toolFrameResultSummary,同一条 trace.RedactText 路径)—— 回看面
// `toolCalls[].resultSummary` 读的就是它;
// - result_digest:字节数 / rune 数 / 内容 sha256 前缀(trace.DigestOfValue)。
//
// 🔴 原文绝不进 span:工具结果正文本来就留在会话库里,span 与 stderr 的 JSONL
// 都不需要它,而它们正是最容易扩散的两个落盘面(一次 stderr tail 就能把整段
// 工具输出拷走)。排查「这次调用拿到了多大一份东西、两次是不是同一份」靠
// Digest 就够了。
func recordToolResult(span *trace.Span, result any) {
if span == nil {
return
}
if summary := toolFrameResultSummary(result); summary != "" {
_ = span.SetProp("result_summary", summary)
}
_ = span.SetProp("result_digest", trace.DigestOfValue(result))
}
// argSummary 生成工具参数的「值摘要」:只放行 argValueKeys 表内的键,且 // argSummary 生成工具参数的「值摘要」:只放行 argValueKeys 表内的键,且
// 密钥类键一律不给值(trace.IsSecretKey 与出图清洗同一判据);非标量值只给 // 密钥类键一律不给值(trace.IsSecretKey 与出图清洗同一判据);非标量值只给
// 占位(数组(N)/对象(N)),因为容器里可能藏着正文或凭据。键按字典序处理, // 占位(数组(N)/对象(N)),因为容器里可能藏着正文或凭据。键按字典序处理,
+336
View File
@@ -0,0 +1,336 @@
package loop
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
// 调了 speak = 这条想用语音;不调 = 发文本。
//
// 三条执行口径:
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
speakToolName = "speak"
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
speakBudgetBaseMs = 3000
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
speakBudgetPerRuneMs = 25
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
// 绝不让她的一条消息把整个回合挂住。
speakBudgetMaxMs = 20000
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
speakMaxRunes = 1200
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
speakAudioOrigName = "speak."
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
// 客户端据此决定怎么显示「这条本来想用语音」)。
speakReasonTimeout = "tts_timeout"
speakReasonFailed = "tts_failed"
speakReasonNoAudio = "tts_no_audio"
speakReasonStoreFailed = "tts_store_failed"
speakReasonTooLong = "text_too_long"
speakReasonInvalidArgs = "invalid_args"
)
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
type speakTool struct {
synth VoiceSynth
store VoiceStore
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
budget func(runes int) int
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
// (发音人绑人格,链本身不动)。
persona func(context.Context) string
log *slog.Logger
}
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
var _ sdk.Tool = speakTool{}
// Name 返回工具标识。
func (speakTool) Name() string { return speakToolName }
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
func (speakTool) Description() string {
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
}
// Parameters 返回工具输入的 JSON Schema。
func (speakTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
},
"emotion": map[string]any{
"type": "string",
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
"这个字段只做额外微调,不会换掉发音人。",
},
},
"required": []string{"text"},
}, nil
}
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
type speakArgs struct {
text string
emotion string
runes int
}
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
in, err := parseSpeakArgs(args)
if err != nil {
return nil, err
}
if t.synth == nil || t.store == nil {
return nil, &speakError{Code: speakReasonFailed,
Err: errors.New("speak: voice synthesis is not configured")}
}
budget := t.budgetMs(in.runes)
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
defer cancel()
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
started := time.Now()
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
cost := int(time.Since(started) / time.Millisecond)
if err != nil {
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
}
ext, extOK := speakExt(audio.Format)
if len(audio.Data) == 0 || !extOK {
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
}
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
if err != nil {
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
}
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
return map[string]any{
"name": ref.Name,
"mime": ref.MIME,
"size": ref.Size,
"durationMs": int64(0),
"budgetMs": budget,
"costMs": cost,
}, nil
}
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
if t.persona != nil {
if pid := t.persona(turnCtx); pid != "" {
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
}
}
if emotion != "" {
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
}
return synthCtx
}
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
func (t speakTool) budgetMs(runes int) int {
if t.budget != nil {
if ms := t.budget(runes); ms > 0 {
return ms
}
}
return speakBudgetMs(runes)
}
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
code := speakReasonFailed
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
code = speakReasonTimeout
}
if t.log != nil {
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
t.log.Warn("speak degraded to text",
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
}
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
}
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
if t.log == nil {
return
}
t.log.Info("speak synthesized",
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
}
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
type speakError struct {
Code string
BudgetMs int
CostMs int
Err error
}
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
func (e *speakError) Error() string {
if e.Err == nil {
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
}
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
}
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
func (e *speakError) Unwrap() error { return e.Err }
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
// 归到 argument-validation 而不是执行失败。
func parseSpeakArgs(args any) (speakArgs, error) {
m, err := speakArgMap(args)
if err != nil {
return speakArgs{}, err
}
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
}
runes := utf8.RuneCountInString(text)
if runes > speakMaxRunes {
return speakArgs{}, &speakError{Code: speakReasonTooLong,
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
}
emotion, _ := m["emotion"].(string)
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
}
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
func speakArgMap(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
m, ok := args.(map[string]any)
if !ok {
return nil, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
}
return m, nil
}
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
func speakBudgetMs(runes int) int {
if runes < 0 {
runes = 0
}
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
if ms > speakBudgetMaxMs {
return speakBudgetMaxMs
}
if ms < speakBudgetBaseMs {
return speakBudgetBaseMs
}
return ms
}
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
// 绝不拿一个读不出 Content-Type 的名字落盘。
func speakExt(format string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(format)) {
case "":
return "wav", true
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
return strings.ToLower(strings.TrimSpace(format)), true
default:
return "", false
}
}
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
func (l *Loop) speakSpec() *model.ToolSpec {
if !l.voiceEnabled() {
return nil
}
t := l.speakTool()
params, err := t.Parameters()
if err != nil {
return nil
}
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
}
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
func (l *Loop) speakTool() speakTool {
return speakTool{
synth: l.voiceSynth,
store: l.voiceStore,
budget: l.speakBudget,
persona: l.personaFor,
log: l.log,
}
}
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
// panic 兜底与允许集软判对普通工具仍然生效。
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
if tc.Name == speakToolName && l.voiceEnabled() {
return l.speakTool().Execute(ctx, tc.Args)
}
if d == nil {
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
}
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
}
+334
View File
@@ -0,0 +1,334 @@
package loop
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
// audio 决定成功时的产物,provider 供日志断言。
type stubSynth struct {
audio mvoice.Audio
provider string
err error
delay time.Duration
gotText string
}
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
s.gotText = text
if s.delay > 0 {
select {
case <-time.After(s.delay):
case <-ctx.Done():
return mvoice.Audio{}, "", ctx.Err()
}
}
if s.err != nil {
return mvoice.Audio{}, "", s.err
}
return s.audio, s.provider, nil
}
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
type stubStore struct {
ref VoiceRef
err error
gotName string
gotData []byte
}
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
s.gotName = origName
s.gotData = data
if s.err != nil {
return VoiceRef{}, s.err
}
return s.ref, nil
}
// newSpeakTool 装配一个注入了桩的 speak 工具。
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
return speakTool{synth: synth, store: store}
}
// speakCodeOf 取出 speak 失败的结构化原因码。
func speakCodeOf(t *testing.T, err error) *speakError {
t.Helper()
var se *speakError
if !errors.As(err, &se) {
t.Fatalf("error %v is not *speakError", err)
}
return se
}
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
// 长文封顶,负数按 0 处理(绝不返回负预算)。
func TestSpeakBudgetMs(t *testing.T) {
t.Parallel()
cases := []struct {
runes int
want int
}{
{-5, speakBudgetBaseMs},
{0, speakBudgetBaseMs},
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
{680, speakBudgetMaxMs},
{speakMaxRunes, speakBudgetMaxMs},
}
for _, c := range cases {
if got := speakBudgetMs(c.runes); got != c.want {
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
}
}
}
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
// 名字落盘)。
func TestSpeakExt(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want string
ok bool
}{
{"", "wav", true},
{" ", "wav", true},
{"WAV", "wav", true},
{"mp3", "mp3", true},
{" Opus ", "", false},
{"exe", "", false},
}
for _, c := range cases {
got, ok := speakExt(c.format)
if got != c.want || ok != c.ok {
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
}
}
}
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
func TestParseSpeakArgs_Validation(t *testing.T) {
t.Parallel()
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
}
// 恰好上限、且是多字节:按 rune 计数,应当放行。
in, err := parseSpeakArgs(map[string]any{
"text": strings.Repeat("あ", speakMaxRunes),
"emotion": " 温柔 ",
})
if err != nil {
t.Fatalf("at-limit runes rejected: %v", err)
}
if in.runes != speakMaxRunes {
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
}
if in.emotion != "温柔" {
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
}
}
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
// 且 durationMs 恒为 0(未解析,不是零长度)。
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
t.Parallel()
synth := &stubSynth{
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
provider: "emotiontts",
}
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
tk := newSpeakTool(synth, store)
tk.budget = func(int) int { return 777 }
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
if len(store.gotData) != 3 {
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", out)
}
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
}
if m["durationMs"] != int64(0) {
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
}
if m["budgetMs"] != 777 {
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
}
if _, ok := m["costMs"].(int); !ok {
t.Fatalf("costMs = %v, want int", m["costMs"])
}
}
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
&stubStore{},
)
tk.budget = func(int) int { return 1 }
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonTimeout {
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
}
if se.BudgetMs != 1 {
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
}
if se.CostMs < 0 {
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
}
}
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
t.Parallel()
cases := []struct {
name string
audio mvoice.Audio
}{
{"empty bytes", mvoice.Audio{Format: "wav"}},
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
}
if store.gotName != "" {
t.Fatalf("store was called with %q, want no write at all", store.gotName)
}
})
}
}
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
&stubStore{err: errors.New("disk full")},
)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonStoreFailed {
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
}
if !errors.Is(err, se.Err) {
t.Fatalf("error chain lost the store error: %v", err)
}
}
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
// 以结构化失败返回,使回合仍能继续。
func TestSpeakTool_NotConfigured(t *testing.T) {
t.Parallel()
for _, tk := range []speakTool{
newSpeakTool(nil, &stubStore{}),
newSpeakTool(&stubSynth{}, nil),
} {
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
}
}
}
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
func TestSpeakSpec(t *testing.T) {
t.Parallel()
if spec := New(Options{}).speakSpec(); spec != nil {
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
}
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
}
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
if spec == nil {
t.Fatal("wired speakSpec = nil, want a spec")
}
if spec.Name != speakToolName {
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
}
if spec.Parameters == nil {
t.Fatal("spec parameters = nil, want the JSON schema")
}
}
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
// (因此不受前面任何收缩影响)、已有同名不重复追加。
func TestWithSpeakSpec(t *testing.T) {
t.Parallel()
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
unwired := New(Options{})
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
}
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
got := wired.withSpeakSpec(base)
if len(got) != len(base)+1 {
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
}
if last := got[len(got)-1].Name; last != speakToolName {
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
}
again := wired.withSpeakSpec(got)
if len(again) != len(got) {
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
}
}
+6 -6
View File
@@ -26,15 +26,15 @@ import (
// toolFrameResultSummary 把工具返回值渲染成 loop 侧的结果摘要文本,口径与图上那份 // toolFrameResultSummary 把工具返回值渲染成 loop 侧的结果摘要文本,口径与图上那份
// `props.result_summary`(读回看面的数据源)**逐字一致**。 // `props.result_summary`(读回看面的数据源)**逐字一致**。
// //
// 图侧的取值链是:tool span 的 `result` prop → `trace.safeSpanProps` → // 图侧的取值链是:tool span 的 `result_summary` prop(由 spanlog.go 的
// `RedactText(summarize(result))`。`RedactText` 是导出 API(凭据形状清洗 + 截断, // recordToolResult 用**本函数**写入)→ `trace.safeSpanProps` 的白名单投影 →
// 连同密钥键名防线都在 trace/redact.go),但 `summarize` 只服务出图、未导出, // 节点 `props.result_summary`。因此两侧不再是「各自重述同一步渲染」,而是同一个
// 且 `internal/runtime/trace/**` 不在本切片文件面内 —— 因此这里按同一口径重述那一步 // 生产函数:span 里不再存结果原文(那正是 M40 修掉的写侧泄漏),出图也不可能
// 渲染(nil ⇒ 空、字符串原样、其余 JSON 编码,编码失败才回落 %v)。 // 再从原文反推 —— 摘要只在这里生成一次。
// //
// 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器** // 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器**
// (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 —— // (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 ——
// 照字面量写断言会与实现一起漂移。若哪天 trace 导出了这一步渲染,这里应改为直接调用。 // 照字面量写断言会与实现一起漂移。
func toolFrameResultSummary(result any) string { func toolFrameResultSummary(result any) string {
return trace.RedactText(renderToolResultSummary(result)) return trace.RedactText(renderToolResultSummary(result))
} }
+32
View File
@@ -3,6 +3,7 @@ package loop
import ( import (
"context" "context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
} }
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线 // emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中 // sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。 // 安全门仍能工作(软 not_allowed 结果)。
@@ -8,7 +8,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
) )
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
} }
} }
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
// 主人环境照常放行。
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
cases := []struct {
env restrict.ActorEnv
allow bool
}{
{restrict.ActorGroupMember, false},
{restrict.ActorStranger, false},
{restrict.ActorOwner, true},
}
for _, tc := range cases {
mu.Lock()
audited = nil
mu.Unlock()
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
actions := []toolCallAction{{run: true}}
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
if actions[0].run != tc.allow {
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
}
mu.Lock()
got := append([]AuditEvent(nil), audited...)
mu.Unlock()
if tc.allow {
if len(got) != 0 {
t.Fatalf("env %s audited %+v, want none", tc.env, got)
}
continue
}
if !strings.Contains(actions[0].reason, "not_allowed") {
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
}
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
}
}
// speak 未接线时没有可执行的旁路,门不该动任何调用。
actions := []toolCallAction{{run: true}}
New(Options{}).applyToolAdmissions(
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
nil,
[]agent.ToolCall{{Name: speakToolName}},
actions,
)
if !actions[0].run {
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
}
}
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。 // mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) { func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
t.Helper() t.Helper()
+17 -93
View File
@@ -5,12 +5,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用 // Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
@@ -77,6 +75,9 @@ type toolRunResult struct {
messages []model.Message messages []model.Message
executed int executed int
terminate bool terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
} }
// toolLoopState 携带关键工具循环安全网的每次 run 状态。 // toolLoopState 携带关键工具循环安全网的每次 run 状态。
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。 // 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes) l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮 // 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。 // (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) { if !res.terminate && shouldTerminateBatch(outcomes) {
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计; // 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。② // 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit // BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证 // 逐调用否决。
// 此时整批以错误回灌)。 //
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) { func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil { if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions) l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
} }
if l.hooks.BatchAdmit != nil { if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok { if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
} }
} }
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
l.spanProp(toolSpan, "result", result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 classifyToolBatch 为两种跳过原因返回。 // 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string { func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch" return "duplicate call with identical arguments already issued in this batch"
+107
View File
@@ -0,0 +1,107 @@
package loop
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := l.invokeTool(ctx, d, tc)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
Loaded 100 of 331 files, more files were not shown because too many files have changed in this diff. Show more