Context & memory
Sundae can look things up, but she starts every reply blind — she doesn't know who's writing or what they've asked before. In this chapter we fix that with resolvers: the pieces that assemble the context the model sees before inference.
What a resolver does
A resolver is a subclass of Protege::Resolver that contributes messages to the model's context — a system prompt, a database record, the conversation so far. An agent's resolvers run in order and their contributions are concatenated into the prompt. Where tools are the agent's hands, resolvers are its briefing.
The CustomerServicePersona class already has a chain, from the generator:
# app/personas/customer_service_persona.rb
message_resolvers do |chain|
chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
chain.use Protege::ThreadHistoryResolver
endThat's two built-ins already earning their keep: LoadTextResolver puts Sundae's editable instructions in as the system prompt, and ThreadHistoryResolver gives her memory (more on that below). Now let's add who she's talking to.
Load the customer
Scaffold a resolver:
bin/rails g protege:resolver customer_contextFill it in — it looks up the Customer who emailed and hands the model a short brief:
# app/resolvers/customer_context_resolver.rb
class CustomerContextResolver < Protege::Resolver
def resolve(context:)
customer = Customer.find_by(email: context.message.from_address)
return nil unless customer # unknown sender → contribute nothing
message(role: :system, content: <<~TXT)
You're speaking with #{customer.name} (#{customer.email}).
Recent orders: #{customer.orders.recent.map(&:number).join(', ').presence || 'none'}.
TXT
end
endresolve(context:)returns a message, an array of messages, ornil. Returningnilis normal — a first-time emailer isn't a customer yet, so there's simply nothing to add.- Build messages with the
message(role:, content:)helper.role: :systemfolds this into the prompt. - The
contextis the same one tools get:context.message.from_addressis who wrote in.
Add the menu, then wire the chain
Resolvers do nothing until you add them to a persona's chain — order matters, and different roles want different context. Let's also drop in today's menu using the built-in LoadRecordResolver, which renders records to text:
# app/personas/customer_service_persona.rb
message_resolvers do |chain|
chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
chain.use CustomerContextResolver
chain.use(Protege::LoadRecordResolver) { Flavor.available } # today's menu
chain.use Protege::ThreadHistoryResolver
endThe chain runs top to bottom: Sundae's persona prompt, then who she's talking to, then what's in stock, then the conversation history — and finally the harness appends the customer's new message as the closing turn. Now when a customer asks "is pistachio back yet?", Sundae answers from the actual menu, by name.
Memory comes from the thread
The last resolver, ThreadHistoryResolver, is what makes Sundae feel like she remembers. On each turn it replays the thread's earlier messages — inbound as the customer, outbound as Sundae — and, crucially, her earlier tool use too: if she looked up an order three replies ago, that result is replayed, so she doesn't look it up again or contradict herself.
It's bounded by default (a recent window) so long threads don't blow up the prompt; pass limit: nil to replay everything, or limit: 20 for a specific window. See Resolvers for the details.
Memory is per-thread today
Sundae remembers within a conversation. She does not yet remember a customer across separate threads — persona-level cross-thread memory is planned but not built. For durable facts (like the customer's record), load them explicitly with a resolver, exactly as CustomerContextResolver does.
Next
Sundae knows her customer and the menu, and remembers the conversation. Now let's widen what she can do — refunds, stock checks, new orders — and teach her to handle photos.

