Files
Cyrene_v2/internal/services/backthink/thinktool.go
T
AskaEth 2d211c2d16 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)。
2026-10-06 21:16:23 +08:00

210 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}