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,:workspaceor: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
endRegister 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
endOptions
:provider— required, the provider module under test.:registry— round-trip the provider throughLemonMemory.Providers: register, confirm it appears instatus/1, search through the fan-out, unregister. Defaulttrue; requires:lemon_memoryto be started, andasync: falsebecause the registry is node-global.:document—{Module, :function}returning a%LemonMemory.Document{}to hand toput/2, called with the test context. Defaults to a synthetic document; override when your provider needs particular fields.:queries— extra query strings to probesearch/2with, 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()iskeyword(). 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:limitis accepted, not that it is obeyed.put/2has no delete or update counterpart. Retention is enforced byLemonMemory.Storefor 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
@spec ensure_memory_started!() :: :ok
Starts :lemon_memory if it is not already running.
@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.
@spec sample_document(keyword()) :: LemonMemory.Document.t()
Builds a synthetic LemonMemory.Document for put/2 probes.