feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)

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)。
This commit is contained in:
AskaEth committed 2026-10-06 21:16:23 +08:00
1 parent 2bf3b536dd
commit 2d211c2d16
20 files changed
+1568 -176

No files matched your search

-106
View File
@@ -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
}
+18
View File
@@ -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)
}
}
+41
View File
@@ -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
}
@@ -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 钉住落点:`<dataDir>/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)
}
}
+12 -6
View File
@@ -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...)
+10 -2
View File
@@ -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,
}
}
+104 -27
View File
@@ -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
}
@@ -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)
}
}
}
+209
View File
@@ -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,
}
}
@@ -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())
}
}