package backthink import ( "context" "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 是交给运行时的一次无人值守自主思考的 // 指令。与旧 backgroundPrompt 不同,它刻意鼓励 // 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格 // 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上, // 绝不在这段提示词文本上(红线);提示词只描述运行 // 环境(记忆可读/可写、工具可调用),而那是 env 已经 // 授予的。 // // 工具面口径(X1 缺口 e):这里**不再枚举**具体工具——旧文案承诺的「IoT / 日历 / 提醒」 // 在全仓都没有对应工具,是空头支票。现在它只说「与主人对话同等的工具面」,并指名 // **自查入口** `capability_list`,而那是可验证的事实:工具面就是同一个注册表 // (LoopThinker 包的是同一个 loop),清单工具如实列出当下注册了什么(含外围子系统)。 // 因此这段文案与实现不可能再各说一套。 // // 状态口径(X1 缺口 d):主人当前状态摘要由 thinkInput 追加在本提示末尾 // (设备/日程遥测的**事实**;读不到时该块缺席),使她的语气/时机判断与随后生效的 // 打扰门用的是同一份状态。 // // 提示词要求一个紧凑的结构化收尾——push/no_push 决策、 // 可选的目标渠道、她自报的紧急度与消息正文——好让服务尊重她自己的 // 判断,而不是按触发的重要度把关(design/20 §2 P2)。该 // 格式是引导而非契约:她省略或 // 写乱该格式时,parseProactive / parseProactiveUrgency 失败安全 // (不推送、不上抬紧急度)。 // // 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的 // 「回来」问候。 func guidancePrompt(trigger string) string { if trigger == StartupSummary { return startupPrompt() } return "[自主思考 · 无人在旁]\n" + "触发:" + trigger + "\n" + "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + "请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" + "比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\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" + "整轮思考保持简短、单轮,不反复自我触发。" } // startupPrompt 是启动轮思考的专属引导(裁决 07.4.4,design/12 §1「启动即思考 + // 拟人化启动消息」):进程刚启动、她刚「回来」,引导她以角色口吻向主人说一句简短 // 温暖的问候(如「我回来啦」)。此轮例外允许以 chat 形式发出;推送仍走与心跳完全 // 相同的门(她的结论 + 打扰级联 + 活动门 + PushGate 限频),深夜重启或反复重启都 // 不会刷屏主人。工具面口径与 guidancePrompt 逐字一致(自查入口 `capability_list`, // 不再枚举具体工具)。 func startupPrompt() string { return "[自主思考 · 刚刚回来]\n" + "触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" + "你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(检索 / 终端 / 文件等,具体有哪些以 `capability_list` 列出的为准)。\n" + "想确认自己此刻有哪些工具、技能、插件可用、外围子系统(设备等)现在什么状态,就调用 `capability_list`;主人当前的状态摘要附在本提示末尾(读不到时不会出现)。\n" + "请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" + "请把「是否主动说」与「发到哪个渠道」写成与平时相同的固定结尾,解析规则如下:\n" + "[我决定:push] 或 [我决定:no_push]\n" + "[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" + "上面两行之后,紧接你想说的话作为正文。\n" + "整轮思考保持简短、单轮,不反复自我触发。" }