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, } }