From 93aff2fea5733638619fb9f85e6c342f7821bf9e Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 19:21:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(backthink):=20=E8=87=AA=E4=B8=BB=E6=80=9D?= =?UTF-8?q?=E8=80=83=E5=BC=BA=E5=8C=96=20P1=E2=80=94=E2=80=94=E4=B8=BB?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=8A=E4=B8=8B=E6=96=87+owner=20env+?= =?UTF-8?q?=E5=8E=BB=E5=8E=8B=E5=88=B6(design/20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backthink 从"心跳空转(独立sess-backthink+写死nothing to report宏)"改成"看得见主人、 能读写记忆、能调工具":think 读主会话最近20条(history.Recent,倒序映射)作为上下文 + 重写引导prompt(温暖/有针对性/主动表达,删掉 Do not produce chat-style message) + 注入 loop.OwnerEnv(MemWritable=true/ActorOwner/全工具)。 - context.go: mainContext/recentProvider/thinkInput/guidancePrompt - thinker.go: LoopThinker 加 recent;Think 读主会话上下文+OwnerEnv+MutedBroadcast - wire.go: NewLoopThinker(a.loop, a.history) - 刻意偏离(安全): loop.Run session id 仍用 sess-backthink(不写"web"),避免与用户实时回合 checkpoint/中断窗口复用污染;上下文来源+运行环境=主会话/owner env,目标达成。红线靠actor元数据。 - 测试: prompt无压制/有鼓励;主会话消息映射;nil退化;端到端Think(owner env 生效) 验收: build/vet/test 全绿+windows/darwin交叉编译绿. P2(主动推送+渠道选择)后置. --- cmd/cyrene/wire.go | 2 +- internal/services/backthink/backthink.go | 8 +- internal/services/backthink/context.go | 95 ++++++++ internal/services/backthink/thinker.go | 42 ++-- internal/services/backthink/thinker_test.go | 237 ++++++++++++++++++++ 5 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 internal/services/backthink/context.go create mode 100644 internal/services/backthink/thinker_test.go diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index be851c9..347fd33 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -495,7 +495,7 @@ func (a *app) wireAuxiliary() error { bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{ Bus: a.bus, - Thinker: backthink.NewLoopThinker(a.loop), + Thinker: backthink.NewLoopThinker(a.loop, a.history), State: backthink.NewIoTStateProvider(iotMgr, a.log), DB: a.db, Log: logger.WithSource(a.log, "services/backthink"), diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index 4ec8227..f46b8df 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -55,8 +55,12 @@ const ( // defaultThinkTimeout bounds a single unattended thought so it can never // wedge a worker goroutine. defaultThinkTimeout = 30 * time.Second - // defaultSessionID scopes a think when the caller supplies none. The - // background pass owns no conversation, so a stable synthetic id is used. + // defaultSessionID scopes a think when the caller supplies none. It is the + // loop.Run durability key, deliberately distinct from history.MainSessionID so + // an autonomous think never collides with the live main session's checkpoint + // store (channelbridge saves/resumes under the main id). The think still sees + // the main conversation: LoopThinker reads history.MainSessionID for context and + // runs under loop.OwnerEnv (design/20 §2). defaultSessionID = "sess-backthink" ) diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go new file mode 100644 index 0000000..06ed015 --- /dev/null +++ b/internal/services/backthink/context.go @@ -0,0 +1,95 @@ +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. +func guidancePrompt(trigger string) string { + return "[自主思考 · 无人在旁]\n" + + "触发:" + trigger + "\n" + + "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" + + "请回顾最近与主人的相处、她的状态、你们共同经历的事与临近的日程,判断此刻是否有一件真正值得主动告诉她的、温暖而有针对性的事情——" + + "比如关心她的近况、记起她曾说过的某件事、提醒一件即将到来的日程、见证一个属于你们的共同时刻,或注意到某个值得留意的变化。\n" + + "若有,就用温暖自然的语气,用一到两句把它说出来——这正是你此刻最想对她说的话;若确实没有值得说的,只需回复「无」,也不要编造。\n" + + "整轮思考保持简短、单轮,不反复自我触发。" +} diff --git a/internal/services/backthink/thinker.go b/internal/services/backthink/thinker.go index 6854eaa..05299e8 100644 --- a/internal/services/backthink/thinker.go +++ b/internal/services/backthink/thinker.go @@ -5,43 +5,43 @@ import ( "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" - "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // LoopThinker adapts a *loop.Loop into a Thinker. It is the production wiring -// that lets a background pass drive the full tool loop (memory injection, model -// steps, tool calls such as IoT/calendar/reminders) without the service knowing -// the loop's concrete shape. +// that lets an autonomous background pass drive the full tool loop (memory +// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing +// the main session's recent conversation and running under the owner environment. type LoopThinker struct { - loop *loop.Loop + loop *loop.Loop + recent recentProvider } -// NewLoopThinker wraps l as a Thinker. -func NewLoopThinker(l *loop.Loop) *LoopThinker { return &LoopThinker{loop: l} } +// NewLoopThinker wraps l as a Thinker over the given recent-messages provider +// (*history.Store in production; nil degrades the think to the guidance prompt +// alone, see mainContext). +func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker { + return &LoopThinker{loop: l, recent: recent} +} // Think runs one unattended think through the wrapped loop and returns the -// conclusion text. It returns ErrNoLoop when no loop was injected. +// conclusion text. It inherits the main session's recent conversation as input +// context and injects the owner session environment (MemWritable=true, ActorOwner, +// full tool set) so the think can read/write memory and call tools. The loop's +// Run session id is the service's own (not the main session id) so the think never +// collides with the live main session's durable checkpoint store (design/20). +// It returns ErrNoLoop when no loop was injected. func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) { if t.loop == nil { return "", ErrNoLoop } - messages := []model.Message{model.TextMessage(model.RoleUser, backgroundPrompt(trigger))} + recent := mainContext(ctx, t.recent) + messages := thinkInput(recent, trigger) + envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv()) // Mute UI broadcast: background reflection must not leak its intermediate // reasoning into the user-facing chat stream (it only needs the conclusion). - res, err := t.loop.Run(loop.WithMutedBroadcast(ctx), sessionID, messages) + res, err := t.loop.Run(loop.WithMutedBroadcast(envCtx), sessionID, messages) if err != nil { return "", fmt.Errorf("backthink: think: %w", err) } return res.Text, nil } - -// backgroundPrompt is the instruction handed to the runtime for one unattended -// thought. It explicitly avoids asking for a chat-style message to the user. -func backgroundPrompt(trigger string) string { - return "[background autonomous reflection]\n" + - "Trigger: " + trigger + "\n" + - "Review recent context, device states, calendar and reminders. Decide " + - "whether anything is worth proactively telling the owner. If nothing " + - "matters, conclude \"nothing to report\". Keep the conclusion to one or " + - "two sentences. Do not produce a chat-style message to the user." -} diff --git a/internal/services/backthink/thinker_test.go b/internal/services/backthink/thinker_test.go new file mode 100644 index 0000000..cf4355d --- /dev/null +++ b/internal/services/backthink/thinker_test.go @@ -0,0 +1,237 @@ +package backthink + +import ( + "context" + "errors" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// fakeRecent is a recentProvider stub returning a fixed tail/error for the +// main-session context read. +type fakeRecent struct { + msgs []history.Message + err error +} + +func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, error) { + return f.msgs, f.err +} + +// captureAdapter is a model.Adapter that records every request it is handed so a +// test can assert on the messages the loop actually fed to the model. +type captureAdapter struct { + reqs []model.Request +} + +func (c *captureAdapter) Name() string { return "capture" } + +func (c *captureAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { + c.reqs = append(c.reqs, req) + return model.Response{Content: "结论:想陪她聊聊"}, nil +} + +func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { + return c.Chat(context.Background(), req, model.Options{}) +} + +// newThinkLoop builds a real loop over ad with a working manual memory-extraction +// path so a test can prove the owner environment is in effect (extractMemory runs +// only when MemWritable=true). +func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) { + t.Helper() + a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + store := newMemoryStore(t) + l := loop.New(loop.Options{ + Agent: a, + Memory: store, + Extractor: memory.NewManualExtractor("persona-a", "role-a"), + PersonaID: "persona-a", + }) + return l, store +} + +// newMemoryStore opens an isolated in-memory memory store for one test. +func newMemoryStore(t *testing.T) *memory.Store { + t.Helper() + d, err := db.Open(context.Background(), ":memory:", nil) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + s, err := memory.NewStore(context.Background(), d, discardLog()) + if err != nil { + t.Fatalf("memory.NewStore: %v", err) + } + return s +} + +// modelText returns the concatenated text of a model message's text parts. +func modelText(m model.Message) string { + var b strings.Builder + for _, c := range m.Content { + if c.Type == model.ContentTypeText { + b.WriteString(c.Text) + } + } + return b.String() +} + +// TestGuidancePrompt_EncouragesProactiveWithoutSuppression guards the rewrite: +// the guidance must drop the old suppression phrases and push warm, targeted +// proactive expression (design/20 §2). +func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) { + t.Parallel() + p := guidancePrompt("heartbeat") + for _, banned := range []string{"Do not produce a chat-style message", "nothing to report"} { + if strings.Contains(p, banned) { + t.Fatalf("guidance prompt still suppresses: contains %q", banned) + } + } + for _, want := range []string{"值得主动告诉", "温暖"} { + if !strings.Contains(p, want) { + t.Fatalf("guidance should encourage warm proactive expression, missing %q", want) + } + } +} + +// TestMainContext_MapsRecentMessages proves the recent main-session tail is +// converted in order with roles mapped and image parts preserved. +func TestMainContext_MapsRecentMessages(t *testing.T) { + t.Parallel() + prov := &fakeRecent{msgs: []history.Message{ + {Role: history.RoleUser, Content: "今天有点累"}, + {Role: history.RoleAssistant, Content: "我陪你"}, + {Role: history.RoleUser, Content: "看这张图", Image: "data:image/png;base64,x"}, + }} + got := mainContext(context.Background(), prov) + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + if got[0].Role != model.RoleUser || got[0].Content[0].Text != "今天有点累" { + t.Fatalf("msg[0] = %+v, want user '今天有点累'", got[0]) + } + if got[1].Role != model.RoleAssistant { + t.Fatalf("msg[1].Role = %q, want assistant", got[1].Role) + } + if got[2].Content[1].Type != model.ContentTypeImage { + t.Fatalf("msg[2] missing image part: %+v", got[2].Content) + } +} + +// TestMainContext_Degrades ensures a nil provider or a failed read yields a nil +// tail so the think runs on the guidance prompt alone rather than failing. +func TestMainContext_Degrades(t *testing.T) { + t.Parallel() + if got := mainContext(context.Background(), nil); got != nil { + t.Fatalf("nil provider should yield nil, got %+v", got) + } + if got := mainContext(context.Background(), &fakeRecent{err: errors.New("boom")}); got != nil { + t.Fatalf("failed read should degrade to nil, got %+v", got) + } +} + +// TestThinkInput_AppendsGuidanceLast verifies the assembled input is the recent +// context followed by a single guidance turn. +func TestThinkInput_AppendsGuidanceLast(t *testing.T) { + t.Parallel() + in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat") + if len(in) != 2 { + t.Fatalf("len = %d, want 2", len(in)) + } + if in[1].Role != model.RoleUser || !strings.Contains(modelText(in[1]), guidancePrompt("heartbeat")) { + t.Fatalf("last message is not the guidance prompt: %+v", in[1]) + } +} + +// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts +// three things from design/20 P1: the model saw the main-session recent message, +// the guidance prompt carries no suppression, and the think ran under the owner +// environment (the manual extractor persists only when MemWritable=true). +func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) { + ad := &captureAdapter{} + l, store := newThinkLoop(t, ad) + lt := NewLoopThinker(l, &fakeRecent{msgs: []history.Message{ + {Role: history.RoleUser, Content: "记住 她喜欢冰美式"}, + }}) + + text, err := lt.Think(context.Background(), "sess-backthink", "heartbeat") + if err != nil { + t.Fatalf("Think: %v", err) + } + if text != "结论:想陪她聊聊" { + t.Fatalf("text = %q, want the adapter conclusion", text) + } + + if len(ad.reqs) == 0 { + t.Fatal("no model request captured") + } + last := ad.reqs[len(ad.reqs)-1] + var sawContext, sawGuidance bool + for _, m := range last.Messages { + txt := modelText(m) + if strings.Contains(txt, "冰美式") { + sawContext = true + } + if strings.Contains(txt, "无人在旁") { + sawGuidance = true + } + } + if !sawContext { + t.Fatalf("input missing the main-session recent message: %+v", last.Messages) + } + if !sawGuidance { + t.Fatalf("input missing the guidance prompt: %+v", last.Messages) + } + + // Owner env proved: ManualExtractor only persists under MemWritable=true. + items, err := store.ListByPersona(context.Background(), "persona-a") + if err != nil { + t.Fatalf("ListByPersona: %v", err) + } + if len(items) == 0 { + t.Fatal("owner env not in effect: no memory extracted (extractMemory runs only when MemWritable=true)") + } + if !strings.Contains(items[0].Content, "冰美式") { + t.Fatalf("extracted content = %q, want the remembered fact", items[0].Content) + } +} + +// TestLoopThinker_NilHistoryDegrades keeps the pre-existing backpressure: with no +// history provider the think still runs (guidance prompt only) and never panics. +func TestLoopThinker_NilHistoryDegrades(t *testing.T) { + ad := &captureAdapter{} + l, _ := newThinkLoop(t, ad) + lt := NewLoopThinker(l, nil) + + if _, err := lt.Think(context.Background(), "sess-backthink", "heartbeat"); err != nil { + t.Fatalf("Think with nil history: %v", err) + } + if len(ad.reqs) == 0 { + t.Fatal("no model request captured") + } + last := ad.reqs[len(ad.reqs)-1] + // With no history provider the input carries the guidance prompt and no + // main-session messages. (The agent prepends its own instruction system message, + // so we assert presence rather than an exact count.) + foundGuidance := false + for _, m := range last.Messages { + if strings.Contains(modelText(m), guidancePrompt("heartbeat")) { + foundGuidance = true + break + } + } + if !foundGuidance { + t.Fatalf("degraded think missing the guidance prompt: %+v", last.Messages) + } +}