How do I make an agent answer from my documents?¶
Memory is what the agent can reach for. It is how an answer comes from your handbook, your tickets, or your codebase instead of from whatever the model absorbed in training.
When you'd want this¶
Ask a general-purpose model a question about your refund policy and it will answer. The answer will be well-written, specific, and about somebody else's refund policy — and there is nothing in the reply that says so.
Grounding fixes that by putting the relevant passages in front of the model on the way in, with attribution, so the answer can cite what it came from and you can tell when it came from nothing.
The seam is one Protocol, MemorySource, with a deliberately narrow
shape: query(query, k, ctx) and write(items, ctx). Every backend —
vector store, files, an ordinary search tool, a fan-out over all three —
answers with the same MemoryItem, so the cognition consuming results
never learns where they came from.
Working code¶
"""Runs offline. FakeLLM stands in for the model; the retrieval is real."""
import asyncio
from agentkit.agents import Agent
from agentkit.state.memory import MemoryItem, VectorMemory
from agentkit.core.contracts.types import Scope
from agentkit.state.vectors import InMemoryVector
from agentkit.core.testing import FakeLLM, make_test_ctx
HANDBOOK = [
MemoryItem(
content="Refunds are issued to the original payment method within 5 business days.",
source="handbook",
metadata={"id": "refunds", "team": "support"},
),
MemoryItem(
content="Orders over $500 need a manager's approval before they ship.",
source="handbook",
metadata={"id": "approvals", "team": "ops"},
),
]
async def main() -> None:
memory = VectorMemory(vector=InMemoryVector(), name="handbook")
ctx = make_test_ctx(
llm=FakeLLM("Refunds go back to the original payment method in 5 business days."),
scope=Scope(org_id="acme"),
)
await memory.write(HANDBOOK, ctx=ctx)
# `memory=` is the whole wiring. The Agent builds a grounder from it.
agent = Agent(
name="support",
model="claude-sonnet-4-6",
prompt="Answer only from the grounding block. If it isn't there, say you don't know.",
memory=memory,
)
print((await agent.run("How long do refunds take?", ctx)).output)
# What the grounder retrieved and injected, with its scores:
for item in await memory.query("How long do refunds take?", k=2, ctx=ctx):
print(f" {item.score:.2f} [{item.source}] {item.content}")
# Isolation is the port's job, and it is real — a different tenant
# searching the same store sees nothing.
other = make_test_ctx(llm=FakeLLM("x"), scope=Scope(org_id="globex"))
print("other tenant sees:", await memory.query("refunds", k=2, ctx=other))
asyncio.run(main())
Output:
Refunds go back to the original payment method in 5 business days.
0.13 [handbook] Refunds are issued to the original payment method within 5 business days.
other tenant sees: []
What the model actually sees¶
Setting memory= on an Agent wires as_grounder(memory) into the
RequestBuilder. On the first turn the grounder runs, its results are
rendered as [source] content lines, and the block lands in the
prefix — the cache-stable head of the working context, alongside the
system prompt:
Answer only from the grounding block. If it isn't there, say you don't know.
Relevant context:
[handbook] Refunds are issued to the original payment method within 5 business days.
Two consequences worth holding on to.
The prefix is not rewritten mid-run. That is the KV-cache
discipline: mutating a token in the prefix invalidates the provider's
cache from that point onwards. Grounding is fetched once by default, and
compaction only ever touches the tail. The cost of that default shows up
in a multi-turn conversation — turn 5 gets turn 1's evidence — and
reground_every_turn
is the knob that trades it back for a 10x prefix bill.
The query is the task, verbatim. as_grounder passes the user's
string straight to memory.query(...). If you want query rewriting —
HyDE, decomposition, a cheap model rewriting the question first — wrap
the MemorySource rather than reaching inside the grounder.
Taking control of retrieval¶
The auto-wiring is one k and one rendering. Build the
RequestBuilder yourself when you need more:
from agentkit.agents import Agent
from agentkit.agents.prompts import Prompt
from agentkit.state.memory import VectorMemory
from agentkit.state.vectors import InMemoryVector
from agentkit.agents.requests import RequestBuilder
from agentkit.state.memory import as_grounder
memory = VectorMemory(vector=InMemoryVector(), name="handbook")
agent = Agent(
name="support",
model="claude-sonnet-4-6",
request_builder=RequestBuilder(
prompt=Prompt(id="support", version="3", template="Answer from the sources."),
grounder=as_grounder(
memory,
k=8,
where={"team": "support"}, # metadata filter
format=lambda items: "\n".join( # your own rendering
f"{i}. {it.content} ({it.source})" for i, it in enumerate(items, 1)
),
),
# Re-retrieve on every `build()` — i.e. every user turn of a
# conversation that reuses one WorkingContext, NOT every step of a
# tool loop. Costs a prefix cache miss per turn; worth it when each
# question needs different evidence.
reground_every_turn=True,
),
)
request_builder= overrides prompt= and memory= entirely — it is
the "I'll take it from here" seam.
Composing sources¶
The backends are small and they nest:
| What it is | |
|---|---|
VectorMemory |
a VectorPort — the canonical RAG case |
FileMemory |
the read side of a file tree the agent maintains |
JournalMemory |
the run's own mutation journal, made queryable |
ScratchpadMemory |
notes the agents wrote this run |
ToolMemory |
any Tool adapted into a source (a search API, say) |
CompositeMemory |
fan out across several, merge and rerank |
SequentialMemory |
try in order, stop at the first hit (cache → fallback) |
ScopedMemory |
fail-loud tenant guard around any of the above |
CachedMemory |
TTL cache in front of any of the above |
CompactedMemory |
shrink each item's content through a Compactor |
from agentkit.state.memory import CompositeMemory, VectorMemory
from agentkit.tools import InMemoryFiles
from agentkit.state.vectors import InMemoryVector
from agentkit.state.memory import CachedMemory, FileMemory, score_sort_rerank
everything = CompositeMemory(
[
CachedMemory(VectorMemory(vector=InMemoryVector(), name="docs"), ttl_seconds=60.0),
FileMemory(files=InMemoryFiles(), name="runbooks"),
],
reranker=score_sort_rerank, # a `Reranker`: (query, items, *, k) -> items
name="all",
)
print(everything.name, [s.name for s in everything.sources])
CompositeMemory queries its sources concurrently and merges the
results; the Reranker decides the final order and cut. The default
sorts by the backends' own scores, which is only meaningful when the
backends score comparably — swap in your own for a cross-encoder.
What bites people¶
ScopedMemorydemands BOTHorg_idanddomain_idby default. AScope(org_id="acme")with nodomain_idfails the default check withPermissionError, before the inner source is touched. That is the intent — a half-specified tenant is how cross-tenant leaks happen — but it surprises people who wrapped a source and had a working query stop working. Pass your ownenforce=callable for a different policy.MemoryItem.metadatais frozen.item.metadata["path"] = praises. Backends pass metadata through when they rebuild items, so a reranker stamping a score onto one item could otherwise land it on a copy another source still holds. The migration is one line:dataclasses.replace(item, metadata={**item.metadata, "path": p}).metadata["id"]is consumed at write time.VectorMemory.writepopsidout of the metadata to use as the chunk id (falling back to a fresh uuid). It will not be inmetadatawhen the item comes back. Write it deliberately if you want stable, idempotent upserts — re-writing the same id replaces rather than duplicating.InMemoryVectoris not an embedding model. It is cosine-over-term-frequency: deterministic, dependency-free, and good enough to test wiring and tenant isolation with. Scores from it are not comparable to a real embedding store's, and it finds nothing for a paraphrase that shares no words. Production isPgVectorStore(extra:arc-agentkit[postgres]) or your ownVectorPort.- An empty grounding block produces no message at all. The grounder
returning
""means "nothing for this task", andRequestBuilderomits the block rather than injecting an empty header. So a prompt that says "answer only from the grounding block" needs to also say what to do when there isn't one. - Memory is not a tool, and the difference matters. Memory is what
the cognition fetches around a call; a tool is what the model
decides to call mid-loop. Wrap a source as a tool (or a tool as a
source, with
ToolMemory) when you want the other behaviour — but pick deliberately, because "the model chose to search" and "we always search" produce very different bills and very different failures.
Related¶
- Give an agent a tool — the other way an agent reaches outside itself.
- Keep a long conversation in budget — grounding grows the prompt; this is what bounds it.
- Consume MCP tools from an agent —
mcp_resources()returns aMemorySourcelike any other.