Skip to content

Commit 1248730

Browse files
committed
Fix scenario card merge apply
1 parent 2fc4ba6 commit 1248730

13 files changed

Lines changed: 422 additions & 50 deletions

docs/architecture/memory.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ Implementation details:
197197

198198
- `pkg/memory/scenario_card.go` owns rendering, parsing, draft generation, merge, upsert, and retrieval filtering.
199199
- `pkg/memory/scenario_identity.go` owns deterministic extraction, corpus statistics, pair scoring, and conservative candidate choice.
200-
- `MemoryMerger.SaveOrMerge` drafts/upserts a scenario card for platform memory saves and fails closed if the card cannot be saved. It must not fall back to raw-memory insertion because that would reintroduce scattered notes.
200+
- `MemoryMerger.SaveOrMerge` drafts/upserts a scenario card for platform memory saves and fails closed if the card cannot be saved. It must not fall back to raw-memory insertion because that would reintroduce scattered notes. Post-turn reflector saves created from a trace with successful non-memory/non-knowledge tool calls are marked `verified`; saves without execution evidence remain `draft` until a later successful run confirms them.
201201
- Promotion is upsert-based. Personal → team and team → org promotion draft a scenario card in the target scope and merge it with any existing card that resolves to the same scenario; after a successful upsert, the source raw memory is deleted.
202202
- Retrieval asks the underlying search for extra candidates, runs `memory.FilterPreferredScenarioResults`, prefers scenario cards, suppresses any transitional raw memories that are explicitly listed as `source_memory_ids`, and de-duplicates equivalent scenario cards as a safety net.
203203

@@ -214,7 +214,7 @@ Memory Health returns reviewable, actionable recommendations, not automatic writ
214214
- clean up raw source memories that are already represented by an existing scenario card
215215
- merge duplicate scenario cards that deterministic identity resolution considers the same operational scenario
216216

217-
Each recommendation contains the proposed card, target scope, source memory IDs, and the diagnostic flags that explain why it was suggested. Duplicate-card recommendations also include `duplicate_card_ids`, `resolver_signals`, and a `match_score`; applying one first upserts the merged card and then deletes only those explicit duplicate scenario-card rows. Applying any recommendation uses the same scenario-card upsert endpoint as manual consolidation; after the card is saved or merged, incorporated raw source memories are deleted. Cleanup recommendations re-save the existing card metadata and delete the still-visible raw source rows. If the proposed card contains only the placeholder recipe, the raw inputs are discarded and no placeholder card is saved.
217+
Each recommendation contains the proposed card, target scope, source memory IDs, and the diagnostic flags that explain why it was suggested. Duplicate-card recommendations also include `duplicate_card_ids`, `resolver_signals`, and a `match_score`; applying one saves the merged content in the target scope, preserves any existing non-duplicate card for the same resolved scenario, and then deletes only the explicit duplicate scenario-card rows. Applying any recommendation uses the same scenario-card upsert endpoint as manual consolidation; after the card is saved or merged, incorporated raw source memories are deleted. Cleanup recommendations re-save the existing card metadata and delete the still-visible raw source rows. If the proposed card contains only the placeholder recipe, the raw inputs are discarded and no placeholder card is saved.
218218

219219
`GET /api/memories/map` remains available as the advanced diagnostic report behind the Memory Health UI. It groups likely related memory chunks by a canonical topic key and flags conditions that make memory feel scattered or unsafe:
220220

docs/website/docs/agent/memory.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,6 @@ memory:
109109
- Use categories for organization (the agent does this automatically)
110110
- In team deployments, publish useful memories to your team via Studio so colleagues benefit
111111
- The agent automatically searches memory before responding — no manual retrieval needed
112+
- In platform mode, durable operational knowledge is organized into scenario cards. Successful trace-backed recipes can be marked verified; unsupported notes remain draft until a later successful run confirms them.
112113
113114
See [Sessions](./sessions.md) for how session history differs from memory, and [Three-Tier Memory](../platform/three-tier-memory.md) for the full multi-tier system.

docs/website/docs/platform/three-tier-memory.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Personal ──publish──▶ Team ──promote──▶ Org
5757
1. **Personal → Team**: any team member can publish a memory entry to their team via Studio. This makes it searchable by all team members.
5858
2. **Team → Org**: a team admin or org admin promotes team knowledge to org level via Studio, making it available to every team in the organization.
5959

