Skip to main content

Memory Provider interface

Provider (internal/memory/provider.go) is the formal contract for pluggable memory backends. Today Crewship ships one implementation — LocalDispatcher, which wraps the on-disk dispatcher behind the interface. The interface exists so a future HTTP-backed external store can slot into the same wire surface the orchestrator and sidecar already speak, without touching agent call sites. This page is for developers building a new provider. Operators looking for how the on-disk default behaves should read Agent memory instead.

Why a formal interface

Before the interface, every external memory backend would have to fork internal/memory/tools.go and re-wire the four tool handlers (memory.read / memory.write / memory.search / memory.append_daily) against its own SDK. The dispatcher then becomes the integration boundary instead of a clean contract. Provider flips that:
  • The dispatcher remains the on-disk path (call sites unchanged)
  • New backends implement four methods (Retain / Recall / Forget / Health) against the interface
  • The orchestrator picks a Provider at boot from config — no per-call branching needed
  • Tests can stub the interface without spinning up a real backend
The interface is additive only in PR-F: LocalDispatcher proves the shape fits the existing dispatcher logic, but production call sites still route through *Dispatcher directly. The swap to provider-routed call sites lands as PR-F17 once the second implementation (PR-F18) is ready to switch between.

The four methods

Every method MUST be safe to call from concurrent goroutines.

Retain — persist content

Design notes:
  • Flat struct, no nested option bags. New fields require deciding which bag they live in; flat ages better.
  • Mode is required, no default. A “default mode” hides intent at the wire — when the operator inspects logs they should see exactly what was requested. Local impl returns an error for an empty mode; remote impls should too.
  • Tier is the closed enum from the dispatcher. Adding a tier requires extending validTiers in tools.go AND every provider that switches on tier — a tier nobody implements is worse than a tier nobody declares.
  • Key is tier-specific. Required for daily (date) and peers (user slug); ignored elsewhere. Providers validate at the boundary; the local impl errors on missing key for these tiers.
  • Content is plain UTF-8. Binary payloads use the existing blob-store path, not the memory dispatcher.
The returned ID is the canonical handle for Forget(byID). For LocalDispatcher the ID is the tier-relative source label (e.g. "AGENT.md", "daily/2026-05-21.md") — stable across reads on the same disk, and the same shape the model sees in memory.read metadata. Remote providers should pick whatever stable id their backend uses (a row id, a content-hash, a path).

Recall — search snippets

Design notes:
  • Tier empty means “all accessible tiers” — providers iterate the tiers the caller’s AgentID can read.
  • Limit is advisory. Providers SHOULD return fewer than Limit when the corpus is small; they SHOULD cap at 20 to match the dispatcher’s existing JSON Schema (the model has been trained on the cap).
  • Score is 0..1 and provider-specific. Local impl returns 1.0 for substring hits (no ranking signal). Vector providers return cosine similarity. BM25 providers return rescaled BM25. Don’t compare scores across providers.
  • Snippet is the matched excerpt the model sees in the tool result. Keep it short (≤500 chars) — the model has the Source if it wants to fetch the full content via memory.read.
  • Source is the tier-relative label, never an absolute filesystem path (leaking bind-mount layout to the model is a small but real info-disclosure surface — see tools.go::pathToSourceLabel for the canonicalisation contract).
  • Quarantined is for files the inbound prompt-injection scanner rejected (Layer 5 — memory prompt-injection scanner). Surface these in the operator UI but NEVER feed them back to the model — the model would re-read poisoned content into its context.

Forget — delete

Design notes:
  • Two selectors, mutually exclusive. ID is for the operator-driven “delete this specific row” path. DataSubjectID is the GDPR Article 17 cascade path (PR-F1). Mixing them is rejected — the caller knows which they want, and round-9 hardening rejects the both-set case explicitly with a “must set exactly one” error.
  • The on-disk LocalDispatcher rejects DataSubjectID with an explicit “cascade not implemented in local provider; use the PR-F1 API endpoint” error because the on-disk tier doesn’t carry data_subject_id foreign keys. GDPR cascade goes through the SQL handler (internal/api/admin_gdpr.go) which queries the cascade-aware tables directly. This is documented behaviour — local-disk providers should return the same error.
  • Removed reports items actually removed. Already-absent items count as 0 (per-id) or contribute to the total based on what the provider actually touched. Re-issued DELETE for an already-deleted item returns Removed: 0, error: nil (no-op semantics, not a failure).

