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  }