Class: Protege::Persona

Inherits:
ApplicationRecord show all
Includes:
BroadcastablePersona, ToolScoped
Defined in:
app/models/protege/persona.rb

Overview

Active Record base class for every Protege persona — the agent identity behind an email address.

Personas are single-table-inheritance (STI) records: each concrete agent is a subclass defined in the host application and persisted in protege_personas, discriminated by the type column. A persona owns the conversations addressed to it (+EmailThread+, Message) and declares the resolver chain that the Orchestrator runs to build the inference context for each turn.

The email_address is operator-chosen and must line up with the host's MX records. Inbound routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the persona whose canonical address is alice@example.com (see Persona.lookup).

Examples:

Define a persona in the host application

class Agent < Protege::Persona
  self.display_name = 'AI Agent'

  resolvers do |chain|
    chain.use Protege::ThreadHistoryResolver
  end
end

Constant Summary

Constants included from BroadcastablePersona

BroadcastablePersona::STREAM

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ToolScoped

#available_tool_ids, #available_tools

Class Method Details

.display_nameString?

Return the human-readable label for this persona subclass.

Falls back to the demodulized class name when no explicit label has been set, so an unconfigured Foo::Agent subclass reads as "Agent".

Returns:

  • (String, nil)

    the configured label, or the demodulized class name



93
94
95
# File 'app/models/protege/persona.rb', line 93

def display_name
  _display_name || name&.demodulize
end

.display_name=(val) ⇒ String

Set the human-readable label for this persona subclass.

Parameters:

  • val (String)

    the label to display in the UI

Returns:

  • (String)

    the assigned label



101
102
103
# File 'app/models/protege/persona.rb', line 101

def display_name=(val)
  self._display_name = val
end

.lookup(address) ⇒ Protege::Persona?

Find the active persona that owns the given address, honouring subaddressing.

Parses the address through Gateway::Mail::Address and matches its routing_key (the tag-stripped, lowercased local@domain) against the canonical email_address. So +Alice+thread9@Example.com+ resolves to the persona at alice@example.com. Returns nil for blank or malformed input.

Parameters:

  • address (String, nil)

    the recipient address from an inbound email

Returns:

  • (Protege::Persona, nil)

    the matching active persona, or nil when none routes



165
166
167
168
169
# File 'app/models/protege/persona.rb', line 165

def lookup(address)
  active.find_by(email_address: Gateway.routing_key(address))
rescue ArgumentError
  nil
end

.message_resolver(klass, *args, **kwargs) ⇒ Class

Append a single resolver to the reply chain.

Parameters:

  • klass (Class)

    the resolver class to register

  • args (Array)

    positional arguments forwarded to the resolver

  • kwargs (Hash)

    keyword arguments forwarded to the resolver

Returns:

  • (Class)

    self, to allow chaining further message_resolver calls



125
126
127
128
# File 'app/models/protege/persona.rb', line 125

def message_resolver(klass, *args, **kwargs)
  message_resolvers { |chain| chain.use(klass, *args, **kwargs) }
  self
end

.message_resolvers {|chain| ... } ⇒ Orchestrator::ResolverChain

Return the resolver chain that assembles context for threaded email replies — the reactive path — optionally mutating it in a block.

Each subclass lazily builds and owns its own Orchestrator::ResolverChain; chains are not shared with the parent class. Passing a block yields the chain so resolvers can be appended.

Yield Parameters:

Returns:



113
114
115
116
117
# File 'app/models/protege/persona.rb', line 113

def message_resolvers(&block)
  @message_resolvers ||= Orchestrator::ResolverChain.new
  block&.call(@message_resolvers)
  @message_resolvers
end

.responsibility_resolver(klass, *args, **kwargs) ⇒ Class

Append a single resolver to the responsibility chain.

Parameters:

  • klass (Class)

    the resolver class to register

  • args (Array)

    positional arguments forwarded to the resolver

  • kwargs (Hash)

    keyword arguments forwarded to the resolver

Returns:

  • (Class)

    self, to allow chaining further responsibility_resolver calls



151
152
153
154
# File 'app/models/protege/persona.rb', line 151

def responsibility_resolver(klass, *args, **kwargs)
  responsibility_resolvers { |chain| chain.use(klass, *args, **kwargs) }
  self
end

.responsibility_resolvers {|chain| ... } ⇒ Orchestrator::ResolverChain

Return the resolver chain that assembles context for scheduled responsibility runs — the proactive path (the Loop layer) — optionally mutating it in a block.

Held separately from the reply chain so a persona can present different context when acting on its own initiative (e.g. a task prompt, no inbound thread) than when answering mail. Like the reply chain it is lazily built and owned per subclass, never shared with the parent.

Yield Parameters:

Returns:



139
140
141
142
143
# File 'app/models/protege/persona.rb', line 139

def responsibility_resolvers(&block)
  @responsibility_resolvers ||= Orchestrator::ResolverChain.new
  block&.call(@responsibility_resolvers)
  @responsibility_resolvers
end

Instance Method Details

#active?Boolean

Whether the persona is active (not archived).

Returns:

  • (Boolean)


188
189
190
# File 'app/models/protege/persona.rb', line 188

def active?
  archived_at.nil?
end

#archive!void

This method returns an undefined value.

Archive the persona — stops inbound routing and scheduled responsibility runs; data is preserved.



202
203
204
# File 'app/models/protege/persona.rb', line 202

def archive!
  update!(archived_at: Time.current)
end

#archived?Boolean

Whether the persona has been archived (soft-removed): out of routing + scheduled runs, data kept.

Returns:

  • (Boolean)


195
196
197
# File 'app/models/protege/persona.rb', line 195

def archived?
  archived_at.present?
end

#find_attachment(id:) ⇒ ActiveStorage::Attachment?

Find one of this persona's own message attachments by its Active Storage attachment id.

Scopes the lookup to attachments whose record is one of this persona's messages. The file tools now resolve by blob id (see Protege::StoredFile.find), so this persona-scoped lookup is currently unused by them — it is retained to become the scoping filter for the deferred authorization pass (see the blob-currency spec §9). Returns nil when the id is unknown or belongs to a different persona.

Parameters:

  • id (Integer, String)

    the ActiveStorage::Attachment id

Returns:

  • (ActiveStorage::Attachment, nil)

    the scoped attachment, or nil



223
224
225
226
227
# File 'app/models/protege/persona.rb', line 223

def find_attachment(id:)
  ActiveStorage::Attachment
    .where(name: 'attachments', record_type: 'Protege::Message', record_id: messages.select(:id))
    .find_by(id:)
end

#message_resolversOrchestrator::ResolverChain

Return this persona's resolver chains by delegating to the class-level chains — the reply chain (+message_resolvers+) and the scheduled-run chain (+responsibility_resolvers+).

Returns:



178
# File 'app/models/protege/persona.rb', line 178

delegate :message_resolvers, :responsibility_resolvers, to: :class

#unarchive!void

This method returns an undefined value.

Return an archived persona to active service.



209
210
211
# File 'app/models/protege/persona.rb', line 209

def unarchive!
  update!(archived_at: nil)
end