diff --git a/go-api/internal/tools/remember.go b/go-api/internal/tools/remember.go new file mode 100644 index 0000000..60b276f --- /dev/null +++ b/go-api/internal/tools/remember.go @@ -0,0 +1,191 @@ +package tools + +// The write trigger for long-term memory. +// +// THE QUESTION THIS FILE ANSWERS is not "how do we store a memory" — that is +// internal/memory — but "what decides that something is worth remembering". +// Three answers were available and two of them are worse: +// +// A second model call after each run, asked to extract durable facts. It +// judges well and it costs a whole extra call against a deployment ceiling +// of 8,000 tokens a minute, on every run, most of which have nothing worth +// keeping. Rejected on cost. +// +// A heuristic in the loop — remember when a write happened, when a figure +// was quoted. Cheap, and it remembers the wrong things: the shape of a run +// says nothing about whether a fact outlives it, so the table fills with +// restatements of rows the database already holds. +// +// A TOOL THE AGENT MAY CALL, which is this. It costs nothing extra: the +// model is already mid-run with a tool catalogue in front of it, and +// remembering is one more call it may make when it has just learned +// something that will not be in the records next time. It is automatic in +// the sense that matters — nobody types "remember this" — and it is visible +// in the trajectory, which an extraction pass would not be. +// +// WHY IT IS A CONFIRMED WRITE. EffectWrite forces RequiresConfirmation, and +// that is the invariant working rather than an obstacle: this tool stores +// personal data that will shape later hiring answers, which is the single +// most consequential thing a model can do here short of assigning somebody to +// a shift. A reader sees the sentence before it is kept. If a deployment later +// decides workspace facts should be kept without asking, the honest change is +// a second tool scoped to workspace subjects — not loosening this one, which +// would silently make personal memories unconfirmed too. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/memory" +) + +// MemoryWriter is the store's write half, as this package needs it. Declared +// here rather than imported as a struct so the tool can be tested without a +// database, and so tools does not depend on memory's internals. +type MemoryWriter interface { + Remember(ctx context.Context, who authctx.Identity, w memory.Write) (string, error) +} + +// Remember builds the tool that stores one memory. +func Remember(store MemoryWriter) Tool { + return Tool{ + Name: "remember", + Description: "Keep one short fact for later runs, when you have learned something " + + "durable that will NOT be in the records next time — a standing preference, a " + + "constraint somebody stated, a decision and its reason. Do not use it for anything " + + "a tool can look up again, for figures that change, or to restate what you just " + + "said. One sentence. Say who it is about: a candidate or a person needs their id, " + + "a fact about how this workspace operates does not.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + "description": "The fact, in one sentence, as it should read months from now.", + }, + "subject": map[string]any{ + "type": "string", + "enum": []string{"workspace", "candidate", "user"}, + "description": "Who it is about. 'workspace' for how this organisation " + + "operates, 'candidate' for a named person in the pipeline, 'user' for " + + "a preference somebody stated about their own working.", + }, + "subject_id": map[string]any{ + "type": "string", + "description": "The id of the candidate or person. Required unless the " + + "subject is the workspace.", + }, + }, + "required": []string{"text", "subject"}, + "additionalProperties": false, + }, + Effect: EffectWrite, + MaxResultBytes: DefaultMaxResultBytes, + + /* What a person is shown before a memory is kept. + The subject and the author are both on the card, because the two + questions somebody needs answered before agreeing are "about whom" + and "who decided this" — and the answer to the second is always an + agent, which is exactly why they are being asked. */ + Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) { + in, bad := decodeRemember(inputs) + if bad != nil { + return nil, bad + } + + details := []Detail{ + {Label: "Remember", Value: in.Text}, + {Label: "About", Value: subjectLabel(in.Subject, in.SubjectID)}, + {Label: "Written by", Value: "an agent, not a person"}, + {Label: "Kept until", Value: "90 days from now, then it expires"}, + } + + var warnings []string + if in.Subject != string(memory.SubjectWorkspace) { + warnings = append(warnings, + "This is personal data. It will be read into later answers about this "+ + "person, and it can be listed or erased on request.") + } + + return &Confirmation{ + Summary: "Keep this for later runs?", + Details: details, + Warnings: warnings, + }, nil + }, + + Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result { + in, bad := decodeRemember(inputs) + if bad != nil { + return *bad + } + if store == nil { + return Failf(CodeUnavailable, "this deployment does not keep memories") + } + + id, err := store.Remember(ctx, tc.Principal, memory.Write{ + SubjectType: memory.Subject(in.Subject), + SubjectID: strings.TrimSpace(in.SubjectID), + Text: strings.TrimSpace(in.Text), + /* Always. A model may not claim a person wrote something. */ + Author: memory.AuthorModel, + SourceRunID: tc.RunID, + }) + if err != nil { + /* The store's own refusals are the interesting ones — a personal + memory with no subject, a memory longer than a sentence — and + they are the model's mistake to correct, so they come back as + a validation failure it can read rather than as "unavailable". */ + return Failf(CodeInvalidInput, "that memory was not kept: %s", err.Error()) + } + + return OK(map[string]any{ + "remembered": true, + "id": id, + "subject": in.Subject, + "note": "Kept for later runs. It expires in 90 days and can be listed or " + + "erased by subject at any time.", + }) + }, + } +} + +type rememberInput struct { + Text string `json:"text"` + Subject string `json:"subject"` + SubjectID string `json:"subject_id"` +} + +func decodeRemember(inputs json.RawMessage) (rememberInput, *Result) { + var in rememberInput + if len(inputs) > 0 { + if err := json.Unmarshal(inputs, &in); err != nil { + r := Failf(CodeInvalidInput, "the arguments to remember were not valid JSON") + return in, &r + } + } + if strings.TrimSpace(in.Text) == "" { + r := Failf(CodeInvalidInput, "a memory needs text") + return in, &r + } + switch in.Subject { + case string(memory.SubjectWorkspace), string(memory.SubjectCandidate), string(memory.SubjectUser): + default: + r := Failf(CodeInvalidInput, "subject must be workspace, candidate or user") + return in, &r + } + return in, nil +} + +func subjectLabel(subject, id string) string { + if subject == string(memory.SubjectWorkspace) { + return "this workspace" + } + if strings.TrimSpace(id) == "" { + return subject + } + return fmt.Sprintf("%s %s", subject, id) +} diff --git a/go-api/internal/tools/remember_test.go b/go-api/internal/tools/remember_test.go new file mode 100644 index 0000000..d140274 --- /dev/null +++ b/go-api/internal/tools/remember_test.go @@ -0,0 +1,149 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/krow/krow-backend/go-api/internal/authctx" + "github.com/krow/krow-backend/go-api/internal/memory" +) + +type recordingStore struct { + writes []memory.Write + err error +} + +func (r *recordingStore) Remember(_ context.Context, _ authctx.Identity, w memory.Write) (string, error) { + if r.err != nil { + return "", r.err + } + r.writes = append(r.writes, w) + return "mem-1", nil +} + +func rememberCtx() Context { + return Context{ + Principal: authctx.Identity{UserID: "u1", OrgID: "o1", Role: "admin"}, + RunID: "run_abc", + } +} + +// I4. A memory stores personal data that shapes later hiring answers, so it +// goes through the same gate as any other write — and the registry is what +// enforces that, not this tool's good intentions. +func TestRememberIsAConfirmedWrite(t *testing.T) { + tool := Remember(&recordingStore{}) + if tool.Effect != EffectWrite { + t.Errorf("Effect = %q, want write", tool.Effect) + } + r := NewRegistry() + r.MustRegister(tool) + registered := r.Catalogue() + var found bool + for _, info := range registered { + if info.Name == "remember" { + found = true + if !info.RequiresConfirmation { + t.Error("remember was registered without a confirmation gate") + } + } + } + if !found { + t.Fatal("remember did not register") + } +} + +// A model may not claim a person wrote something. The distinction is what +// keeps "the agent inferred X" from being read back later as "X". +func TestARememberedMemoryIsAlwaysAttributedToTheModel(t *testing.T) { + store := &recordingStore{} + tool := Remember(store) + res := tool.Handler(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"This venue staffs on Thursdays.","subject":"workspace"}`)) + if res.Error != nil { + t.Fatalf("the write failed: %+v", res.Error) + } + if len(store.writes) != 1 { + t.Fatalf("got %d writes, want 1", len(store.writes)) + } + if store.writes[0].Author != memory.AuthorModel { + t.Errorf("Author = %q, want model", store.writes[0].Author) + } +} + +// Without the run id, a memory that shaped an answer cannot be traced to where +// it came from, and "why did it say that" stops being answerable. +func TestARememberedMemoryCarriesItsRun(t *testing.T) { + store := &recordingStore{} + Remember(store).Handler(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"Thursdays are short-staffed.","subject":"workspace"}`)) + if len(store.writes) == 0 || store.writes[0].SourceRunID != "run_abc" { + t.Error("the memory does not name the run that wrote it") + } +} + +func TestAnInventedSubjectIsRefused(t *testing.T) { + store := &recordingStore{} + res := Remember(store).Handler(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"x","subject":"everything"}`)) + if res.Error == nil { + t.Error("an invented subject was accepted") + } + if len(store.writes) != 0 { + t.Error("a refused memory still reached the store") + } +} + +func TestAMemoryWithNoWordsIsRefused(t *testing.T) { + store := &recordingStore{} + res := Remember(store).Handler(context.Background(), rememberCtx(), + json.RawMessage(`{"text":" ","subject":"workspace"}`)) + if res.Error == nil || len(store.writes) != 0 { + t.Error("an empty memory was accepted") + } +} + +/* ── What a person is shown before agreeing ──────────────────────────────── */ + +// The two questions somebody needs answered before keeping a memory are +// "about whom" and "who decided this". +func TestTheConfirmationSaysWhatIsKeptAndWhoDecided(t *testing.T) { + c, bad := Remember(&recordingStore{}).Confirm(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"Prefers Bay Area venues.","subject":"user","subject_id":"u9"}`)) + if bad != nil { + t.Fatalf("the confirmation was refused: %+v", bad) + } + flat := c.Summary + for _, d := range c.Details { + flat += " " + d.Label + "=" + d.Value + } + for _, want := range []string{"Prefers Bay Area venues.", "user u9", "an agent, not a person", "90 days"} { + if !strings.Contains(flat, want) { + t.Errorf("the card does not state %q:\n%s", want, flat) + } + } +} + +// A personal memory is flagged as such, because the thing being agreed to is +// different in kind from remembering an opening time. +func TestAPersonalMemoryWarnsAndAWorkspaceFactDoesNot(t *testing.T) { + tool := Remember(&recordingStore{}) + + personal, _ := tool.Confirm(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"Was late twice.","subject":"candidate","subject_id":"c1"}`)) + if len(personal.Warnings) == 0 || + !strings.Contains(strings.Join(personal.Warnings, " "), "personal data") { + t.Errorf("a memory about a person carries no warning: %+v", personal.Warnings) + } + if !strings.Contains(strings.Join(personal.Warnings, " "), "erased") { + t.Error("the warning does not say the memory can be erased") + } + + operational, _ := tool.Confirm(context.Background(), rememberCtx(), + json.RawMessage(`{"text":"Thursdays are short-staffed.","subject":"workspace"}`)) + if len(operational.Warnings) != 0 { + t.Errorf("an operational fact was warned about: %+v", operational.Warnings) + } +}