Skip to content

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:

ruby
# 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
end
  • resolve(context:) returns an Array<ModelMessage>, a single message, or nil. Returning nil (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 a ModelMessage by hand. The role decides 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 context tools 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):

ruby
# 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
end

The 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:

ResolverContributes
Protege::LoadTextResolverThe string a block returns — the canonical home of a persona's system prompt (`{
Protege::LoadRecordResolverActiveRecord record(s) a loader block returns, rendered to text (JSON by default, or a serialize: method/proc). "No matching rows" contributes nothing.
Protege::LoadFileResolverThe 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::ThreadHistoryResolverThe 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. Pass limit: nil to replay the entire thread.
  • include_tool_calls: — set false to replay only the plain message turns, dropping the tool history.
ruby
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 thread

Next