LemonPlatformTest.ProviderCase (lemon_platform_test v0.1.0)

View Source

Compliance suite for LemonMemory.Provider implementations.

What a provider is

A memory provider is a searchable place to keep what an agent has already done. LemonMemory ships one — LemonMemory.Providers.Local, backed by SQLite FTS — and fans out to any others you register: a vector database, a wiki, an issue tracker, a company knowledge base.

Two callbacks. put/2 receives a LemonMemory.Document when a run finishes; search/2 receives a query and returns documents. LemonMemory.Providers is what calls both, and it is deliberately paranoid: it runs providers in tasks, applies a per-provider timeout, rescues exceptions, discards results that are not LemonMemory.Document structs, and logs rather than propagates. A broken provider degrades memory search; it does not break the agent.

That isolation is a safety net, not a licence. It only works if your provider returns in bounded time and returns the right shape.

The contract

search/2 returns a list of documents, always

Not {:ok, docs}, not nil, not a stream — a plain list of %LemonMemory.Document{}. An empty list is the correct answer for "no matches", "backend unreachable", "query made no sense". The registry treats anything else as a provider failure and drops the whole result set.

search/2 must not raise on user input

Queries come from agents and from people. They contain quotes, wildcards, boolean operators, emoji, and the occasional ten-thousand-character paste. Full-text engines are notoriously eager to reject those with a syntax error — sanitise the query rather than passing it through. (The built-in local provider strips FTS5 metacharacters and AND-joins the remaining terms.)

search/2 receives scoped options

The same options LemonMemory.SessionSearch uses:

  • :scope:session, :agent, :workspace or :all
  • :scope_key — the session key, agent id or workspace key to scope to; may be absent, in which case a scoped search must return [] rather than silently widening to everything
  • :limit — how many documents the caller wants
  • :provider_id — the id you were registered under, injected by the registry

Unknown options must be ignored, not rejected: the platform adds keys over time and old providers must keep working.

put/2 returns :ok or {:error, reason}

It is called on the run-finalisation path with a fully populated document. Anything other than :ok/{:error, _} is logged as a failure by the registry. put/2 should be cheap; if your store is slow, buffer internally.

Minimal implementation

defmodule MyApp.MemoryProvider do
  @behaviour LemonMemory.Provider

  alias LemonMemory.Document

  @impl true
  def put(%Document{} = doc, _opts) do
    MyApp.Index.upsert(doc.doc_id, doc.prompt_summary <> " " <> doc.answer_summary)
  end

  @impl true
  def search(query, opts) do
    limit = Keyword.get(opts, :limit, 5)

    query
    |> MyApp.Index.query(limit: limit)
    |> Enum.map(&to_document/1)
  rescue
    _ -> []
  end
end

Register it when your application starts:

LemonMemory.Providers.register_provider(%{
  id: "my-provider",
  module: MyApp.MemoryProvider,
  label: "My knowledge base",
  scopes: [:agent, :workspace, :all],
  timeout_ms: 1_500
})

Running the suite

defmodule MyApp.MemoryProviderComplianceTest do
  use LemonPlatformTest.ProviderCase, async: false, provider: MyApp.MemoryProvider
end

Options

  • :provider — required, the provider module under test.
  • :registry — round-trip the provider through LemonMemory.Providers: register, confirm it appears in status/1, search through the fan-out, unregister. Default true; requires :lemon_memory to be started, and async: false because the registry is node-global.
  • :document{Module, :function} returning a %LemonMemory.Document{} to hand to put/2, called with the test context. Defaults to a synthetic document; override when your provider needs particular fields.
  • :queries — extra query strings to probe search/2 with, appended to the built-in hostile set.

Known gaps in the behaviour

LemonMemory.Provider is the thinnest behaviour in the platform, and the one whose @callbacks say the least:

  • search/2's failure mode is undocumented. The typespec is [Document.t()] with no error branch, so "backend down" and "no results" are the same answer. Providers cannot report degradation and the platform cannot distinguish an empty index from an unreachable one.
  • search_opts() is keyword(). Which keys are passed, and which a provider is obliged to honour, live in the moduledoc rather than the types. In particular nothing forces a provider to honour :limit — the registry re-trims after merging — so this suite checks that :limit is accepted, not that it is obeyed.
  • put/2 has no delete or update counterpart. Retention is enforced by LemonMemory.Store for the local provider only; a registered external provider is never told that a document was pruned.

Summary

Functions

Starts :lemon_memory if it is not already running.

The query strings every provider is probed with.

Builds a synthetic LemonMemory.Document for put/2 probes.

Functions

ensure_memory_started!()

@spec ensure_memory_started!() :: :ok

Starts :lemon_memory if it is not already running.

hostile_queries()

@spec hostile_queries() :: [String.t()]

The query strings every provider is probed with.

Deliberately full of full-text-search metacharacters: these are the inputs that turn an unsanitised query into a syntax error from the search engine.

sample_document(overrides \\ [])

@spec sample_document(keyword()) :: LemonMemory.Document.t()

Builds a synthetic LemonMemory.Document for put/2 probes.