From 2b3c27601938deaca6d6c2227fc561c237ad57e4 Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Sat, 4 Jul 2026 00:00:00 -0400 Subject: [PATCH] fix(mcp): avoid startup preference usage signals --- docs/architecture.md | 4 ++ internal/consolidation/graduate.go | 7 ++- internal/consolidation/graduate_test.go | 17 ++++++- internal/mcp/server.go | 20 ++++++--- internal/mcp/server_test.go | 60 ++++++++++++++++++++++++- 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index aa92975..a8e60ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,3 +100,7 @@ embeddings are stored locally as a derived index and are never federated. Noema supports short, mid, and long memory tiers. Consolidation can promote short-term traces heuristically or through an optional local LLM pipeline; a separate graduation pass promotes durable mid-tier traces to long-term memory. +Preference traces are excluded from automatic mid-to-long graduation because +startup reads and mutable user defaults are not enough evidence to lock a trace +into the immutable long tier. Operators can still promote a preference manually +when they intend to curate it as long-term policy. diff --git a/internal/consolidation/graduate.go b/internal/consolidation/graduate.go index fb1f7da..c95caa8 100644 --- a/internal/consolidation/graduate.go +++ b/internal/consolidation/graduate.go @@ -13,7 +13,9 @@ import ( // criterion below is an AND-gate: a trace graduates only when it // clears every threshold simultaneously. Defaults derive from the // consolidation-plan ยง15 design: 14 days + 3 reads + unmodified + -// no active downvotes. +// no active downvotes. Preference traces are excluded because repeated +// startup loading is not evidence that a mutable preference should be +// locked into the immutable long tier. type GraduationConfig struct { // MinAge is the minimum age a mid-tier trace must reach before it // can be considered for graduation. Zero defaults to 14 days. @@ -115,6 +117,9 @@ func GraduatePass(cx GraduationProvider, cfg GraduationConfig, log func(format s // only ever generate search_hit_count, so insisting on raw read_count // alone would lock those cortexes out of long-tier graduation entirely. func shouldGraduate(pc cortex.PromotionCandidate, cfg GraduationConfig) bool { + if pc.Type == string(trace.TypePreference) { + return false + } if pc.ReadCount+pc.SearchHitCount < cfg.MinReadCount { return false } diff --git a/internal/consolidation/graduate_test.go b/internal/consolidation/graduate_test.go index 3d58e64..38810b4 100644 --- a/internal/consolidation/graduate_test.go +++ b/internal/consolidation/graduate_test.go @@ -34,7 +34,7 @@ func (f *fakeGraduationProvider) Promote(id, newTier string) error { func TestGraduatePass_AllCriteriaMet(t *testing.T) { provider := &fakeGraduationProvider{ candidates: []cortex.PromotionCandidate{ - {ID: "a", Tier: "mid", ReadCount: 5, ModifyCount: 0, TierVotes: 0}, + {ID: "a", Tier: "mid", Type: "fact", ReadCount: 5, ModifyCount: 0, TierVotes: 0}, }, } pass := consolidation.GraduatePass(provider, consolidation.GraduationConfig{ @@ -49,6 +49,21 @@ func TestGraduatePass_AllCriteriaMet(t *testing.T) { } } +func TestGraduatePass_PreferenceTraceBlocked(t *testing.T) { + provider := &fakeGraduationProvider{ + candidates: []cortex.PromotionCandidate{ + {ID: "pref", Tier: "mid", Type: "preference", ReadCount: 99, SearchHitCount: 99, ModifyCount: 0, TierVotes: 5}, + }, + } + pass := consolidation.GraduatePass(provider, consolidation.GraduationConfig{ + MinReadCount: 3, AllowModified: false, + }, nil) + _ = pass(context.Background(), "cron") + if len(provider.promoted) != 0 { + t.Errorf("promoted = %v, want empty (preference traces require manual long-tier curation)", provider.promoted) + } +} + func TestGraduatePass_InsufficientReads(t *testing.T) { provider := &fakeGraduationProvider{ candidates: []cortex.PromotionCandidate{ diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 30e5061..33ced57 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -154,12 +154,17 @@ func NewServer(cx *cortex.Cortex, noemaVersion string, federationMode string) *s s.AddTool(mcp.NewTool("get_trace", mcp.WithDescription("Get a trace by ID, including its full body"), mcp.WithString("id", mcp.Description("Trace ID"), mcp.Required()), + mcp.WithBoolean("record_usage", mcp.Description("Record this as an agent read for memory-tier promotion signals (default false). Pass true for task-driven retrieval where the read should count toward durability.")), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := req.RequireString("id") if err != nil { return nil, err } - row, err := cx.GetAs(id, cortex.ActorAgent) + actor := cortex.ActorSystem + if req.GetBool("record_usage", false) { + actor = cortex.ActorAgent + } + row, err := cx.GetAs(id, actor) if err != nil { return nil, fmt.Errorf("trace %q not found", id) } @@ -1161,10 +1166,10 @@ func buildCortexUsage(cx *cortex.Cortex, m cortex.Manifest, noemaVersion string, "startup": map[string]any{ "preference_sequence": []map[string]any{ {"tool": "list_traces", "arguments": map[string]any{"tag": "user-preference"}}, - {"tool": "get_trace", "for_each_result": true, "body_policy": "binding durable preference content"}, - {"tool": "list_traces", "arguments": map[string]any{"type": "preference"}, "optional": true, "purpose": "find untagged preferences"}, + {"tool": "get_trace", "for_each_result": true, "arguments": map[string]any{"record_usage": false}, "body_policy": "binding durable preference content"}, }, - "failure_policy": "If preference retrieval fails because of transport, auth, or schema issues, surface the failure explicitly and proceed with ordinary defaults.", + "preference_discovery": "Do not broad-scan type=preference during normal startup. Use type=preference only for task-scoped discovery when the current task needs preference search beyond active startup rules.", + "failure_policy": "If preference retrieval fails because of transport, auth, or schema issues, surface the failure explicitly and proceed with ordinary defaults.", }, "trace_model": map[string]any{ "types": traceTypes, @@ -1355,8 +1360,11 @@ Before establishing user or project defaults, fetch durable preferences from this Cortex: 1. list_traces with tag="user-preference". -2. get_trace for each relevant result; the body is the binding content. -3. Optionally list_traces with type="preference" to find untagged preferences. +2. get_trace for each relevant result with record_usage=false; the body is the binding content. + +Do not broad-scan type="preference" during normal startup. Use type="preference" +only for task-scoped discovery when the current task needs preference search +beyond active startup rules. If preference retrieval fails because of transport, auth, or schema issues, surface that failure explicitly and proceed with ordinary defaults. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index f59f7cb..d6048e9 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -62,6 +62,29 @@ func TestGetTrace_IncludesTierLine(t *testing.T) { } } +func TestGetTrace_UsageSignalRequiresExplicitOptIn(t *testing.T) { + cx := newTestCortex(t) + tr := trace.New("startup pref", "preference", "agent", []string{"user-preference"}, "body") + if err := cx.Add(tr); err != nil { + t.Fatalf("Add: %v", err) + } + + s := NewServer(cx, "test-version", "") + if text, isErr := callTool(t, s, "get_trace", map[string]any{"id": tr.ID}); isErr { + t.Fatalf("get_trace default returned error: %s", text) + } + if got := aggregateReadCount(t, cx, tr.ID); got != 0 { + t.Fatalf("read_count after default get_trace = %d, want 0", got) + } + + if text, isErr := callTool(t, s, "get_trace", map[string]any{"id": tr.ID, "record_usage": true}); isErr { + t.Fatalf("get_trace record_usage=true returned error: %s", text) + } + if got := aggregateReadCount(t, cx, tr.ID); got != 1 { + t.Fatalf("read_count after record_usage=true = %d, want 1", got) + } +} + func TestTierGlyph(t *testing.T) { cases := map[string]string{ trace.TierShort: "s", @@ -180,8 +203,8 @@ func TestRenderInstructions_IncludesPreferenceStartupPattern(t *testing.T) { for _, want := range []string{ `list_traces with tag="user-preference"`, - "get_trace for each relevant result", - `list_traces with type="preference"`, + "get_trace for each relevant result with record_usage=false", + `Do not broad-scan type="preference" during normal startup`, "surface that failure explicitly", } { if !strings.Contains(out, want) { @@ -190,6 +213,27 @@ func TestRenderInstructions_IncludesPreferenceStartupPattern(t *testing.T) { } } +func TestCortexUsage_StartupGetTraceDoesNotRecordUsage(t *testing.T) { + cx := newTestCortex(t) + s := NewServer(cx, "usage-test", cortex.FederationModePublish) + initServer(t, s) + + result := callToolResult(t, s, "cortex_usage", nil) + if result.IsError { + t.Fatalf("cortex_usage returned error: %s", toolResultText(result)) + } + text := toolResultText(result) + if !strings.Contains(text, `"record_usage": false`) { + t.Fatalf("cortex_usage startup sequence missing record_usage=false:\n%s", text) + } + if strings.Contains(text, `"type": "preference"`) { + t.Fatalf("cortex_usage startup sequence should not broad-scan type=preference:\n%s", text) + } + if !strings.Contains(text, "Do not broad-scan type=preference during normal startup") { + t.Fatalf("cortex_usage missing task-scoped preference discovery guidance:\n%s", text) + } +} + func TestCortexUsage_ReturnsStructuredMCPContext(t *testing.T) { cx := newTestCortex(t) s := NewServer(cx, "usage-test", cortex.FederationModePublish) @@ -608,6 +652,18 @@ func toolResultText(result mcp.CallToolResult) string { return "" } +func aggregateReadCount(t *testing.T, cx *cortex.Cortex, traceID string) int { + t.Helper() + var reads int + if err := cx.DB.QueryRow( + `SELECT COALESCE(SUM(read_count), 0) FROM trace_usage WHERE trace_id = ?`, + traceID, + ).Scan(&reads); err != nil { + t.Fatalf("reading aggregate read_count: %v", err) + } + return reads +} + // initServer drives the MCP initialize handshake so tools can be called. func initServer(t *testing.T, s *server.MCPServer) { t.Helper()