Switch memory on: the agent may keep one, and carries them into the next run
The store and the write trigger existed and nothing used either. This wires both ends, so "we have long-term memory" stops being a statement about code that exists and becomes one about behaviour. READ. Memories are recalled before retrieval and placed before it in the prompt: it is the smaller block and the more general one — a standing preference frames how the documents should be read, where a document does not frame a preference. The question stays last, because a model reads the last thing and answers it, and evidence after the question becomes the prompt. Skipped for smalltalk on the same terms as retrieval. Nobody needs remembering to say good morning, and paying for it is how "hi" came to cost six thousand tokens. FAILS QUIET, RECORDED LOUDLY. A memory store that is unreachable must not take the run with it: an answer without memory is worse, not wrong, and the alternative is an outage in the knowledge layer becoming an outage in the product. The trajectory records the failure, and records separately when the store returned recency instead of relevance — a reader comparing two answers needs to know which one got which. WRITE. tools.Remember is registered only where there is somewhere to put it, through DefaultToolsWithMemory rather than a nil check inside the old constructor: a deployment that has not migrated 000017 must not offer a tool whose every call fails against a table that is not there. Passing nil registers exactly the catalogue that was there before. Memory shares retrieval's embedder, and memory.Embedder is knowledge.Embedder by structure so it cannot be given a different one. Two embedding models in one deployment produce vectors that cannot be compared, and the failure is silent: a recall that returns nothing rather than an error. Five new tests on the loop, including the two that matter — a failing store still answers, and a greeting carries nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
113
go-api/internal/runtime/recall_test.go
Normal file
113
go-api/internal/runtime/recall_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/memory"
|
||||
)
|
||||
|
||||
type fakeMemory struct {
|
||||
records []memory.Record
|
||||
degraded string
|
||||
err error
|
||||
asked string
|
||||
}
|
||||
|
||||
func (f *fakeMemory) Recall(_ context.Context, _ authctx.Identity, question string, _ int) ([]memory.Record, string, error) {
|
||||
f.asked = question
|
||||
return f.records, f.degraded, f.err
|
||||
}
|
||||
|
||||
func TestARecalledMemoryReachesThePrompt(t *testing.T) {
|
||||
gw := &fakeGateway{text: "answered"}
|
||||
mem := &fakeMemory{records: []memory.Record{
|
||||
{SubjectType: memory.SubjectWorkspace, Author: memory.AuthorModel, Text: "Thursdays are short-staffed."},
|
||||
}}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil).WithMemory(mem)
|
||||
|
||||
if _, err := exec.ExecuteAgent(context.Background(), testAgent(), testInput("who is free?")); err != nil {
|
||||
t.Fatalf("run failed: %v", err)
|
||||
}
|
||||
sent := gw.lastReq.Messages[0].Text
|
||||
if !strings.Contains(sent, "Thursdays are short-staffed.") {
|
||||
t.Errorf("the memory did not reach the model:\n%s", sent)
|
||||
}
|
||||
if !strings.Contains(sent, "<memory>") {
|
||||
t.Error("the memory was not fenced")
|
||||
}
|
||||
}
|
||||
|
||||
// The question goes last. A model reads the last thing and answers it; put the
|
||||
// evidence after it and the evidence becomes the prompt.
|
||||
func TestTheQuestionStaysLastWhenMemoryIsCarried(t *testing.T) {
|
||||
gw := &fakeGateway{text: "answered"}
|
||||
mem := &fakeMemory{records: []memory.Record{
|
||||
{SubjectType: memory.SubjectWorkspace, Author: memory.AuthorModel, Text: "A remembered thing."},
|
||||
}}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil).WithMemory(mem)
|
||||
exec.ExecuteAgent(context.Background(), testAgent(), testInput("who is free?"))
|
||||
|
||||
sent := gw.lastReq.Messages[0].Text
|
||||
if !strings.HasSuffix(strings.TrimSpace(sent), "who is free?") {
|
||||
t.Errorf("the question is not last:\n%s", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A store that is down must not take the run with it: an answer without
|
||||
// memory is worse, not wrong.
|
||||
func TestAMemoryFailureDoesNotFailTheRun(t *testing.T) {
|
||||
gw := &fakeGateway{text: "answered anyway"}
|
||||
mem := &fakeMemory{err: errors.New("the memory store is unreachable")}
|
||||
sink := &MemorySink{}
|
||||
exec := NewModelExecutor(gw, sink, nil).WithMemory(mem)
|
||||
|
||||
res, err := exec.ExecuteAgent(context.Background(), testAgent(), testInput("who is free?"))
|
||||
if err != nil {
|
||||
t.Fatalf("a memory failure took the run with it: %v", err)
|
||||
}
|
||||
if res.Termination != TerminationCompleted {
|
||||
t.Errorf("Termination = %q, want Completed", res.Termination)
|
||||
}
|
||||
|
||||
// ...and it is recorded, so a thin answer is explainable afterwards.
|
||||
var noted bool
|
||||
for _, e := range sink.Last().Entries {
|
||||
if strings.Contains(e.Name, "memory") || strings.Contains(e.Text, "memory store") {
|
||||
noted = true
|
||||
}
|
||||
}
|
||||
if !noted {
|
||||
t.Error("the memory failure left no trace in the trajectory")
|
||||
}
|
||||
}
|
||||
|
||||
// Greetings skip memory for the same reason they skip retrieval: nobody needs
|
||||
// remembering to say good morning, and paying for it is how "hi" came to cost
|
||||
// six thousand tokens.
|
||||
func TestSmalltalkCarriesNoMemory(t *testing.T) {
|
||||
gw := &fakeGateway{text: "Good morning."}
|
||||
mem := &fakeMemory{records: []memory.Record{
|
||||
{SubjectType: memory.SubjectWorkspace, Author: memory.AuthorModel, Text: "A remembered thing."},
|
||||
}}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil).WithMemory(mem)
|
||||
exec.ExecuteAgent(context.Background(), testAgent(), testInput("good morning"))
|
||||
|
||||
if strings.Contains(gw.lastReq.Messages[0].Text, "<memory>") {
|
||||
t.Errorf("a greeting carried memory:\n%s", gw.lastReq.Messages[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// With no store the loop is exactly what it was.
|
||||
func TestWithoutAMemoryStoreNothingChanges(t *testing.T) {
|
||||
gw := &fakeGateway{text: "answered"}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil)
|
||||
exec.ExecuteAgent(context.Background(), testAgent(), testInput("who is free?"))
|
||||
|
||||
if gw.lastReq.Messages[0].Text != "who is free?" {
|
||||
t.Errorf("the question was altered with no memory configured:\n%q", gw.lastReq.Messages[0].Text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user