60-
The agent can also save knowledge directly during conversations using the `memory_save` tool. The tier is determined by the current context.
60+
The agent can also save knowledge directly during conversations using the `memory_save` tool. The tier is determined by the current context. In platform mode, operational knowledge is stored as scenario cards when possible: trace-backed saves with successful external tool evidence are marked verified, while saves without execution evidence stay draft until a later successful run confirms them.
6161

6262
Promotion now performs a scenario-card upsert. When possible, the selected memory is distilled into an efficient successful-path card in the target tier and merged with an existing card for the same scenario. Scenario matching uses deterministic identity anchors such as system, service family, resource type, operation, environment, credential, endpoint host family, API family, HTTP method, and URL path; the canonical key is treated as an alias, not the only identity. After the card is saved, the original raw source memory is deleted; if it cannot form a useful card, it is discarded rather than kept as durable memory.
6363

@@ -68,7 +68,7 @@ Here is how knowledge compounds in practice:
6868
1. **Alice** debugs a tricky Kubernetes networking issue. The agent saves the resolution to her personal memory via `memory_save`.
6969
2. Alice publishes the resolution to the **Backend team** via Studio. Now when any backend engineer hits a similar issue, the agent surfaces Alice's solution.
7070
3. The team admin notices this resolution is relevant org-wide and **promotes it to org level** via Studio. If an org scenario card already exists for the same scenario identity, the new evidence is merged into that card instead of creating another duplicate memory.
71-
4. If two cards were already created with different labels for the same scenario, **Memory Health** can recommend a duplicate-card merge, show the resolver signals, save the merged card, and delete only the explicit duplicate card rows.
71+
4. If two cards were already created with different labels for the same scenario, **Memory Health** can recommend a duplicate-card merge, show the resolver signals, save the merged card into the target tier, preserve any existing non-duplicate card for that resolved scenario, and delete only the explicit duplicate card rows.
7272
5. **Dave** on the Frontend team later encounters the same networking issue. The agent finds the org-level scenario card and guides him through the efficient path — even though Dave never interacted with Alice.
7373

7474
Each step is explicit. Knowledge does not leak upward automatically.

