Files
krow_backend/go-api/internal/tools/remember.go
Aravind 57abafe73b
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
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>
2026-10-07 20:05:02 +05:30

192 lines
7.2 KiB
Go

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