Health — liveness check

Design notes:
  • Health MUST return promptly — under 200ms for local, under 1000ms for remote. A stuck Health() blocks the operator’s aux-status panel render. Implementations SHOULD use a hard deadline (context.WithTimeout(ctx, 1*time.Second) internally) and return OK=false, Message="health check timeout" rather than block.
  • Message is for failure context only. On OK=true, leave it empty. On OK=false, give enough text for the operator to triage (“connection refused”, “auth expired”, “503 from upstream”).
  • CheckedAt lets the panel show “last successful check N seconds ago” without piggy-backing on the framework’s clock — each provider stamps its own observation time.
  • A provider identifier (e.g. "local-dispatcher", "http://memory.internal:9090") lives in the workspace’s provider config, NOT on the HealthStatus — the panel knows which provider it’s asking about.

Reference implementation — LocalDispatcher

internal/memory/provider.go ships LocalDispatcher, the on-disk wrapper:
The dispatcher does all the work — LocalDispatcher just translates Provider requests into ToolCall invocations and back into typed results. Look at TestLocalDispatcher_Retain_PersistsToDisk etc. for shape examples. The wrapper exists for two reasons:
  1. It proves the Provider interface fits the existing dispatcher logic without forcing a rewrite — if the on-disk impl didn’t fit, we’d need to widen the interface
  2. It gives tests a stub seam — code that depends on Provider can use LocalDispatcher in unit tests without spinning up a remote backend, and remote-provider tests can use the same dispatcher fixtures the local one uses

Building a new provider

Step-by-step for a new HTTP-backed external store:

1. Define the type

2. Implement the four methods

Each method translates between the Provider wire shape and the upstream API. Be defensive about upstream:
  • Wrap upstream errors so callers can errors.Is(err, ErrUpstreamTimeout) — don’t leak raw HTTP error strings
  • Implement timeouts via the passed ctx; don’t trust upstream to time out cooperatively
  • Return OK=false from Health rather than panicking on a network blip — the operator can see the panel and react

3. Wire into the bootstrap

The orchestrator picks a Provider at boot based on config. For a new provider, add:
  • crewship.yaml config block (e.g. memory.provider: foo + memory.foo.url: etc.)
  • A factory in the bootstrap that constructs the provider from config
  • A test that exercises Retain → Recall → Forget round-trip against a mock upstream

4. Run the parity test suite

Crewship will ship a provider-parity test suite (PR-F17 deliverable) — every provider runs the same fixture suite and must produce equivalent results for the same input. Until then, mirror the tests in internal/memory/provider_test.go and verify your provider passes them all (substituting your upstream URL for the on-disk path).

5. Document the operator-facing behaviour

A new provider needs:
  • A line in Agent memory under “Backends” naming the new option
  • A config block reference in Configuration → Providers
  • A threat-model note if the provider changes any tenant-isolation or data-locality property

Versioning

The interface is append-only. New methods, new fields on existing request/result structs, new error types — all OK provided existing implementations keep compiling and behaving the same for inputs that don’t carry the new field. Breaking changes (renaming methods, removing fields, changing the meaning of an existing parameter) require a major version bump on the interface AND a parallel old-shape interface for backwards compatibility during the transition. The bar for breaking is high — every external provider has to be updated in lockstep. The opposite trap is “let’s just add a *Options bag and stuff new flags in there.” Don’t. The interface owns the contract, not the option struct — a new flag in *Options is the same breaking surface as a new method, except harder to spot in code review.

When NOT to add a provider

A new provider is the right answer when:
  • The data needs to live in a backend that has its own SLA, security perimeter, or query model (vector recall, conversation history, GDPR-tagged enterprise store)
  • The operator already runs that backend for other reasons and wants Crewship’s agents to share it
A new provider is the WRONG answer when:
  • You want to add a new memory tier alongside AGENT / CREW / PERSONA / etc. — that’s a validTiers extension in the dispatcher, not a provider
  • You want to add a new memory tool alongside memory.read / memory.write / etc. — that’s a ToolSchemas addition in the dispatcher
  • You want the local backend to behave slightly differently — that’s a fork of LocalDispatcher not a new provider

Cross-references

  • internal/memory/provider.go — the interface + reference impl
  • internal/memory/provider_test.go — the test suite shape
  • Agent memory — operator-facing memory model
  • Configuration → Providers — where providers get wired
  • GDPR cascade — how Forget interacts with Article 17