feat(backthink): 自主思考强化 P1——主会话上下文+owner env+去压制(design/20)
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(主动推送+渠道选择)后置.
This commit is contained in:
1 parent
44e7a5c2c5
commit
93aff2fea5
5 files changed
+360
-24
No files matched your search
+1
-1
@@ -495,7 +495,7 @@ func (a *app) wireAuxiliary() error {
|
|||||||
|
|
||||||
bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{
|
bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{
|
||||||
Bus: a.bus,
|
Bus: a.bus,
|
||||||
Thinker: backthink.NewLoopThinker(a.loop),
|
Thinker: backthink.NewLoopThinker(a.loop, a.history),
|
||||||
State: backthink.NewIoTStateProvider(iotMgr, a.log),
|
State: backthink.NewIoTStateProvider(iotMgr, a.log),
|
||||||
DB: a.db,
|
DB: a.db,
|
||||||
Log: logger.WithSource(a.log, "services/backthink"),
|
Log: logger.WithSource(a.log, "services/backthink"),
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ const (
|
|||||||
// defaultThinkTimeout bounds a single unattended thought so it can never
|
// defaultThinkTimeout bounds a single unattended thought so it can never
|
||||||
// wedge a worker goroutine.
|
// wedge a worker goroutine.
|
||||||
defaultThinkTimeout = 30 * time.Second
|
defaultThinkTimeout = 30 * time.Second
|
||||||
// defaultSessionID scopes a think when the caller supplies none. The
|
// defaultSessionID scopes a think when the caller supplies none. It is the
|
||||||
// background pass owns no conversation, so a stable synthetic id is used.
|
// 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"
|
defaultSessionID = "sess-backthink"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
"整轮思考保持简短、单轮,不反复自我触发。"
|
||||||
|
}
|
||||||
@@ -5,43 +5,43 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
"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
|
// 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
|
// that lets an autonomous background pass drive the full tool loop (memory
|
||||||
// steps, tool calls such as IoT/calendar/reminders) without the service knowing
|
// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing
|
||||||
// the loop's concrete shape.
|
// the main session's recent conversation and running under the owner environment.
|
||||||
type LoopThinker struct {
|
type LoopThinker struct {
|
||||||
loop *loop.Loop
|
loop *loop.Loop
|
||||||
|
recent recentProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLoopThinker wraps l as a Thinker.
|
// NewLoopThinker wraps l as a Thinker over the given recent-messages provider
|
||||||
func NewLoopThinker(l *loop.Loop) *LoopThinker { return &LoopThinker{loop: l} }
|
// (*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
|
// 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) {
|
func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) {
|
||||||
if t.loop == nil {
|
if t.loop == nil {
|
||||||
return "", ErrNoLoop
|
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
|
// Mute UI broadcast: background reflection must not leak its intermediate
|
||||||
// reasoning into the user-facing chat stream (it only needs the conclusion).
|
// 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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("backthink: think: %w", err)
|
return "", fmt.Errorf("backthink: think: %w", err)
|
||||||
}
|
}
|
||||||
return res.Text, nil
|
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."
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user