Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion internal/consolidation/graduate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 16 additions & 1 deletion internal/consolidation/graduate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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{
Expand Down
20 changes: 14 additions & 6 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 58 additions & 2 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading