Retrieving knowledge
Putting knowledge in front of the agent
A retriever fetches information and places it in front of your agent before it writes a reply. The agent does not have to decide to look anything up; the knowledge is there when it starts to reason. A retriever can run on every turn, or only when its topic comes up. Use one for help articles, policies, and customer memory.
- What a retriever is and the model step it saves
- How to write one and what the context gives it
- How to run a retriever only when a rule matches
- When to use a retriever and when to use a tool
Some knowledge belongs in front of your agent every time a situation comes up. If every delivery question needs your shipping policy, you shouldn't have to rely on the model deciding to search for it. Instead, you can have Parlant fetch the relevant information before the agent writes its reply.
A retriever is the function that does that work. It's an async function that Parlant runs during a turn's preparation, in parallel with rule matching (the step where Parlant judges which policies and observations apply to this turn). Whatever it returns, Parlant injects into the prompt as retrieved context.
The purpose of a retriever is to save a model step. Without one, the model first has to decide whether to call a lookup, and only then can it look. With one, the information is there when the model starts to reason.
Note that the lookup itself still takes time. What a retriever saves is the decision. And for an agent-wide retriever, the lookup can run alongside rule matching rather than after it.
Writing a retriever
Now that we know what a retriever is for, let's write one.
A retriever receives a p.RetrieverContext and returns a p.RetrieverResult or None. Here's a knowledge-base lookup keyed on the customer's latest message:
import parlant.sdk as p
async def recall_articles(ctx: p.RetrieverContext) -> p.RetrieverResult | None:
message = ctx.interaction.last_customer_message # the conversation so far, from the context
if not message:
return None
articles = await knowledge_base.search(message.content, limit=3)
if not articles:
return None
return p.RetrieverResult(
data="Relevant help articles:\n" + "\n\n".join(a.text for a in articles),
)
await agent.attach_retriever(recall_articles) # from now on, runs on every turn
data is any JSON-serializable value, and it reaches the model as context for this one reply.
Returning None injects nothing. That's the right answer whenever the search came back empty. An empty section is just noise for the model to wade through.
How long the result lives
The result's lifespan is the current reply. Parlant doesn't write retrieved context to the session, and it doesn't carry it into later turns. This keeps your conversation history clean and your average prompt short.
If the same information is relevant on the next turn, the retriever fetches it again.
What the context gives you
The context gives your retriever what it usually needs: interaction for the conversation so far, customer and agent, session, and server, container, and logger for anything else.
It also carries the resolved variables: the facts about the customer that Parlant loads into every turn.
Guests and customers covers where those variables come from.
Note that the .current accessors work here too, so p.Customer.current.id is the natural key for a per-customer store.
Scoping a retriever to a situation
Once you've attached a retriever to the agent, it runs on every turn. Most knowledge doesn't belong on every turn: pricing tables matter when pricing comes up, and shipping policy when shipping does.
The purpose of scoping is to make the retriever run only when its knowledge is relevant. To do that, you attach it to a rule instead of the agent, and Parlant runs it only on turns where that rule matched.
A rule is a policy or an observation. A policy gives the agent an instruction for a situation. An observation only names the situation, in a plain-language condition, so that other things can react to it. Knowledge has nothing to instruct, so an observation is the natural anchor:
pricing = await agent.create_observation(
condition="the customer asks about prices, plans, or discounts",
)
async def recall_pricing(ctx: p.RetrieverContext) -> p.RetrieverResult | None:
return p.RetrieverResult(data=await pricing_store.current_table())
await pricing.attach_retriever(recall_pricing)
This is the same scoping that keeps tools out of turns where they don't belong, and it works for the same reason: an observation or policy names the situation, and Parlant judges it against the live conversation.
Attaching a retriever also tells Parlant to give that rule a careful matching verdict each turn (i.e., to properly evaluate it, not just discover it), since the retriever's run depends on it.
Note that you should prefer a rule-scoped retriever whenever the knowledge has a topic. Keep an agent-wide one for context that's always relevant, such as memory about the customer.
Naming retrievers in the Dashboard
Give an attachment a title and a short description so your team can recognize it in the Dashboard:
await pricing.attach_retriever(
recall_pricing,
id="pricing-knowledge",
title="Current pricing",
description="Supplies the current pricing table when pricing comes up.",
)
title and description are optional, keyword-only arguments on both agent.attach_retriever and a rule's attach_retriever. They describe the attachment without changing when it runs or what it returns. Keep customer data and credentials out of these descriptions.
The selected agent's Retrievers tab lists agent-wide and rule-attached retrievers, with links back to their policies or observations. Without a title, it shows the local registration name. The list describes configuration; it does not show retrieved values or prove that a callback ran. Reading it does not execute direct or deferred retrievers.
Reusing an agent-wide id replaces that agent's existing callback. Each rule attachment installs a separate hook, even when you reuse its local id. The catalog preserves that distinction; its internal attachment ids belong to the current server and are not historical execution ids.
Older servers show an upgrade notice rather than an empty catalog. A failed retriever read has its own retry state, while other configuration remains available.
Deciding after matching
There's a middle case between the two forms above. A slow lookup can start while Parlant matches rules, but you may only want its result included when a particular rule applies. In other words, you want the fetch to start early and the decision to wait.
A deferred result lets you separate the two. Return a callable instead of a result, and Parlant calls it right before generation with p.EngineContext, the engine's full view of the turn, including which rules matched:
async def recall_if_relevant(ctx: p.RetrieverContext) -> p.DeferredRetriever:
message = ctx.interaction.last_customer_message
documents = await knowledge_base.search(message.content, limit=3) if message else []
async def decide(engine_ctx: p.EngineContext) -> p.RetrieverResult | None:
matches = engine_ctx.state.ordinary_rule_matches
if any(match.rule.id == pricing.id for match in matches):
if documents:
return p.RetrieverResult(data="\n\n".join(article.text for article in documents))
return None
return decide
await agent.attach_retriever(recall_if_relevant)
The fetch runs in parallel with matching, and the decision waits for the matches.
Note that this form solves a specific problem. Reach for it when the fetch is expensive and the rule-scoped form would start it too late; otherwise, the rule-scoped form is simpler.
More than data
So far, our retrievers have returned data alone. A retriever result also carries the same extra channels as a tool result, so let's look at what else it can bring into the turn.
rules adds transient instructions for this reply. For example, "the article says the feature is deprecated; say so plainly."
A canned response is an approved example message the agent imitates when it writes. canned_responses supplies literal response texts that skip ranking and come first, and canned_response_fields fills placeholders.
metadata goes to the frontend, not the model.
Shaping tone and wording covers the canned-response side.
Retriever or tool?
To bring it all together, here's how to choose between a retriever and a tool:
| Use a retriever when | Use a tool when |
|---|---|
| The information should be present before the agent writes. | The agent must decide whether to act, and with what arguments. |
| The lookup needs no arguments beyond the conversation and the customer. | The call has side effects, or needs confirmation first. |
| Latency matters and the fetch can run in parallel with matching. | The result should be recorded in the session as a tool event. |
Note that the two combine. A retriever can put the customer's open orders in front of the agent on every turn, and a tool can cancel one when the customer asks.
For long-term memory that a retriever reads and an observation writes, see Cross-session memory.