pkg/agent/memory_merge.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type MergeResult struct {
3030
//
3131
// Returns the merge result indicating what action was taken.
3232
func (mm *MemoryMerger) SaveOrMerge(ctx context.Context, memStore store.MemoryStore, entry store.MemoryEntry) (MergeResult, error) {
33+
return mm.SaveOrMergeWithStatus(ctx, memStore, entry, "")
34+
}
35+
36+
func (mm *MemoryMerger) SaveOrMergeWithStatus(ctx context.Context, memStore store.MemoryStore, entry store.MemoryEntry, status string) (MergeResult, error) {
3337
if memStore == nil {
3438
return MergeResult{}, fmt.Errorf("memory store is nil")
3539
}
@@ -42,6 +46,12 @@ func (mm *MemoryMerger) SaveOrMerge(ctx context.Context, memStore store.MemorySt
4246
} else {
4347
card = mem.DraftScenarioCardFromMemoryEntry("team", entry)
4448
}
49+
if status != "" {
50+
card.Status = status
51+
if status == mem.ScenarioCardStatusVerified && card.Confidence < 0.8 {
52+
card.Confidence = 0.8
53+
}
54+
}
4555
if !mem.HasUsableScenarioRecipe(card) {
4656
return MergeResult{Action: "discarded"}, nil
4757
}

pkg/agent/memory_merge_test.go

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package agent
33
import (
44
"context"
55
"errors"
6+
"strings"
67
"testing"
78

9+
mem "github.com/SAP/astonish/pkg/memory"
810
"github.com/SAP/astonish/pkg/store"
911
)
1012

@@ -43,9 +45,56 @@ func TestMemoryMergerDiscardsUncardableMemory(t *testing.T) {
4345
}
4446
}
4547

48+
func TestMemoryMergerCanMarkTraceBackedScenarioCardsVerified(t *testing.T) {
49+
memStore := &failingScenarioUpsertStore{}
50+
merger := &MemoryMerger{}
51+
52+
result, err := merger.SaveOrMergeWithStatus(context.Background(), memStore, store.MemoryEntry{
53+
Content: "Use the noVNC ticket endpoint, then open the websocket with the returned ticket.",
54+
Category: "proxmox-console-access",
55+
}, mem.ScenarioCardStatusVerified)
56+
if err != nil {
57+
t.Fatalf("SaveOrMergeWithStatus returned error: %v", err)
58+
}
59+
if result.Action != "created" {
60+
t.Fatalf("Action = %q, want created", result.Action)
61+
}
62+
if !strings.Contains(memStore.added.Content, "status: verified") {
63+
t.Fatalf("saved card was not marked verified: %s", memStore.added.Content)
64+
}
65+
}
66+
67+
func TestPlatformReflectorMarksExistingSessionCardsVerified(t *testing.T) {
68+
card := mem.ScenarioCard{
69+
CanonicalKey: "proxmox-console-access",
70+
Title: "Proxmox Console Access",
71+
RecommendedRecipe: []string{"Use the noVNC ticket endpoint."},
72+
Status: mem.ScenarioCardStatusDraft,
73+
}
74+
memStore := &failingScenarioUpsertStore{entries: map[string]store.MemorySearchResult{
75+
"card-1": {
76+
ID: "card-1",
77+
Snippet: mem.RenderScenarioCard(card),
78+
Category: mem.ScenarioCardCategory,
79+
SessionID: "session-1",
80+
},
81+
}}
82+
83+
(&PlatformReflector{}).markScenarioCardsVerifiedForSession(context.Background(), memStore, "session-1")
84+
updated, ok := mem.ParseScenarioCard(memStore.entries["card-1"].Snippet)
85+
if !ok {
86+
t.Fatalf("updated memory is not a scenario card: %s", memStore.entries["card-1"].Snippet)
87+
}
88+
if updated.Status != mem.ScenarioCardStatusVerified {
89+
t.Fatalf("card was not verified: %#v", updated)
90+
}
91+
}
92+
4693
type failingScenarioUpsertStore struct {
4794
listErr error
4895
addCalled bool
96+
added store.MemoryEntry
97+
entries map[string]store.MemorySearchResult
4998
}
5099

51100
func (f *failingScenarioUpsertStore) Search(context.Context, string, int, float64) ([]store.MemorySearchResult, error) {
@@ -56,16 +105,29 @@ func (f *failingScenarioUpsertStore) SearchByCategory(context.Context, string, i
56105
return nil, nil
57106
}
58107

59-
func (f *failingScenarioUpsertStore) Add(context.Context, store.MemoryEntry) error {
108+
func (f *failingScenarioUpsertStore) Add(_ context.Context, entry store.MemoryEntry) error {
60109
f.addCalled = true
110+
f.added = entry
61111
return nil
62112
}
63113

64-
func (f *failingScenarioUpsertStore) Get(context.Context, string) (*store.MemorySearchResult, error) {
65-
return nil, nil
114+
func (f *failingScenarioUpsertStore) Get(_ context.Context, id string) (*store.MemorySearchResult, error) {
115+
entry, ok := f.entries[id]
116+
if !ok {
117+
return nil, nil
118+
}
119+
return &entry, nil
66120
}
67121

68-
func (f *failingScenarioUpsertStore) Update(context.Context, string, string, string) error {
122+
func (f *failingScenarioUpsertStore) Update(_ context.Context, id, content, category string) error {
123+
if f.entries == nil {
124+
f.entries = make(map[string]store.MemorySearchResult)
125+
}
126+
entry := f.entries[id]
127+
entry.ID = id
128+
entry.Snippet = content
129+
entry.Category = category
130+
f.entries[id] = entry
69131
return nil
70132
}
71133

@@ -75,8 +137,14 @@ func (f *failingScenarioUpsertStore) List(context.Context, string, int, int) ([]
75137
return nil, f.listErr
76138
}
77139

78-
func (f *failingScenarioUpsertStore) ListBySession(context.Context, string) ([]store.MemorySearchResult, error) {
79-
return nil, nil
140+
func (f *failingScenarioUpsertStore) ListBySession(_ context.Context, sessionID string) ([]store.MemorySearchResult, error) {
141+
var out []store.MemorySearchResult
142+
for _, entry := range f.entries {
143+
if entry.SessionID == sessionID {
144+
out = append(out, entry)
145+
}
146+
}
147+
return out, nil
80148
}
81149

82150
func (f *failingScenarioUpsertStore) Count() int { return 0 }

0 commit comments

Comments
 (0)