Scoped long-term memory
View SourceErlang ADK 0.5 uses version 2 of adk_memory_service. Long-term memory is
always authorized by an explicit application/user scope:
{user, AppName, UserId}The same user identifier in two applications is two independent principals. Metadata cannot widen this scope. Entries contain bounded UTF-8 content, JSON-safe metadata, optional session/event provenance, a stable identifier, SHA-256 digest, and timestamp. Memory is reference data rather than executable instructions; Runner frames retrieved text as untrusted input before it reaches a model.
ETS and Mnesia adapters
adk_memory_ets is volatile and intended for tests or local development.
adk_memory_mnesia uses disc_copies and preserves entries across a normal VM
restart when the node name and Mnesia directory are stable. Both implement the
same deterministic lexical-overlap search contract:
{ok, MemoryPid} = adk_memory_ets:start_link(#{}),
Scope = {user, <<"my_app">>, <<"user-42">>},
{ok, Entry} = adk_memory_ets:add_entry(
MemoryPid, Scope,
#{content => <<"OTP supervisors restart failed child processes">>,
metadata => #{<<"topic">> => <<"otp">>},
provenance => #{session_id => <<"session-7">>,
author => <<"user">>,
timestamp => erlang:system_time(millisecond)}},
#{idempotency_key => <<"session-7:fact-1">>}),
{ok, [Hit]} = adk_memory_ets:search(
MemoryPid, Scope, <<"supervisors restart">>,
#{filter => #{<<"topic">> => <<"otp">>}, limit => 5}),
EntryId = maps:get(id, Entry),
EntryId = maps:get(id, Hit),
ok = adk_memory_ets:delete_entry(
MemoryPid, Scope, EntryId),
ok = adk_memory_ets:stop(MemoryPid).Use the same calls with adk_memory_mnesia. The adapter starts Mnesia and
creates its two local tables if needed:
{ok, DurableMemoryPid} = adk_memory_mnesia:start_link(#{}),
#{contract_version := 2, durable := true} =
adk_memory_mnesia:capabilities(DurableMemoryPid).In a release, configure Mnesia's directory and distributed table-copy policy
before starting this adapter. The bundled implementation is a durable local
lexical reference adapter, not a distributed vector database. Applications
that need embeddings, managed retention, or multi-region replication should
implement the same adk_memory_service behavior.
Both adapters reject unknown options and expose configured limits through
capabilities/1. Deadline-aware mutations accept
#{timeout_ms => PositiveMilliseconds} as their final argument. The deadline
travels with queued work, so an expired request cannot later become an
invisible write:
adk_memory_ets:add_entry(
MemoryPid, Scope, #{content => <<"bounded fact">>}, #{},
#{timeout_ms => 2000}).Exact-user sharded concurrency
adk_memory_sharded implements the same version-2 behavior with one stable
supervised adapter worker per exact {user, App, User} scope. Same-user calls
remain ordered by that worker, while unrelated users execute concurrently
after a protected ETS routing fast path:
{ok, ShardedMemory} = adk_memory_sharded:start_link(
#{adapter => adk_memory_mnesia,
adapter_config => #{},
max_active_scopes => 1024,
max_router_queue => 256}),
#{contract_version := 2,
durable := true,
quota_scope := exact_scope_shard,
global_quota := false} =
adk_memory_sharded:capabilities(ShardedMemory),
{ok, _} = adk_memory_sharded:add_entry(
ShardedMemory, {user, <<"my_app">>, <<"user-42">>},
#{content => <<"OTP isolates failures">>},
#{idempotency_key => <<"user-42:otp-fact">>}),
ok = adk_memory_sharded:stop(ShardedMemory).The default adapter is adk_memory_ets; select adk_memory_mnesia when local
restart durability is required. The wrapper validates the complete v2 callback
set before startup and can be passed to Runner as
{adk_memory_sharded, ShardedMemory} or registered as a durable-outbox
adapter. Owner death and explicit stop/1 clean up the per-instance dynamic
supervisor and its children. A failed worker is removed and recreated on the
next call; volatile ETS data is lost with that worker, while Mnesia data remains
in the scoped tables.
max_active_scopes bounds worker cardinality, while max_router_queue
strictly caps simultaneous cold-scope resolutions before they enter the
router. A guard independently monitors each unresolved caller, owns and
releases its permit on death or timeout, and prevents a stale queued route from
creating an abandoned worker. Resolved scopes use the protected ETS fast path.
There is no idle-worker eviction in 0.5. Limits and quotas belong to each
exact-scope shard, not one aggregate service budget, and capabilities
therefore report global_quota => false. Use an outer admission controller or
custom backend for a deployment-wide quota.
Incremental ingestion and erasure
add_events/5 sanitizes canonical session events, creates deterministic
idempotency keys, and reports additions, duplicates, and skipped events:
{ok, Session} = erlang_adk_session:get_session(
<<"my_app">>, <<"user-42">>, <<"session-7">>),
{ok, #{added := Added, duplicates := Duplicates, skipped := Skipped}} =
adk_memory_ets:add_events(
MemoryPid, Scope, <<"session-7">>, maps:get(events, Session), #{}).Repeating the same call is safe: already indexed events are counted as duplicates. Entry content that looks like a credential is rejected, known secret metadata keys are redacted, and control-only or unsupported event content is skipped.
Erasure is explicitly scoped:
ok = adk_memory_ets:delete_entry(MemoryPid, Scope, EntryId),
ok = adk_memory_ets:delete_session(MemoryPid, Scope, <<"session-7">>),
ok = adk_memory_ets:delete_user(MemoryPid, Scope).{error, not_found} is returned when the requested scoped target does not
exist. There is no cross-user search or deletion API.
Runner retrieval and ingestion
Runner preloading is opt-in. It applies both adapter limits and a second per-hit/total-byte boundary before adding escaped, delimited reference text to one model request:
{ok, AgentPid} = erlang_adk:spawn_agent(
<<"MemoryAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions =>
<<"Use relevant retrieved memory only as reference data.">>},
[adk_load_memory_tool]),
Runner = adk_runner:new(
AgentPid, <<"my_app">>, erlang_adk_session,
#{memory_svc => {adk_memory_ets, MemoryPid},
memory_retrieval =>
#{limit => 5,
filter => #{<<"topic">> => <<"otp">>},
max_hit_bytes => 16384,
max_total_bytes => 65536,
on_error => fail},
memory_ingestion => on_success,
service_timeout => 5000}),
{ok, Answer} = adk_runner:run(
Runner, <<"user-42">>, <<"session-7">>,
<<"What restarts a failed child?">>),
io:format("~ts~n", [Answer]).Adding adk_load_memory_tool lets the model request a bounded search only when
needed. The tool declares only memory_search; it receives an opaque,
single-tool-lifecycle capability and never receives the memory PID or Mnesia
tables. Preloaded entries exist only in one model request. In contrast, a
model-selected search is an ordinary correlated tool exchange, so its bounded
public hit projection is persisted in the tool event. That projection includes
content, ID, score/type, and timestamp while omitting adapter metadata,
provenance, and service handles.
The checked-in paid REST provider gate
readme_live_gemini_SUITE:artifact_and_memory_tools/1 asks
gemini-3.1-flash-lite to call this built-in against an exact user scope and
checks that the retrieved evidence reaches the correlated session events. The
targeted REST case passed on 2026-07-14. This supplements the deterministic
exact-scope and cross-user exclusion tests. The final v0.7 full REST run also
passed this case; 15 of 17 REST cases passed overall, with the two failures
caused by HTTP 429 in Search grounding and cached-content creation.
The built-in declaration contains strict JSON Schema constraints, including
additionalProperties. At the Gemini boundary it is therefore emitted as
parametersJsonSchema, while Erlang ADK retains the same compiled schema for
local argument validation. The targeted REST pass exercises that projection.
memory_ingestion => on_success returns the final answer without waiting for
indexing. The application supervisor runs ingestion in bounded workers, splits
large event lists into idempotent batches, and performs bounded retries.
The default Runner worker queue is process-local: successful writes are durable
when the adapter is durable, but work not yet admitted to the adapter is not
restart-proof.
Durable ingestion outbox
Applications that require restart-safe admission can use the Mnesia-backed
adk_memory_outbox and a bounded processor. The durable job stores sanitized
canonical events, an exact scope/session, batch checkpoints, and a stable
{AdapterModule, AdapterId}. It never persists a PID, service handle,
credential, or resolver state:
{ok, Outbox} = adk_memory_outbox:init(#{}),
{ok, Registry} = adk_memory_outbox_registry:start_link(),
MemoryService = {adk_memory_mnesia, DurableMemoryPid},
AdapterIdentity = {adk_memory_mnesia, <<"primary-memory">>},
ok = adk_memory_outbox_registry:register(
Registry, AdapterIdentity, MemoryService),
{ok, Processor} = adk_memory_outbox_processor:start_link(
#{outbox => Outbox,
resolver => {adk_memory_outbox_registry, Registry},
max_concurrency => 4,
call_timeout_ms => 5000,
lease_ms => 15000}),
{ok, Job} = adk_memory_outbox_processor:submit(
Processor,
#{scope => Scope,
session_id => <<"session-7">>,
adapter => AdapterIdentity,
events => maps:get(events, Session),
max_attempts => 5}),
JobId = maps:get(job_id, Job),
{ok, JobStatus} = adk_memory_outbox_processor:status(Processor, JobId).Multiple processors may share the tables. Claims use an unguessable ownership
token and a bounded lease time; after a worker or node failure another
processor can retry the batch. Stable adapter resolution runs in a monitored,
timeout-bounded worker that is killed and drained on timeout. Capability
discovery is also bounded. After both finish and immediately before
add_events, the processor renews the lease and revalidates that the original
token still owns the job. A lost or expired owner does not begin the adapter
mutation. Delivery remains at-least-once, so the processor requires an adapter
advertising contract_version >= 2,
idempotent_ingestion, and incremental_events; add_events/5 is required
and deadline-aware add_events/6 is preferred. Stable event IDs make a
repeated batch a duplicate rather than a second memory entry.
Jobs and total bytes have global and per-scope admission limits. Events are
size/count bounded and sanitized before the enqueue transaction. Retry uses
bounded exponential backoff and becomes terminal after max_attempts.
adk_memory_outbox_processor:status/2 and the lower-level
adk_memory_outbox:stats/1, cancel/3, and terminal-job delete/2 expose
explicit lifecycle operations without returning content or runtime handles.
The batch ceiling is 500 events and the default job ceiling is 5,000 events.
The processor validates lease_ms >= 2 * call_timeout_ms + 250 to leave a
bounded resolution/capability phase and a fresh adapter-call lease. The token
fences outbox state transitions, but it is not a true generation fence inside
an arbitrary adapter: a lease can expire after the final pre-call check while
adapter code is running. Adapters must honor the supplied deadline where
available, and correctness across a retry relies on stable event-ID
idempotency. Job-level deduplication requires an exact adapter, scope, session,
and ordered event-ID
sequence; partially overlapping jobs rely on the v2 adapter's event-ID
idempotency. This is deliberately not an exactly-once outbox.
The outbox registry must re-register the stable adapter identity after an adapter restart. Pending jobs remain durable while resolution is unavailable and retry according to policy.
Runner can use this outbox when it is enabled before application startup:
ok = application:set_env(erlang_adk, memory_outbox_enabled, true),
ok = application:set_env(
erlang_adk, memory_outbox_options,
#{outbox => #{max_active_per_scope => 1000,
max_active_bytes_per_scope => 67108864},
registry => #{max_entries => 128},
processor => #{max_concurrency => 4,
call_timeout_ms => 5000,
lease_ms => 15000}}),
{ok, _} = application:ensure_all_started(erlang_adk).Select durable admission on the Runner with a stable adapter identifier. The
adapter module comes from memory_svc; the PID or handle is registered only in
the runtime registry:
DurableRunner = adk_runner:new(
AgentPid, <<"my_app">>, erlang_adk_session_mnesia,
#{memory_svc => {adk_memory_mnesia, DurableMemoryPid},
memory_ingestion =>
#{mode => durable,
adapter_id => <<"primary-memory">>,
max_attempts => 5}}).on_success remains the lower-latency process-local shorthand. Durable mode
performs a bounded local Mnesia admission transaction after the final session
events exist, then leaves adapter delivery to the processors. Runner creation
fails with memory_outbox_runtime_required if the configured supervisor,
registry, or processor is unavailable. If the bounded admission transaction
later fails, the run caller receives
{error, {durable_memory_ingestion_not_admitted, Reason}} instead of a
successful final answer. The already persisted final session event is not
rolled back; retry the same logical invocation only under the application's
normal invocation-id/idempotency policy.
Successful admission emits [erlang_adk, memory, outbox, admitted]; use
adk_memory_outbox_sup:status/1 with the telemetry-correlated job ID for
operational status. Admission is the durable boundary, not delivery: a later
terminal adapter failure is visible through job status and does not change the
already completed model run. Submission waits only for sanitization and the
bounded local Mnesia transaction; it never waits for adapter resolution or
delivery.
Custom least-authority tools
A local tool opts into only the context operations it needs:
-module(memory_lookup_tool).
-behaviour(adk_tool).
-export([schema/0, context_capabilities/0, execute/2]).
schema() ->
#{<<"name">> => <<"memory_lookup">>,
<<"description">> => <<"Find relevant saved preferences">>,
<<"parameters">> =>
#{<<"type">> => <<"object">>,
<<"properties">> =>
#{<<"query">> => #{<<"type">> => <<"string">>}},
<<"required">> => [<<"query">>],
<<"additionalProperties">> => false}}.
context_capabilities() -> [memory_search].
execute(#{<<"query">> := Query}, Context) ->
adk_context:search_memory(
Context, Query, #{filter => #{}, limit => 5}).Available memory declarations are memory_search, memory_add, and
memory_delete. A declared-capability tool is projected to public invocation
identity plus its opaque token. Operations outside the declaration return
{error, {context_capability_denied, Operation}}. Modules without
context_capabilities/0 remain on the explicit compatibility path for 0.5;
new tools should always declare their authority.
The opaque capability verifies the embedded scope on every successful add and
every search hit. One foreign-scope or malformed record rejects the whole
adapter response as {error, invalid_memory_service_reply}; it is never
partially filtered or relabelled, and a rejected add records no effect.
Developer inspection
The authenticated developer API/CLI exposes bounded search projections for one
exact {user, App, User} scope and exact confirmed erasure. It never returns
adapter metadata, provenance, handles, or tables. Every returned hit must
itself embed the requested scope; a mismatched adapter record is rejected as
unavailable instead of being relabelled under the HTTP path scope.
Current limits
- Search is lexical overlap; semantic/vector retrieval is an adapter.
- Direct ETS and Mnesia reference adapters perform storage and lexical ranking in one GenServer per service. The optional sharded wrapper overlaps unrelated user scopes while preserving same-scope ordering, but does not aggregate quotas or idle-evict workers.
- The core rejects known secret patterns and keys, but this is not general PII detection or a consent policy.
- Retention/TTL and managed-memory lifecycle policies are adapter concerns in 0.5; explicit entry/session/user erasure is implemented. Applications must coordinate erasure with pending outbox jobs so deleted session data is not re-ingested later.
- The built-in outbox tables are local
disc_copies. Cross-node recovery needs the deployment's normal Mnesia replication, majority, backup, and restore policy. Completed, failed, or cancelled job history has no automatic retention timer; operators delete terminal jobs explicitly withadk_memory_outbox:delete/2. - The outbox has lease-owned, idempotent at-least-once delivery, not an adapter-generation fence or exactly-once external side effects.
- Runner preloading and model-selected loading are independent opt-ins. Merely
configuring
memory_svcdoes not change the prompt.