Resolvers
Resolvers assemble the context a model sees before inference — the system prompt, the customer's record, today's menu, the conversation so far. A resolver is a subclass of Protege::Resolver, and an agent's resolvers run in order to build the message list for every turn.
Where tools are the agent's hands, resolvers are its briefing. Give Sundae the right context and a small tool set, and she answers well; give her tools but no context, and she's guessing.
Anatomy
Here is CustomerContextResolver, which loads the record of whoever emailed in:
# 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 # nil = contribute nothing
message(role: :system, content: <<~TXT)
You are speaking with #{customer.name} (#{customer.email}).
Recent orders: #{customer.orders.recent.map(&:number).join(', ')}
TXT
end
endresolve(context:)returns anArray<ModelMessage>, a single message, ornil. Returningnil(or an empty array) contributes nothing — a normal outcome when there's nothing to add, like a sender who isn't a known customer yet.- Build messages with the
message(role:, content:, ...)helper — never construct aModelMessageby hand. Theroledecides where the content lands::system— part of the system prompt (instructions, facts, retrieved data).:user/:assistant— seeded conversation turns (history).:tool— a replayed tool result (used by history replay; you rarely emit this directly).
- The same
contexttools receive is available here:context.persona,context.message(reply runs),context.responsibility(scheduled runs).
Resolvers are wired, not discovered
Unlike tools, a resolver does nothing until you add it to an agent's chain. That's deliberate: order matters, and different agents want different context. You declare the chain in the persona class (the class names the role — customer service — while the record is the individual agent, Sundae; see Personas):
# app/personas/customer_service_persona.rb
class CustomerServicePersona < Protege::Persona
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
end
endThe chain runs top to bottom and concatenates every contribution into the message list — there's no short-circuiting or wrapping, just accumulation. Fresh resolver instances are built on each run.
The two chains
A persona class declares two resolver chains, because an agent needs different context depending on what triggered the run:
message_resolvers— the reactive chain, used when answering an inbound email. It typically includes thread history, since there's a conversation to continue.responsibility_resolvers— the proactive chain, used for a scheduled run. There's no inbound thread, so it's usually just the system prompt plus the duty's own instructions.
Chains don't inherit down STI
The two resolver chains are declared per persona subclass and are not inherited by subclasses of a subclass. (The tools grant is the opposite — it does inherit.) If you build a family of personas on a shared base, each concrete persona declares its own chains.
Bundled resolvers
Most agents are built almost entirely from these four, without writing a custom resolver at all:
| Resolver | Contributes |
|---|---|
Protege::LoadTextResolver | The string a block returns — the canonical home of a persona's system prompt (`{ |
Protege::LoadRecordResolver | ActiveRecord record(s) a loader block returns, rendered to text (JSON by default, or a serialize: method/proc). "No matching rows" contributes nothing. |
Protege::LoadFileResolver | The contents of a file — a prompt, a knowledge doc, few-shot examples. Static path or a block; a missing file is a misconfiguration and raises. |
Protege::ThreadHistoryResolver | The current thread's prior messages as conversation history — the agent's memory across exchanges. |
All four take a role: (defaulting to :system) so you decide how their content enters the conversation. LoadRecordResolver and LoadTextResolver take a block that receives the context, so what they load can key off the sender or the persona — the menu example above ignores the context; the customer example keys off context.message.from_address.
Thread history in depth
ThreadHistoryResolver is what gives an agent conversational memory. On each turn it replays the thread's earlier messages — inbound mapped to :user, outbound to :assistant — with the current inbound excluded (that arrives as the final user turn through the normal flow). It also weaves back the agent's tool use between turns: the tool calls and results recorded on each prior run are replayed as the same structured messages a live run builds, so on the next turn the agent remembers what it searched, read, or looked up earlier.
Two knobs:
limit:— how many recent messages to replay. It defaults to a bounded window (the most recent 50), which pairs well with a running summary for older context and guards against unbounded prompts on long threads. Passlimit: nilto replay the entire thread.include_tool_calls:— setfalseto replay only the plain message turns, dropping the tool history.
chain.use Protege::ThreadHistoryResolver # recent window, with tool replay
chain.use Protege::ThreadHistoryResolver, limit: 10 # last 10 messages
chain.use Protege::ThreadHistoryResolver, limit: nil # entire threadNext
- Personas — declaring the chains and the system prompt.
- Context & memory — building Sundae's chain, step by step.

