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) }