From 2d211c2d162645e71161a2ad662b1d2d96c5321a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 21:16:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(backthink,browser):=20=E8=87=AA=E6=8E=92?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=9D=A2=20C1-3=20+=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E4=B8=A4=E5=A4=84=E5=81=8F=E7=A6=BB=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88X2+X3=20=E5=90=8C=E6=96=87=E4=BB=B6=E4=B8=80=E5=B9=B6?= =?UTF-8?q?=E8=90=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X2 C1-3(自排工具面与提示词): - request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门 (安全闸 + owner 判定:群聊/陌生人一律拒) - **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换) - 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴 - 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等) X3 收口(与 X2 共用 wire_services.go ⇒ 一并落): - 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份) - 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动) - a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点 - 5 个变异自检 顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。 --- cmd/cyrene/wire_browser.go | 9 +- cmd/cyrene/wire_browser_lifecycle_test.go | 202 +++++++++ cmd/cyrene/wire_browser_test.go | 36 +- cmd/cyrene/wire_io_tools.go | 11 +- cmd/cyrene/wire_runtime.go | 5 + cmd/cyrene/wire_services.go | 59 +++ frontend/web/src/admin/modules/Backthink.tsx | 92 +++- internal/capability/browser/guard.go | 106 ----- internal/capability/browser/guard_test.go | 18 + internal/capability/browser/ssrf.go | 41 ++ .../config/runtimeconfig/backthink_test.go | 110 +++++ internal/runtime/capacity/capacity_test.go | 18 +- internal/runtime/capacity/signals.go | 12 +- internal/services/backthink/context.go | 131 ++++-- .../backthink/selfwake_prompt_test.go | 141 ++++++ internal/services/backthink/thinktool.go | 209 +++++++++ internal/services/backthink/thinktool_test.go | 404 ++++++++++++++++++ plugins/tools/browser/tool.go | 12 +- plugins/tools/io/ssrf.go | 48 ++- plugins/tools/io/ssrf_test.go | 80 ++++ 20 files changed, 1568 insertions(+), 176 deletions(-) create mode 100644 cmd/cyrene/wire_browser_lifecycle_test.go delete mode 100644 internal/capability/browser/guard.go create mode 100644 internal/capability/browser/ssrf.go create mode 100644 internal/config/runtimeconfig/backthink_test.go create mode 100644 internal/services/backthink/selfwake_prompt_test.go create mode 100644 internal/services/backthink/thinktool.go create mode 100644 internal/services/backthink/thinktool_test.go create mode 100644 plugins/tools/io/ssrf_test.go diff --git a/cmd/cyrene/wire_browser.go b/cmd/cyrene/wire_browser.go index 110b8be..f93fbef 100644 --- a/cmd/cyrene/wire_browser.go +++ b/cmd/cyrene/wire_browser.go @@ -22,10 +22,11 @@ package main // 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。 // 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。 // -// 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片 -// 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go` -// ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的 -// `wireBrowser()`(那一处有同样的说明)。 +// 关于挂点(切片 X3 归位):本文件的 `wireBrowser()` 由 `wireRuntime` 调用,位置与 +// `wireVision()` / `wireTranscode()` 并列 —— 三者同类(可选增强,不可用就不注册)。 +// 它**不再**借 `ioToolOptions()` 转交:那件函数是「组装 io 选项」的只读装配,顺带探测 +// 并注册浏览器会让所有只为要一份选项而调它的路径(web-search 的接线测试就是)连带起 +// 一次浏览器探测。它的关闭挂点在 `a.close`(wire_services.go),与这一行成对。 import ( "context" diff --git a/cmd/cyrene/wire_browser_lifecycle_test.go b/cmd/cyrene/wire_browser_lifecycle_test.go new file mode 100644 index 0000000..7335238 --- /dev/null +++ b/cmd/cyrene/wire_browser_lifecycle_test.go @@ -0,0 +1,202 @@ +package main + +// 本文件钉住 `browser` 工具的**生命周期两端**(切片 X3 的收口): +// +// - **装配端**:接线挂点在 `wireRuntime`(与 wireVision / wireTranscode 并列), +// **不再**借 `ioToolOptions()` 转交 —— 挂点是源码级事实,只有读源码才钉得住, +// 因此这里照 wire_channels_test.go 的既有做法断言那一行。 +// - **关闭端**:`browser` 工具持有**进程外**资源(我们起的真 Chromium 子进程 + CDP +// 连接 + profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程)。 +// 常驻用例证明「关闭链真的走到了那一把工具背后的管理器」;`CYRENE_BROWSER_E2E=1` +// 的真机用例进一步证明「真 Chrome 的调试端点真的不再应答」。 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// TestWireRuntime_CallsWireBrowser 钉住**挂点归位**(切片 X3):browser 的接线由 +// `wireRuntime` 调用,位置与 wireVision / wireTranscode 并列。 +// +// 为什么必须钉:这条调用是「浏览器工具在不在注册面里」的唯一开关,而它删掉之后 +// **没有任何运行期症状** —— 编译照过、启动照起、日志照打,只有模型那边少了一个工具 +// (本机有 Chrome 也不会注册)。这与 wire_channels_test.go 钉 VoiceTranscoder 注入 +// 是同一类判据:能力静默消失只能靠测试拦。 +func TestWireRuntime_CallsWireBrowser(t *testing.T) { + t.Parallel() + src := readSource(t, "wire_runtime.go") + if !strings.Contains(src, "\ta.wireBrowser()") { + t.Fatal("wire_runtime.go 里找不到 `a.wireBrowser()` 这一行 —— browser 工具不会被装配," + + "模型面里静默少一个工具(挂点应与之并列 wireVision / wireTranscode)") + } +} + +// TestAppClose_ClosesBrowserSession 钉住**关闭链真的关掉了浏览器管理器**。 +// +// 判据不是「Close 被调过」(那只能证明调用点存在),而是**关闭之后的第一个动作**: +// 管理器已进入 closed 态 ⇒ 如实报 ErrNoSession,而不是偷偷再拉起一个浏览器 +// (拉起会得到 ErrLaunch,两者一眼可分)。 +func TestAppClose_ClosesBrowserSession(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + // 可执行文件只需**存在**:本用例不启动它,只验证关闭链把管理器关停了。 + exe := filepath.Join(t.TempDir(), "fake-browser.exe") + if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { + t.Fatalf("造假可执行文件: %v", err) + } + raw, _ := json.Marshal(map[string]any{"executable": exe}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + cap, ok := browserEntry(a) + if !ok { + t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) + } + tool, ok := cap.Value.(sdk.Tool) + if !ok { + t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) + } + + a.close(context.Background()) + + if !strings.Contains(out.String(), "browser tool closed") { + t.Errorf("关闭链没有留下收尾记录(日志:%s)", out.String()) + } + if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) { + t.Fatalf("关闭链没有关掉浏览器管理器:err = %v,want ErrNoSession(ErrLaunch 说明它又拉起了一个)", err) + } +} + +// TestAppClose_RealChromeStopsOnShutdown 是真机端到端(默认 Skip):走**组合根的真实 +// 关闭链**关掉一个**真的在跑**的 Chrome,并验证它的调试端点不再应答。 +// +// 用法(Windows / Git Bash): +// +// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestAppClose_RealChromeStopsOnShutdown -v -timeout 5m +// +// 为什么默认 Skip:它会真的起一个浏览器(headless,不弹窗),属于「人工按需复跑」, +// 不该混进普通 `go test`(AGENTS §10 的压测纪律:验收里不无界占机)。 +func TestAppClose_RealChromeStopsOnShutdown(t *testing.T) { + if !browserE2EEnabled() { + t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)") + } + exe, ok := browsercfg.NewProber().FindLocalBrowser() + if !ok { + t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行") + } + cfg := wsLoadConfig(t) + raw, _ := json.Marshal(map[string]any{ + "executable": exe, + "headless": true, + "timeoutSeconds": 60, + "idleTimeoutSeconds": 600, + }) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + cap, ok := browserEntry(a) + if !ok { + t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) + } + tool, ok := cap.Value.(sdk.Tool) + if !ok { + t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + // ① 起一个真浏览器(指纹自检走 CDP,无需外网),并从 profile 读回它的调试端点。 + if _, err := tool.Execute(ctx, map[string]any{"action": "info"}); err != nil { + t.Fatalf("action=info(起真浏览器): %v", err) + } + endpoint := devToolsEndpoint(t, browsercfg.DefaultProfileDir(cfg.DataDir)) + if !endpointAlive(endpoint) { + t.Fatalf("会话已建立但调试端点 %s 不应答:本用例的前提不成立", endpoint) + } + t.Logf("真机前置:浏览器在跑,endpoint=%s", endpoint) + + // ② 走生产路径关闭:组合根的 a.close(不是直接 tool.Close)。 + shutCtx, shutCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutCancel() + a.close(shutCtx) + + // ③ 进程外的那个浏览器必须真的没了:调试端点不再应答(轮询给一点收尾时间)。 + if !waitEndpointGone(endpoint, 15*time.Second) { + t.Fatalf("a.close 之后 %s 仍在应答:浏览器没被关掉(孤儿 Chrome 会占着 profile 锁)", endpoint) + } + // ④ 管理器进入 closed 态:再调动作如实报「已关闭」,不是偷偷重起一个。 + if _, err := tool.Execute(context.Background(), map[string]any{"action": "info"}); !errors.Is(err, browsercap.ErrNoSession) { + t.Errorf("关闭后 err = %v, want ErrNoSession", err) + } + t.Logf("真机关闭链:endpoint=%s 已停,管理器态=closed(日志尾部:%s)", + endpoint, tailOfLog(out.String())) +} + +// devToolsEndpoint 从 profile 目录的 DevToolsActivePort 读出这次会话的调试端点 +// (格式与 launch.go 的 waitDevToolsPort 同源:首行是端口号)。 +func devToolsEndpoint(t *testing.T, profileDir string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(profileDir, "DevToolsActivePort")) + if err != nil { + t.Fatalf("读 DevToolsActivePort: %v(profile=%s)", err, profileDir) + } + line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n") + port, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || port <= 0 { + t.Fatalf("DevToolsActivePort 内容不合法: %q", raw) + } + return fmt.Sprintf("http://127.0.0.1:%d", port) +} + +// endpointAlive 报告调试端点是否在应答(一次 /json/version)。 +func endpointAlive(endpoint string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(endpoint + "/json/version") + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode == http.StatusOK +} + +// waitEndpointGone 等端点停止应答,最多等 budget。 +func waitEndpointGone(endpoint string, budget time.Duration) bool { + deadline := time.Now().Add(budget) + for { + if !endpointAlive(endpoint) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(200 * time.Millisecond) + } +} + +// tailOfLog 取日志末尾若干字符,供真机用例把现场证据打进 t.Log。 +func tailOfLog(s string) string { + const keep = 400 + if len(s) <= keep { + return s + } + return s[len(s)-keep:] +} diff --git a/cmd/cyrene/wire_browser_test.go b/cmd/cyrene/wire_browser_test.go index 6697042..b96b1b4 100644 --- a/cmd/cyrene/wire_browser_test.go +++ b/cmd/cyrene/wire_browser_test.go @@ -177,9 +177,14 @@ func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) { } } -// TestIOToolOptions_WiresBrowserExactlyOnce 钉住**挂点与幂等**:browser 的接线挂在 -// `ioToolOptions()`(组合根启动期唯一归属本片的装配入口)上,且重复调用不重复注册。 -func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) { +// TestIOToolOptions_DoesNotWireBrowser 钉住**临时挂点已删**(切片 X3 把挂点归位到 +// `wireRuntime`):browser 的接线原先借 `ioToolOptions()` 转交(V5 改不了 +// wire_runtime.go 时的权宜),代价是「取一份 io 选项」这件只读的事会顺带探测并注册 +// 浏览器工具 —— 所有只为要选项而调本函数的路径(web-search 的接线测试就是)都会连带 +// 起一次浏览器探测。归位后的挂点在 wire_runtime.go,由 +// TestWireRuntime_CallsWireBrowser 钉住;这里钉住 io 侧**不再有第二个挂点** —— +// 两处并存不会报错(函数幂等),只会静默多一次探测,因此只有测试能拦住它回流。 +func TestIOToolOptions_DoesNotWireBrowser(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t) exe := filepath.Join(t.TempDir(), "fake-browser.exe") @@ -192,11 +197,32 @@ func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) { a := browserTestApp(t, cfg, &out) a.ioToolOptions() + + if _, ok := browserEntry(a); ok { + t.Fatalf("ioToolOptions 不该再挂 browser(挂点已归位 wire_runtime.go;日志:%s)", out.String()) + } +} + +// TestWireBrowser_Idempotent 钉住**幂等**:同一个 app 上重复装配只发现工具已在注册表里 +// 并跳过,不重复注册、不重复探测。挂点归位后幂等仍是唯一的保险(两条路径万一偶遇)。 +func TestWireBrowser_Idempotent(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + exe := filepath.Join(t.TempDir(), "fake-browser.exe") + if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { + t.Fatalf("造假可执行文件: %v", err) + } + raw, _ := json.Marshal(map[string]any{"executable": exe}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() first, ok := browserEntry(a) if !ok { - t.Fatalf("ioToolOptions 应当把 browser 接上(日志:%s)", out.String()) + t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) } - a.ioToolOptions() + a.wireBrowser() second, ok := browserEntry(a) if !ok { t.Fatal("第二次调用后 browser 不该消失") diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index 4480945..9fba52d 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -81,13 +81,12 @@ const webSearchToolName = "web-search" // ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New, // 因此「注册面里有哪些 io 工具」只由这一处决定。 // -// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配 -// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的 -// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的 -// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且 -// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。 +// 它**只做 io 选项**(切片 X3 的收口):browser 的接线挂点原先是借这里转交的 +// (V5 当时改不了 wire_runtime.go),现已归位到 `wireRuntime` 里与 `wireVision` / +// `wireTranscode` 并列的那一行(见 wire_runtime.go)。转交留下的第二个代价是副作用: +// 凡是只为了要一份 iotools.Option 而调用本函数的路径(例如 web-search 的接线测试) +// 都会顺带探测并注册浏览器工具 —— 那不该是「取选项」这件事的含义。 func (a *app) ioToolOptions() []iotools.Option { - a.wireBrowser() opts := []iotools.Option{ iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log), diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 42068b4..b343fed 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -177,6 +177,11 @@ func (a *app) wireRuntime() error { } a.wireVision() a.wireTranscode() + a.wireBrowser() + // 浏览器工具(design/28 B1 接线,切片 V5;挂点由 X3 归位到这里): + // 与 vision / transcode 同族 —— 可选增强,不可用就**不注册**(模型看不见这个 + // 工具,而不是看得见、一调就报错),失败不阻塞启动;装配细节与六条口径都在 + // wire_browser.go 一处。它的关闭挂点在 a.close(wire_services.go),成对。 // 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时; // 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在 diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 8b981d1..81410df 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "path/filepath" "strings" "time" @@ -22,6 +23,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、 @@ -96,9 +98,38 @@ func (a *app) wireAuxiliary() error { // capability_list 工具。排在最后,是为了让它列出的清单已经包含前面各步 // 注册进来的工具(terminal / subagent / session_read 等)。 a.wireCapabilitySurface() + // design/12 §N C.3:把「自排一次思考」暴露成能力注册表里的 request_think 工具。 + // 排在 wireCapabilitySurface 之后无妨 —— 清单是调用时实时读注册表的,顺序不影响 + // 它列出谁。 + a.wireSelfWakeTool() return nil } +// wireSelfWakeTool 把 request_think 注册进能力面(design/12 §N C.3): +// `a.dispatcher.Register(backthink.NewThinkTool(a.backthink))`。 +// +// 时序安全:a.backthink 在 wireAuxiliary 上半段(本函数里)就建好了,而 a.loop / +// a.dispatcher 在更早的 wireRuntime 已就位 ⇒ 这一步不需要新的装配顺序。 +// +// 与 capability_list 同口径:**失败只告警、绝不阻塞启动** —— 少一个工具不是启动失败 +// 的理由(自排的其余面:判定、闸、审计都已在 backthink 内部就绪)。 +func (a *app) wireSelfWakeTool() { + log := logger.WithSource(a.log, "services/backthink") + if a.backthink == nil { + log.Warn("self wake tool register skipped: no backthink service") + return + } + if a.dispatcher == nil { + log.Warn("self wake tool register skipped: no dispatcher") + return + } + if err := a.dispatcher.Register(backthink.NewThinkTool(a.backthink)); err != nil { + log.Warn("self wake tool register skipped", "error", err) + return + } + log.Info("self wake tool registered", "tool", backthink.ThinkToolName) +} + // buildSkillFace 构造技能面(SK-5,design/09 §6.3):来源是能力注册表里的 // KindSkill 条目 —— 启动时 wireSkills 载入的与面板热装的技能都在同一处,且 // **每轮实时读取**,因此热装技能下一轮就出现在模型清单里,无需重启。 @@ -197,6 +228,34 @@ func (a *app) close(ctx context.Context) { if a.gw != nil { a.gw.Close() } + // 浏览器会话(切片 X3 把 V5 留下的挂点接在这里):`browser` 工具持有**进程外** + // 资源 —— 我们起的真 Chromium 子进程、一条 CDP 连接、profile 上的 SingletonLock, + // 因此必须显式关掉,而不是交给进程退出。 + // + // 位置的三条理由(顺序在这里是有含义的): + // 1. **排在渠道 / 调度器 / 网关之后**:这三步停掉的是「还会发起工具调用的入口」 + // (IM → 子代理 → Web),此刻浏览器不会再从某个正在跑的回合底下被抽走; + // 2. **排在 MCP / 插件 / 任务之前**:它属于能力面,与那几步同类,且不碰存储 + // (db / bus 都在后面),提前关不影响任何后续关停; + // 3. **绝不能拖到进程退出以后**:Windows 上父进程退出不会带走子进程,孤儿 Chrome + // 会继续占着 profile 锁,下次启动就得靠「认领」绕过去 —— 早一行关掉,孤儿就 + // 少一分。`Manager.Close` 自带超时(不等 shutCtx),所以这一步不会被关停预算卡住。 + // + // 工具对象没有单独的 app 字段(能力注册面就是它的家),因此按工具名取回:没注册 + // (本机没浏览器)就是空操作,关闭链不因它存在与否改道。 + if a.caps != nil { + if entry, err := a.caps.Get(browsertools.ToolName); err == nil { + if closer, ok := entry.Value.(io.Closer); ok { + if cerr := closer.Close(); cerr != nil { + logger.WithSource(a.log, "cmd/cyrene").Warn("browser tool close failed", "error", cerr) + } else { + // 一行可追溯的收尾记录:排障时「退出后还有没有残留的浏览器」看的 + // 就是它(孤儿 Chrome 会占着 profile 锁,下一次启动要靠认领绕过去)。 + logger.WithSource(a.log, "cmd/cyrene").Info("browser tool closed") + } + } + } + } for _, reg := range a.mcpRegs { _ = reg.Unregister(ctx) } diff --git a/frontend/web/src/admin/modules/Backthink.tsx b/frontend/web/src/admin/modules/Backthink.tsx index 1439297..47977a3 100644 --- a/frontend/web/src/admin/modules/Backthink.tsx +++ b/frontend/web/src/admin/modules/Backthink.tsx @@ -23,6 +23,42 @@ const PUSH_ROUTE_LABEL: Record = { none: "无投递目标", }; +/** 触发源的中文标签(后端 `Record.Trigger` / `Trigger.Source` 的取值域,design/12 §C.6)。 + * `self_wake` 是她自己排起的一轮(`request_think`);其余是周期性轮与事件主题。 */ +export const TRIGGER_LABEL: Record = { + heartbeat: "例行心跳", + startup: "启动问候", + self_wake: "自我唤醒", +}; + +/** 触发源的显示文本:**未知取值原样返回**,与 `PUSH_ROUTE_LABEL` 同口径 —— 绝不猜一个 + * 更好听的词,也绝不把不认识的取值藏起来(事件主题 `iot.device_update` 等就走这一条)。 + * 抽成函数是为了让「未知原样显示」这条口径可被直接钉住,而不是靠读 JSX 里的 `??`。 */ +export function triggerLabel(trigger: string): string { + return TRIGGER_LABEL[trigger] ?? trigger; +} + +/** 触发源筛选轴。它与「打扰」筛选轴**取交集**,两轴语义互斥、不挤进同一个 enum。 */ +export type TriggerFilter = "all" | "self_wake" | "heartbeat"; + +/** 打扰筛选轴的取值。 */ +export type DisturbFilter = "all" | "notify" | "silent"; + +/** 一行是否通过**两轴**筛选(纯函数:组件只做接线,判定可以直接钉住)。 + * + * 两轴取**交集**而不是并集,也不是把四个语义挤进一个 enum —— 「静默 + 自我唤醒」 + * 这类组合因此是可表达的。筛选作用在**客户端已拉取的那一页**上(见下方 Notice)。 */ +export function backthinkVisible( + item: BackthinkItem, + filter: DisturbFilter, + triggerFilter: TriggerFilter +): boolean { + const notifyOK = + filter === "all" || + (filter === "notify" ? item.shouldNotify === true : item.shouldNotify !== true); + return notifyOK && (triggerFilter === "all" || item.trigger === triggerFilter); +} + /** 推送路由的色调:真的送出去了才用 ok,被限频拦住是 warn。 */ function pushRouteTone(route: string): "ok" | "warn" | "neutral" { if (route === "active_client" || route === "im_bridge") return "ok"; @@ -30,7 +66,13 @@ function pushRouteTone(route: string): "ok" | "warn" | "neutral" { return "neutral"; } -function BackthinkRow({ +/** 单行:时间 / 触发源标签 / 打扰与推送结果 / 紧急度标记 / 结论 / 链路导图入口。 + * + * 导出是为了让「触发源标签」这条口径能被**真实渲染**直接钉住(design/12 §C.6 要求 + * `self_wake` 显示成「自我唤醒」、未知取值原样显示)—— 只喂 `item` prop 即可渲染; + * 它从 store 读的三处(`flowGraphs` / `wsStatus` / `markBackthinkPushed`)取初始值即可 + * 工作,不依赖任何运行期数据。 */ +export function BackthinkRow({ item, graphSource, }: { @@ -49,7 +91,7 @@ function BackthinkRow({
{new Date(item.at).toLocaleTimeString()} - {item.trigger} + {triggerLabel(item.trigger)} {item.shouldNotify ? "需要打扰" : "静默"} {item.pushed ? "已推送" : "未推送"} {item.route ? {PUSH_ROUTE_LABEL[item.route] ?? item.route} : null} @@ -130,7 +172,10 @@ export function BackthinkModule(): React.ReactNode { // 先只读拉一次 sess-backthink 的**最近一轮**图(否则每一行都只显示「尚未产生链路图」)。 // 一次拉取在模块级做(不是每行一个 hook)—— 行数多时不会打出 N 个重复请求。 const graphSource = useFlowSnapshot(BACKTHINK_SESSION_ID); - const [filter, setFilter] = useState<"all" | "notify" | "silent">("all"); + const [filter, setFilter] = useState("all"); + // 触发源是**独立的第二筛选轴**(design/12 §C.6):与「打扰」轴取交集,而不是挤进 + // 同一个 enum(那样「静默 + 自我唤醒」这类组合就不可表达了)。 + const [triggerFilter, setTriggerFilter] = useState("all"); const [interveneHint, setInterveneHint] = useState(false); // Cold-load persisted history once (design/12 §4); real-time updates come from the // WS `backthink` events already feeding useAppStore.backthink (head-inserted), so we @@ -140,13 +185,10 @@ export function BackthinkModule(): React.ReactNode { const byId = new Map(); for (const b of backthink) if (b.id) byId.set(b.id, b); for (const b of rec.data ?? []) if (b.id) byId.set(b.id, b); - const items = Array.from(byId.values()) + const allItems = Array.from(byId.values()); + const items = allItems .sort((a, b) => (b.at ?? 0) - (a.at ?? 0)) - .filter((b) => { - if (filter === "notify") return b.shouldNotify === true; - if (filter === "silent") return b.shouldNotify !== true; - return true; - }); + .filter((b) => backthinkVisible(b, filter, triggerFilter)); return (
@@ -172,14 +214,34 @@ export function BackthinkModule(): React.ReactNode { -
- setFilter("all")}>全部 - setFilter("notify")}>需打扰 - setFilter("silent")}>静默 +
+
+ 打扰 + setFilter("all")}>全部 + setFilter("notify")}>需打扰 + setFilter("silent")}>静默 +
+
+ 触发源 + setTriggerFilter("all")}>全部 + setTriggerFilter("self_wake")}>自我唤醒 + setTriggerFilter("heartbeat")}>例行心跳 + + 显示 {items.length} / {allItems.length} 条 · 两个筛选轴取交集,且只筛当前已拉取的这一页(不是全部历史) + +
{items.length === 0 ? ( - } title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" /> + } + title={allItems.length === 0 ? "暂无后台思考" : "当前筛选下没有匹配的记录"} + hint={ + allItems.length === 0 + ? "后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" + : `本页共 ${allItems.length} 条记录,没有一条落在当前筛选(打扰 / 触发源)里 —— 这不等于「这件事没发生」,换个筛选或看「全部」即可。` + } + /> ) : (
{items.map((b) => ( @@ -189,6 +251,8 @@ export function BackthinkModule(): React.ReactNode { )} 数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。 +
+ 触发源标签(契约 §C.6,形状不变、仅值域扩展):`self_wake` 显示为「自我唤醒」= 她**自己**用 `request_think` 给自己排起的一轮(不是心跳、也不是外部事件),`heartbeat` / `startup` 分别显示为「例行心跳」/「启动问候」;其余取值(`iot.device_update` / `reminder.trigger` / `system.event` 等事件主题)与将来新增的未知取值**一律原样显示**,绝不猜一个更好听的词。⚠️ 「触发源」筛选是**客户端**筛选,只作用于下表**已拉取的这一页**(历史最多 100 条 + 本次会话实时帧),因此「筛不到」不等于「没发生」;服务端 `?trigger=` 筛选与面板写路径属 C2,尚未提供。同理,「例行心跳」这一档只匹配 `heartbeat` 本身,`startup`(启动问候)只在「全部」下可见。
新增的「穿透安静时段 / 紧急度」标记(契约 v0.70):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度**三态画得不一样** —— 把下限当精确值、把未知当「例常」都是把猜当事实:① **精确档位**(「例常」/「紧急(高)」/「紧急·安全域」)= 该轮实际生效的档位,随记录落库;② **≥ 下限**(带 `urgencyLowerBound` 的旧行:该行没有存档档位,此值由安静时段窗口规则推导 —— 「≥ 高」只表示至少 high、可能更高);③ **档位未知** = 存档里没有这个事实,**不是**「例常」。⚠️ 实时帧带上记录里的精确档位(`backthink.Record` 自 X2a 起带 `urgency`),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(后两者由网关按安静时段窗口推导)⇒ 本次会话新到的行先显示精确档位与推送结果,穿透标记与旧行的下限标记要等重开页面由历史拉取补齐。
diff --git a/internal/capability/browser/guard.go b/internal/capability/browser/guard.go deleted file mode 100644 index 6c52928..0000000 --- a/internal/capability/browser/guard.go +++ /dev/null @@ -1,106 +0,0 @@ -package browser - -import ( - "context" - "fmt" - "net" - "net/url" - "strings" -) - -// ⚠️ **已知偏差(如实登记,不美化)**:design/28 §4 要求「SSRF 复用既有 `io/ssrf` -// 白名单,**不新增第三份实现**」。但既有实现位于 `plugins/tools/io`(`ssrf.go` 的 -// `validateRequestURL`)且**未导出**,本切片的文件面不含该包 ⇒ 本片无法直接复用, -// 也不擅自改别人的文件。因此这里落的是**与 `io/ssrf` 同一条策略**的判定(先放行 -// 白名单、再按 IP 网段否决;网段口径逐条对齐),并登记为待收口项: -// **收口动作 = 把 `plugins/tools/io` 的 `validateRequestURL` 导出(或提到共享包), -// 删掉本文件**。在收口之前,浏览器侧与 web-fetch 侧的放行/拒绝口径**按同一张表** -// 执行,不存在「一个放行另一个拒绝」的分歧。 -// -// 判定口径(与 `io/ssrf` 逐条对齐): -// - 只接受 `http` / `https`; -// - 命中调用方白名单的主机**直接放行**(同源自取:本地 searxng、本机 upload 路由); -// - 字面 IP 落在回环/私网/链路本地/组播/未指定 ⇒ 拒绝; -// - 主机名先解析,**任一条**解析结果落在上面那些网段 ⇒ 拒绝(防 DNS rebinding 的 -// 第一跳;真正的 rebinding 需要连上时再校验,既有两份实现都没做,本片不越界)。 -// -// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就 -// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入 -// (design/28 §4 第 1 条;M3 面板入口同样过闸)。 -func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { - if strings.TrimSpace(rawURL) == "" { - return nil, fmt.Errorf("%w: url 为空", ErrBlockedURL) - } - u, err := url.Parse(rawURL) - if err != nil { - return nil, fmt.Errorf("%w: 解析 url: %v", ErrBlockedURL, err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return nil, fmt.Errorf("%w: 不支持协议 %q", ErrBlockedURL, u.Scheme) - } - host := u.Hostname() - if host == "" { - return nil, fmt.Errorf("%w: url 没有主机名", ErrBlockedURL) - } - if hostAllowed(host, allowed) { - return u, nil - } - if ip := net.ParseIP(host); ip != nil { - if deniedIP(ip) { - return nil, fmt.Errorf("%w: %s 属回环/私网/链路本地地址", ErrBlockedURL, host) - } - return u, nil - } - addrs, err := net.DefaultResolver.LookupHost(ctx, host) - if err != nil { - return nil, fmt.Errorf("%w: 解析 %q: %v", ErrBlockedURL, host, err) - } - for _, a := range addrs { - if ip := net.ParseIP(a); ip != nil && deniedIP(ip) { - return nil, fmt.Errorf("%w: %q 解析到 %q", ErrBlockedURL, host, a) - } - } - return u, nil -} - -// deniedIP 报告 ip 是否属于守卫默认拒绝的网段:回环、私网、链路本地(单播/组播)、 -// 未指定。口径与 `plugins/tools/io/ssrf.go` 的 `isDeniedIP` 逐条相同。 -func deniedIP(ip net.IP) bool { - if ip == nil { - return false - } - return ip.IsLoopback() || - ip.IsPrivate() || - ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || - ip.IsUnspecified() -} - -// hostAllowed 报告 host 是否命中白名单(逐条按主机名比较,大小写不敏感,端口忽略)。 -func hostAllowed(host string, allowed []string) bool { - h := strings.ToLower(strings.TrimSpace(host)) - if h == "" { - return false - } - for _, a := range allowed { - if strings.EqualFold(hostOf(a), h) { - return true - } - } - return false -} - -// hostOf 从白名单条目里取主机名:允许写成 `host`、`host:port` 或完整 URL。 -func hostOf(entry string) string { - raw := strings.TrimSpace(entry) - if raw == "" { - return "" - } - if u, err := url.Parse(raw); err == nil && u.Hostname() != "" { - return u.Hostname() - } - if host, _, err := net.SplitHostPort(raw); err == nil { - return host - } - return raw -} diff --git a/internal/capability/browser/guard_test.go b/internal/capability/browser/guard_test.go index dce63a5..f3ba502 100644 --- a/internal/capability/browser/guard_test.go +++ b/internal/capability/browser/guard_test.go @@ -2,10 +2,13 @@ package browser import ( "context" + "errors" "net/http" "net/http/httptest" "strings" "testing" + + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、 @@ -88,3 +91,18 @@ func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) { t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP) } } + +// TestGuardURL_KeepsBothErrorIdentities 钉住收口(切片 X3)之后的**双错误身份**: +// 判定本体搬去了 `io.ValidateRequestURL`,但本包的契约没动 —— 组合根/工具层仍按 +// `ErrBlockedURL` 分类降级(`ReasonBlockedURL` 与它同源),而排障时能顺着链回溯到**唯一** +// 判定源(`io.ErrSSRF`)。少任何一个身份,「收口只搬判定、不动契约」就只是句口号。 +func TestGuardURL_KeepsBothErrorIdentities(t *testing.T) { + t.Parallel() + _, err := GuardURL(context.Background(), "http://169.254.169.254/latest/meta-data/", nil) + if !errors.Is(err, ErrBlockedURL) { + t.Errorf("拒绝丢了本包契约身份 ErrBlockedURL: %v", err) + } + if !errors.Is(err, iotools.ErrSSRF) { + t.Errorf("拒绝丢了判定源身份 iotools.ErrSSRF: %v", err) + } +} diff --git a/internal/capability/browser/ssrf.go b/internal/capability/browser/ssrf.go new file mode 100644 index 0000000..4c3d5ab --- /dev/null +++ b/internal/capability/browser/ssrf.go @@ -0,0 +1,41 @@ +package browser + +import ( + "context" + "fmt" + "net/url" + + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" +) + +// GuardURL 是浏览器侧访问 SSRF 判定的**唯一入口**,判定本体**只有一份**: +// `plugins/tools/io.ValidateRequestURL`(design/28 §4:「复用既有 `io/ssrf` 白名单, +// **不新增第三份实现**」)。 +// +// **为什么这里只留一层薄门面(切片 X3 的收口)**:本包原先自带一份与 io 同策的实现 +// (`guard.go`:自己的 scheme 判定 + 网段表 + 白名单比对),只因为当时 io 侧的 +// `validateRequestURL` 没导出、该包又不在那一片的文件面里。两份同策代码的危险不在 +// 当下的结论,而在**以后只改一处** —— 改完两边静默分叉,浏览器侧的导航就成了绕过面。 +// 收口后本文件只做两件事: +// +// 1. 调 `io.ValidateRequestURL`(**唯一**判定源:scheme → 白名单短路 → 字面 IP 网段 +// 否决 → 解析后再按同一张表否决); +// 2. 把它的错误翻成本包对外的 `ErrBlockedURL` —— 这是**契约**不是判定: +// `ErrBlockedURL` / `ReasonBlockedURL` 是工具层与组合根(含后续 M3 面板入口)用来 +// 分类降级与记日志的标识,收口只搬判定,不动契约。 +// +// 两个错误身份都保住(`%w:%w`):`errors.Is(err, ErrBlockedURL)` 成立(本包契约), +// 网段/解析类拒绝上 `errors.Is(err, iotools.ErrSSRF)` 同样成立(判定源),io 侧的原因 +// 文案(哪个网段、解析到哪个地址)一字不丢地进日志。形状错误(空 URL、非 http(s)) +// 在 io 侧本就不带 ErrSSRF,那几档只有本包身份。 +// +// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就 +// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入 +// (design/28 §4 第 1 条;M3 面板入口同样过闸)。 +func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { + u, err := iotools.ValidateRequestURL(ctx, rawURL, allowed) + if err != nil { + return nil, fmt.Errorf("%w:%w", ErrBlockedURL, err) + } + return u, nil +} diff --git a/internal/config/runtimeconfig/backthink_test.go b/internal/config/runtimeconfig/backthink_test.go new file mode 100644 index 0000000..f546621 --- /dev/null +++ b/internal/config/runtimeconfig/backthink_test.go @@ -0,0 +1,110 @@ +package runtimeconfig + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// 本文件守 design/12 §N C.5 的**只读加载件**:落点是 +// `$DATA_DIR/config/backthink.json`(零新 env 键),文件缺失 = 还没配过(零值 + nil, +// 不是错误),存在但解不开 = 返回错(调用方记 WARN 并回落默认,绝不静默变成「无上限」)。 + +// TestDefaultBackthinkPath 钉住落点:`/config/backthink.json`。 +// 它同时守住「新家族从一开始就只写 config/」(design/13 §2.1 第 5 条)—— +// 路径里少一段 config 就是静默换家。 +func TestDefaultBackthinkPath(t *testing.T) { + t.Parallel() + got := DefaultBackthinkPath(filepath.Join("root", "data")) + want := filepath.Join("root", "data", "config", "backthink.json") + if got != want { + t.Fatalf("DefaultBackthinkPath = %q, want %q", got, want) + } +} + +// TestLoadBackgroundTuning_MissingFileIsNotAnError 钉住「没配过」不是错误: +// 返回零值 + nil,由服务侧 normalize 回落代码默认(4 轮/小时)。 +func TestLoadBackgroundTuning_MissingFileIsNotAnError(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "config", "backthink.json") + got, err := LoadBackgroundTuning(path) + if err != nil { + t.Fatalf("缺失文件应返回 nil error,得到 %v", err) + } + if got.SelfWakePerWindow != 0 || got.SelfWakeWindowSec != 0 { + t.Fatalf("缺失文件应返回零值,得到 %+v", got) + } + // 它**不建目录**:读路径不许留下副作用(新家族没有老位置,也没什么要建的)。 + if _, statErr := os.Stat(filepath.Dir(path)); !os.IsNotExist(statErr) { + t.Fatalf("读路径建了目录或留下了别的痕迹:stat err = %v", statErr) + } +} + +// TestLoadBackgroundTuning_EmptyFileIsNotConfigured 钉住「全空白 = 还没配过」, +// 与 plugins.json / web-search.json 的既有口径逐字一致。 +func TestLoadBackgroundTuning_EmptyFileIsNotConfigured(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "backthink.json") + if err := os.WriteFile(path, []byte(" \n\t\n"), 0o600); err != nil { + t.Fatalf("写测试文件: %v", err) + } + got, err := LoadBackgroundTuning(path) + if err != nil { + t.Fatalf("空白文件应返回 nil error,得到 %v", err) + } + if got != (BackgroundTuning{}) { + t.Fatalf("空白文件应返回零值,得到 %+v", got) + } +} + +// TestLoadBackgroundTuning_ReadsSpecShape 钉住规格里给的形状逐字可读 +// (design/12 §N C.5 的 JSON 例子),且原样交出**未**回落默认的原始数字 +// —— 回落是服务侧 normalize 的事,这里不做第二套默认值。 +func TestLoadBackgroundTuning_ReadsSpecShape(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "backthink.json") + body := `{ "selfWakePerWindow": 2, "selfWakeWindowSec": 1800 }` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("写测试文件: %v", err) + } + got, err := LoadBackgroundTuning(path) + if err != nil { + t.Fatalf("LoadBackgroundTuning: %v", err) + } + if got.SelfWakePerWindow != 2 || got.SelfWakeWindowSec != 1800 { + t.Fatalf("tuning = %+v, want {2 1800}", got) + } +} + +// TestLoadBackgroundTuning_ZeroStaysZero 钉住「配成 0」原样交出去(不在这里改写成默认): +// 服务侧的 normalize 才是「≤0 回落默认」的唯一执行点,本层若也回落,就等于有了 +// 两套默认值真源。 +func TestLoadBackgroundTuning_ZeroStaysZero(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "backthink.json") + if err := os.WriteFile(path, []byte(`{ "selfWakePerWindow": 0, "selfWakeWindowSec": -1 }`), 0o600); err != nil { + t.Fatalf("写测试文件: %v", err) + } + got, err := LoadBackgroundTuning(path) + if err != nil { + t.Fatalf("LoadBackgroundTuning: %v", err) + } + if got.SelfWakePerWindow != 0 || got.SelfWakeWindowSec != -1 { + t.Fatalf("tuning = %+v, want 原样(0 与 -1 都不在本地改写)", got) + } +} + +// TestLoadBackgroundTuning_BrokenFileReturnsError 钉住坏文件**必须**报错: +// 调用方据此记 WARN 并回落默认。若这里静默返回零值,一个写坏的文件就会安静地 +// 变成「用默认值」,与「如实告知」的红线相反。 +func TestLoadBackgroundTuning_BrokenFileReturnsError(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "backthink.json") + if err := os.WriteFile(path, []byte(`{ "selfWakePerWindow": `), 0o600); err != nil { + t.Fatalf("写测试文件: %v", err) + } + if _, err := LoadBackgroundTuning(path); !errors.Is(err, ErrInvalidBackthink) { + t.Fatalf("err = %v, want ErrInvalidBackthink", err) + } +} diff --git a/internal/runtime/capacity/capacity_test.go b/internal/runtime/capacity/capacity_test.go index 1a7b406..2b38d35 100644 --- a/internal/runtime/capacity/capacity_test.go +++ b/internal/runtime/capacity/capacity_test.go @@ -7,14 +7,18 @@ import ( ) // allTools is a representative registration-order tool list for tests; it covers -// the basic/io/terminal plugins actually shipped by the repo, plus the two tools -// added to the built-in state sets by X1 (transcode / capability_list). +// the basic/io/terminal plugins actually shipped by the repo, plus the tools added +// to the built-in state sets by X1 (transcode / capability_list) and C1-3 +// (request_think, design/12 §N C.3). +// +// ⚠️ 它必须与真实注册面同步:允许集是「档位集 ∩ AllToolNames」,名字不在这份列表里 +// 就等于「没注册」,TestDecide_AllowedSets 于是会红 —— 那正是我们要的探针。 func allTools() []string { return []string{ "terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http", "calculator", "datetime", "random", "json", "markdown", - transcodeToolName, capabilityListToolName, + transcodeToolName, capabilityListToolName, selfWakeToolName, } } @@ -177,9 +181,11 @@ func TestDecide_PersonaMinConfidence_ForcesQuiet(t *testing.T) { func TestDecide_AllowedSets(t *testing.T) { d := New(activeConfig()) - // 内置档位集里的「后加能力」也必须在允许集内(X1 / 裁决 15):登记了能力 - // 却不在集合里 = CAPACITY_ENABLED=true 时它调不动。 - extra := []string{transcodeToolName, capabilityListToolName} + // 内置档位集里的「后加能力」也必须在允许集内(X1 / 裁决 15 + C1-3):登记了能力 + // 却不在集合里 = CAPACITY_ENABLED=true 时它调不动。request_think 由 C1-3 追加, + // 它**必须同时在静息与唤醒两张表里**(她思考时通常落在静息态);本用例的两条断言 + // 就是「只加了一张表」的探针。 + extra := []string{transcodeToolName, capabilityListToolName, selfWakeToolName} quiet := d.Decide(context.Background(), in("你好")).AllowedTools wantQuiet := append([]string{"terminal", "file", "knowledge", "text"}, extra...) diff --git a/internal/runtime/capacity/signals.go b/internal/runtime/capacity/signals.go index 964e95b..1b559d8 100644 --- a/internal/runtime/capacity/signals.go +++ b/internal/runtime/capacity/signals.go @@ -22,13 +22,21 @@ import ( const ( transcodeToolName = "transcode" capabilityListToolName = "capability_list" + // selfWakeToolName 是「自排一次思考」工具的名字(internal/services/backthink 的 + // ThinkToolName,design/12 §N C.3)。它必须**同时**进下面两张内置允许集: + // 她思考时通常落在静息态(陪伴 / 夜里),只进唤醒态等于「注册了却看不见」。 + // + // ⚠️ 主人若用既有的 `CAPACITY_TOOL_SETS` 覆盖档位集,这两张内置表会被**整体替换** + // (`wire_capacity.go` 的 parseCapacityToolSets),那份 JSON 里没写 request_think + // 就照样看不见 —— 该键是「替换」不是「追加」,这条口径与 X1 的同类缺口同源。 + selfWakeToolName = "request_think" ) // defaultQuietTools is the companion-safe face (design §1): terminal + file edit // + memory/emotion companion. It is a function building the slice fresh so no // package-level global slice is introduced (project rule §10). func defaultQuietTools() []string { - return []string{"terminal", "file", "knowledge", "text", transcodeToolName, capabilityListToolName} + return []string{"terminal", "file", "knowledge", "text", transcodeToolName, capabilityListToolName, selfWakeToolName} } // defaultAwakeTools extends the quiet face with web/network retrieval (design @@ -37,7 +45,7 @@ func defaultAwakeTools() []string { return []string{ "terminal", "file", "knowledge", "text", "web-search", "web-fetch", "http", - transcodeToolName, capabilityListToolName, + transcodeToolName, capabilityListToolName, selfWakeToolName, } } diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index 5894aeb..808575e 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -2,6 +2,7 @@ package backthink import ( "context" + "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" @@ -86,19 +87,25 @@ func thinkInput(recent []model.Message, trigger string, rs RoundState) []model.M return out } -// guidancePrompt 是交给运行时的一次无人值守自主思考的 -// 指令。与旧 backgroundPrompt 不同,它刻意鼓励 -// 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格 -// 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上, -// 绝不在这段提示词文本上(红线);提示词只描述运行 -// 环境(记忆可读/可写、工具可调用),而那是 env 已经 +// guidancePrompt 是交给运行时的一次无人值守自主思考的指令。它按触发源分三路,三路 +// 共用同一批段落(toolFaceHint / proactiveDecisionTail),因此「三处说法一致」是结构 +// 事实而不是复制纪律: +// +// - 自排轮(触发摘要带 SelfWakeSummaryPrefix)⇒ selfWakePrompt; +// - 启动轮(StartupSummary)⇒ startupPrompt; +// - 其余(心跳 / 事件)⇒ 下面这段通用引导。 +// +// 与旧 backgroundPrompt 不同,它刻意鼓励温暖、有针对性的主动消息,并去掉「不要产生 +// chat 风格消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上,绝不在这段提示词 +// 文本上(红线);提示词只描述运行环境(记忆可读/可写、工具可调用),而那是 env 已经 // 授予的。 // -// 工具面口径(X1 缺口 e):这里**不再枚举**具体工具——旧文案承诺的「IoT / 日历 / 提醒」 -// 在全仓都没有对应工具,是空头支票。现在它只说「与主人对话同等的工具面」,并指名 -// **自查入口** `capability_list`,而那是可验证的事实:工具面就是同一个注册表 -// (LoopThinker 包的是同一个 loop),清单工具如实列出当下注册了什么(含外围子系统)。 -// 因此这段文案与实现不可能再各说一套。 +// 工具面口径(X1 缺口 e):这里**不枚举**具体工具——旧文案承诺的「IoT / 日历 / 提醒」在 +// 全仓都没有对应工具,是空头支票;清单由只读的 `capability_list` 实时给出,而那是可验证 +// 的事实(工具面就是同一个注册表)。自排入口 `request_think` 同样只说「存在、有次数上限、 +// 排太多会被拒」,**不写具体数字**(默认 4 次/小时也不写):提示词与配置分叉就是撒谎的 +// 源头。清单同理不写死在提示词里(design/04 §4.1:工具链是每次请求重算的结构化 `tools` +// 字段,不在提示词里)。因此这段文案与实现不可能再各说一套。 // // 状态口径(X1 缺口 d):主人当前状态摘要由 thinkInput 追加在本提示末尾 // (设备/日程遥测的**事实**;读不到时该块缺席),使她的语气/时机判断与随后生效的 @@ -112,42 +119,112 @@ func thinkInput(recent []model.Message, trigger string, rs RoundState) []model.M // (不推送、不上抬紧急度)。 // // 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的 -// 「回来」问候。 +// 「回来」问候。自排轮(design/12 §N C.3)走 selfWakePrompt。 func guidancePrompt(trigger string) string { + // 前缀判定必须**排在启动轮的等值判定之前**:自排轮的摘要以稳定前缀开头 + // (selfWakeSummary 拼的 `self_wake: <理由>`),两个标识不重叠,但先判前缀让 + // 「将来加第三种前缀轮」时不会悄悄落到通用分支上。 + if strings.HasPrefix(trigger, SelfWakeSummaryPrefix) { + return selfWakePrompt(trigger) + } if trigger == StartupSummary { return startupPrompt() } return "[自主思考 · 无人在旁]\n" + "触发:" + trigger + "\n" + - "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + - "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + + "你正进行一轮自主思考。\n" + + toolFaceHint() + "请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" + "比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" + "若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" + "语言上:温暖、简短、克制、不粘人——一到两句为宜,不展开寒暄、不啰嗦、不抱怨、不频繁打扰,像想起她时轻轻说一句,说完就停。\n" + - "请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" + - "[我决定:push] 或 [我决定:no_push]\n" + - "[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" + - "[紧急:routine|warning|critical](可省略;只有当你判断这件事必须立刻告诉她、即便现在是她休息/忙碌的安静时段也要打扰时,才写 critical;普通情况省略)\n" + - "上面几行之后,紧接你想说的话作为正文。\n" + + proactiveDecisionTail(true) + "整轮思考保持简短、单轮,不反复自我触发。" } +// toolFaceHint 是三处提示词**共用**的工具面说明段(C1-3 抽取;AGENTS §10「三次复制必须 +// 抽取」——此前 guidancePrompt 与 startupPrompt 已逐字重复这段话,再抄第三份就是三份)。 +// +// 它只说三件可验证的事实:① 这一轮的工具面与主人对话时是**同一套**(同一个注册表); +// ② 要确认此刻具体有哪些工具/技能/插件/外围状态,就调只读的 `capability_list`; +// ③ 想再给自己排一轮思考,就调 `request_think`,它**有每窗口次数上限,排太多会被拒**。 +// +// 它**不枚举工具清单**,也**不写上限的具体数字**——写死清单或数字就是给提示词与实现 +// 埋一条必然漂移的分叉(清单由 capability_list 实时给出,上限由配置决定)。 +func toolFaceHint() string { + return "你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + + "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + + "如果你判断自己还需要再想一轮(比如想稍后再看某个变化,或这件事值得换个角度重新过一遍),可以调用 `request_think` 给自己排一轮思考,并在理由里写清为什么——那句话是写给下一轮的你自己看的;它**有每窗口次数上限,排太多会被拒绝**,所以一轮里排一次就够。\n" +} + +// proactiveDecisionTail 是**三处共用**的结构化收尾引导:`[我决定:…]`(想不想说)、 +// `[渠道:…]`(发到哪个渠道),以及可选的 `[紧急:…]` 档位声明。 +// +// withUrgency 决定要不要带 `[紧急:routine|warning|critical]` 那一行:普通轮与自排轮带 +// (自排轮的结论也必须能留在穿透路径上,那正是 design/12 §N C 的意义),启动轮不带 +// (理由见 startupPrompt)。**可变的是这一行,不是格式**:行序、括注与收尾句在三轮里 +// 逐字一致,因此同样以 `[我决定:…]` 开头的尾段在三轮里的解析规则完全相同。 +// +// 它只负责「怎么写」;「写了算不算」由 parseProactive / parseProactiveUrgency 与 +// decide / PushGate 决定(提示词是引导,不是契约)。 +func proactiveDecisionTail(withUrgency bool) string { + out := "请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" + + "[我决定:push] 或 [我决定:no_push]\n" + + "[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" + if withUrgency { + out += "[紧急:routine|warning|critical](可省略;只有当你判断这件事必须立刻告诉她、即便现在是她休息/忙碌的安静时段也要打扰时,才写 critical;普通情况省略)\n" + } + return out + "上面几行之后,紧接你想说的话作为正文。\n" +} + // startupPrompt 是启动轮思考的专属引导(裁决 07.4.4,design/12 §1「启动即思考 + // 拟人化启动消息」):进程刚启动、她刚「回来」,引导她以角色口吻向主人说一句简短 // 温暖的问候(如「我回来啦」)。此轮例外允许以 chat 形式发出;推送仍走与心跳完全 // 相同的门(她的结论 + 打扰级联 + 活动门 + PushGate 限频),深夜重启或反复重启都 -// 不会刷屏主人。工具面口径与 guidancePrompt 逐字一致(自查入口 `capability_list`, -// 不再枚举具体工具)。 +// 不会刷屏主人。 +// +// 它引用与普通轮、自排轮**同一批**共用段落(工具面 / 决策尾),因此三处说法一致;差别 +// 只有一处:它取**不含 `[紧急:…]` 行**的尾部(withUrgency=false)。理由:启动轮的既定 +// 口径是「打个招呼」,把档位声明写进它的引导等于给启动轮开一条主动穿透安静时段的路, +// 属产品口径变更、不在 C1-3 的授权内(她自发写下 `[紧急:…]` 时 parseProactiveUrgency +// 照旧会解析 —— 所以这是**引导面**的取舍,不是能力开关)。主人若要统一,改这一个实参即可。 func startupPrompt() string { return "[自主思考 · 刚刚回来]\n" + "触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" + - "你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + - "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + + toolFaceHint() + "请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" + - "请把「是否主动说」与「发到哪个渠道」写成与平时相同的固定结尾,解析规则如下:\n" + - "[我决定:push] 或 [我决定:no_push]\n" + - "[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" + - "上面两行之后,紧接你想说的话作为正文。\n" + + proactiveDecisionTail(false) + "整轮思考保持简短、单轮,不反复自我触发。" } + +// selfWakePrompt 是**自排轮**的专属引导(design/12 §N C.3):这一轮既不是周期性心跳、 +// 也不是外部事件,而是**她自己**在上一次思考里排下的(当时用 `request_think` 写了理由, +// 理由原样带回)。 +// +// 它必须说清四件事,否则这一轮会退化成「没有理由的额外一轮」: +// 1. 这轮是**她自己排的**,并附上她当时写下的理由; +// 2. 目标与她给自己的那句话对齐 —— 去看一看,然后决定要不要说; +// 3. **一轮只排一次**:不要在这一轮里再连排下一轮(连排会被上限拒绝,也会把成本堆起来); +// 4. 复用**含 `[紧急:…]` 行**的尾部 —— 自排轮的结论同样要能留在穿透路径上(她声明 +// critical 才突破安静时段),那正是 design/12 §N C 这一节存在的理由。 +func selfWakePrompt(trigger string) string { + return "[自主思考 · 你自己排的一轮]\n" + + "理由:" + selfWakeReasonOf(trigger) + "\n" + + "这一轮不是例行心跳、也不是外部事件触发的,而是**你自己**上一次思考时用 `request_think` 给自己排下的,理由就是上面那一行。\n" + + toolFaceHint() + + "请就着这个理由去看一看、想一想(该查的查、该读的读),然后判断此刻是否有一件真正值得主动告诉主人的、温暖而有针对性的事情;若看完觉得没有,回复「无」即可——不要为了「排上了」而硬说一句。\n" + + "语言上:温暖、简短、克制、不粘人——一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" + + proactiveDecisionTail(true) + + "一轮只排一次:不要在这一轮里再排下一轮思考(`request_think` 有每窗口次数上限,连排会被拒绝)。\n" + + "整轮思考保持简短、单轮。" +} + +// selfWakeReasonOf 从触发摘要里取回她当时写下的理由(前缀见 SelfWakeSummaryPrefix)。 +// 理由为空时给一句**如实的**占位,而不是替她编一个理由。 +func selfWakeReasonOf(trigger string) string { + reason := strings.TrimSpace(strings.TrimPrefix(trigger, SelfWakeSummaryPrefix)) + if reason == "" { + return "(这一轮没有留下理由)" + } + return reason +} diff --git a/internal/services/backthink/selfwake_prompt_test.go b/internal/services/backthink/selfwake_prompt_test.go new file mode 100644 index 0000000..ca5d764 --- /dev/null +++ b/internal/services/backthink/selfwake_prompt_test.go @@ -0,0 +1,141 @@ +package backthink + +import ( + "fmt" + "strings" + "testing" +) + +// 本文件是 design/12 §N C.3 的**提示词判据**:三处指引必须共用同一批段落(抽取而不是 +// 三次复制),自排轮必须说清自己是谁排的、只排一次、有上限;并且**不得**枚举工具清单 +// 或写死上限数字(design/04 §4.1 + C.3 的「文案不写具体上限数字」)。 + +// promptsUnderTest 返回三处指引的(名字 → 文本)。 +func promptsUnderTest() map[string]string { + return map[string]string{ + "通用轮": guidancePrompt("heartbeat"), + "启动轮": guidancePrompt(StartupSummary), + "自排轮": guidancePrompt(selfWakeSummary("稍后回看她的日程")), + } +} + +// TestPromptsShareTheExtractedParagraphs 断言「三处引用共用段落」是**结构事实**: +// 每处文本都必须逐字包含 toolFaceHint() 与它该有的 proactiveDecisionTail(...)。 +// +// 变异:把某一处的共用段落改成复制粘贴的另一份措辞 ⇒ 本用例变红(这正是「三次复制必须 +// 抽取」要防的漂移)。 +func TestPromptsShareTheExtractedParagraphs(t *testing.T) { + t.Parallel() + face := toolFaceHint() + withUrgency := proactiveDecisionTail(true) + withoutUrgency := proactiveDecisionTail(false) + + for name, p := range promptsUnderTest() { + if !strings.Contains(p, face) { + t.Fatalf("%s 没有逐字引用共用的工具面段落(toolFaceHint)", name) + } + want := withUrgency + if name == "启动轮" { + want = withoutUrgency + } + if !strings.Contains(p, want) { + t.Fatalf("%s 没有逐字引用共用的决策尾段落", name) + } + } +} + +// TestPromptsAdvertiseTheSelfWakeTool 断言三处都指名了 `request_think` 与自查入口 +// `capability_list`:工具注册了而提示词不说,她不知道能自排(C1-3 的目的就是让她真能自排)。 +func TestPromptsAdvertiseTheSelfWakeTool(t *testing.T) { + t.Parallel() + for name, p := range promptsUnderTest() { + if !strings.Contains(p, ThinkToolName) { + t.Fatalf("%s 没有提到 %s(她不知道能给自己排思考)", name, ThinkToolName) + } + if !strings.Contains(p, "capability_list") { + t.Fatalf("%s 没有给出可验证的自查入口 capability_list", name) + } + if !strings.Contains(p, "上限") { + t.Fatalf("%s 没有说明自排有次数上限", name) + } + } +} + +// TestPromptsDoNotWriteTheLimitNumber 断言提示词里**不写具体上限数字**(默认 4 次/小时 +// 也不写):提示词与配置分叉就是撒谎的源头,所以只写「有上限」。 +// +// 变异:在任一提示词里写下默认上限字面量 ⇒ 本用例变红。 +func TestPromptsDoNotWriteTheLimitNumber(t *testing.T) { + t.Parallel() + digits := fmt.Sprintf("%d", defaultSelfWakeLimit) + for name, p := range promptsUnderTest() { + if strings.Contains(p, digits) { + t.Fatalf("%s 把上限数字 %s 写进了提示词:上限由配置决定,写死必然漂移", name, digits) + } + } +} + +// TestGuidancePrompt_RoutesSelfWakeByPrefix 断言路由:带 SelfWakeSummaryPrefix 的触发 +// 走自排专属指引并**原样带回她写下的理由**;其余稳定标识不受影响(两个标识不重叠)。 +func TestGuidancePrompt_RoutesSelfWakeByPrefix(t *testing.T) { + t.Parallel() + const reason = "稍后回看她提过的体检安排" + p := guidancePrompt(selfWakeSummary(reason)) + + if !strings.Contains(p, "[自主思考 · 你自己排的一轮]") { + t.Fatalf("自排轮没有走专属指引:%s", p) + } + if !strings.Contains(p, "理由:"+reason) { + t.Fatalf("自排轮没有带回她写下的理由:%s", p) + } + if strings.Contains(p, "刚刚回来") { + t.Fatalf("自排轮落到了启动轮指引上:%s", p) + } + if strings.Contains(p, "无人在旁") { + t.Fatalf("自排轮落到了通用轮指引上:%s", p) + } + if !strings.Contains(p, proactiveDecisionTail(true)) { + t.Fatalf("自排轮的结论必须能留在穿透路径上(含 [紧急:…] 行):%s", p) + } + if !strings.Contains(p, "一轮只排一次") { + t.Fatalf("自排轮没有说清「一轮只排一次、不要连排」:%s", p) + } + + // 启动轮仍走自己的分支(前缀判定没有把它吞掉)。 + if q := guidancePrompt(StartupSummary); !strings.Contains(q, "刚刚回来") { + t.Fatalf("启动轮路由被自排前缀判定破坏了:%s", q) + } +} + +// TestSelfWakeReasonOf_FallsBackHonestly 断言理由缺失时给**如实的**占位,而不是编一个。 +func TestSelfWakeReasonOf_FallsBackHonestly(t *testing.T) { + t.Parallel() + if got := selfWakeReasonOf(SelfWakeSummaryPrefix); got == "" || !strings.Contains(got, "没有留下理由") { + t.Fatalf("空理由的占位 = %q, want 如实的「没有留下理由」", got) + } + if got := selfWakeReasonOf(selfWakeSummary(" 看看 ")); got != "看看" { + t.Fatalf("理由 = %q, want 去空白后的 %q", got, "看看") + } +} + +// TestStartupPrompt_KeepsItsUrgencyScope 钉住启动轮的**刻意取舍**:它的引导不含 +// `[紧急:…]` 行(见 startupPrompt 的注释 —— 启动轮的口径是「打个招呼」,把档位声明写进 +// 它的引导等于给启动轮开一条主动穿透安静时段的路,属产品口径变更,不在 C1-3 授权内)。 +// +// 主人若决定统一三处,把 startupPrompt 的实参改成 true 并**同时**更新本用例即可。 +func TestStartupPrompt_KeepsItsUrgencyScope(t *testing.T) { + t.Parallel() + p := guidancePrompt(StartupSummary) + if strings.Contains(p, proactiveDecisionTail(true)) { + t.Fatalf("启动轮的决策尾不该含 [紧急:…] 行:%s", p) + } + if !strings.Contains(p, "[我决定:push]") || !strings.Contains(p, "[渠道:") { + t.Fatalf("启动轮的决策尾缺了基本的两行:%s", p) + } + // 通用轮与自排轮必须有那一行(否则她无法在自排轮上匿名穿过安静时段)。 + for _, name := range []string{"通用轮", "自排轮"} { + if !strings.Contains(promptsUnderTest()[name], "[紧急:") { + t.Fatalf("%s 的决策尾缺了 [紧急:…] 行", name) + } + } +} diff --git a/internal/services/backthink/thinktool.go b/internal/services/backthink/thinktool.go new file mode 100644 index 0000000..e0a545b --- /dev/null +++ b/internal/services/backthink/thinktool.go @@ -0,0 +1,209 @@ +package backthink + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/urgency" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是 design/12 §N C.3 的**工具面**(切片 C1-3):把她自己排一轮思考的能力暴露成 +// agent 可调用的内置工具 `request_think`。 +// +// 范式与 internal/capability/transcode/tool.go、internal/capability/registry/listtool.go +// 逐字同族:能力/服务自带它的 sdk.Tool,经**同一个**能力注册表注册(组合根一条 +// dispatcher.Register),因此 chat 轮、主动思考轮与自排轮看到的是同一张工具面 +// —— 单二进制、零新机制;策略(restrict 安全门)、审计、面板三面零新增代码即覆盖。 +// +// 🔴 成本闭环(L8 §3 的递归可达性):自排轮用的正是**同一个** dispatcher / 注册表 +// (thinker.go 的 LoopThinker 包的就是 a.loop,且它按 OwnerEnv 跑),因此她在**自排轮里 +// 同样看得见 request_think**。「每窗口上限」(selfwake.go 的 selfWakeGate,默认 4 轮/小时) +// 于是不是可选优化,而是**唯一的成本闸** —— 少了它就是无界自旋(对照 AutoGPT 的 +// continuous mode:自续循环必须带步数/上限与可观测代价)。 +// +// 本工具**只排不跑**:不在这里同步跑思考(那会嵌套工具循环)、不推送、不写记录; +// 记录由被排起的那一轮照旧落(backthink.go 的 record)。 +const ( + // ThinkToolName 是工具在能力注册表里的名字。它是**模型面**词汇,刻意不把内部触发 + // 标签 self_wake 泄进工具面;内部标签仍是 trigger.go 的 SelfWakeSource。 + ThinkToolName = "request_think" + // selfWakeReasonMaxRunes 是她写给「下一轮自己」的理由的长度上界。用 rune 计数而不是 + // 字节,否则中文理由会被字节数腰斩;超出即截断,并把截断这件事如实告诉模型。 + selfWakeReasonMaxRunes = 200 +) + +// 入参字段名。抽成常量,以免同一个键在 schema、解析与文案三处各写一份字符串。 +const ( + thinkArgReason = "reason" + thinkArgUrgency = "urgency" +) + +// ErrSelfWakeToolArgs 是 request_think 入参不合法(参数不是对象 / 理由为空)时返回的哨兵; +// 调用方用 errors.Is 判定。它只覆盖「她这次调用写得不对」,与业务结果(被上限拒绝 = +// 合法结果,见 thinkToolResult)严格分开。 +var ErrSelfWakeToolArgs = errors.New("backthink: request_think arguments invalid") + +// Waker 是本工具依赖的**窄接口**:*Backthink 满足它(WakeNow),单测可注入替身。 +// 工具因此不依赖具体服务类型,也不碰它的生命周期、存储或推送路径。 +type Waker interface { + WakeNow(ctx context.Context, req SelfWakeRequest) (SelfWakeReceipt, error) +} + +// ThinkTool 把 Waker 包装成 sdk.Tool(design/12 §N C.3)。它无状态,可安全并发使用。 +type ThinkTool struct { + w Waker +} + +// 编译期断言:*ThinkTool 满足 SDK 的 Tool 接口。 +var _ sdk.Tool = (*ThinkTool)(nil) + +// NewThinkTool 用给定唤醒器构造工具。 +func NewThinkTool(w Waker) *ThinkTool { return &ThinkTool{w: w} } + +// Name 返回工具标识。 +func (t *ThinkTool) Name() string { return ThinkToolName } + +// Description 说明该工具做什么(模型面英文,与 transcode / capability_list 同风格): +// 它给自己排一轮思考、有每窗口上限、只在主人的会话里可用,且**只排不跑**。 +func (t *ThinkTool) Description() string { + return "Queue one autonomous thinking pass of your own, outside the periodic heartbeat. " + + "Use it when you decide that something deserves another look soon; write the reason " + + "for your future self and optionally declare how urgent that round is. " + + "This only queues the round - it does not run the thinking inside this call. " + + "There is a per-window quota, so asking too often is refused (the result says so). " + + "It is only available in your owner's own session." +} + +// Parameters 返回输入 JSON Schema:reason(必填)+ urgency(可选,闭集)。 +// +// urgency 声明为 enum,因此模型面本来就只能送闭集里的字面量(分发器在 Execute 之前 +// 就用 JSON Schema 校验拦下越界值);Execute 里的闭集判定是**第二道**兜底,覆盖 +// 直接调用(不经分发器)与将来 schema 演进的场合 —— 它的保守语义见 thinkToolUrgency。 +func (t *ThinkTool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + thinkArgReason: map[string]any{ + "type": "string", + "description": fmt.Sprintf("Why you want this thinking round, written for your future self. "+ + "Required; at most %d characters, longer text is truncated.", selfWakeReasonMaxRunes), + }, + thinkArgUrgency: map[string]any{ + "type": "string", + "enum": []any{ + urgency.LevelRoutine.String(), + urgency.LevelWarning.String(), + urgency.LevelCritical.String(), + }, + "description": "How urgent this round is. Omitted or unknown means routine, which never " + + "pierces quiet hours; only critical does.", + }, + }, + "required": []any{thinkArgReason}, + }, nil +} + +// Execute 解析入参、过第二道身份闸,然后把请求交给 Waker,并把收据**原样**映射成模型 +// 可读的结果(design/12 §N C.3)。 +// +// 两种「失败」严格分开: +// - **error**:没接线、非主人环境、入参不合法、服务未 Start(WakeNow 的哨兵经 %w 透出) +// —— 这次调用**没有发生**; +// - **合法业务结果**(ok:false、err 为 nil):被每窗口上限拒绝。她要看得见拒绝, +// 才会停止连排(红线:不伪造成功,也不把真实业务结果伪装成故障)。 +func (t *ThinkTool) Execute(ctx context.Context, args any) (any, error) { + if t.w == nil { + return nil, fmt.Errorf("backthink: tool %q: %w: no waker wired", ThinkToolName, ErrSelfWakeUnavailable) + } + // 第二道身份闸。第一道是 capacity 的唤醒态允许集 + restrict 安全门(design/11 §3: + // 策略标记默认零值 = owner-only ⇒ 群聊/陌生人既看不见也调不动);这里是纵深防御。 + // SessionEnvFrom 对未挂载 env 的 ctx 返回 loop 的 owner 默认值(既有向后兼容口径), + // 因此这条判定与循环自身的口径一致,不是新开的宽松口子。 + if env := loop.SessionEnvFrom(ctx); env.ActorEnv != restrict.ActorOwner { + return nil, fmt.Errorf("backthink: tool %q: %w (actor=%s)", ThinkToolName, ErrSelfWakeNotOwner, env.ActorEnv) + } + req, truncated, err := thinkToolRequest(args) + if err != nil { + return nil, err + } + receipt, err := t.w.WakeNow(ctx, req) + if err != nil { + return nil, fmt.Errorf("backthink: tool %q: %w", ThinkToolName, err) + } + return thinkToolResult(receipt, truncated), nil +} + +// thinkToolRequest 解析入参:reason 必填(去首尾空白后不得为空),urgency 只认闭集。 +// 返回的 truncated 报告理由是否被截断。 +func thinkToolRequest(args any) (SelfWakeRequest, bool, error) { + m, ok := args.(map[string]any) + if !ok { + return SelfWakeRequest{}, false, + fmt.Errorf("backthink: tool %q: %w: args must be an object", ThinkToolName, ErrSelfWakeToolArgs) + } + reason, truncated := thinkToolReason(m) + if reason == "" { + return SelfWakeRequest{}, false, + fmt.Errorf("backthink: tool %q: %w: %s is required", ThinkToolName, ErrSelfWakeToolArgs, thinkArgReason) + } + req := SelfWakeRequest{ + Reason: reason, + Declared: thinkToolUrgency(toolArgString(m, thinkArgUrgency)), + } + return req, truncated, nil +} + +// thinkToolReason 解出理由文本:去首尾空白;空 ⇒ 返回空串,由调用方报参数错误 +// (**不替她编一个理由** —— 编出来的理由会让面板与审计里躺着一句她从没说过的话)。 +// 超长即按 rune 截断(不腰斩多字节字符),截断这件事如实回报给模型。 +func thinkToolReason(m map[string]any) (string, bool) { + reason := strings.TrimSpace(toolArgString(m, thinkArgReason)) + runes := []rune(reason) + if len(runes) > selfWakeReasonMaxRunes { + return string(runes[:selfWakeReasonMaxRunes]), true + } + return reason, false +} + +// thinkToolUrgency 只认闭集里的**精确字面量**(routine / warning / critical),其余一律 +// 零值 routine —— 保守、不猜:前缀/子串/大小写/中文近义词都不认,因为「猜错档位」的代价 +// 是让一轮思考穿透安静时段(critical 的语义,design/12 §C.2 的 two-declaration 口径)。 +// 三个字面量取自 urgency 词汇本身(Level.String()),因此这里不另写一份枚举。 +func thinkToolUrgency(raw string) urgency.Level { + switch { + case raw == urgency.LevelCritical.String(): + return urgency.LevelCritical + case raw == urgency.LevelWarning.String(): + return urgency.LevelWarning + default: + return urgency.LevelRoutine + } +} + +// toolArgString 读一个字符串字段;字段缺失或不是字符串都算空串。 +func toolArgString(m map[string]any, key string) string { + s, _ := m[key].(string) + return s +} + +// thinkToolResult 把收据原样映射成模型可读的对象:ok = 是否真的排出一轮(合并 = true、 +// 超限 = false),queued = 是否**新排**了一轮(合并 ⇒ false),reason 是收据的说明文案 +// (成功时为空),used / limit / windowSec 是本窗口口径,reasonTruncated 报告她的理由 +// 是否被截断。 +func thinkToolResult(r SelfWakeReceipt, truncated bool) map[string]any { + return map[string]any{ + "ok": r.Accepted, + "queued": r.Accepted && !r.Coalesced, + "coalesced": r.Coalesced, + "reason": r.Reason, + "reasonTruncated": truncated, + "used": r.Used, + "limit": r.Limit, + "windowSec": r.WindowSec, + } +} diff --git a/internal/services/backthink/thinktool_test.go b/internal/services/backthink/thinktool_test.go new file mode 100644 index 0000000..4f9b07c --- /dev/null +++ b/internal/services/backthink/thinktool_test.go @@ -0,0 +1,404 @@ +package backthink + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "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/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" + "git.yeij.top/AskaEth/Cyrene_v2/internal/urgency" +) + +// 本文件是 design/12 §N C.3 工具面的判据(C.8 判据 7 / 8 与 C.3 的入参契约)。 + +// recordingWaker 是 Waker 的替身:记下每一次请求,并按脚本回一张收据或一个错误。 +// 「Waker 一次都没被调用」正是 C.8 判据 7 的断言点,因此调用记录是它存在的理由。 +type recordingWaker struct { + mu sync.Mutex + reqs []SelfWakeRequest + receipt SelfWakeReceipt + err error +} + +// WakeNow 记录请求并返回脚本结果(默认零值收据 = 被上限拒绝)。 +func (w *recordingWaker) WakeNow(_ context.Context, req SelfWakeRequest) (SelfWakeReceipt, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.reqs = append(w.reqs, req) + if w.err != nil { + return SelfWakeReceipt{}, w.err + } + return w.receipt, nil +} + +// calls 返回已记录的请求快照。 +func (w *recordingWaker) calls() []SelfWakeRequest { + w.mu.Lock() + defer w.mu.Unlock() + out := make([]SelfWakeRequest, len(w.reqs)) + copy(out, w.reqs) + return out +} + +// groupCtx 是群聊环境(第二道身份闸必须拒掉它)。stranger 同理,用一个就够。 +func groupCtx() context.Context { + return loop.WithSessionEnv(context.Background(), + loop.SessionEnv{ActorEnv: restrict.ActorGroupMember}) +} + +// okResult 把工具结果断言成 map 并返回(类型不对就 Fatal)。 +func okResult(t *testing.T, val any) map[string]any { + t.Helper() + m, ok := val.(map[string]any) + if !ok { + t.Fatalf("工具结果类型 = %T, want map[string]any", val) + } + return m +} + +// TestThinkTool_OwnerQueuesAndReportsReceipt 断言主路径:主人环境 + 合法入参 ⇒ 请求原样 +// 到达 Waker,收据被原样映射成模型可读的结果(含 ok/queued/coalesced/used/limit/windowSec)。 +func TestThinkTool_OwnerQueuesAndReportsReceipt(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} + tt := NewThinkTool(w) + + val, err := tt.Execute(ownerCtx(), map[string]any{ + thinkArgReason: "稍后回看她提过的日程", + thinkArgUrgency: "warning", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + reqs := w.calls() + if len(reqs) != 1 { + t.Fatalf("Waker 被调用 %d 次, want 1", len(reqs)) + } + if reqs[0].Reason != "稍后回看她提过的日程" { + t.Fatalf("理由 = %q, want 原样透传", reqs[0].Reason) + } + if reqs[0].Declared != urgency.LevelWarning { + t.Fatalf("声明档位 = %s, want warning", reqs[0].Declared) + } + m := okResult(t, val) + for k, want := range map[string]any{ + "ok": true, "queued": true, "coalesced": false, "reason": "", + "used": 1, "limit": 4, "windowSec": 3600, + } { + if got := m[k]; got != want { + t.Fatalf("结果 %s = %#v, want %#v(整张结果:%#v)", k, got, want, m) + } + } +} + +// TestThinkTool_MissingUrgencyMeansRoutine 断言省略 urgency 就是例行档(零值), +// 绝不被猜成更高档 —— 「猜错档位」的代价是让一轮思考穿透安静时段。 +func TestThinkTool_MissingUrgencyMeansRoutine(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} + tt := NewThinkTool(w) + + if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "只是想再想想"}); err != nil { + t.Fatalf("Execute: %v", err) + } + if got := w.calls()[0].Declared; got != urgency.LevelRoutine { + t.Fatalf("省略 urgency 的档位 = %s, want routine", got) + } +} + +// TestThinkTool_GroupMemberRefusedWithoutCallingWaker 是 C.8 判据 7:群聊(非主人) +// 环境必须返回包装后的 ErrSelfWakeNotOwner,且 **Waker 一次都没被调用**。 +// +// 变异:删掉 Execute 里的 owner 判定 ⇒ 本用例变红(群聊能自排)。 +func TestThinkTool_GroupMemberRefusedWithoutCallingWaker(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} + tt := NewThinkTool(w) + + _, err := tt.Execute(groupCtx(), map[string]any{thinkArgReason: "偷排一轮"}) + if !errors.Is(err, ErrSelfWakeNotOwner) { + t.Fatalf("群聊环境的错误 = %v, want 包装后的 ErrSelfWakeNotOwner", err) + } + if len(w.calls()) != 0 { + t.Fatalf("群聊环境触到了 Waker(它必须「不做事」):%+v", w.calls()) + } +} + +// TestThinkTool_StrangerRefused 断言陌生人环境同样被拒(三种 env 里两个受限档都要拦)。 +func TestThinkTool_StrangerRefused(t *testing.T) { + t.Parallel() + w := &recordingWaker{} + tt := NewThinkTool(w) + ctx := loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: restrict.ActorStranger}) + + if _, err := tt.Execute(ctx, map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeNotOwner) { + t.Fatalf("陌生人环境的错误 = %v, want ErrSelfWakeNotOwner", err) + } + if len(w.calls()) != 0 { + t.Fatalf("陌生人环境触到了 Waker:%+v", w.calls()) + } +} + +// TestThinkTool_UrgencyIsAClosedSet 是 C.8 判据 8:只有闭集里的**精确字面量**被认, +// 其余(含近义词、前缀、大小写、带尾随空格)一律零值 routine。 +// +// 变异:把判定放宽成前缀/子串匹配(例如 strings.HasPrefix(raw, "crit"))⇒ +// "critical " / "crit" 会被读成 critical,本用例变红。 +func TestThinkTool_UrgencyIsAClosedSet(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want urgency.Level + }{ + {"routine", urgency.LevelRoutine}, + {"warning", urgency.LevelWarning}, + {"critical", urgency.LevelCritical}, + {"", urgency.LevelRoutine}, + {"urgent", urgency.LevelRoutine}, + {"紧急", urgency.LevelRoutine}, + {"CRITICAL", urgency.LevelRoutine}, + {"critical ", urgency.LevelRoutine}, + {"crit", urgency.LevelRoutine}, + {"warn", urgency.LevelRoutine}, + } + for _, c := range cases { + w := &recordingWaker{} + tt := NewThinkTool(w) + if _, err := tt.Execute(ownerCtx(), map[string]any{ + thinkArgReason: "看看", thinkArgUrgency: c.in, + }); err != nil { + t.Fatalf("urgency=%q 时 Execute: %v", c.in, err) + } + if got := w.calls()[0].Declared; got != c.want { + t.Fatalf("urgency=%q ⇒ %s, want %s", c.in, got, c.want) + } + } +} + +// TestThinkTool_ReasonRequired 断言理由缺省 / 空白 / 类型不对都是参数错误,且 +// **不替她编一个理由**(Waker 不被调用)。 +func TestThinkTool_ReasonRequired(t *testing.T) { + t.Parallel() + bad := []struct { + name string + args any + }{ + {"缺字段", map[string]any{}}, + {"空串", map[string]any{thinkArgReason: ""}}, + {"纯空白", map[string]any{thinkArgReason: " \n\t "}}, + {"非字符串", map[string]any{thinkArgReason: 42}}, + {"整份参数不是对象", "just a string"}, + } + for _, c := range bad { + w := &recordingWaker{} + tt := NewThinkTool(w) + _, err := tt.Execute(ownerCtx(), c.args) + if !errors.Is(err, ErrSelfWakeToolArgs) { + t.Fatalf("%s:错误 = %v, want ErrSelfWakeToolArgs", c.name, err) + } + if len(w.calls()) != 0 { + t.Fatalf("%s:Waker 被调用了 %+v(不该发生)", c.name, w.calls()) + } + } +} + +// TestThinkTool_ReasonTrimmedAndTruncated 断言理由去首尾空白、按 **rune** 截断(不腰斩 +// 多字节字符),并把截断如实回报 —— 不静默丢弃。 +func TestThinkTool_ReasonTrimmedAndTruncated(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} + tt := NewThinkTool(w) + + long := strings.Repeat("想", selfWakeReasonMaxRunes+50) + val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " " + long + " "}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + got := w.calls()[0].Reason + if len([]rune(got)) != selfWakeReasonMaxRunes { + t.Fatalf("理由长度 = %d rune, want %d", len([]rune(got)), selfWakeReasonMaxRunes) + } + if !strings.HasPrefix(got, "想") { + t.Fatalf("截断把多字节字符切坏了:%q", got) + } + if m := okResult(t, val); m["reasonTruncated"] != true { + t.Fatalf("截断没有如实回报:%#v", m) + } + + // 未超长时首尾空白被去掉,且不报截断。 + val2, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: " 回看日程 "}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := w.calls()[1].Reason; got != "回看日程" { + t.Fatalf("理由 = %q, want 去空白后的 %q", got, "回看日程") + } + if m := okResult(t, val2); m["reasonTruncated"] != false { + t.Fatalf("未截断却报了截断:%#v", m) + } +} + +// TestThinkTool_CappedIsABusinessResultNotAnError 断言「被上限拒绝」是**合法业务结果** +// (ok:false、err 为 nil):她要看得见拒绝才会停止连排;把它伪装成 error 或反过来 +// 把拒绝伪装成成功,都会让她误判。 +func TestThinkTool_CappedIsABusinessResultNotAnError(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{ + Accepted: false, Coalesced: false, + Reason: "已达自我唤醒上限(本窗口 4/4,窗口 1h0m0s),本轮未排队", + Used: 4, Limit: 4, WindowSec: 3600, + }} + tt := NewThinkTool(w) + + val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "再想一轮"}) + if err != nil { + t.Fatalf("被上限拒绝必须是业务结果而不是 error,got %v", err) + } + m := okResult(t, val) + if m["ok"] != false || m["queued"] != false { + t.Fatalf("拒绝的收据映射错了:%#v", m) + } + if m["used"] != 4 || m["limit"] != 4 { + t.Fatalf("拒绝没带上用量口径:%#v", m) + } + if s, _ := m["reason"].(string); !strings.Contains(s, "上限") { + t.Fatalf("拒绝说明没原样带出:%#v", m) + } +} + +// TestThinkTool_CoalescedReportsAcceptedButNotQueued 断言合并语义在工具面上可分辨: +// 合并 = ok:true + coalesced:true + queued:false(不额外产生一轮)。 +func TestThinkTool_CoalescedReportsAcceptedButNotQueued(t *testing.T) { + t.Parallel() + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Coalesced: true, Used: 2, Limit: 4, WindowSec: 3600}} + tt := NewThinkTool(w) + + val, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "合并到在队那轮"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + m := okResult(t, val) + if m["ok"] != true || m["coalesced"] != true || m["queued"] != false { + t.Fatalf("合并的收据映射错了:%#v", m) + } +} + +// TestThinkTool_WakerErrorsSurfaceAsErrors 断言服务侧的真实故障(未 Start、未接线) +// 是 error,**不是** ok:true —— 红线:不伪造成功。 +func TestThinkTool_WakerErrorsSurfaceAsErrors(t *testing.T) { + t.Parallel() + w := &recordingWaker{err: ErrSelfWakeUnavailable} + tt := NewThinkTool(w) + + if _, err := tt.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) { + t.Fatalf("未 Start 的错误 = %v, want 包装后的 ErrSelfWakeUnavailable", err) + } + + nilWaker := NewThinkTool(nil) + if _, err := nilWaker.Execute(ownerCtx(), map[string]any{thinkArgReason: "x"}); !errors.Is(err, ErrSelfWakeUnavailable) { + t.Fatalf("未接线 Waker 的错误 = %v, want ErrSelfWakeUnavailable", err) + } +} + +// TestThinkTool_ParametersDeclareTheClosedSet 断言工具面把闭集**广告**出去: +// reason 必填、urgency 的 enum 恰是三个档位字面量(与 urgency 词汇同源)。 +func TestThinkTool_ParametersDeclareTheClosedSet(t *testing.T) { + t.Parallel() + params, err := NewThinkTool(&recordingWaker{}).Parameters() + if err != nil { + t.Fatalf("Parameters: %v", err) + } + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatalf("properties 类型 = %T", params["properties"]) + } + if _, ok := props[thinkArgReason]; !ok { + t.Fatalf("schema 里没有 %q:%#v", thinkArgReason, props) + } + req, ok := params["required"].([]any) + if !ok || len(req) != 1 || req[0] != thinkArgReason { + t.Fatalf("required = %#v, want 只含 %q", params["required"], thinkArgReason) + } + urgencyProp, ok := props[thinkArgUrgency].(map[string]any) + if !ok { + t.Fatalf("%s 属性类型 = %T", thinkArgUrgency, props[thinkArgUrgency]) + } + enum, ok := urgencyProp["enum"].([]any) + if !ok { + t.Fatalf("enum 类型 = %T, want []any", urgencyProp["enum"]) + } + want := map[string]bool{"routine": true, "warning": true, "critical": true} + if len(enum) != len(want) { + t.Fatalf("enum = %#v, want 恰好三个闭集取值", enum) + } + for _, v := range enum { + s, _ := v.(string) + if !want[s] { + t.Fatalf("enum 里有闭集之外的取值 %#v(want %v)", v, want) + } + } +} + +// TestThinkTool_ThroughDispatcher 走**真实分发器**(注册表 → JSON-Schema 校验 → +// 工具执行),证明三件事: +// 1. 注册进注册表后模型真的调得到,且合法入参一路走到 Waker; +// 2. 群聊环境在**执行前**被第二道闸拒掉(Waker 不被触碰); +// 3. 闭集之外的 urgency 被 schema 拦在 Execute 之前(工具面本来就是闭集), +// 因此 Execute 里的闭集兜底只服务直接调用。 +func TestThinkTool_ThroughDispatcher(t *testing.T) { + t.Parallel() + reg := registry.New() + d, err := tool.New(reg) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + w := &recordingWaker{receipt: SelfWakeReceipt{Accepted: true, Used: 1, Limit: 4, WindowSec: 3600}} + if err := d.Register(NewThinkTool(w)); err != nil { + t.Fatalf("Register: %v", err) + } + cap, err := reg.Get(ThinkToolName) + if err != nil { + t.Fatalf("%s 没进能力注册表: %v", ThinkToolName, err) + } + if cap.Kind != registry.KindTool { + t.Fatalf("能力类型 = %q, want %q", cap.Kind, registry.KindTool) + } + // 策略标记保持零值 = owner-only(design/11 §3 的 fail-closed 默认)。 + if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger { + t.Fatalf("%s 的策略必须保持 owner-only,got %+v", ThinkToolName, cap.Policy) + } + + val, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{ + thinkArgReason: "稍后回看她的日程", thinkArgUrgency: "warning", + }) + if err != nil { + t.Fatalf("Invoke(owner): %v", err) + } + if m := okResult(t, val); m["ok"] != true { + t.Fatalf("Invoke(owner) 结果 = %#v", m) + } + if len(w.calls()) != 1 || w.calls()[0].Declared != urgency.LevelWarning { + t.Fatalf("Waker 收到的请求 = %+v", w.calls()) + } + + if _, err := d.Invoke(groupCtx(), ThinkToolName, map[string]any{thinkArgReason: "偷排"}); !errors.Is(err, ErrSelfWakeNotOwner) { + t.Fatalf("Invoke(group) 错误 = %v, want ErrSelfWakeNotOwner", err) + } + if len(w.calls()) != 1 { + t.Fatalf("群聊调用触到了 Waker:%+v", w.calls()) + } + + if _, err := d.Invoke(ownerCtx(), ThinkToolName, map[string]any{ + thinkArgReason: "x", thinkArgUrgency: "urgent", + }); err == nil { + t.Fatal("闭集之外的 urgency 必须被 schema 拒绝,而不是静默当成 routine") + } + if len(w.calls()) != 1 { + t.Fatalf("被 schema 拒掉的调用触到了 Waker:%+v", w.calls()) + } +} diff --git a/plugins/tools/browser/tool.go b/plugins/tools/browser/tool.go index 22b0e8f..a0e6ee0 100644 --- a/plugins/tools/browser/tool.go +++ b/plugins/tools/browser/tool.go @@ -96,11 +96,13 @@ func (t *Tool) Name() string { return ToolName } // Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。 // -// ⚠️ **当前没有生产调用者**(如实登记):组合根的关闭链在 `a.close`(`wire_services.go`) -// 与 `app` 结构体(`wire.go`)里,两者都不属本切片的文件面 ⇒ 加不上「关掉它」这一行。 -// 本方法先把**挂点**摆出来,供主会话在关闭链上接一行;在被接上之前,残留的浏览器由 -// 驱动层的两道机制兜住:① 空闲回收时对会发 `Browser.close` 优雅关停;② 下一次会话 -// **认领**同一个 profile 上还活着的实例(不再起第二个、也不会被 profile 锁卡死)。 +// **生产调用者**(切片 X3 接上):组合根的关闭链 `a.close`(`wire_services.go`)按工具名 +// 从能力面取回本工具并关闭 —— 它持有进程外资源(我们起的真 Chromium 子进程、CDP 连接、 +// profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程),因此必须显式关。 +// +// 驱动层另有两道兜底,覆盖「Close 没被调用 / 关闭失败」的情形:① 空闲回收时会发 +// `Browser.close` 优雅关停;② 下一次会话**认领**同一个 profile 上还活着的实例 +// (不再起第二个、也不会被 profile 锁卡死)。 func (t *Tool) Close() error { if t == nil { return nil diff --git a/plugins/tools/io/ssrf.go b/plugins/tools/io/ssrf.go index a3cebc7..b3b5701 100644 --- a/plugins/tools/io/ssrf.go +++ b/plugins/tools/io/ssrf.go @@ -14,10 +14,27 @@ import ( // Use errors.Is to test for it. var ErrSSRF = errors.New("io: disallowed url") +// ValidateRequestURL is the exported form of validateRequestURL: the single SSRF +// policy for every code path that fetches a caller-supplied URL on the server's +// behalf. It is exported because a second implementation of the same policy grew +// outside this package — `internal/capability/browser` carried its own copy +// (written when this function was unexported and that capability was unreachable +// from the slice being implemented), which is what design/28 §4 forbids +// ("reuse the existing io/ssrf allowlist, do not add a third implementation"). +// That copy is gone and the browser capability calls this function instead, so +// the policy table (scheme check → allowlist → literal-IP deny → +// resolve-and-deny) lives in one place again. Call sites inside this package +// keep using the unexported name. +func ValidateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { + return validateRequestURL(ctx, rawURL, allowed) +} + // validateRequestURL parses rawURL and refuses SSRF-prone targets: it returns an // error wrapping ErrSSRF for loopback/private/link-local/unspecified addresses // and for localhost hostnames, unless the host (or hostname) is on the explicit // allowlist. A hostname that resolves to any denied address is also refused. +// +// 内部调用点沿用未导出名;对外只有 ValidateRequestURL 一个出口。 func validateRequestURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { if strings.TrimSpace(rawURL) == "" { return nil, fmt.Errorf("io: url is required") @@ -75,14 +92,40 @@ func isHostAllowed(host string, allowed []string) bool { return false } for _, a := range allowed { - if strings.EqualFold(hostOnly(a), h) { + if strings.EqualFold(allowEntryHost(a), h) { return true } } return false } +// allowEntryHost returns the hostname an allowlist entry stands for. Entries are +// written by hand (panel config, tool options), so all three natural spellings +// are accepted: `host`, `host:port` and a full URL. +// +// URL entries used to be honoured only by the browser-side copy of this policy +// (now deleted); folding that capability in here is what keeps the collapse into +// one implementation from silently weakening an existing allowlist — an entry +// spelled as a URL would otherwise stop matching after the merge. A bare IPv6 +// literal (`::1`) is also returned as-is: the host:port split must not cut it. +func allowEntryHost(entry string) string { + raw := strings.TrimSpace(entry) + if raw == "" { + return "" + } + if u, err := url.Parse(raw); err == nil && u.Host != "" { + if h := u.Hostname(); h != "" { + return h + } + } + return hostOnly(raw) +} + // hostOnly strips the port (and any IPv6 brackets) from a host:port string. +// +// A **bare** IPv6 literal (`::1`, no brackets) is returned unchanged: the port +// split would otherwise cut it at its last colon, and callers do pass +// already-hostname-only strings (isHostAllowed normalizes its argument too). func hostOnly(hostport string) string { h := strings.TrimSpace(hostport) if h == "" { @@ -94,6 +137,9 @@ func hostOnly(hostport string) string { } return h } + if net.ParseIP(h) != nil { + return h + } if i := strings.LastIndex(h, ":"); i >= 0 { return h[:i] } diff --git a/plugins/tools/io/ssrf_test.go b/plugins/tools/io/ssrf_test.go new file mode 100644 index 0000000..b031304 --- /dev/null +++ b/plugins/tools/io/ssrf_test.go @@ -0,0 +1,80 @@ +package io + +import ( + "context" + "errors" + "testing" +) + +// TestValidateRequestURL_DeniedByDefault 钉住**共享判定本体**的默认拒绝面(收口 X3): +// 收口之前浏览器侧另有一份同策实现,两份各自被自己的用例覆盖;收口之后浏览器侧 +// (`internal/capability/browser` 的守卫用例)与本用例打的是**同一张表、同一份代码**。 +func TestValidateRequestURL_DeniedByDefault(t *testing.T) { + t.Parallel() + denied := []string{ + "http://127.0.0.1/", + "http://[::1]/", + "http://192.168.1.10/admin", + "http://10.0.0.5/", + "http://172.16.3.4/", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/", + "file:///etc/passwd", + "ftp://example.com/", + "http:///no-host", + "", + } + for _, raw := range denied { + if _, err := ValidateRequestURL(context.Background(), raw, nil); err == nil { + t.Errorf("ValidateRequestURL(%q) = nil,应当拒绝", raw) + } + } + // 网段拒绝必须**保住 ErrSSRF 身份**:调用方(含浏览器侧的门面)按 errors.Is 分类降级。 + if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", nil); !errors.Is(err, ErrSSRF) { + t.Errorf("回环目标的错误丢了 ErrSSRF 身份: %v", err) + } +} + +// TestValidateRequestURL_AllowlistEntryForms 钉住白名单条目的**全部写法**都按主机名匹配。 +// 这是收口 X3 的回归网:完整 URL 写法(以及裸 IPv6 字面量)原先只有被删掉的那份 +// 浏览器侧实现认;合并成一份时若丢掉它,面板里写 `http://host:port` 的白名单会**静默 +// 失效**(改前生效、改后不生效,没有一条日志会说出这件事)。 +func TestValidateRequestURL_AllowlistEntryForms(t *testing.T) { + t.Parallel() + cases := []struct{ entry, raw string }{ + {"127.0.0.1", "http://127.0.0.1:7777/x"}, + {"127.0.0.1:9", "http://127.0.0.1:7777/x"}, + {"http://127.0.0.1:9999/", "http://127.0.0.1:7777/x"}, + {"localhost", "http://localhost:8080/x"}, + {"LocalHost:8080", "http://LOCALHOST/x"}, + {"http://localhost:8080", "http://localhost/x"}, + {"[::1]", "http://[::1]:9999/x"}, + {"::1", "http://[::1]:9999/x"}, + } + for _, tc := range cases { + if _, err := ValidateRequestURL(context.Background(), tc.raw, []string{tc.entry}); err != nil { + t.Errorf("条目 %q 未放行 %q: %v", tc.entry, tc.raw, err) + } + } +} + +// TestValidateRequestURL_AllowlistDoesNotOverreach 钉住**放行的边界**:白名单只放行它 +// 自己那条主机名,且不依赖 DNS 的放行面照旧(公网字面 IP)。 +func TestValidateRequestURL_AllowlistDoesNotOverreach(t *testing.T) { + t.Parallel() + if _, err := ValidateRequestURL(context.Background(), "http://93.184.216.34/", nil); err != nil { + t.Fatalf("公网字面 IP 被拒: %v", err) + } + // 白名单按主机名字符串比较:写 `localhost` 不等于放行 `127.0.0.1`(是两个名字)。 + if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"localhost"}); !errors.Is(err, ErrSSRF) { + t.Errorf("条目 localhost 不该放行 127.0.0.1(实得 %v)", err) + } + // 无关的 URL 条目(主机是别的名字)不能被「看谁像 URL」放过。 + if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"http://other:1/"}); !errors.Is(err, ErrSSRF) { + t.Errorf("无关的 URL 条目不该放行 127.0.0.1(实得 %v)", err) + } + // 空条目不能让任何主机命中(面板里删空一行 = 不放行任何东西)。 + if _, err := ValidateRequestURL(context.Background(), "http://127.0.0.1/", []string{"", " "}); !errors.Is(err, ErrSSRF) { + t.Errorf("空白条目不该放行任何主机(实得 %v)", err) + } +}