mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
perf(memory): stop composing the saved-fact index into the cached prefix
MEMORY.md was folded into the system prompt at boot, ahead of the project instructions and the skills index. Measured on this repo's own REASONIX.md with eight saved facts: the memory block sits 2,192 bytes into a 23,731-byte system message, so one `remember` re-sent 21,539 bytes — 90.8% of the prompt — on the next session, of which roughly 19KB was instruction text that had not changed. PrefixCost counted the index's own 881 bytes and called that the cost. The facts were already reachable without it. The `memory` tool has search, read and list — list is the index — and relevant facts already ride the user-turn tail through auto-recall. The prompt copy was a third path to the same data, and the only one that charged the prefix for it. What replaces it is a fixed protocol paragraph: how to reach saved facts, how to treat them, which tools write them. It names no fact, so it is byte-identical for every project and session on the machine. Pinned bodies stay in the prefix: `activation: pinned` is explicit, off by default, and documented as an always-available fact, so it is not a churn source the way an ordinary save is. `Set.Index` goes with it — a field nothing reads is where the index grows back — and PrefixCost now counts pinned bodies only, because a number named for the prefix that included something not in the prefix was measuring the store. <memory-update> survives for changes made outside the conversation — a "#" quick-add, the memory panel — which nothing else tells a running session about. It no longer fires for the model's own remember/forget: that tool result is already in the conversation, and echoing it also suppressed the turn's recall, which says the same thing better and in context. For an empty store the prefix grows 349 bytes (the protocol is longer than the "no facts yet" notice it replaced); with facts saved it shrinks, and either way it stops moving. The golden baseline is re-recorded for that text. The effect test asserts the claim where it has to hold: a session that saves a fact leaves the next session's system message byte-identical, and the test fails if the index is composed back in.
This commit is contained in:
1 parent
ff6d74e4dc
commit
101f2ca12b
13 files changed
+161
-104
No files matched your search
@@ -18,7 +18,9 @@ import (
|
||||
|
||||
"reasonix/internal/ablation"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
@@ -550,3 +552,68 @@ func systemOf(req provider.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// TestEffectRememberDoesNotMoveTheCachedPrefix is the cache boundary for saved
|
||||
// facts: a session that saves one must leave the next session's system message
|
||||
// byte-identical. The index it writes used to be composed in ahead of the
|
||||
// project instructions and the skills index, so one remember re-sent all of
|
||||
// them; the facts are reached through the memory tool instead.
|
||||
func TestEffectRememberDoesNotMoveTheCachedPrefix(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
writeFile(t, dir, "reasonix.toml", effectProbeConfig)
|
||||
writeFile(t, dir, "REASONIX.md", "Project rule: keep the prefix stable.")
|
||||
|
||||
indexPath := filepath.Join(memory.StoreFor(config.MemoryUserDir(), dir).Dir, "MEMORY.md")
|
||||
|
||||
first, err := Build(context.Background(), Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("first Build: %v", err)
|
||||
}
|
||||
before := systemMessage(first.History())
|
||||
if _, err := first.SaveMemory(memory.Memory{
|
||||
Name: "deploy-target", Description: "where releases go", Type: memory.TypeProject,
|
||||
Body: "Releases go to the staging bucket first.",
|
||||
}); err != nil {
|
||||
first.Close()
|
||||
t.Fatalf("SaveMemory: %v", err)
|
||||
}
|
||||
first.Close()
|
||||
|
||||
// A prefix that did not move because nothing was written proves nothing.
|
||||
index, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read the index the save was supposed to write: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(index), "deploy-target") {
|
||||
t.Fatalf("the save never reached the index this test is about:\n%s", index)
|
||||
}
|
||||
|
||||
second, err := Build(context.Background(), Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("second Build: %v", err)
|
||||
}
|
||||
after := systemMessage(second.History())
|
||||
second.Close()
|
||||
|
||||
if before != after {
|
||||
t.Fatalf("a saved fact moved the cached prefix; every byte after it is re-sent next session:\nfirst diff site: %q",
|
||||
firstDivergence(before, after))
|
||||
}
|
||||
if strings.Contains(after, "deploy-target") {
|
||||
t.Fatalf("the saved fact reached the prefix:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// effectProbeConfig is a build-only config: a provider that is never called,
|
||||
// so a test can compose a prefix without registering a recording kind.
|
||||
const effectProbeConfig = `
|
||||
default_model = "test-model"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SystemHash": "292efa12b27ee278",
|
||||
"SystemHash": "2e65ab237e13ca7a",
|
||||
"ToolsHash": "c773ef5f3af52d20",
|
||||
"PrefixHash": "bb2d15b3f8c95659",
|
||||
"PrefixHash": "700e0b938f2c1a6e",
|
||||
"LogRewriteVersion": 0,
|
||||
"ToolSchemaTokens": 4545
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"Messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "BASE SYSTEM PROMPT\n\nUser-owned choices: when a consequential decision has no safe, obvious default, call the ask tool so the user can choose. Otherwise proceed with a sensible reversible default. Do not ask in prose when ask is available. In non-interactive runs, state the assumption and take the safest reversible path.\n\nWork practices: when the request or a linked discussion names a specific approach or expected behavior, implement exactly that; if you believe a different approach is better, state the trade-off explicitly instead of silently substituting it. Keep scratch work (repro scripts, probes, generated output) out of the repository — write it under $TMPDIR, which is yours for the session, or clean up before finishing — and review the final diff before declaring done: only the intended changes, no leftovers, and no known regressions. Scale verification to the change: reproduce the problem and run the most relevant focused tests.\n\nTool calls in one reply are run together, so calls that do not depend on each other belong in the same reply — several reads, a search and a listing, edits to different files. A reply carrying a single call pays a whole round trip for it.\n\nReply in the same language the user is using in their most recent message: if they write in Chinese answer in Chinese, in English answer in English, and switch whenever they switch. Let this also guide the language you think in. Always keep code, identifiers, file paths, shell commands, and technical terms in their original form — never translate them.\n\n# Memory\n\n## Background memory index\n\nNo facts are saved for this project yet. Durable ones — who the user is, how they want you to work, project constraints the code does not record — reach later sessions only when you save them with the `remember` tool."
|
||||
"content": "BASE SYSTEM PROMPT\n\nUser-owned choices: when a consequential decision has no safe, obvious default, call the ask tool so the user can choose. Otherwise proceed with a sensible reversible default. Do not ask in prose when ask is available. In non-interactive runs, state the assumption and take the safest reversible path.\n\nWork practices: when the request or a linked discussion names a specific approach or expected behavior, implement exactly that; if you believe a different approach is better, state the trade-off explicitly instead of silently substituting it. Keep scratch work (repro scripts, probes, generated output) out of the repository — write it under $TMPDIR, which is yours for the session, or clean up before finishing — and review the final diff before declaring done: only the intended changes, no leftovers, and no known regressions. Scale verification to the change: reproduce the problem and run the most relevant focused tests.\n\nTool calls in one reply are run together, so calls that do not depend on each other belong in the same reply — several reads, a search and a listing, edits to different files. A reply carrying a single call pays a whole round trip for it.\n\nReply in the same language the user is using in their most recent message: if they write in Chinese answer in Chinese, in English answer in English, and switch whenever they switch. Let this also guide the language you think in. Always keep code, identifiers, file paths, shell commands, and technical terms in their original form — never translate them.\n\n# Memory\n\n## Background memory\n\nDurable facts you saved in earlier sessions are stored for this project. They are not listed here: relevant ones are retrieved onto the turn automatically, and the `memory` tool reaches the rest (`search` ranks them, `list` returns the whole index, `read` returns one body). They reflect what was true when written and may now be stale — treat them as background, not standing instructions, and before acting on one that names a file, function, or flag, verify it still exists. Save new durable facts with the `remember` tool; archive ones that turn out wrong with `forget`."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
+2
-2
@@ -10,6 +10,6 @@ Reply in the same language the user is using in their most recent message: if th
|
||||
|
||||
# Memory
|
||||
|
||||
## Background memory index
|
||||
## Background memory
|
||||
|
||||
No facts are saved for this project yet. Durable ones — who the user is, how they want you to work, project constraints the code does not record — reach later sessions only when you save them with the `remember` tool.
|
||||
Durable facts you saved in earlier sessions are stored for this project. They are not listed here: relevant ones are retrieved onto the turn automatically, and the `memory` tool reaches the rest (`search` ranks them, `list` returns the whole index, `read` returns one body). They reflect what was true when written and may now be stale — treat them as background, not standing instructions, and before acting on one that names a file, function, or flag, verify it still exists. Save new durable facts with the `remember` tool; archive ones that turn out wrong with `forget`.
|
||||
@@ -63,7 +63,6 @@ func TestRenderMemoryGroupsDocsAndStore(t *testing.T) {
|
||||
got := renderMemory(width, &memory.Set{
|
||||
Docs: []memory.Source{{Path: "/Users/me/project/REASONIX.md", Scope: memory.ScopeProject}},
|
||||
Store: store,
|
||||
Index: store.Index(),
|
||||
})
|
||||
for _, want := range []string{
|
||||
"memory", "instructions", "precedence=1", "scope=project", "REASONIX.md",
|
||||
|
||||
@@ -180,9 +180,9 @@ func (c *Controller) composeWithGoal(
|
||||
text = agent.WithResponseLanguage(text, responseLanguage)
|
||||
text = agent.WithReasoningLanguageForSource(text, reasoningLanguage, source)
|
||||
|
||||
// Memory added mid-session rides the turn (never the cached system prefix),
|
||||
// so it takes effect now without invalidating the prompt cache. It folds into
|
||||
// the system prefix on the next session, where it costs nothing per turn.
|
||||
// Memory changed from outside the conversation — a "#" quick-add, the memory
|
||||
// panel — rides the turn: nothing else tells a running session. The model's
|
||||
// own remember/forget queues nothing; its tool result is already here.
|
||||
if len(notes) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("<memory-update>\n")
|
||||
@@ -212,9 +212,9 @@ func (c *Controller) composeWithGoal(
|
||||
if block := c.drainHookContextBlock(); block != "" {
|
||||
text = block + "\n\n" + text
|
||||
}
|
||||
// Relevant facts ride only the real user-turn tail. This preserves the
|
||||
// stable system/tool prefix and keeps synthetic recovery turns free of
|
||||
// accidental recall. A just-written fact already arrives in memory-update.
|
||||
// Relevant facts ride only the real user-turn tail, which preserves the
|
||||
// stable prefix and keeps synthetic recovery turns free of accidental
|
||||
// recall. What an out-of-band write just supplied verbatim is not re-fetched.
|
||||
if len(notes) == 0 && !c.ablation.Off(ablation.Retrieval) {
|
||||
result := c.memory.recall(source)
|
||||
event.RecordMemoryRecall(c.sink, memoryRecallAudit(result))
|
||||
|
||||
@@ -950,19 +950,16 @@ func TestParseGoalCommandStrictOnlyConsumesLeadingFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDrainsQueuedMemory(t *testing.T) {
|
||||
c := New(Options{}) // no executor/memory — QueueMemory still queues a turn-tail note
|
||||
// The model's own remember/forget queues nothing onto the turn: its tool result
|
||||
// is already in the conversation, and the reloaded snapshot lets a later turn
|
||||
// retrieve the fact. Saying it a second time in a <memory-update> also
|
||||
// suppressed that turn's retrieval, which says it better.
|
||||
func TestComposeDoesNotEchoTheModelsOwnMemoryWrite(t *testing.T) {
|
||||
c := New(Options{}) // no executor/memory — this is the tool-originated path
|
||||
|
||||
c.QueueMemory("Saved memory \"rmb\": user's balance is in RMB")
|
||||
got := c.Compose("hello")
|
||||
if !strings.Contains(got, "<memory-update>") || !strings.Contains(got, "user's balance is in RMB") {
|
||||
t.Fatalf("queued memory should ride the turn: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "hello") {
|
||||
t.Fatalf("user text should follow the memory block: %q", got)
|
||||
}
|
||||
if got2 := c.Compose("again"); got2 != "again" {
|
||||
t.Fatalf("pendingMemory should drain after one turn, got %q", got2)
|
||||
if got := c.Compose("hello"); got != "hello" {
|
||||
t.Fatalf("the model's own save rode the turn: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -279,18 +279,15 @@ func (m *memoryManager) restoreArchived(archivePath string) (memory.Memory, erro
|
||||
return result.Memory, nil
|
||||
}
|
||||
|
||||
// queue rides a note on the next turn — the model's remember/forget tool path
|
||||
// (memory.Queue). It refreshes the snapshot a memory panel reads when memory is
|
||||
// enabled, and still queues the turn-tail note when it isn't (there's no snapshot
|
||||
// to re-discover).
|
||||
func (m *memoryManager) queue(note string) {
|
||||
// queue refreshes the snapshot a memory panel reads after the model's own
|
||||
// remember/forget tool wrote (memory.Queue). It queues no turn-tail note: the
|
||||
// tool result is already in the conversation, and the reloaded snapshot lets a
|
||||
// later turn retrieve the fact. A note would say the same thing twice and
|
||||
// suppress the retrieval that says it better.
|
||||
func (m *memoryManager) queue(string) {
|
||||
m.writeMu.Lock()
|
||||
defer m.writeMu.Unlock()
|
||||
if mem := m.current(); mem != nil {
|
||||
m.applyWrite(mem, note)
|
||||
return
|
||||
m.applyWrite(mem, "")
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pending = append(m.pending, note)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
@@ -439,13 +439,12 @@ func memoryOneLine(value string) string {
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
|
||||
// renderMemoryPrefixCost reports what memory costs in the cached prefix. The
|
||||
// budgets that bound it ship off, so this is how a user sees what their store
|
||||
// is charging them before deciding whether to set one.
|
||||
// renderMemoryPrefixCost reports what memory costs in the cached prefix: pinned
|
||||
// bodies, and nothing else. The saved-fact index is reached through the memory
|
||||
// tool and no session pays for it, so it is not reported as a prefix cost.
|
||||
func renderMemoryPrefixCost(cost memory.PrefixCost) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("prefix cost (paid once per session)\n")
|
||||
fmt.Fprintf(&b, " index=%d chars across %d facts\n", cost.IndexChars, cost.Facts)
|
||||
if cost.Pinned > 0 || cost.Budget > 0 {
|
||||
fmt.Fprintf(&b, " pinned=%d chars across %d facts", cost.PinnedChars, cost.Pinned)
|
||||
if cost.Budget > 0 {
|
||||
|
||||
+30
-42
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// Set is everything memory loaded for one session: the hierarchical docs and a
|
||||
// handle to the auto-memory store (whose index is captured at load time). It is
|
||||
// handle to the auto-memory store. It is
|
||||
// assembled once at boot and folded into the system prompt by Compose. CWD and
|
||||
// UserDir are retained so the controller can resolve quick-add targets without
|
||||
// re-deriving discovery context.
|
||||
@@ -19,7 +19,6 @@ type Set struct {
|
||||
Docs []Source // REASONIX.md / AGENTS.md, ascending precedence
|
||||
PinnedGuidance []Memory // stable snapshot of pinned fact bodies (incl. legacy global user/feedback)
|
||||
Store Store // auto-memory store (may be a zero/disabled Store)
|
||||
Index string // MEMORY.md contents at load time
|
||||
CWD string // project working dir used for discovery
|
||||
UserDir string // user config root (may be "")
|
||||
InstructionDiagnostics []instruction.Diagnostic
|
||||
@@ -67,7 +66,6 @@ func Load(opts Options) *Set {
|
||||
Docs: resolved.Documents,
|
||||
PinnedGuidance: store.pinnedGuidanceForProject(),
|
||||
Store: store,
|
||||
Index: store.Index(),
|
||||
CWD: cwd,
|
||||
UserDir: opts.UserDir,
|
||||
InstructionDiagnostics: resolved.Diagnostics,
|
||||
@@ -107,7 +105,7 @@ func (s *Set) DocPath(scope Scope) string {
|
||||
// the base prompt byte-for-byte untouched (and the cache prefix maximal) when
|
||||
// there is no memory at all.
|
||||
func (s *Set) Empty() bool {
|
||||
return s == nil || (len(s.Docs) == 0 && len(s.PinnedGuidance) == 0 && strings.TrimSpace(s.Index) == "" && s.Store.Dir == "")
|
||||
return s == nil || (len(s.Docs) == 0 && len(s.PinnedGuidance) == 0 && s.Store.Dir == "")
|
||||
}
|
||||
|
||||
// docScopes are the scopes the panel can target for a quick-add or a new doc.
|
||||
@@ -151,20 +149,26 @@ func (s *Set) WriteDoc(path, body string) (string, error) {
|
||||
return path, writeDocFile(path, body)
|
||||
}
|
||||
|
||||
// BackgroundBlock renders durable preferences and the fact index without
|
||||
// standing instruction files. Keeping these sections separate prevents stale
|
||||
// facts from acquiring instruction authority.
|
||||
// BackgroundBlock renders the memory protocol and any pinned preferences. The
|
||||
// saved-fact index is deliberately absent: it changes on every remember, and
|
||||
// everything after it in the prefix — the project instructions, the skills
|
||||
// index — would be re-sent with it. The tools reach it instead.
|
||||
func (s *Set) BackgroundBlock() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
index := strings.TrimSpace(s.Index)
|
||||
if len(s.PinnedGuidance) == 0 && index == "" {
|
||||
return emptyStoreBlock(s.Store)
|
||||
if s.Store.Dir == "" && len(s.PinnedGuidance) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Memory\n\n")
|
||||
if s.Store.Dir != "" {
|
||||
b.WriteString(memoryProtocol)
|
||||
}
|
||||
if len(s.PinnedGuidance) > 0 {
|
||||
if s.Store.Dir != "" {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("## Pinned preferences and feedback\n\n")
|
||||
b.WriteString("Facts the user pinned to be always available. Apply them when relevant. " +
|
||||
"The current user request and more specific standing instructions take precedence, and factual details may be stale.\n")
|
||||
@@ -173,27 +177,19 @@ func (s *Set) BackgroundBlock() string {
|
||||
NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), strings.TrimSpace(m.Body))
|
||||
}
|
||||
}
|
||||
if index != "" {
|
||||
b.WriteString("\n## Background memory index\n\n")
|
||||
b.WriteString("Facts you saved in earlier sessions. They reflect what was true when written and may now be stale — treat them as background, not standing instructions. " +
|
||||
"Read a relevant linked fact with the `memory` tool, and before acting on one that names a file, function, or flag, verify it still exists. " +
|
||||
"Save new durable facts with the `remember` tool; archive ones that turn out wrong with `forget`.\n\n")
|
||||
b.WriteString(index)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// emptyStoreBlock states that an available store holds nothing yet. The index
|
||||
// section is the only place the prompt names `remember`, so skipping the block
|
||||
// on an empty store leaves a fresh project unable to save its first fact.
|
||||
func emptyStoreBlock(store Store) string {
|
||||
if store.Dir == "" {
|
||||
return ""
|
||||
}
|
||||
return "# Memory\n\n## Background memory index\n\n" +
|
||||
"No facts are saved for this project yet. Durable ones — who the user is, how they want you to work, " +
|
||||
"project constraints the code does not record — reach later sessions only when you save them with the `remember` tool."
|
||||
}
|
||||
// memoryProtocol is how the model reaches saved facts. It names the tools and
|
||||
// carries no facts of its own, so it is byte-identical for every project and
|
||||
// session on a machine — which is the whole reason the index it replaced went.
|
||||
const memoryProtocol = "## Background memory\n\n" +
|
||||
"Durable facts you saved in earlier sessions are stored for this project. They are not listed here: " +
|
||||
"relevant ones are retrieved onto the turn automatically, and the `memory` tool reaches the rest " +
|
||||
"(`search` ranks them, `list` returns the whole index, `read` returns one body). " +
|
||||
"They reflect what was true when written and may now be stale — treat them as background, not standing " +
|
||||
"instructions, and before acting on one that names a file, function, or flag, verify it still exists. " +
|
||||
"Save new durable facts with the `remember` tool; archive ones that turn out wrong with `forget`."
|
||||
|
||||
// Block combines background memory with separately resolved standing
|
||||
// instructions. Background comes first so the higher-authority, more specific
|
||||
@@ -239,33 +235,25 @@ func (s *Set) LoadOptions() Options {
|
||||
}
|
||||
|
||||
// PrefixCost is what this memory set costs in the cached system-prompt prefix:
|
||||
// characters paid once per session, every session. It exists because the
|
||||
// budgets that bound it are the user's to set, and a number nobody can see is
|
||||
// not a decision anyone can make.
|
||||
// characters paid once per session, every session. Only pinned bodies land
|
||||
// there. The fact index does not — it is reached through the `memory` tool —
|
||||
// so it is not counted here; a number under this name that included it would
|
||||
// be measuring the store, not the prefix.
|
||||
type PrefixCost struct {
|
||||
IndexChars int // the fact index every session carries
|
||||
PinnedChars int // pinned bodies folded in verbatim
|
||||
Facts int // index lines
|
||||
Pinned int // pinned facts
|
||||
Budget int // configured pinned ceiling, 0 when unset
|
||||
}
|
||||
|
||||
// Total is the whole memory block's prefix footprint.
|
||||
func (c PrefixCost) Total() int { return c.IndexChars + c.PinnedChars }
|
||||
func (c PrefixCost) Total() int { return c.PinnedChars }
|
||||
|
||||
// PrefixCost measures the snapshot the session is actually running on.
|
||||
func (s *Set) PrefixCost() PrefixCost {
|
||||
if s == nil {
|
||||
return PrefixCost{}
|
||||
}
|
||||
cost := PrefixCost{
|
||||
IndexChars: utf8.RuneCountInString(s.Index),
|
||||
Pinned: len(s.PinnedGuidance),
|
||||
Budget: s.opts.PinnedBudgetChars,
|
||||
}
|
||||
if trimmed := strings.TrimSpace(s.Index); trimmed != "" {
|
||||
cost.Facts = strings.Count(trimmed, "\n") + 1
|
||||
}
|
||||
cost := PrefixCost{Pinned: len(s.PinnedGuidance), Budget: s.opts.PinnedBudgetChars}
|
||||
for _, m := range s.PinnedGuidance {
|
||||
cost.PinnedChars += utf8.RuneCountInString(strings.TrimSpace(m.Body))
|
||||
}
|
||||
|
||||
@@ -19,28 +19,40 @@ func TestComposeEmptyIsIdentity(t *testing.T) {
|
||||
if got != base {
|
||||
t.Fatalf("empty memory changed the prompt:\n base=%q\n got =%q", base, got)
|
||||
}
|
||||
// A nil-ish set (no docs, blank index) must also be identity.
|
||||
if got := Compose(base, &Set{Index: " \n"}); got != base {
|
||||
t.Fatalf("blank index changed the prompt: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The index section is the only place the prompt names `remember`, so a store
|
||||
// that exists but holds nothing still has to say so: a silent empty store is
|
||||
// what a first session reads as an agent with no memory at all.
|
||||
func TestEmptyStoreStillNamesRemember(t *testing.T) {
|
||||
// The protocol section is the only place the prompt names `remember`, so a store
|
||||
// that exists still has to say so: a silent store is what a first session reads
|
||||
// as an agent with no memory at all.
|
||||
func TestAvailableStoreNamesRemember(t *testing.T) {
|
||||
block := (&Set{Store: Store{Dir: testenv.TempDir(t)}}).Block()
|
||||
if !strings.Contains(block, "remember") {
|
||||
t.Fatalf("empty store block never names the remember tool:\n%s", block)
|
||||
t.Fatalf("store block never names the remember tool:\n%s", block)
|
||||
}
|
||||
// An unavailable store (no user dir) stays identical to no memory at all.
|
||||
if got := Compose("BASE", &Set{}); got != "BASE" {
|
||||
t.Fatalf("unavailable store changed the prompt: %q", got)
|
||||
}
|
||||
// Once facts exist the index replaces the empty notice rather than joining it.
|
||||
populated := (&Set{Store: Store{Dir: testenv.TempDir(t)}, Index: "- [a fact](a.md) — hook"}).Block()
|
||||
if strings.Contains(populated, "No facts are saved") {
|
||||
t.Fatalf("populated index still carries the empty notice:\n%s", populated)
|
||||
}
|
||||
|
||||
// What the store holds must not reach the prefix at all: saving a fact changes
|
||||
// MEMORY.md, and if that text were composed in, every byte after it — the
|
||||
// project instructions, the skills index — would be re-sent on the next
|
||||
// session. The facts are reached through the `memory` tool instead.
|
||||
func TestBlockIsIdenticalWhateverTheStoreHolds(t *testing.T) {
|
||||
empty := testenv.TempDir(t)
|
||||
full := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(full, "MEMORY.md"), []byte("- [a fact](a.md) — hook\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
emptyBlock := (&Set{Store: Store{Dir: empty}}).Block()
|
||||
fullBlock := (&Set{Store: Store{Dir: full}}).Block()
|
||||
if emptyBlock != fullBlock {
|
||||
t.Fatalf("a saved fact changed the cached prefix:\nempty: %q\nfull: %q", emptyBlock, fullBlock)
|
||||
}
|
||||
if strings.Contains(fullBlock, "a fact") {
|
||||
t.Fatalf("the saved-fact index reached the prefix:\n%s", fullBlock)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +73,10 @@ func TestComposeAppendsAfterBase(t *testing.T) {
|
||||
func TestBlockSeparatesStandingInstructionsFromBackgroundMemory(t *testing.T) {
|
||||
set := &Set{
|
||||
Docs: []Source{{Path: "/p/AGENTS.md", Scope: ScopeProject, Directory: "/p", Body: "Always run tests.", Depth: 0}},
|
||||
Index: "- [API decision](api-decision.md) — [project/project] Chosen in an earlier session",
|
||||
Store: Store{Dir: "/memory/project"},
|
||||
}
|
||||
block := set.Block()
|
||||
for _, want := range []string{"# Instructions", "## workspace/AGENTS.md (project", "## Background memory index", "background, not standing instructions"} {
|
||||
for _, want := range []string{"# Instructions", "## workspace/AGENTS.md (project", "## Background memory", "background, not standing instructions"} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("Block() missing %q:\n%s", want, block)
|
||||
}
|
||||
@@ -353,7 +364,7 @@ func TestLoadHidesMemoryUnderExperimentEnv(t *testing.T) {
|
||||
|
||||
t.Setenv("REASONIX_EXPERIMENT_NO_MEMORY", "1")
|
||||
set := Load(Options{CWD: proj, UserDir: user})
|
||||
if len(set.PinnedGuidance) != 0 || strings.TrimSpace(set.Index) != "" || set.Store.Dir != "" {
|
||||
if len(set.PinnedGuidance) != 0 || set.Store.Dir != "" {
|
||||
t.Fatalf("memory-off arm leaked store state: %+v", set)
|
||||
}
|
||||
if !strings.Contains(set.Block(), "STANDING INSTRUCTION BODY") {
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestIndexDropsExpiredFacts(t *testing.T) {
|
||||
// The store's prefix footprint has to be reportable, because the budgets that
|
||||
// would bound it are off by default: a cost nobody can see is not a decision
|
||||
// anyone can make.
|
||||
func TestPrefixCostCountsIndexAndPinnedBodies(t *testing.T) {
|
||||
func TestPrefixCostCountsPinnedBodiesOnly(t *testing.T) {
|
||||
dir, global := testenv.TempDir(t), testenv.TempDir(t)
|
||||
store := StoreFor("", "")
|
||||
store.Dir, store.GlobalDir = dir, global
|
||||
@@ -50,17 +50,16 @@ func TestPrefixCostCountsIndexAndPinnedBodies(t *testing.T) {
|
||||
}, SaveOptions{}); err != nil {
|
||||
t.Fatalf("save pinned fact: %v", err)
|
||||
}
|
||||
set := &Set{Store: store, Index: store.Index(), PinnedGuidance: store.pinnedGuidanceForProject()}
|
||||
set := &Set{Store: store, PinnedGuidance: store.pinnedGuidanceForProject()}
|
||||
|
||||
cost := set.PrefixCost()
|
||||
if cost.Facts != 1 || cost.IndexChars == 0 {
|
||||
t.Fatalf("index cost = %+v, want one counted fact with a non-zero size", cost)
|
||||
}
|
||||
if cost.Pinned != 1 || cost.PinnedChars != len([]rune(body)) {
|
||||
t.Fatalf("pinned cost = %+v, want the body counted verbatim", cost)
|
||||
}
|
||||
if cost.Total() != cost.IndexChars+cost.PinnedChars {
|
||||
t.Fatalf("total %d does not equal its parts %+v", cost.Total(), cost)
|
||||
// The saved-fact index is not in the prefix, so it is not in this number.
|
||||
// Counting it here is what made the name a claim the code did not keep.
|
||||
if cost.Total() != cost.PinnedChars {
|
||||
t.Fatalf("total %d counts something other than pinned bodies: %+v", cost.Total(), cost)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ func (rememberTool) Description() string {
|
||||
"if asked to remember one of those, save instead the non-obvious point behind it. " +
|
||||
"Choose scope \"project\" for the current workspace (the safe default) or \"global\" only when the fact should affect every project. " +
|
||||
"Standing rules that must always be followed belong in project or global REASONIX.md/AGENTS.md instructions, not background memory. " +
|
||||
"Before saving, check the loaded memory index for an entry that already covers this — reuse that name to update it rather than create a near-duplicate, and use `forget` to drop one that is now wrong. " +
|
||||
"The saved index loads into context at the start of each session."
|
||||
"Before saving, search the store with the `memory` tool for an entry that already covers this — reuse that name to update it rather than create a near-duplicate, and use `forget` to drop one that is now wrong. " +
|
||||
"Saved facts are not listed in the prompt: relevant ones are retrieved onto a later turn, and the `memory` tool reaches the rest."
|
||||
}
|
||||
|
||||
func (rememberTool) Schema() json.RawMessage {
|
||||
|
||||
Reference in new issue
Block a user