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)。
210 lines
9.8 KiB
Go
210 lines
9.8 KiB
Go
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,
|
||
}
|
||
}
|