Let an agent keep a memory, through the same gate as any other write
The write trigger, which was the open question. Three answers were available and two are worse. A second model call after each run, asked to extract durable facts, judges well and costs an entire extra call against a ceiling of 8,000 tokens a minute — on every run, most of which have nothing worth keeping. A heuristic in the loop is cheap and 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. So: a tool. It costs nothing extra, because the model is already mid-run with a 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. IT IS A CONFIRMED WRITE, and that is the invariant working rather than an obstacle. EffectWrite forces RequiresConfirmation, and this tool stores personal data that will shape later hiring answers — the most consequential thing a model can do here short of assigning somebody to a shift. A reader sees the sentence before it is kept, who it is about, that an agent and not a person decided it, and that it expires in ninety days. A memory about a person also carries a warning that says so and says it can be erased. If a deployment later wants workspace facts kept without asking, the honest change is a SECOND tool scoped to workspace subjects. Loosening this one would quietly make personal memories unconfirmed too, which is the whole thing this gate is for. Author is always "model" and is not a field the model can set: an inference must never be readable later as though a person had written it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
191
go-api/internal/tools/remember.go
Normal file
191
go-api/internal/tools/remember.go
Normal file
@@ -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)
|
||||
}
|
||||
149
go-api/internal/tools/remember_test.go
Normal file
149
go-api/internal/tools/remember_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user