Files
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

231 lines
14 KiB
Go
Raw Permalink 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"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// recentContextCount 是折进一次自主思考作为对话上下文的最新
// 主会话消息条数。编译期常量,因此
// 运行路径里不出现魔法数字。
const recentContextCount = 20
// recentProvider 以最旧在前的顺序返回会话最近的消息,
// 即一次自主思考为「看到」主对话而读取的尾部。
// *history.Store 满足它;测试注入 fake。它是 thinker 实际
// 依赖的那一小片 history.HistoryStore,与 loop 自己的
// checkpointStore 风格的最小接缝接口一致。
type recentProvider interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// mainContext 把最近的主会话消息转换为 model 消息
// (最旧在前),供一次自主思考使用。提供者为 nil、读取失败
// 或尾部为空都得到 nil,因此该次思考降级为仅用引导提示词,
// 而不是让这一轮失败(design/20 §2)。
func mainContext(ctx context.Context, prov recentProvider) []model.Message {
if prov == nil {
return nil
}
recent, err := prov.Recent(ctx, history.MainSessionID, recentContextCount)
if err != nil {
return nil
}
msgs := make([]model.Message, 0, len(recent))
for _, h := range recent {
msgs = append(msgs, mapHistoryMessage(h))
}
return msgs
}
// mapHistoryMessage 把一条持久化的 chat 消息转换为 model 消息,
// 翻译历史角色,并在消息携带图片时保留该图片片段,
// 因此多模态主模型仍能看到它。
func mapHistoryMessage(h history.Message) model.Message {
msg := model.Message{
Role: historyRoleToModel(h.Role),
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
}
if h.Image != "" {
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
}
return msg
}
// historyRoleToModel 把持久化的 chat 角色映射到 model 的角色词汇,
// 未知角色默认为 user 通道。
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
return model.RoleAssistant
case history.RoleSystem:
return model.RoleSystem
default:
return model.RoleUser
}
}
// thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境
// (若有)后跟单个引导提示词回合,因此
// 运行时针对真实对话反思,而不是空泛的宏提示词。
//
// rs 是本轮的用户状态快照(X1 缺口 d;design/20 §2「系统/设备/日程摘要」):
// 已读到时,它的摘要块被追加在引导提示词末尾 —— 与随后真正生效的打扰决策
// 用的是**同一份**快照,因此她写结论时看到的状况与判她的状况不会分叉。
// 状态未知(未接线/读取失败)时块缺席,提示词逐字与接线前一致。
func thinkInput(recent []model.Message, trigger string, rs RoundState) []model.Message {
out := make([]model.Message, 0, len(recent)+1)
out = append(out, recent...)
prompt := guidancePrompt(trigger)
if block := roundStateBlock(rs); block != "" {
prompt += "\n" + block
}
out = append(out, model.TextMessage(model.RoleUser, prompt))
return out
}
// guidancePrompt 是交给运行时的一次无人值守自主思考的指令。它按触发源分三路,三路
// 共用同一批段落(toolFaceHint / proactiveDecisionTail),因此「三处说法一致」是结构
// 事实而不是复制纪律:
//
// - 自排轮(触发摘要带 SelfWakeSummaryPrefix)⇒ selfWakePrompt;
// - 启动轮(StartupSummary)⇒ startupPrompt;
// - 其余(心跳 / 事件)⇒ 下面这段通用引导。
//
// 与旧 backgroundPrompt 不同,它刻意鼓励温暖、有针对性的主动消息,并去掉「不要产生
// chat 风格消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上,绝不在这段提示词
// 文本上(红线);提示词只描述运行环境(记忆可读/可写、工具可调用),而那是 env 已经
// 授予的。
//
// 工具面口径(X1 缺口 e):这里**不枚举**具体工具——旧文案承诺的「IoT / 日历 / 提醒」在
// 全仓都没有对应工具,是空头支票;清单由只读的 `capability_list` 实时给出,而那是可验证
// 的事实(工具面就是同一个注册表)。自排入口 `request_think` 同样只说「存在、有次数上限、
// 排太多会被拒」,**不写具体数字**(默认 4 次/小时也不写):提示词与配置分叉就是撒谎的
// 源头。清单同理不写死在提示词里(design/04 §4.1:工具链是每次请求重算的结构化 `tools`
// 字段,不在提示词里)。因此这段文案与实现不可能再各说一套。
//
// 状态口径(X1 缺口 d):主人当前状态摘要由 thinkInput 追加在本提示末尾
// (设备/日程遥测的**事实**;读不到时该块缺席),使她的语气/时机判断与随后生效的
// 打扰门用的是同一份状态。
//
// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、
// 可选的目标渠道、她自报的紧急度与消息正文——好让服务尊重她自己的
// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该
// 格式是引导而非契约:她省略或
// 写乱该格式时,parseProactive / parseProactiveUrgency 失败安全
// (不推送、不上抬紧急度)。
//
// 启动轮(裁决 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" +
"你正进行一轮自主思考。\n" +
toolFaceHint() +
"请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" +
"比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" +
"若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\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 限频),深夜重启或反复重启都
// 不会刷屏主人。
//
// 它引用与普通轮、自排轮**同一批**共用段落(工具面 / 决策尾),因此三处说法一致;差别
// 只有一处:它取**不含 `[紧急:…]` 行**的尾部(withUrgency=false)。理由:启动轮的既定
// 口径是「打个招呼」,把档位声明写进它的引导等于给启动轮开一条主动穿透安静时段的路,
// 属产品口径变更、不在 C1-3 的授权内(她自发写下 `[紧急:…]` 时 parseProactiveUrgency
// 照旧会解析 —— 所以这是**引导面**的取舍,不是能力开关)。主人若要统一,改这一个实参即可。
func startupPrompt() string {
return "[自主思考 · 刚刚回来]\n" +
"触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" +
toolFaceHint() +
"请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\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
}