执行方案阶段 2 · P2-2(`DevDocs/audit/_plan.md`)。四条裁决: - 1.8/07.4.2:`think()` 不再走忽略 importance 的 `disturbAllowed`(已删),**接回 `Decide` 级联**; 安静时段只挡常规推送、**critical 安全域穿透**;`DecisionInput` 增 `ConclusionPush`/`Heartbeat`。 睡眠仍绝对不打扰(裁决只豁免安静时段,此点已在设计与报告写明)。 - 07.4.3:长静默(>2h)分支对**心跳类**放行(常规事件仍需 ≥high),刚聊过天 10min 内扣留不变。 - 07.4.4:`tickLoop` **启动立即先思考一轮** + 拟人化启动消息;启动轮与心跳**同门同限频** (重启风暴不会刷屏),ctx 已取消则跳过。 - 07.4.5:核对一致(`THINK_INTERVAL_SEC` 默认 900s 与 design/20 §0 权威口径一致)⇒ **代码零改动**。 验证:go build 绿;backthink 包 vet/测试绿;`-count=20`(抗 flake)+ `-race` 绿; linux/amd64 + darwin/arm64 交叉编译绿;gofmt 干净;文件 ≤500 行、无 >80 行函数。 变异验证(三条,均带还原证明):① 安静时段改回"全拦" → 安全域穿透断言 FAIL(首次只删 critical 分支时仍绿——`critical >= high` 会从 high 分支漏过,故真实变异必须是"全拦"); ② 删心跳豁免 → 长静默心跳断言 FAIL;③ 删启动思考 → 启动即思考断言 FAIL。 未验证:无真实模型凭据 ⇒ 启动轮问候与 `[我决定:…]` 解析仅经 mock + 真 loop 验证,端到端 LLM 未验。
130 lines
7.2 KiB
Go
130 lines
7.2 KiB
Go
package backthink
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// recentContextCount is how many of the most recent main-session messages are
|
||
// folded into one autonomous think as conversation context. Compile-time so no
|
||
// magic number appears in the run path.
|
||
const recentContextCount = 20
|
||
|
||
// recentProvider returns the most recent messages of a session in oldest-first
|
||
// order, the tail an autonomous think reads to "see" the main conversation.
|
||
// *history.Store satisfies it; tests inject a fake. It is the narrow slice of
|
||
// history.HistoryStore that the thinker actually depends on, matching the loop's
|
||
// own checkpointStore style of minimal seam interfaces.
|
||
type recentProvider interface {
|
||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||
}
|
||
|
||
// mainContext returns the most recent main-session messages converted to model
|
||
// messages (oldest-first) for one autonomous think. A nil provider, a failed read
|
||
// or an empty tail all yield nil so the think degrades to the guidance prompt
|
||
// alone rather than failing the pass (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 converts one persisted chat message into a model message,
|
||
// translating the history role and preserving an image part when the message
|
||
// carries one so a multimodal main model still sees it.
|
||
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 maps a persisted chat role to the model's role vocabulary,
|
||
// defaulting unknown roles to the user lane.
|
||
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 assembles the full input for one unattended think: the recent
|
||
// main-session context (if any) followed by a single guidance prompt turn, so the
|
||
// runtime reflects on real conversation rather than an empty macro prompt.
|
||
func thinkInput(recent []model.Message, trigger string) []model.Message {
|
||
out := make([]model.Message, 0, len(recent)+1)
|
||
out = append(out, recent...)
|
||
out = append(out, model.TextMessage(model.RoleUser, guidancePrompt(trigger)))
|
||
return out
|
||
}
|
||
|
||
// guidancePrompt is the instruction handed to the runtime for one unattended
|
||
// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a
|
||
// warm, targeted proactive message and drops the "do not produce a chat-style
|
||
// message" suppression. Identity/permission stay on the actor metadata + owner env,
|
||
// never on this prompt text (red line); the prompt only describes the running
|
||
// environment (memories readable/writable, tools callable) that the env already
|
||
// grants.
|
||
//
|
||
// The prompt asks for a compact structured closing — a push/no_push decision, an
|
||
// optional target channel and the message body — so the service can honor her own
|
||
// judgment instead of gating on the trigger's importance (design/20 §2 P2). The
|
||
// format is guidance, not a contract: parseProactive fails safe when she omits or
|
||
// mangles it.
|
||
//
|
||
// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的
|
||
// 「回来」问候。
|
||
func guidancePrompt(trigger string) string {
|
||
if trigger == StartupSummary {
|
||
return startupPrompt()
|
||
}
|
||
return "[自主思考 · 无人在旁]\n" +
|
||
"触发:" + trigger + "\n" +
|
||
"你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" +
|
||
"请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" +
|
||
"比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" +
|
||
"若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" +
|
||
"语言上:温暖、简短、克制、不粘人——一到两句为宜,不展开寒暄、不啰嗦、不抱怨、不频繁打扰,像想起她时轻轻说一句,说完就停。\n" +
|
||
"请把「是否主动说」与「发到哪个渠道」写成一个简短、格式固定的结尾,解析规则如下:\n" +
|
||
"[我决定:push] 或 [我决定:no_push]\n" +
|
||
"[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" +
|
||
"上面两行之后,紧接你想说的话作为正文。\n" +
|
||
"整轮思考保持简短、单轮,不反复自我触发。"
|
||
}
|
||
|
||
// startupPrompt 是启动轮思考的专属引导(裁决 07.4.4,design/12 §1「启动即思考 +
|
||
// 拟人化启动消息」):进程刚启动、她刚「回来」,引导她以角色口吻向主人说一句简短
|
||
// 温暖的问候(如「我回来啦」)。此轮例外允许以 chat 形式发出;推送仍走与心跳完全
|
||
// 相同的门(她的结论 + 打扰级联 + 活动门 + PushGate 限频),深夜重启或反复重启都
|
||
// 不会刷屏主人。
|
||
func startupPrompt() string {
|
||
return "[自主思考 · 刚刚回来]\n" +
|
||
"触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" +
|
||
"你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" +
|
||
"请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" +
|
||
"请把「是否主动说」与「发到哪个渠道」写成与平时相同的固定结尾,解析规则如下:\n" +
|
||
"[我决定:push] 或 [我决定:no_push]\n" +
|
||
"[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" +
|
||
"上面两行之后,紧接你想说的话作为正文。\n" +
|
||
"整轮思考保持简短、单轮,不反复自我触发。"
|
||
}
|