github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/memories.go (about) 1 package environments 2 3 // Session-level memory-store download and sync. 4 // 5 // A session may have several memory stores attached. SessionMemoryStores 6 // resolves where each store's folder goes on disk, opens a confined 7 // filestore.FileStore there, and reconciles each folder with its remote 8 // store — the merge rules live on Sync. 9 10 import ( 11 "cmp" 12 "context" 13 "crypto/sha256" 14 "encoding/hex" 15 "errors" 16 "fmt" 17 "iter" 18 "log/slog" 19 "maps" 20 "path/filepath" 21 "slices" 22 "strings" 23 "sync" 24 "time" 25 26 anthropic "github.com/anthropics/anthropic-sdk-go" 27 "github.com/anthropics/anthropic-sdk-go/internal/filestore" 28 "github.com/anthropics/anthropic-sdk-go/option" 29 "github.com/anthropics/anthropic-sdk-go/packages/param" 30 "golang.org/x/sync/errgroup" 31 ) 32 33 // MemoryDeleteMode is whether a locally deleted file may delete its 34 // memory on the server. [MemorySyncDeletionsLogOnly] runs the same checks and 35 // only logs what it would delete. 36 type MemoryDeleteMode int 37 38 const ( 39 MemorySyncDeletionsEnabled MemoryDeleteMode = iota 40 MemorySyncDeletionsLogOnly 41 MemorySyncDeletionsDisabled 42 ) 43 44 // DefaultMemorySyncInterval is how often the worker syncs the session's 45 // memory stores back while the session runs. Checked after each dispatched 46 // tool call. 47 const DefaultMemorySyncInterval = 15 * time.Second 48 49 // MinMemorySyncInterval is the shortest sync interval accepted; a shorter 50 // positive interval is rejected with [ErrMemorySyncIntervalTooShort]. 51 const MinMemorySyncInterval = 5 * time.Second 52 53 // ErrMemorySyncIntervalTooShort reports a positive sync interval below 54 // [MinMemorySyncInterval]. Zero (the default) and negative (disabled) 55 // intervals are never rejected. 56 var ErrMemorySyncIntervalTooShort = fmt.Errorf( 57 "memory sync interval must be at least %s (or zero for the default, negative to disable)", 58 MinMemorySyncInterval, 59 ) 60 61 // MemoryFlushTimeout bounds each of the two network passes 62 // [SessionMemoryStores.Cleanup] runs — the Finish, then the 63 // [SessionMemoryStores.FlushWrites] — so a slow server cannot stall 64 // teardown. A pass that hits the bound logs a warning. 65 var MemoryFlushTimeout = 30 * time.Second 66 67 // MarkerPath is the reserved file at each store root whose content gates 68 // whether a sync trusts the folder. Never itself synced. 69 const MarkerPath = ".anthropic-memory-store" 70 71 const markerVersion = 1 72 73 func markerSHA(memoryStoreID string) string { 74 sum := sha256.Sum256(fmt.Appendf(nil, "version %d\n%s", markerVersion, memoryStoreID)) 75 return hex.EncodeToString(sum[:]) 76 } 77 78 // DeleteCorroborationWindow is how long a file must stay missing locally 79 // before its server delete is sent. 80 const DeleteCorroborationWindow = 30 * time.Second 81 82 // One sync sends at most a quarter of the store's files as deletes, 83 // clamped to [floor, ceiling] so small stores don't crawl. 84 const ( 85 deleteCapFloor = 8 86 deleteCapCeiling = 50 87 ) 88 89 // Page sizes for memory listings — the API's maximum per view: basic pages 90 // carry up to 100 items, full pages are capped by the server at 20. 91 const ( 92 listPageSize = 100 93 fullListPageSize = 20 94 ) 95 96 // fetchConcurrency is how many single-memory content fetches may be in 97 // flight at once during one store's pull pass. A sync rarely pulls more 98 // than a handful of memories, so a higher cap buys nothing in the common 99 // case. 100 const fetchConcurrency = 16 101 102 // uploadConcurrency is how many uploads one store's flush keeps in flight. 103 // At ~0.3s per upload, 32 clears the server's 2000-memories-per-store cap 104 // inside [MemoryFlushTimeout]. 105 const uploadConcurrency = 32 106 107 // pendingPull is a memory the sha pass decided to write to disk; the 108 // content pass fetches and writes it. 109 type pendingPull struct { 110 rel string 111 listed anthropic.BetaManagedAgentsMemory 112 } 113 114 // pendingUpload is a file the flush decided to push; uploadAll sends it. 115 type pendingUpload struct { 116 rel string 117 localSHA string 118 existing *anthropic.BetaManagedAgentsMemory 119 } 120 121 type deletePass struct { 122 mode MemoryDeleteMode 123 cap int 124 // True on the final sync, where waiting would drop the delete. 125 waiveWindow bool 126 attempted int 127 capped int 128 suppressed int 129 } 130 131 func (d *deletePass) takeSlot() bool { 132 if d.attempted >= d.cap { 133 d.capped++ 134 return false 135 } 136 d.attempted++ 137 return true 138 } 139 140 // markerScan is one directory scan with the marker checked and removed — 141 // same scan, so a folder deleted mid-sync cannot pass the check and then 142 // read as empty. 143 type markerScan struct { 144 files map[string]string 145 // The only state in which the scan may drive uploads or deletes. 146 markerOK bool 147 distrustReason string 148 } 149 150 // SessionMemoryError reports a memory store that could not be materialised on 151 // disk. Only [SessionMemoryStores.Download] returns it. 152 // 153 // Err is the cause — the refusal of a directory that already existed, a failed 154 // listing, an unusable root — and [errors.As]/[errors.Is] reach it through 155 // Unwrap. 156 type SessionMemoryError struct { 157 // MemoryStoreID is the store that could not be materialised. 158 MemoryStoreID string 159 // Err is why it could not be. Never nil. 160 Err error 161 } 162 163 func (e *SessionMemoryError) Error() string { 164 return fmt.Sprintf("memory store %s: %v", e.MemoryStoreID, e.Err) 165 } 166 167 func (e *SessionMemoryError) Unwrap() error { return e.Err } 168 169 // ErrSessionMemoryNoToken reports a work item that carried no sessions token 170 // for a session with memory stores attached. The item fails, as a hosted 171 // sandbox does; the poller must issue a per-item secret carrying a sessions 172 // token, or the operator must disable memory sync with a negative interval. 173 var ErrSessionMemoryNoToken = errors.New( 174 "the work item carried no sessions token, so the session's memory stores cannot be mounted", 175 ) 176 177 // storeFiles is the slice of [filestore.FileStore] the sync uses; an 178 // interface so tests can inject disk failures. 179 type storeFiles interface { 180 Put(rel string, data []byte, opts ...filestore.PutOption) error 181 Get(rel string) ([]byte, error) 182 HashTree(under string) (map[string]string, error) 183 HashFile(rel string) (string, error) 184 Remove(rel string) error 185 CreateRoot() error 186 Root() filestore.Root 187 Dispose() error 188 } 189 190 func openFileStore(root string, opts ...filestore.Option) (storeFiles, error) { 191 return filestore.Open(root, opts...) 192 } 193 194 // attachedStore is one attached store: its FileStore on disk plus the sync baseline. 195 type attachedStore struct { 196 memoryStoreID string 197 files storeFiles 198 readOnly bool 199 // mu guards baseline and refusedSHAs while a pass fans out its fetches 200 // or uploads; the merge decisions before the fan-out run serially and 201 // take no lock. 202 mu sync.Mutex 203 // baseline is {rel path → content sha} as of the last download or 204 // successful sync. 205 baseline map[string]string 206 // refusedSHAs is {rel path → sha} the server refused; retried only after 207 // the file changes. 208 refusedSHAs map[string]string 209 // When each path was first seen missing locally. 210 pendingDeletes map[string]time.Time 211 } 212 213 // SessionMemoryStoresOptions configures a [SessionMemoryStores]. 214 type SessionMemoryStoresOptions struct { 215 // Workdir anchors the {workdir}/memory/<name> fallback for stores that 216 // carry no mount path. Required. 217 Workdir string 218 // SyncInterval is the minimum gap between SyncIfDue syncs. Zero uses 219 // [DefaultMemorySyncInterval]; a positive value below 220 // [MinMemorySyncInterval] is rejected. 221 SyncInterval time.Duration 222 // SyncDeletions is whether a file deleted locally may delete its memory 223 // on the server; the zero value is [MemorySyncDeletionsEnabled]. 224 SyncDeletions MemoryDeleteMode 225 // RequestOptions are applied to every request — the memory endpoints 226 // reject the environment key, so the worker passes its per-item 227 // token-scoped auth options here. 228 RequestOptions []option.RequestOption 229 // Logger receives non-fatal warnings. Defaults to slog.Default(). 230 Logger *slog.Logger 231 } 232 233 // SessionMemoryStores is the memory stores attached to one session, 234 // materialised on disk. 235 // 236 // [SessionMemoryStores.Download] opens a confined store at each attached 237 // store's directory (its mount path, or a workdir fallback — see Download), 238 // pulls its memories, and records each one's content_sha256 as the sync 239 // baseline. Each sync ([SessionMemoryStores.SyncIfDue] on the worker's 240 // cadence, [SessionMemoryStores.Finish] once at the end) reconciles disk 241 // against server, per store and per path: 242 // 243 // - a memory changed only remotely is written to disk; 244 // - a file changed only locally is uploaded — an update with a 245 // content_sha256 precondition, or a create for a new file; 246 // - a file changed on both sides logs a warning and takes the server 247 // version; 248 // - a file the server refuses (too large, invalid content) is skipped — 249 // warned once and retried only after the file changes; other files keep 250 // syncing; 251 // - a file deleted locally is deleted on the server, guarded by an 252 // expected_content_sha256 precondition so a concurrent server-side 253 // edit wins and restores the file. The delete waits 254 // [DeleteCorroborationWindow] for a later sync to confirm the file is 255 // still gone (Finish skips the wait), and each sync sends a bounded 256 // number. [MemoryDeleteMode] gates whether deletes are sent at all; 257 // - a memory deleted on the server is deleted on disk — unless the 258 // local file holds an un-pushed edit, in which case a writable 259 // store re-creates the memory from it and a read-only store keeps 260 // the file unsynced; 261 // - a store attached read-only pulls but never pushes. 262 // 263 // A download pulls the whole store, so it lists with content included. The 264 // recurring syncs instead run two phases: a content-free listing (paths and 265 // shas) drives the merge decisions, then only the memories actually being 266 // written to disk are fetched, a bounded number at a time. A sync that finds 267 // nothing changed moves no content at all. 268 // 269 // A file whose write to disk failed is never in the baseline, so its absence 270 // reads as a failed download — it is pulled again, never deleted. A write 271 // never re-creates a store folder that vanished mid-sync: it fails, and the 272 // next sync's scan finds whatever is at the path by then — nothing 273 // (re-downloaded) or someone else's files (left alone) — under the rules 274 // below. 275 // 276 // Download writes a [MarkerPath] file into each folder, and sync only acts 277 // on a folder whose marker matches the store. An empty or missing folder is 278 // rebuilt from the server. A folder with files but no matching marker is 279 // left untouched: nothing is uploaded from it, and nothing is deleted on 280 // the server because of it. 281 // 282 // The syncs never fail: one bad store or file is logged and the rest 283 // continue. Instances are not safe for concurrent use. Lifecycle: 284 // Download, SyncIfDue per tool call, Cleanup at the end. 285 type SessionMemoryStores struct { 286 client anthropic.Client 287 workdir string 288 syncInterval time.Duration 289 syncDeletions MemoryDeleteMode 290 reqOpts []option.RequestOption 291 log *slog.Logger 292 lastSync time.Time 293 finished bool 294 stores []*attachedStore 295 296 // now is the time source SyncIfDue measures the gap with; setClock 297 // swaps it (the worker's test hook and this package's tests). 298 now func() time.Time 299 // openStore opens the confined folder for one store's root; tests swap 300 // it to inject disk failures. 301 openStore func(root string, opts ...filestore.Option) (storeFiles, error) 302 } 303 304 // checkMemorySyncInterval rejects a positive interval below the floor. 305 func checkMemorySyncInterval(d time.Duration) error { 306 if d > 0 && d < MinMemorySyncInterval { 307 return fmt.Errorf("%w: got %s", ErrMemorySyncIntervalTooShort, d) 308 } 309 return nil 310 } 311 312 // NewSessionMemoryStores returns a [SessionMemoryStores] bound to client. 313 // Call [SessionMemoryStores.Download] to materialise the stores. It fails 314 // only on an invalid SyncInterval ([ErrMemorySyncIntervalTooShort]). 315 func NewSessionMemoryStores(client anthropic.Client, opts SessionMemoryStoresOptions) (*SessionMemoryStores, error) { 316 if err := checkMemorySyncInterval(opts.SyncInterval); err != nil { 317 return nil, err 318 } 319 if opts.SyncInterval == 0 { 320 opts.SyncInterval = DefaultMemorySyncInterval 321 } 322 if opts.Logger == nil { 323 opts.Logger = slog.Default() 324 } 325 return &SessionMemoryStores{ 326 client: client, 327 workdir: opts.Workdir, 328 syncInterval: opts.SyncInterval, 329 syncDeletions: opts.SyncDeletions, 330 reqOpts: opts.RequestOptions, 331 log: opts.Logger, 332 now: time.Now, 333 lastSync: time.Now(), 334 openStore: openFileStore, 335 }, nil 336 } 337 338 // setClock replaces the time source and re-seeds the last-sync mark so the 339 // cadence math runs entirely on the new clock. 340 func (s *SessionMemoryStores) setClock(now func() time.Time) { 341 s.now = now 342 s.lastSync = now() 343 } 344 345 // Roots returns every attached store's folder. The worker lists these in the 346 // file tools' AllowedRoots so a store mounted outside the working directory 347 // stays reachable. 348 func (s *SessionMemoryStores) Roots() []string { 349 var roots []string 350 for _, st := range s.stores { 351 roots = append(roots, st.files.Root().Path) 352 } 353 return roots 354 } 355 356 // ReadOnlyRoots returns the root directories of stores attached read-only. 357 // The file tools consult this to refuse writes into read-only stores. 358 func (s *SessionMemoryStores) ReadOnlyRoots() []string { 359 var roots []string 360 for _, st := range s.stores { 361 if st.readOnly { 362 roots = append(roots, st.files.Root().Path) 363 } 364 } 365 return roots 366 } 367 368 // Download downloads every attached store's memories to disk. 369 // 370 // session arrives already fetched — one snapshot shared with the skills 371 // download, so the two cannot disagree about the resources. 372 // 373 // Fails on the first store it cannot materialise, returning a 374 // [SessionMemoryError] — a session missing a folder its system prompt names 375 // runs with amnesia and syncs nothing back. Stores that did land stay 376 // tracked, so the caller's Dispose still removes the directories this created. 377 func (s *SessionMemoryStores) Download(ctx context.Context, session *anthropic.BetaManagedAgentsSession) error { 378 for _, res := range session.Resources { 379 if res.Type != "memory_store" { 380 continue 381 } 382 resource := res.AsMemoryStore() 383 root, err := s.storeRoot(resource) 384 if err != nil { 385 return &SessionMemoryError{MemoryStoreID: resource.MemoryStoreID, Err: err} 386 } 387 if err := s.downloadStore(ctx, resource, root); err != nil { 388 return &SessionMemoryError{MemoryStoreID: resource.MemoryStoreID, Err: err} 389 } 390 } 391 s.lastSync = s.now() 392 return nil 393 } 394 395 // storeRoot is where one store's files land on disk. 396 // 397 // The store's files land at its MountPath — the very location the agent's 398 // system prompt tells it to read. A MountPath we cannot use verbatim is refused 399 // rather than quietly relocated: the agent would read an empty folder at the 400 // path it was told about, and write notes somewhere the next session looks for 401 // nothing. 402 func (s *SessionMemoryStores) storeRoot(resource anthropic.BetaManagedAgentsMemoryStoreResource) (string, error) { 403 if root := resource.MountPath; root != "" { 404 if !filestore.IsPathLegal(root) { 405 return "", fmt.Errorf("mount_path is not a clean absolute path: %q", root) 406 } 407 return root, nil 408 } 409 // No mount path at all: nothing points the agent anywhere, so the workdir 410 // is as good a home as any — {workdir}/memory/<name>, or the store id when 411 // there is no name. 412 return filepath.Join(s.workdir, "memory", cmp.Or(resource.Name, resource.MemoryStoreID)), nil 413 } 414 415 func (s *SessionMemoryStores) downloadStore(ctx context.Context, resource anthropic.BetaManagedAgentsMemoryStoreResource, root string) error { 416 // WithUTF8: a binary file is refused at Put/Get, not mid-sync. 417 files, err := s.openStore(root, filestore.WithUTF8()) 418 if err != nil { 419 return err 420 } 421 // A root Open did not create is a dead run's leftovers; the first sync 422 // would upload them into the customer's store. Left exactly as found. 423 if !files.Root().RemovedOnDispose { 424 // The configured path, not Root().Path — log the operator's own 425 // string, not our normalization of it. 426 return fmt.Errorf("something already exists at the memory store's path: %s; it must not exist when the session starts", root) 427 } 428 if err := files.CreateRoot(); err != nil { 429 // An unmountable root fails the item, not just one file. 430 _ = files.Dispose() // removes the root iff the failed mkdir got that far 431 return fmt.Errorf("cannot create the memory store's folder: %s: %w; the worker host must make this mount path writable", root, err) 432 } 433 store := &attachedStore{ 434 memoryStoreID: resource.MemoryStoreID, 435 files: files, 436 readOnly: resource.Access == anthropic.BetaManagedAgentsMemoryStoreResourceAccessReadOnly, 437 baseline: map[string]string{}, 438 refusedSHAs: map[string]string{}, 439 pendingDeletes: map[string]time.Time{}, 440 } 441 if err := s.stampAndPull(ctx, store); err != nil { 442 // A half-downloaded folder self-destructs; Dispose removes only what 443 // this Open created — the guard above already established it did. 444 _ = files.Dispose() 445 return err 446 } 447 s.log.Info("downloaded memories", 448 slog.Int("count", len(store.baseline)), 449 slog.String("memory_store_id", store.memoryStoreID), 450 slog.String("dir", store.files.Root().Path)) 451 s.stores = append(s.stores, store) 452 return nil 453 } 454 455 func (s *SessionMemoryStores) scanMarker(store *attachedStore) (markerScan, error) { 456 local, err := store.files.HashTree("/") 457 if err != nil { 458 return markerScan{}, err 459 } 460 marker, hasMarker := local[MarkerPath] 461 delete(local, MarkerPath) 462 if hasMarker && marker == markerSHA(store.memoryStoreID) { 463 return markerScan{files: local, markerOK: true}, nil 464 } 465 reason := "the marker file is gone" 466 if hasMarker { 467 reason = "the marker file does not match this store" 468 } 469 return markerScan{files: local, markerOK: false, distrustReason: reason}, nil 470 } 471 472 // rebuild rebuilds a destroyed folder from the server; sends no deletes, no uploads. 473 func (s *SessionMemoryStores) rebuild(ctx context.Context, store *attachedStore, reason string) error { 474 s.log.Warn(reason+"; re-downloading the memory store folder instead of syncing", 475 slog.String("root", store.files.Root().Path), 476 slog.String("memory_store_id", store.memoryStoreID)) 477 if err := store.files.CreateRoot(); err != nil { 478 return err 479 } 480 return s.stampAndPull(ctx, store) 481 } 482 483 // stampAndPull writes the trust marker, then pulls every remote memory; 484 // pushes nothing. The baseline is rebuilt from only the writes that 485 // succeed, so it never holds a file that isn't on disk. Every memory is 486 // needed here, so the listing carries the content — pages cost far fewer 487 // round-trips than a request per memory. 488 func (s *SessionMemoryStores) stampAndPull(ctx context.Context, store *attachedStore) error { 489 store.baseline = map[string]string{} 490 clear(store.pendingDeletes) // earlier absence observations no longer describe this disk 491 if err := store.files.Put(MarkerPath, 492 fmt.Appendf(nil, "version %d\n%s", markerVersion, store.memoryStoreID)); err != nil { 493 return err 494 } 495 for item, err := range s.listMemories(ctx, store.memoryStoreID, anthropic.BetaManagedAgentsMemoryViewFull) { 496 if err != nil { 497 return err 498 } 499 rel := strings.TrimLeft(item.Path, "/") 500 if s.write(store, rel, item.Content) { 501 store.baseline[rel] = item.ContentSha256 502 } 503 } 504 return nil 505 } 506 507 // listMemories iterates the store's memories at the largest page size the 508 // view allows — basic (shas, no content) at [listPageSize], full at 509 // [fullListPageSize]. Prefix rollups and the marker path are skipped. 510 func (s *SessionMemoryStores) listMemories(ctx context.Context, memoryStoreID string, view anthropic.BetaManagedAgentsMemoryView) iter.Seq2[anthropic.BetaManagedAgentsMemory, error] { 511 limit := int64(listPageSize) 512 if view == anthropic.BetaManagedAgentsMemoryViewFull { 513 limit = fullListPageSize 514 } 515 return func(yield func(anthropic.BetaManagedAgentsMemory, error) bool) { 516 pager := s.client.Beta.MemoryStores.Memories.ListAutoPaging(ctx, memoryStoreID, 517 anthropic.BetaMemoryStoreMemoryListParams{View: view, Limit: param.NewOpt(limit)}, s.reqOpts...) 518 for pager.Next() { 519 item := pager.Current() 520 if item.Type != "memory" { 521 continue 522 } 523 if strings.TrimLeft(item.Path, "/") == MarkerPath { 524 s.log.Warn("the server listed the reserved marker path; skipping", 525 slog.String("path", item.Path), slog.String("memory_store_id", memoryStoreID)) 526 continue 527 } 528 if !yield(item.AsMemory(), nil) { 529 return 530 } 531 } 532 if err := pager.Err(); err != nil { 533 yield(anthropic.BetaManagedAgentsMemory{}, err) 534 } 535 } 536 } 537 538 // Cleanup is the teardown, run once the session's tools are closed: the 539 // last full sync when the session ended cleanly, a flush of unsynced 540 // writes always, then the folders are removed. Each network step gets its 541 // own [MemoryFlushTimeout]-bounded context — the session context may 542 // already be cancelled — and a step that hits the bound logs a warning. 543 func (s *SessionMemoryStores) Cleanup(cleanEnd bool) { 544 if cleanEnd { 545 ctx, cancel := context.WithTimeout(context.Background(), MemoryFlushTimeout) 546 s.Finish(ctx) 547 if ctx.Err() != nil { 548 s.log.Warn(fmt.Sprintf("final memory sync cut off after %s; the flush that follows still uploads changed files", 549 MemoryFlushTimeout)) 550 } 551 cancel() 552 } 553 // A fresh bound of its own even after Finish: Finish may have hit its 554 // bound or swallowed a failure, and after a clean Finish the flush 555 // finds nothing dirty and makes no network calls. 556 ctx, cancel := context.WithTimeout(context.Background(), MemoryFlushTimeout) 557 s.FlushWrites(ctx) 558 if ctx.Err() != nil { 559 s.log.Warn(fmt.Sprintf("memory flush cut off after %s; changed files it had not uploaded yet are not saved", 560 MemoryFlushTimeout)) 561 } 562 cancel() 563 s.Dispose() 564 } 565 566 // Finish is the session's last sync — call once, at a clean end. Server 567 // deletes skip the [DeleteCorroborationWindow] wait. Panics if called twice. 568 func (s *SessionMemoryStores) Finish(ctx context.Context) { 569 if s.finished { 570 panic("Finish() was already called: it is the session's last sync and runs once") 571 } 572 s.finished = true 573 s.syncAll(ctx, true) 574 } 575 576 func (s *SessionMemoryStores) syncAll(ctx context.Context, final bool) { 577 var wg sync.WaitGroup 578 for _, store := range s.stores { 579 wg.Add(1) 580 go func() { 581 defer wg.Done() 582 if err := s.syncStore(ctx, store, final); err != nil { 583 s.log.Warn("memory sync failed", 584 slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 585 } 586 }() 587 } 588 wg.Wait() 589 s.lastSync = s.now() 590 } 591 592 // SyncIfDue runs one sync when the interval has elapsed since the last. 593 // Never returns an error — a store that fails is logged and the rest 594 // continue. 595 func (s *SessionMemoryStores) SyncIfDue(ctx context.Context) { 596 if s.now().Sub(s.lastSync) < s.syncInterval { 597 return 598 } 599 s.syncAll(ctx, false) 600 } 601 602 // FlushWrites uploads new and changed files and does nothing else — no 603 // server deletes, no local removals, no pulls. 604 // 605 // The shutdown rescue for a session that ended on an error or cancel: 606 // best-effort, bounded by the caller, so an errored session can still 607 // lose edits. Each store uploads up to [uploadConcurrency] files at a 608 // time; a store cut off part-way logs how many changed files it had not 609 // uploaded. Read-only stores, refused files, files the server already 610 // holds, and folders that fail the marker check are skipped. Never 611 // returns an error. 612 func (s *SessionMemoryStores) FlushWrites(ctx context.Context) { 613 var wg sync.WaitGroup 614 for _, store := range s.stores { 615 wg.Add(1) 616 go func() { 617 defer wg.Done() 618 if err := s.flushStore(ctx, store); err != nil { 619 s.log.Warn("memory flush failed", 620 slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 621 } 622 }() 623 } 624 wg.Wait() 625 } 626 627 func (s *SessionMemoryStores) flushStore(ctx context.Context, store *attachedStore) error { 628 if store.readOnly { 629 return nil 630 } 631 scan, err := s.scanMarker(store) 632 if err != nil { 633 return err 634 } 635 if !scan.markerOK { 636 s.log.Warn(scan.distrustReason+"; not uploading anything from the memory store folder", 637 slog.String("root", store.files.Root().Path), 638 slog.String("memory_store_id", store.memoryStoreID)) 639 return nil 640 } 641 dirty := map[string]string{} 642 for rel, sha := range scan.files { 643 if sha != store.baseline[rel] && store.refusedSHAs[rel] != sha { 644 dirty[rel] = sha 645 } 646 } 647 if len(dirty) == 0 { 648 return nil 649 } 650 unfinished := len(dirty) 651 defer func() { 652 if ctx.Err() != nil && unfinished > 0 { 653 s.log.Warn(fmt.Sprintf("memory flush cut off part-way; %d of %d changed files had not finished uploading", 654 unfinished, len(dirty)), 655 slog.String("memory_store_id", store.memoryStoreID)) 656 } 657 }() 658 remote := map[string]anthropic.BetaManagedAgentsMemory{} 659 for item, err := range s.listMemories(ctx, store.memoryStoreID, anthropic.BetaManagedAgentsMemoryViewBasic) { 660 if err != nil { 661 if ctx.Err() != nil { 662 return nil // reported by the cut-off line, not as a failure 663 } 664 return err 665 } 666 remote[strings.TrimLeft(item.Path, "/")] = item 667 } 668 var uploads []pendingUpload 669 for _, rel := range slices.Sorted(maps.Keys(dirty)) { 670 localSHA := dirty[rel] 671 baseSHA, hasBase := store.baseline[rel] 672 var existing *anthropic.BetaManagedAgentsMemory 673 if item, ok := remote[rel]; ok { 674 existing = &item 675 } 676 if existing != nil && existing.ContentSha256 == localSHA { 677 store.baseline[rel] = localSHA 678 continue 679 } 680 if existing != nil && (!hasBase || existing.ContentSha256 != baseSHA) { 681 s.log.Warn("memory changed both locally and remotely; the flush leaves the remote version", 682 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 683 continue 684 } 685 uploads = append(uploads, pendingUpload{rel: rel, localSHA: localSHA, existing: existing}) 686 } 687 unfinished = s.uploadAll(ctx, store, uploads) 688 return nil 689 } 690 691 // Dispose removes every store directory that Download created. A folder 692 // that holds files but fails the marker check is kept — sync promised to 693 // leave it as found. 694 func (s *SessionMemoryStores) Dispose() { 695 for _, store := range s.stores { 696 root := store.files.Root() 697 scan, err := s.scanMarker(store) 698 if err == nil && !scan.markerOK && len(scan.files) > 0 { 699 s.log.Warn(scan.distrustReason+"; leaving the memory store folder on disk", 700 slog.String("root", root.Path), 701 slog.String("memory_store_id", store.memoryStoreID)) 702 continue 703 } 704 if err == nil { 705 err = store.files.Dispose() 706 } 707 if err != nil { 708 s.log.Warn("failed to remove the memory store folder", 709 slog.String("root", root.Path), 710 slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 711 continue 712 } 713 if root.RemovedOnDispose { 714 s.log.Info("removed memory store dir", 715 slog.String("dir", root.Path), slog.String("memory_store_id", store.memoryStoreID)) 716 } 717 } 718 } 719 720 func (s *SessionMemoryStores) syncStore(ctx context.Context, store *attachedStore, final bool) error { 721 scan, err := s.scanMarker(store) 722 if err != nil { 723 return err 724 } 725 local := scan.files 726 if !scan.markerOK { 727 if len(local) > 0 { 728 s.log.Warn(scan.distrustReason+"; leaving the memory store folder as found and not syncing. "+ 729 "Delete it to recover — the server copy is intact.", 730 slog.String("root", store.files.Root().Path), 731 slog.String("memory_store_id", store.memoryStoreID)) 732 return nil 733 } 734 return s.rebuild(ctx, store, "the folder or its marker is gone") 735 } 736 // A lone file vanishing is an ordinary deletion; two or more at once 737 // with nothing left is a wiped folder. 738 if len(local) == 0 && len(store.baseline) > 1 { 739 return s.rebuild(ctx, store, "every memory file is gone at once") 740 } 741 742 remote := map[string]anthropic.BetaManagedAgentsMemory{} 743 for item, err := range s.listMemories(ctx, store.memoryStoreID, anthropic.BetaManagedAgentsMemoryViewBasic) { 744 if err != nil { 745 return err 746 } 747 remote[strings.TrimLeft(item.Path, "/")] = item 748 } 749 750 deletes := &deletePass{ 751 mode: s.syncDeletions, 752 cap: max(deleteCapFloor, min(deleteCapCeiling, len(store.baseline)/4)), 753 waiveWindow: final, 754 } 755 var pulls []pendingPull 756 baseline := map[string]string{} 757 for _, rel := range sortedKeyUnion(remote, local, store.baseline) { 758 var remoteItem *anthropic.BetaManagedAgentsMemory 759 if item, ok := remote[rel]; ok { 760 remoteItem = &item 761 } 762 localSHA, localPresent := local[rel] 763 baseSHA, hasBase := store.baseline[rel] 764 // Locally deleted, remotely unchanged: a candidate server delete. 765 if !localPresent && hasBase && remoteItem != nil && remoteItem.ContentSha256 == baseSHA && !store.readOnly { 766 if sha, ok := s.corroboratedDelete(ctx, store, rel, remoteItem, baseSHA, deletes); ok { 767 baseline[rel] = sha 768 } 769 continue 770 } 771 if sha, ok := s.syncPath(ctx, store, rel, remoteItem, localSHA, localPresent, &pulls); ok { 772 baseline[rel] = sha 773 } 774 } 775 store.baseline = baseline 776 // The content pass: everything above moved only shas. 777 s.pullAll(ctx, store, pulls) 778 if deletes.suppressed > 0 { 779 s.log.Debug("server deletes are disabled; locally deleted memories stay on the server", 780 slog.Int("count", deletes.suppressed), slog.String("memory_store_id", store.memoryStoreID)) 781 } 782 if deletes.capped > 0 { 783 verb := "sent" 784 if deletes.mode == MemorySyncDeletionsLogOnly { 785 verb = "would send" 786 } 787 s.log.Warn(fmt.Sprintf("delete cap reached: %s %d deletes, held %d for later syncs", 788 verb, deletes.attempted, deletes.capped), 789 slog.String("memory_store_id", store.memoryStoreID)) 790 } 791 return nil 792 } 793 794 // sortedKeyUnion returns the sorted union of the three maps' keys. 795 // Deterministic order keeps each store's logs and server traffic stable 796 // across runs (stores sync in parallel, so cross-store order is not). 797 func sortedKeyUnion(remote map[string]anthropic.BetaManagedAgentsMemory, local, base map[string]string) []string { 798 keys := slices.Collect(maps.Keys(remote)) 799 keys = slices.AppendSeq(keys, maps.Keys(local)) 800 keys = slices.AppendSeq(keys, maps.Keys(base)) 801 slices.Sort(keys) 802 return slices.Compact(keys) 803 } 804 805 // syncPath reconciles a single path and returns its entry in the next 806 // sync's baseline. The baseline advances only on a successful disk write 807 // or server call. A path that needs the remote content is appended to 808 // pulls and keeps its old baseline entry until the content pass writes it. 809 func (s *SessionMemoryStores) syncPath(ctx context.Context, store *attachedStore, rel string, remote *anthropic.BetaManagedAgentsMemory, localSHA string, localPresent bool, pulls *[]pendingPull) (string, bool) { 810 baseSHA, hasBase := store.baseline[rel] 811 if localPresent { 812 delete(store.pendingDeletes, rel) 813 } 814 815 if remote == nil { 816 if !localPresent { 817 delete(store.pendingDeletes, rel) 818 return "", false 819 } 820 if hasBase { 821 if localSHA == baseSHA { 822 fresh, onDisk := s.removeLocal(store, rel, baseSHA) 823 if !onDisk { 824 return "", false 825 } 826 if fresh == baseSHA { 827 return baseSHA, true 828 } 829 localSHA = fresh 830 } 831 // The local edit is the only copy: fall through as a 832 // local-only file (uploaded on a writable store, kept on a 833 // read-only one). 834 if store.readOnly { 835 s.log.Warn("memory deleted remotely but edited locally; keeping the file, "+ 836 "which a read-only store cannot push", 837 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 838 } else if store.refusedSHAs[rel] != localSHA { 839 s.log.Info("memory deleted remotely but edited locally; re-creating it from the file", 840 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 841 } 842 } 843 if store.readOnly { 844 return "", false 845 } 846 if store.refusedSHAs[rel] == localSHA { 847 return "", false // refused before, unchanged since 848 } 849 return s.upload(ctx, store, rel, localSHA, nil) 850 } 851 852 remoteSHA := remote.ContentSha256 853 remoteChanged := !hasBase || remoteSHA != baseSHA 854 locallyEdited := localPresent && (!hasBase || localSHA != baseSHA) && localSHA != remoteSHA 855 // Read-only stores never push, so their local edits don't count. 856 localChanged := !store.readOnly && locallyEdited 857 858 if !localPresent && hasBase { 859 // Only successful writes enter the baseline, so this file was 860 // verifiably on disk and is now gone: a real local deletion. 861 if remoteChanged { 862 s.log.Warn("memory deleted locally but changed remotely; restoring the remote version", 863 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 864 delete(store.pendingDeletes, rel) 865 *pulls = append(*pulls, pendingPull{rel: rel, listed: *remote}) 866 } 867 return baseSHA, true 868 } 869 870 switch { 871 case remoteChanged: 872 if localPresent && localSHA == remoteSHA { 873 // The file already holds the remote bytes — adopt without a fetch. 874 return remoteSHA, true 875 } 876 // Read-only stores too: overwriting a local edit deserves a trace. 877 if locallyEdited { 878 s.log.Warn("memory changed both locally and remotely; keeping the remote version", 879 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 880 } 881 *pulls = append(*pulls, pendingPull{rel: rel, listed: *remote}) 882 return baseSHA, hasBase 883 case localChanged: 884 if store.refusedSHAs[rel] == localSHA { 885 return remoteSHA, true // refused before, unchanged since 886 } 887 if sha, ok := s.upload(ctx, store, rel, localSHA, remote); ok { 888 return sha, true 889 } 890 return remoteSHA, true 891 default: 892 return remoteSHA, true 893 } 894 } 895 896 // removeLocal removes the file for a memory the server no longer has, if 897 // it still holds expectSHA. Returns the file's sha after the call and 898 // whether it is still on disk: an edited file is left in place, and an 899 // I/O error is treated as still present so the next sync retries. 900 func (s *SessionMemoryStores) removeLocal(store *attachedStore, rel, expectSHA string) (freshSHA string, onDisk bool) { 901 // The scan may be stale — re-read so a mid-sync edit isn't destroyed. 902 fresh, err := store.files.HashFile(rel) 903 if err != nil { 904 // Dropping the entry would re-create the memory the server just deleted. 905 return expectSHA, true 906 } 907 if fresh == "" { 908 return "", false 909 } 910 if fresh != expectSHA { 911 return fresh, true 912 } 913 if err := store.files.Remove(rel); err != nil { 914 s.log.Warn("failed to remove memory deleted remotely", 915 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 916 return expectSHA, true 917 } 918 return "", false 919 } 920 921 // write puts a memory's content on disk; false (and a warning) on failure. 922 // A ".." component in the wire path reaches here as a *FileStoreError — that 923 // is the escape guard. 924 func (s *SessionMemoryStores) write(store *attachedStore, rel, content string) bool { 925 if err := store.files.Put(rel, []byte(content)); err != nil { 926 s.log.Warn("failed to write memory", 927 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 928 return false 929 } 930 return true 931 } 932 933 // pullAll fetches and writes the given memories, [fetchConcurrency] at a 934 // time — the sync's content pass. The listing carried no content, so each 935 // memory is fetched individually and written as it arrives. On success the 936 // path's baseline advances; on a failed fetch or write the old entry stays 937 // and the next sync retries. A 404 means the memory was deleted after the 938 // listing — the next sync reconciles it. 939 func (s *SessionMemoryStores) pullAll(ctx context.Context, store *attachedStore, pulls []pendingPull) { 940 if len(pulls) == 0 { 941 return 942 } 943 var g errgroup.Group 944 g.SetLimit(fetchConcurrency) 945 for _, p := range pulls { 946 g.Go(func() error { 947 // The write stays inside the limited goroutine so a slow disk 948 // cannot let fetched bodies pile up beyond the concurrency bound. 949 item, err := s.client.Beta.MemoryStores.Memories.Get(ctx, p.listed.ID, 950 anthropic.BetaMemoryStoreMemoryGetParams{ 951 MemoryStoreID: store.memoryStoreID, 952 View: anthropic.BetaManagedAgentsMemoryViewFull, 953 }, s.reqOpts...) 954 if err != nil { 955 if !isStatus(err, 404) { 956 s.log.Warn("failed to fetch memory content", 957 slog.String("path", p.rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 958 } 959 return nil 960 } 961 if s.write(store, p.rel, item.Content) { 962 store.mu.Lock() 963 store.baseline[p.rel] = item.ContentSha256 964 store.mu.Unlock() 965 } 966 return nil 967 }) 968 } 969 _ = g.Wait() 970 } 971 972 // uploadAll pushes the given files, [uploadConcurrency] at a time — the 973 // flush's send pass — and returns how many had not finished when ctx was 974 // done. A finished upload advances the path's baseline; one that failed on 975 // its own is settled too (upload logged it); none starts once ctx is done. 976 func (s *SessionMemoryStores) uploadAll(ctx context.Context, store *attachedStore, uploads []pendingUpload) int { 977 unfinished := len(uploads) 978 var g errgroup.Group 979 g.SetLimit(uploadConcurrency) 980 for _, u := range uploads { 981 g.Go(func() error { 982 if ctx.Err() != nil { 983 return nil 984 } 985 sha, ok := s.upload(ctx, store, u.rel, u.localSHA, u.existing) 986 if !ok && ctx.Err() != nil { 987 return nil 988 } 989 store.mu.Lock() 990 defer store.mu.Unlock() 991 unfinished-- 992 if ok { 993 store.baseline[u.rel] = sha 994 } 995 return nil 996 }) 997 } 998 _ = g.Wait() 999 return unfinished 1000 } 1001 1002 // upload pushes one local file. On failure the path stays out of the 1003 // baseline, so the next pass retries. 1004 // 1005 // A refusal the server would repeat (400/413, the utf-8 gate) enters 1006 // refusedSHAs: warned once, retried only after the file changes. 1007 func (s *SessionMemoryStores) upload(ctx context.Context, store *attachedStore, rel, localSHA string, existing *anthropic.BetaManagedAgentsMemory) (string, bool) { 1008 data, err := store.files.Get(rel) 1009 if err != nil { 1010 var fse *filestore.FileStoreError 1011 if errors.As(err, &fse) && localSHA != "" { 1012 // The utf-8 gate: the same bytes would be refused again. 1013 store.mu.Lock() 1014 store.refusedSHAs[rel] = localSHA 1015 store.mu.Unlock() 1016 s.log.Warn("the server rejected this memory file, so it stays un-synced until its content changes", 1017 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("rejection", err)) 1018 return "", false 1019 } 1020 s.log.Warn("failed to upload memory", 1021 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 1022 return "", false 1023 } 1024 if data == nil { 1025 // The file vanished between HashTree and Get; the next sync sees the 1026 // deletion. 1027 return "", false 1028 } 1029 var item *anthropic.BetaManagedAgentsMemory 1030 if existing == nil { 1031 item, err = s.client.Beta.MemoryStores.Memories.New(ctx, store.memoryStoreID, 1032 anthropic.BetaMemoryStoreMemoryNewParams{ 1033 Path: "/" + rel, 1034 Content: param.NewOpt(string(data)), 1035 }, s.reqOpts...) 1036 } else { 1037 item, err = s.client.Beta.MemoryStores.Memories.Update(ctx, existing.ID, 1038 anthropic.BetaMemoryStoreMemoryUpdateParams{ 1039 MemoryStoreID: store.memoryStoreID, 1040 Content: param.NewOpt(string(data)), 1041 Precondition: anthropic.BetaManagedAgentsPreconditionParam{ 1042 Type: anthropic.BetaManagedAgentsPreconditionTypeContentSha256, 1043 ContentSha256: param.NewOpt(existing.ContentSha256), 1044 }, 1045 }, s.reqOpts...) 1046 } 1047 if err != nil { 1048 switch { 1049 case ctx.Err() != nil: 1050 // The pass was cancelled or hit its bound — not this file's 1051 // failure, so no per-file line and no refusal recorded. 1052 case existing != nil && isStatus(err, 404): 1053 // Deleted remotely since the listing, so this file is now the only copy. 1054 return s.upload(ctx, store, rel, localSHA, nil) 1055 case existing != nil && isStatus(err, 409): 1056 // The precondition lost a race: the remote moved under us, so the 1057 // push is dropped. The local file is now stale — the next sync 1058 // sees remoteChanged and pulls the winner over it. 1059 s.log.Warn("memory changed both locally and remotely; the upload was refused and "+ 1060 "the local edit loses", 1061 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 1062 case (isStatus(err, 400) || isStatus(err, 413)) && localSHA != "": 1063 // The same bytes would be refused again. 1064 store.mu.Lock() 1065 store.refusedSHAs[rel] = localSHA 1066 store.mu.Unlock() 1067 s.log.Warn("the server rejected this memory file, so it stays un-synced until its content changes", 1068 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("rejection", err)) 1069 default: 1070 s.log.Warn("failed to upload memory", 1071 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 1072 } 1073 return "", false 1074 } 1075 store.mu.Lock() 1076 delete(store.refusedSHAs, rel) 1077 store.mu.Unlock() 1078 return item.ContentSha256, true 1079 } 1080 1081 // corroboratedDelete records when the file was first seen absent and 1082 // sends the server delete only after [DeleteCorroborationWindow], a fresh 1083 // re-check that the file is still gone and the marker still intact, 1084 // and the pass's cap. 1085 func (s *SessionMemoryStores) corroboratedDelete(ctx context.Context, store *attachedStore, rel string, remote *anthropic.BetaManagedAgentsMemory, baseSHA string, deletes *deletePass) (string, bool) { 1086 if deletes.mode == MemorySyncDeletionsDisabled { 1087 deletes.suppressed++ 1088 return baseSHA, true 1089 } 1090 if _, ok := store.pendingDeletes[rel]; !ok { 1091 store.pendingDeletes[rel] = s.now() 1092 } 1093 firstAbsent := store.pendingDeletes[rel] 1094 if !deletes.waiveWindow && s.now().Sub(firstAbsent) < DeleteCorroborationWindow { 1095 return baseSHA, true 1096 } 1097 // The folder may have been destroyed since this sync's own scan. 1098 markerNow, err := store.files.HashFile(MarkerPath) 1099 if err != nil || markerNow != markerSHA(store.memoryStoreID) { 1100 return baseSHA, true 1101 } 1102 fileNow, err := store.files.HashFile(rel) 1103 if err != nil { 1104 return baseSHA, true 1105 } 1106 if fileNow != "" { 1107 delete(store.pendingDeletes, rel) 1108 return baseSHA, true 1109 } 1110 if !deletes.takeSlot() { 1111 return baseSHA, true 1112 } 1113 if deletes.mode == MemorySyncDeletionsLogOnly { 1114 s.log.Info("log-only: sync would delete this memory on the server", 1115 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 1116 return baseSHA, true 1117 } 1118 sha, ok := s.deleteRemote(ctx, store, rel, remote, baseSHA) 1119 if !ok { 1120 delete(store.pendingDeletes, rel) 1121 } 1122 return sha, ok 1123 } 1124 1125 // deleteRemote propagates a local deletion, guarded by the last-synced 1126 // content hash. A concurrent remote edit wins: the baseline entry is kept so 1127 // the next sync restores the remote version instead of re-deleting. 1128 func (s *SessionMemoryStores) deleteRemote(ctx context.Context, store *attachedStore, rel string, remote *anthropic.BetaManagedAgentsMemory, baseSHA string) (string, bool) { 1129 _, err := s.client.Beta.MemoryStores.Memories.Delete(ctx, remote.ID, 1130 anthropic.BetaMemoryStoreMemoryDeleteParams{ 1131 MemoryStoreID: store.memoryStoreID, 1132 ExpectedContentSha256: param.NewOpt(baseSHA), 1133 }, s.reqOpts...) 1134 if err != nil { 1135 switch { 1136 case isStatus(err, 404): 1137 return "", false // already gone remotely too 1138 case isStatus(err, 409), isStatus(err, 412): 1139 s.log.Warn("memory deleted locally but changed remotely; keeping the remote version", 1140 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 1141 default: 1142 s.log.Warn("failed to delete memory", 1143 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID), slog.Any("error", err)) 1144 } 1145 return baseSHA, true 1146 } 1147 s.log.Info("propagated local deletion", 1148 slog.String("path", rel), slog.String("memory_store_id", store.memoryStoreID)) 1149 return "", false 1150 }