protege 0.1.0.alpha.2 → 0.1.0.alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +21 -0
  3. data/Rakefile +1 -3
  4. data/app/assets/builds/protege.css +76 -83
  5. data/app/controllers/concerns/protege/agent_scoped.rb +22 -0
  6. data/app/controllers/concerns/protege/agent_toolkit_scoped.rb +22 -0
  7. data/app/controllers/protege/access_rules_controller.rb +13 -13
  8. data/app/controllers/protege/agent_toolkit_rules_controller.rb +62 -0
  9. data/app/controllers/protege/agent_toolkits_controller.rb +44 -0
  10. data/app/controllers/protege/agents/agent_toolkits_controller.rb +65 -0
  11. data/app/controllers/protege/agents_controller.rb +118 -0
  12. data/app/controllers/protege/archives_controller.rb +18 -18
  13. data/app/controllers/protege/home_controller.rb +1 -1
  14. data/app/controllers/protege/messages_controller.rb +1 -1
  15. data/app/controllers/protege/replies_controller.rb +3 -3
  16. data/app/controllers/protege/responsibilities_controller.rb +7 -7
  17. data/app/controllers/protege/threads_controller.rb +4 -4
  18. data/app/controllers/protege/toolkits_controller.rb +84 -0
  19. data/app/helpers/protege/agents_helper.rb +75 -0
  20. data/app/helpers/protege/application_helper.rb +3 -2
  21. data/app/helpers/protege/components/badges_helper.rb +11 -2
  22. data/app/helpers/protege/components/forms_helper.rb +47 -12
  23. data/app/helpers/protege/components/typography_helper.rb +1 -1
  24. data/app/helpers/protege/messages_helper.rb +2 -2
  25. data/app/helpers/protege/responsibilities_helper.rb +2 -2
  26. data/app/helpers/protege/threads_helper.rb +11 -11
  27. data/app/helpers/protege/toolkits_helper.rb +66 -0
  28. data/app/hooks/protege/event_logger_hook.rb +8 -8
  29. data/app/jobs/protege/console_inference_job.rb +8 -8
  30. data/app/jobs/protege/inference_job.rb +11 -11
  31. data/app/jobs/protege/responsibility_job.rb +10 -10
  32. data/app/mailboxes/protege/agent_mailbox.rb +54 -29
  33. data/app/mailers/protege/alert_mailer.rb +41 -0
  34. data/app/mailers/protege/application_mailer.rb +9 -8
  35. data/app/models/concerns/protege/broadcastable_agent.rb +50 -0
  36. data/app/models/concerns/protege/broadcastable_toolkit.rb +49 -0
  37. data/app/models/concerns/protege/toolable.rb +108 -0
  38. data/app/models/protege/access_rule.rb +12 -12
  39. data/app/models/protege/{persona.rb → agent.rb} +45 -45
  40. data/app/models/protege/agent_toolkit.rb +50 -0
  41. data/app/models/protege/agent_toolkit_rule.rb +25 -0
  42. data/app/models/protege/application_record.rb +1 -1
  43. data/app/models/protege/email_thread.rb +16 -13
  44. data/app/models/protege/message.rb +36 -20
  45. data/app/models/protege/responsibility.rb +12 -12
  46. data/app/models/protege/tool_use.rb +6 -6
  47. data/app/models/protege/toolkit.rb +115 -0
  48. data/app/models/protege/trace.rb +1 -1
  49. data/app/providers/protege/open_router_provider.rb +17 -0
  50. data/app/resolvers/README.md +4 -4
  51. data/app/resolvers/protege/load_file_resolver.rb +5 -5
  52. data/app/resolvers/protege/load_text_resolver.rb +3 -3
  53. data/app/resolvers/protege/thread_history_resolver.rb +1 -1
  54. data/app/services/protege/message_search.rb +2 -2
  55. data/app/services/protege/system_toolkits.rb +67 -0
  56. data/app/tools/protege/create_file_tool.rb +2 -0
  57. data/app/tools/protege/read_attachment_tool.rb +3 -1
  58. data/app/tools/protege/search_emails_tool.rb +15 -13
  59. data/app/tools/protege/send_email_tool.rb +60 -18
  60. data/app/tools/protege/web_fetch_tool.rb +2 -0
  61. data/app/tools/protege/web_search_tool.rb +3 -1
  62. data/app/views/layouts/mailer.text.erb +1 -0
  63. data/app/views/protege/access_rules/create.turbo_stream.slim +3 -3
  64. data/app/views/protege/agent_toolkit_rules/create.turbo_stream.slim +6 -0
  65. data/app/views/protege/agent_toolkits/_gate_rule.html.slim +7 -0
  66. data/app/views/protege/agent_toolkits/_gate_rule_form.html.slim +8 -0
  67. data/app/views/protege/agent_toolkits/_gate_rules.html.slim +19 -0
  68. data/app/views/protege/agent_toolkits/show.html.slim +17 -0
  69. data/app/views/protege/{personas → agents}/_access_rule.html.slim +1 -1
  70. data/app/views/protege/{personas → agents}/_access_rule_form.html.slim +1 -1
  71. data/app/views/protege/{personas → agents}/_access_rules.html.slim +5 -5
  72. data/app/views/protege/agents/_actions.html.slim +15 -0
  73. data/app/views/protege/agents/_agent_sidebar_item.html.slim +5 -0
  74. data/app/views/protege/agents/_agent_toolkit.html.slim +7 -0
  75. data/app/views/protege/agents/_agent_toolkit_form.html.slim +7 -0
  76. data/app/views/protege/agents/_form.html.slim +25 -0
  77. data/app/views/protege/agents/_sidebar.html.slim +11 -0
  78. data/app/views/protege/agents/_toolkits.html.slim +19 -0
  79. data/app/views/protege/agents/agent_toolkits/create.turbo_stream.slim +6 -0
  80. data/app/views/protege/agents/edit.html.slim +7 -0
  81. data/app/views/protege/agents/index.html.slim +6 -0
  82. data/app/views/protege/agents/new.html.slim +7 -0
  83. data/app/views/protege/agents/show.html.slim +29 -0
  84. data/app/views/protege/alert_mailer/inference_failed.text.erb +4 -0
  85. data/app/views/protege/email_domains/show.html.slim +1 -1
  86. data/app/views/protege/home/show.html.slim +13 -13
  87. data/app/views/protege/responsibilities/_form.html.slim +2 -2
  88. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +1 -1
  89. data/app/views/protege/responsibilities/new.html.slim +1 -1
  90. data/app/views/protege/responsibilities/show.html.slim +2 -2
  91. data/app/views/protege/responsibility_runs/show.html.slim +1 -1
  92. data/app/views/protege/shared/_header.html.slim +2 -1
  93. data/app/views/protege/shared/_hotkeys.html.slim +15 -11
  94. data/app/views/protege/threads/new.html.slim +2 -2
  95. data/app/views/protege/toolkits/_form.html.slim +13 -0
  96. data/app/views/protege/toolkits/_sidebar.html.slim +11 -0
  97. data/app/views/protege/toolkits/_toolkit_sidebar_item.html.slim +5 -0
  98. data/app/views/protege/toolkits/edit.html.slim +7 -0
  99. data/app/views/protege/toolkits/index.html.slim +6 -0
  100. data/app/views/protege/toolkits/new.html.slim +7 -0
  101. data/app/views/protege/toolkits/show.html.slim +13 -0
  102. data/config/routes.rb +12 -1
  103. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +1 -1
  104. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +1 -1
  105. data/db/migrate/20260728140000_scope_message_and_thread_uniqueness_per_persona.rb +16 -0
  106. data/db/migrate/20260812000001_create_protege_toolkits.rb +37 -0
  107. data/db/migrate/20260815000001_remove_disabled_tool_ids_from_personas.rb +21 -0
  108. data/db/migrate/20260815000002_add_key_to_protege_toolkits.rb +22 -0
  109. data/db/migrate/20260815100000_rename_personas_to_agents.rb +24 -0
  110. data/lib/generators/protege/agent/agent_generator.rb +35 -0
  111. data/lib/generators/protege/{persona/templates/persona.rb.tt → agent/templates/agent.rb.tt} +9 -9
  112. data/lib/generators/protege/extension_naming.rb +1 -1
  113. data/lib/generators/protege/hook/templates/hook.rb.tt +2 -2
  114. data/lib/generators/protege/install/install_generator.rb +16 -11
  115. data/lib/generators/protege/install/templates/initializer.rb.tt +17 -6
  116. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +2 -2
  117. data/lib/generators/protege/resolver/resolver_generator.rb +2 -2
  118. data/lib/generators/protege/resolver/templates/resolver.rb.tt +4 -4
  119. data/lib/generators/protege/tool/templates/tool.rb.tt +7 -5
  120. data/lib/protege/configuration.rb +43 -8
  121. data/lib/protege/engine.rb +3 -1
  122. data/lib/protege/errors/tool_not_available_error.rb +7 -6
  123. data/lib/protege/events/event.rb +2 -2
  124. data/lib/protege/events/inference_chunk_event.rb +1 -1
  125. data/lib/protege/events/inference_completed_event.rb +1 -1
  126. data/lib/protege/events/inference_failed_event.rb +1 -1
  127. data/lib/protege/events/inference_generated_event.rb +1 -1
  128. data/lib/protege/events/inference_max_turns_reached_event.rb +1 -1
  129. data/lib/protege/events/inference_started_event.rb +1 -1
  130. data/lib/protege/events/loop_run_completed_event.rb +1 -1
  131. data/lib/protege/events/loop_run_enqueued_event.rb +1 -1
  132. data/lib/protege/events/loop_run_failed_event.rb +1 -1
  133. data/lib/protege/events/loop_run_started_event.rb +1 -1
  134. data/lib/protege/events/tool_call_completed_event.rb +1 -1
  135. data/lib/protege/events/tool_call_failed_event.rb +1 -1
  136. data/lib/protege/events/tool_call_started_event.rb +1 -1
  137. data/lib/protege/events/tool_calls_received_event.rb +1 -1
  138. data/lib/protege/extensions/hook.rb +1 -0
  139. data/lib/protege/extensions/hook_mixin.rb +1 -1
  140. data/lib/protege/extensions/manifest.rb +67 -0
  141. data/lib/protege/extensions/manifested.rb +81 -0
  142. data/lib/protege/extensions/provider.rb +1 -0
  143. data/lib/protege/extensions/provider_mixin.rb +9 -17
  144. data/lib/protege/extensions/resolver.rb +2 -1
  145. data/lib/protege/extensions/resolver_mixin.rb +2 -2
  146. data/lib/protege/extensions/tool.rb +1 -0
  147. data/lib/protege/extensions/tool_mixin.rb +16 -40
  148. data/lib/protege/gateway/access_control.rb +19 -19
  149. data/lib/protege/gateway/access_policy.rb +2 -2
  150. data/lib/protege/gateway/mail/outbound.rb +22 -5
  151. data/lib/protege/gateway.rb +48 -37
  152. data/lib/protege/loop/scheduler.rb +1 -1
  153. data/lib/protege/orchestrator/context.rb +8 -8
  154. data/lib/protege/orchestrator/harness.rb +26 -24
  155. data/lib/protege/orchestrator/reply_context.rb +3 -3
  156. data/lib/protege/orchestrator/reply_harness.rb +8 -8
  157. data/lib/protege/orchestrator/resolver_chain.rb +3 -3
  158. data/lib/protege/orchestrator/responsibility_context.rb +4 -4
  159. data/lib/protege/orchestrator/responsibility_harness.rb +8 -8
  160. data/lib/protege/orchestrator.rb +7 -7
  161. data/lib/protege/subscribers/alerter.rb +117 -0
  162. data/lib/protege/subscribers/tracing.rb +1 -1
  163. data/lib/protege/version.rb +1 -1
  164. data/lib/protege.rb +2 -2
  165. data/lib/tasks/protege_tasks.rake +12 -0
  166. metadata +66 -23
  167. data/app/controllers/concerns/protege/persona_scoped.rb +0 -22
  168. data/app/controllers/protege/personas_controller.rb +0 -118
  169. data/app/helpers/protege/personas_helper.rb +0 -90
  170. data/app/models/concerns/protege/broadcastable_persona.rb +0 -50
  171. data/app/models/concerns/protege/tool_scoped.rb +0 -88
  172. data/app/views/protege/personas/_actions.html.slim +0 -15
  173. data/app/views/protege/personas/_form.html.slim +0 -29
  174. data/app/views/protege/personas/_persona_sidebar_item.html.slim +0 -5
  175. data/app/views/protege/personas/_sidebar.html.slim +0 -11
  176. data/app/views/protege/personas/edit.html.slim +0 -7
  177. data/app/views/protege/personas/index.html.slim +0 -6
  178. data/app/views/protege/personas/new.html.slim +0 -7
  179. data/app/views/protege/personas/show.html.slim +0 -37
  180. data/lib/generators/protege/persona/persona_generator.rb +0 -35
@@ -6,8 +6,8 @@ module Protege
6
6
  # catalog (id +:search_emails+); when the model emits a +search_emails+ call, the harness routes the
7
7
  # arguments to +#use+.
8
8
  #
9
- # The search is always scoped to the active persona's own messages (via +Persona#messages+) — an
10
- # agent can never read another persona's mail. A single free-text query is matched, case-insensitively,
9
+ # The search is always scoped to the active agent's own messages (via +Agent#messages+) — an
10
+ # agent can never read another agent's mail. A single free-text query is matched, case-insensitively,
11
11
  # against both the participants (from/to/cc) and the content (subject/body); results come back newest
12
12
  # first. Each result carries the message's *full* body — a truncated body could cut the very text that
13
13
  # matched, so it is returned whole. The caller may cap the result count with +limit+ (default
@@ -41,6 +41,8 @@ module Protege
41
41
  rather than raising the limit when you get too many hits.
42
42
  DESC
43
43
 
44
+ summary 'Search the agent\'s own email archive for past messages.'
45
+
44
46
  input_schema(
45
47
  type: 'object',
46
48
  additionalProperties: false,
@@ -58,9 +60,9 @@ module Protege
58
60
  }
59
61
  )
60
62
 
61
- # Search the active persona's archive and return the matching messages as concise summaries.
63
+ # Search the active agent's archive and return the matching messages as concise summaries.
62
64
  #
63
- # @param context [Protege::Orchestrator::Context] exposes the active +persona+
65
+ # @param context [Protege::Orchestrator::Context] exposes the active +agent+
64
66
  # @param query [String] the free-text query supplied by the model
65
67
  # @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
66
68
  # @return [Protege::Result] success carrying +count+ and the shaped +results+, or a failure when
@@ -68,7 +70,7 @@ module Protege
68
70
  def use(context:, query:, limit: nil)
69
71
  return failure(reason: 'query cannot be empty') if query.to_s.strip.empty?
70
72
 
71
- hits = matches(persona: context.persona, query:, limit:)
73
+ hits = matches(agent: context.agent, query:, limit:)
72
74
 
73
75
  success(count: hits.size, results: hits.map { result_hash(_1) })
74
76
  end
@@ -77,21 +79,21 @@ module Protege
77
79
 
78
80
  # Build and run the scoped, wildcard-escaped LIKE query, newest first.
79
81
  #
80
- # @param persona [Protege::Persona] the persona whose messages are searched
82
+ # @param agent [Protege::Agent] the agent whose messages are searched
81
83
  # @param query [String] the raw query supplied by the model
82
84
  # @param limit [Integer, nil] the requested cap, clamped into 1..{MAX_LIMIT}
83
85
  # @return [Array<Protege::Message>] the matching messages, newest first
84
- def matches(persona:, query:, limit:)
86
+ def matches(agent:, query:, limit:)
85
87
  count = (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
86
88
  escaped = ActiveRecord::Base.sanitize_sql_like(query.strip.downcase)
87
89
  clause = SEARCHABLE_COLUMNS.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
88
90
 
89
- persona.messages
90
- .with_attached_attachments
91
- .where(clause, term: "%#{escaped}%")
92
- .order(sent_at: :desc)
93
- .limit(count)
94
- .to_a
91
+ agent.messages
92
+ .with_attached_attachments
93
+ .where(clause, term: "%#{escaped}%")
94
+ .order(sent_at: :desc)
95
+ .limit(count)
96
+ .to_a
95
97
  end
96
98
 
97
99
  # Shape a single message into a result summary. The body is returned in full (never truncated) so
@@ -5,7 +5,7 @@ module Protege
5
5
  # +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:send_email+);
6
6
  # when the model emits a +send_email+ call, the harness routes the arguments to +#use+.
7
7
  #
8
- # +From+ is always enforced to the persona's +email_address+ — the agent cannot impersonate another
8
+ # +From+ is always enforced to the agent's +email_address+ — the agent cannot impersonate another
9
9
  # sender. The +mode+ argument selects behavior:
10
10
  #
11
11
  # * +"reply"+ — continue the current conversation. +Subject+ and threading (+In-Reply-To+ +
@@ -19,7 +19,7 @@ module Protege
19
19
  # Assembly is delegated to +Protege::Gateway.build_outbound+. The agent may attach files by
20
20
  # referencing blob ids — the ids shown in the conversation history, or returned by +create_file+ —
21
21
  # resolved through +Protege::StoredFile.find+ and checked against the configured
22
- # +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; persona scoping is a
22
+ # +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; agent scoping is a
23
23
  # deferred pass — see the blob-currency spec §9.)
24
24
  class SendEmailTool < Protege::Tool
25
25
  description <<~DESC.strip
@@ -38,6 +38,8 @@ module Protege
38
38
  create_file).
39
39
  DESC
40
40
 
41
+ summary 'Send an email — the agent\'s only way to reply to or reach someone.'
42
+
41
43
  input_schema(
42
44
  type: 'object',
43
45
  additionalProperties: false,
@@ -78,7 +80,7 @@ module Protege
78
80
 
79
81
  # Build the outbound mail for the requested mode and hand it to the gateway for delivery.
80
82
  #
81
- # @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +persona+, and +deliver+
83
+ # @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +agent+, and +deliver+
82
84
  # @param mode [String] +"reply"+ or +"new"+
83
85
  # @param body [String] plain-text body supplied by the model
84
86
  # @param to [Array<String>] recipients (required for +"new"+; additions for +"reply"+)
@@ -109,7 +111,7 @@ module Protege
109
111
 
110
112
  # Build the outbound mail for the requested mode.
111
113
  #
112
- # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
114
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
113
115
  # @param mode [String] +"reply"+ or +"new"+
114
116
  # @param to [Array<String>] recipients
115
117
  # @param cc [Array<String>] carbon-copy recipients
@@ -121,17 +123,20 @@ module Protege
121
123
  def build_mail(context:, mode:, to:, cc:, bcc:, subject:, body:, attachments:)
122
124
  case mode
123
125
  when 'reply' then reply_mail(context, to:, cc:, bcc:, body:, attachments:)
124
- when 'new' then new_thread_mail(context.persona, to:, cc:, bcc:, subject:, body:, attachments:)
126
+ when 'new' then new_thread_mail(context.agent, to:, cc:, bcc:, subject:, body:, attachments:)
125
127
  else failure(reason: %(unknown mode #{mode.inspect}; use "reply" or "new"))
126
128
  end
127
129
  end
128
130
 
129
- # Construct a threaded reply: subject and threading from the inbound message, the original sender
130
- # plus any added recipients.
131
+ # Construct a threaded reply: subject and threading from the inbound message, and a reply-all
132
+ # recipient set that keeps the whole conversation intact. The original sender and other To recipients
133
+ # go in To; the original Cc recipients stay in Cc; the model may add more via to/cc/bcc. The agent's
134
+ # own address is dropped (it is the sender, not a recipient), and every list is de-duplicated by
135
+ # routing key so no one is Cc'd who is already in To.
131
136
  #
132
- # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
133
- # @param to [Array<String>] recipients to add beyond the original sender
134
- # @param cc [Array<String>] carbon-copy recipients
137
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
138
+ # @param to [Array<String>] recipients to add beyond the carried-forward participants
139
+ # @param cc [Array<String>] carbon-copy recipients to add
135
140
  # @param bcc [Array<String>] blind-carbon-copy recipients
136
141
  # @param body [String] the plain-text body
137
142
  # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
@@ -140,22 +145,27 @@ module Protege
140
145
  inbound = context.message
141
146
  return failure(reason: 'there is no message to reply to; send a "new" email instead') unless inbound
142
147
 
148
+ own = context.agent.email_address
149
+ reply_to = merge_recipients([inbound.from_address, *inbound.to_addresses, *addresses(to)], excluding: [own])
150
+ reply_cc = merge_recipients([*inbound.cc_addresses, *addresses(cc)], excluding: [own, *reply_to])
151
+
143
152
  Protege::Gateway.build_outbound(
144
- from: context.persona.email_address,
145
- to: ([inbound.from_address] + addresses(to)).uniq,
146
- cc: addresses(cc),
153
+ from: own,
154
+ to: reply_to,
155
+ cc: reply_cc,
147
156
  bcc: addresses(bcc),
148
157
  subject: ensure_re_prefix(inbound.subject),
149
158
  body:,
150
159
  in_reply_to: inbound.message_id,
151
160
  references: build_references(inbound),
152
- attachments:
161
+ attachments:,
162
+ hops: inbound.recursion_hops + 1
153
163
  )
154
164
  end
155
165
 
156
166
  # Construct a new-thread email: the model supplies the recipients and subject; no threading headers.
157
167
  #
158
- # @param persona [Protege::Persona] the sending persona (enforced From)
168
+ # @param agent [Protege::Agent] the sending agent (enforced From)
159
169
  # @param to [Array<String>] recipients (at least one required)
160
170
  # @param cc [Array<String>] carbon-copy recipients
161
171
  # @param bcc [Array<String>] blind-carbon-copy recipients
@@ -163,19 +173,20 @@ module Protege
163
173
  # @param body [String] the plain-text body
164
174
  # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
165
175
  # @return [Mail::Message, Protege::Result] the assembled mail, or a failure when +to+/+subject+ is missing
166
- def new_thread_mail(persona, to:, cc:, bcc:, subject:, body:, attachments:)
176
+ def new_thread_mail(agent, to:, cc:, bcc:, subject:, body:, attachments:)
167
177
  recipients = addresses(to)
168
178
  return failure(reason: 'a new email needs at least one "to" recipient') if recipients.empty?
169
179
  return failure(reason: 'a new email needs a subject') if subject.to_s.strip.empty?
170
180
 
171
181
  Protege::Gateway.build_outbound(
172
- from: persona.email_address,
182
+ from: agent.email_address,
173
183
  to: recipients,
174
184
  cc: addresses(cc),
175
185
  bcc: addresses(bcc),
176
186
  subject:,
177
187
  body:,
178
- attachments:
188
+ attachments:,
189
+ hops: 1
179
190
  )
180
191
  end
181
192
 
@@ -187,6 +198,37 @@ module Protege
187
198
  Array(value).map { |address| address.to_s.strip }.reject(&:empty?)
188
199
  end
189
200
 
201
+ # Build a reply recipient list from several sources, preserving each address's original form but
202
+ # collapsing duplicates and dropping any excluded address — compared by routing key (tag-stripped,
203
+ # lowercased) so casing and plus-tags don't defeat the de-dupe. This is what carries the original
204
+ # thread's participants onto a reply without doubling anyone up. Unparseable entries are dropped.
205
+ #
206
+ # @param candidates [Array<String>] the addresses to include, in priority order
207
+ # @param excluding [Array<String>] addresses to omit (e.g. the agent itself, or the To list)
208
+ # @return [Array<String>] the de-duplicated recipients
209
+ def merge_recipients(candidates, excluding: [])
210
+ seen = excluding.filter_map { |address| routing_key(address) }
211
+
212
+ candidates.each_with_object([]) do |address, kept|
213
+ key = routing_key(address)
214
+ next if key.nil? || seen.include?(key)
215
+
216
+ seen << key
217
+ kept << address
218
+ end
219
+ end
220
+
221
+ # The routing key (tag-stripped, lowercased +local@domain+) for an address, or +nil+ when it can't be
222
+ # parsed — so de-duping treats an unparseable address as droppable rather than raising.
223
+ #
224
+ # @param address [String] the raw address
225
+ # @return [String, nil] the routing key, or nil
226
+ def routing_key(address)
227
+ Protege::Gateway.routing_key(address)
228
+ rescue ArgumentError
229
+ nil
230
+ end
231
+
190
232
  # Resolve blob ids to the stored files they name, limit-checked.
191
233
  #
192
234
  # @param ids [Array<Integer>] the requested blob ids
@@ -43,6 +43,8 @@ module Protege
43
43
  internal/private network addresses are refused.
44
44
  DESC
45
45
 
46
+ summary 'Fetch a web page or text file by URL and return its readable contents.'
47
+
46
48
  input_schema(
47
49
  type: 'object',
48
50
  additionalProperties: false,
@@ -10,7 +10,7 @@ module Protege
10
10
  # It is a keyless, best-effort search: it scrapes DuckDuckGo's HTML endpoint and parses the results
11
11
  # page into a list of +{title, url, snippet}+ the agent can reason over and then +web_fetch+. Because
12
12
  # it scrapes an unofficial endpoint, it can change or be rate-limited — that trade-off buys a search
13
- # that needs no API key and is safe to expose to every persona. Richer or keyed search (Tavily,
13
+ # that needs no API key and is safe to expose to every agent. Richer or keyed search (Tavily,
14
14
  # Perplexity, Brave, …) belongs in a host-app tool, keyed from the host's own config.
15
15
  class WebSearchTool < Protege::Tool
16
16
  # Result count when the caller does not specify a +limit+.
@@ -35,6 +35,8 @@ module Protege
35
35
  limit when results are off.
36
36
  DESC
37
37
 
38
+ summary 'Search the web and get back a list of matching pages.'
39
+
38
40
  input_schema(
39
41
  type: 'object',
40
42
  additionalProperties: false,
@@ -0,0 +1 @@
1
+ <%= yield %>
@@ -1,6 +1,6 @@
1
1
  - if @access_rule.persisted?
2
- = turbo_stream.append "access_rules", partial: "protege/personas/access_rule", locals: { access_rule: @access_rule }
2
+ = turbo_stream.append "access_rules", partial: "protege/agents/access_rule", locals: { access_rule: @access_rule }
3
3
  = turbo_stream.remove "access_rules_empty"
4
- = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @persona.access_rules.build }
4
+ = turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @agent.access_rules.build }
5
5
  - else
6
- = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @access_rule }
6
+ = turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @access_rule }
@@ -0,0 +1,6 @@
1
+ - if @agent_toolkit_rule.persisted?
2
+ = turbo_stream.append "agent_toolkit_rules", partial: "protege/agent_toolkits/gate_rule", locals: { rule: @agent_toolkit_rule }
3
+ = turbo_stream.remove "agent_toolkit_rules_empty"
4
+ = turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit.agent_toolkit_rules.build }
5
+ - else
6
+ = turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit_rule }
@@ -0,0 +1,7 @@
1
+ tr id=dom_id(rule) style="border-top: 1px solid var(--border)"
2
+ td class="py-3 pr-4 align-middle"
3
+ = badge(rule.kind, gate_rule_kind_color(rule))
4
+ td class="py-3 pr-4 align-middle"
5
+ code class="font-mono break-all" = rule.pattern
6
+ td class="py-3 align-middle"
7
+ = destructive_link("Remove", agent_toolkit_agent_toolkit_rule_path(rule.agent_toolkit, rule), confirm: "Remove this rule?")
@@ -0,0 +1,8 @@
1
+ #gate_rule_form
2
+ = form_with model: rule, url: agent_toolkit_agent_toolkit_rules_path(agent_toolkit), scope: :agent_toolkit_rule, class: "space-y-3" do |f|
3
+ = form_errors(rule)
4
+ .flex.items-end.gap-3
5
+ = form_select(f, :kind, Protege::AgentToolkitRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
6
+ .flex-1
7
+ = form_field(f, :pattern, label: "Pattern", placeholder: "*@company.co")
8
+ = button("Add rule", type: :submit, class: "h-9")
@@ -0,0 +1,19 @@
1
+ / The grant's allow/deny sender gate — which correspondents may unlock this agent's copy of the
2
+ / toolkit's tools. Lists existing rules with a remove control and an add form; create/destroy update the
3
+ / table in place via Turbo Streams, falling back to a redirect for non-Turbo.
4
+
5
+ = card do
6
+ .space-y-4
7
+ = heading_2("Sender gate")
8
+ = hint("Allow or deny senders from unlocking these tools. With no rules, every sender is permitted.")
9
+
10
+ = render "gate_rule_form", agent_toolkit: agent_toolkit, rule: agent_toolkit.agent_toolkit_rules.build
11
+
12
+ table class="w-full text-sm"
13
+ tbody id="agent_toolkit_rules"
14
+ - if agent_toolkit.agent_toolkit_rules.any?
15
+ = render partial: "gate_rule", collection: agent_toolkit.agent_toolkit_rules.order(:kind, :pattern), as: :rule
16
+ - else
17
+ tr id="agent_toolkit_rules_empty"
18
+ td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
19
+ | No rules — every sender can unlock these tools.
@@ -0,0 +1,17 @@
1
+ = page_container do
2
+ = page_header(@agent_toolkit.name, "How #{@agent_toolkit.agent.name} may use this toolkit") do
3
+ .flex.items-center.gap-2
4
+ = ui_link("Edit toolkit", edit_toolkit_path(@agent_toolkit.toolkit), class: "px-3")
5
+ = ui_link("Back to agent", agent_path(@agent_toolkit.agent), class: "px-3")
6
+
7
+ .space-y-4
8
+ = info_card("Tools", toolkit_overview(@agent_toolkit.toolkit))
9
+
10
+ = card do
11
+ = heading_2("Scheduled runs")
12
+ = hint("Whether these tools may run on this agent's scheduled duties, where there is no sender to check.")
13
+ = form_with model: @agent_toolkit, url: agent_toolkit_path(@agent_toolkit), method: :patch, scope: :agent_toolkit, class: "mt-3 space-y-3" do |f|
14
+ = form_checkbox(f, :allow_proactive, label: "Allow on scheduled runs")
15
+ = button("Save", type: :submit)
16
+
17
+ = render("gate_rules", agent_toolkit: @agent_toolkit)
@@ -4,4 +4,4 @@ tr id=dom_id(access_rule) style="border-top: 1px solid var(--border)"
4
4
  td class="py-3 pr-4 align-middle"
5
5
  code class="font-mono break-all" = access_rule.pattern
6
6
  td class="py-3 align-middle"
7
- = destructive_link("Remove", persona_access_rule_path(access_rule.persona, access_rule), confirm: "Remove this rule?")
7
+ = destructive_link("Remove", agent_access_rule_path(access_rule.agent, access_rule), confirm: "Remove this rule?")
@@ -1,5 +1,5 @@
1
1
  #access_rule_form
2
- = form_with model: access_rule, url: persona_access_rules_path(persona), scope: :access_rule, class: "space-y-3" do |f|
2
+ = form_with model: access_rule, url: agent_access_rules_path(agent), scope: :access_rule, class: "space-y-3" do |f|
3
3
  = form_errors(access_rule)
4
4
  .flex.items-end.gap-3
5
5
  = form_select(f, :kind, Protege::AccessRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
@@ -1,18 +1,18 @@
1
- / Inbound access rules for a persona — the dashboard-editable, narrowing layer of the access
1
+ / Inbound access rules for an agent — the dashboard-editable, narrowing layer of the access
2
2
  / guardrail. Lists existing rules with a remove control and an add form. Create/destroy update the
3
- / table in place via Turbo Streams (the persona page survives), falling back to a redirect for non-Turbo.
3
+ / table in place via Turbo Streams (the agent page survives), falling back to a redirect for non-Turbo.
4
4
 
5
5
  = card do
6
6
  .space-y-4
7
7
  = heading_2("Inbound access rules")
8
8
  = hint("Allow or deny senders for this agent. Rules only narrow the global policy — they can never widen it.")
9
9
 
10
- = render "access_rule_form", persona: persona, access_rule: persona.access_rules.build
10
+ = render "access_rule_form", agent: agent, access_rule: agent.access_rules.build
11
11
 
12
12
  table class="w-full text-sm"
13
13
  tbody id="access_rules"
14
- - if persona.access_rules.any?
15
- = render partial: "access_rule", collection: persona.access_rules.order(:kind, :pattern), as: :access_rule
14
+ - if agent.access_rules.any?
15
+ = render partial: "access_rule", collection: agent.access_rules.order(:kind, :pattern), as: :access_rule
16
16
  - else
17
17
  tr id="access_rules_empty"
18
18
  td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
@@ -0,0 +1,15 @@
1
+ / The agent show page's action bar — Edit, Archive/Unarchive, and permanent Delete — plus an "Archived"
2
+ / badge when soft-removed. Wrapped in a dom_id so Archive/Unarchive can replace it in place over Turbo
3
+ / (see ArchivesController). The Delete button opens the confirm dialog rendered by the show template.
4
+ div id=dom_id(agent, :actions) class="flex items-center gap-3"
5
+ - if agent.archived?
6
+ = badge("Archived")
7
+
8
+ = ui_link("Edit", edit_agent_path(agent))
9
+
10
+ - if agent.archived?
11
+ = action_button("Unarchive", agent_archive_path(agent), method: :delete)
12
+ - else
13
+ = action_button("Archive", agent_archive_path(agent), method: :post, variant: :secondary)
14
+
15
+ = button("Delete", type: "button", variant: :danger, onclick: show_dialog("delete-agent"))
@@ -0,0 +1,5 @@
1
+ = sidebar_item(agent_label(agent), agent_path(agent),
2
+ detail: agent_status(agent),
3
+ subtitle: agent.email_address,
4
+ active: local_assigns[:active],
5
+ id: dom_id(agent))
@@ -0,0 +1,7 @@
1
+ tr id=dom_id(agent_toolkit) style="border-top: 1px solid var(--border)"
2
+ td class="w-full py-3 pr-6 align-middle font-medium"
3
+ = agent_toolkit.name
4
+ td class="py-3 align-middle"
5
+ .flex.items-center.justify-end.gap-4
6
+ = ui_link("Configure Access Settings", agent_toolkit_path(agent_toolkit), class: "whitespace-nowrap")
7
+ = destructive_link("Detach", agent_agent_toolkit_path(agent_toolkit.agent, agent_toolkit), confirm: "Detach this toolkit?", class: "whitespace-nowrap")
@@ -0,0 +1,7 @@
1
+ #agent_toolkit_form
2
+ = form_with model: agent_toolkit, url: agent_agent_toolkits_path(agent), scope: :agent_toolkit, class: "space-y-3" do |f|
3
+ = form_errors(agent_toolkit)
4
+ .flex.items-end.gap-3
5
+ .flex-1
6
+ = form_select(f, :toolkit_id, attachable_toolkits(agent).map { |t| [t.name, t.id] }, label: "Toolkit", prompt: "Choose a toolkit")
7
+ = button("Attach", type: :submit, class: "h-9")
@@ -0,0 +1,25 @@
1
+ - url = agent.new_record? ? agents_path : agent_path(agent)
2
+ - method = agent.new_record? ? :post : :patch
3
+
4
+ = form_with url:, method:, scope: :agent, class: "space-y-5" do |f|
5
+ = form_errors(agent)
6
+
7
+ = form_field(f, :name, placeholder: "e.g. alice")
8
+
9
+ = form_email_address(agent, available_email_domains)
10
+
11
+ - unless agent.new_record?
12
+ = hint("Changing the address only affects new mail — messages to the old address will no longer reach this agent. Existing threads and history are unaffected.")
13
+
14
+ - if agent.new_record?
15
+ = form_select(f, :type, available_agent_types.map { |k| [k.display_name, k.name] }, label: "Type")
16
+
17
+ = form_text_area(f, :instructions, \
18
+ label: "Instructions", \
19
+ rows: 10, \
20
+ hint: "The agent's system prompt — who it is and how it behaves. Only sent to the model when a resolver (such as LoadText) includes it in the agent's context.")
21
+
22
+ .flex.items-center.gap-3.pt-2
23
+ = button(agent.new_record? ? "Create agent" : "Save changes", type: :submit)
24
+
25
+ = ui_link("Cancel", agent.new_record? ? agents_path : agent_path(agent))
@@ -0,0 +1,11 @@
1
+ - pagy, agents = paginate(all_agents)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Agents (#{pagy.count})", new_path: new_agent_path)
5
+
6
+ = sidebar_stream("protege:agents", pagy: pagy)
7
+
8
+ = sidebar_body(agents, empty: "No agents yet", id: "agents_list") do |agent|
9
+ = render "agent_sidebar_item", agent: agent, active: local_assigns[:current] == agent
10
+
11
+ = sidebar_pagination(pagy)
@@ -0,0 +1,19 @@
1
+ / Toolkits attached to an agent — the sender-gated tool bundles it exposes. Lists attached toolkits with
2
+ / a detach control and an attach form. Attach/detach update the table in place via Turbo Streams (the
3
+ / agent page survives), falling back to a redirect for non-Turbo.
4
+
5
+ = card do
6
+ .space-y-4
7
+ = heading_2("Agent Toolkits")
8
+ = hint("Attach a toolkit to give this agent its tools, then use Configure Access Settings to set who may use them. Toolkits themselves are managed under Toolkits.")
9
+
10
+ = render "agent_toolkit_form", agent: agent, agent_toolkit: agent.agent_toolkits.build
11
+
12
+ table class="w-full text-sm"
13
+ tbody id="agent_toolkits"
14
+ - if agent.agent_toolkits.any?
15
+ = render partial: "agent_toolkit", collection: agent.agent_toolkits.includes(:toolkit), as: :agent_toolkit
16
+ - else
17
+ tr id="agent_toolkits_empty"
18
+ td colspan="2" class="py-6 text-center text-sm" style="color: var(--text-faint)"
19
+ | No toolkits attached — this agent has no tools yet.
@@ -0,0 +1,6 @@
1
+ - if @agent_toolkit.persisted?
2
+ = turbo_stream.append "agent_toolkits", partial: "protege/agents/agent_toolkit", locals: { agent_toolkit: @agent_toolkit }
3
+ = turbo_stream.remove "agent_toolkits_empty"
4
+ = turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent.agent_toolkits.build }
5
+ - else
6
+ = turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent_toolkit }
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @agent
3
+
4
+ = page_container do
5
+ = page_header("Edit #{@agent.name}")
6
+
7
+ = card { render "form", agent: @agent }
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select an agent from the sidebar, or &nbsp;
6
+ = ui_link("create a new one.", new_agent_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("New agent", "Specify the email address this agent receives mail at. It must match your MX records.")
6
+
7
+ = card { render "form", agent: @agent }
@@ -0,0 +1,29 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @agent
3
+
4
+ = page_container do
5
+ = page_header(@agent.name, @agent.class.display_name) do
6
+ = render("actions", agent: @agent)
7
+
8
+ .space-y-4
9
+ = info_card("Email address", { "Address" => @agent.email_address })
10
+
11
+ = card do
12
+ = heading_2("Instructions")
13
+ - if @agent.instructions.present?
14
+ .mt-3
15
+ = body_text(@agent.instructions)
16
+ - else
17
+ .mt-3
18
+ = hint("No instructions set yet — add them via Edit. They're used as the system prompt only when a resolver (e.g. LoadText) includes them in the agent's context.")
19
+
20
+ = render("access_rules", agent: @agent)
21
+
22
+ = render("toolkits", agent: @agent)
23
+
24
+ = render("protege/shared/confirm_dialog",
25
+ id: "delete-agent",
26
+ title: "Delete #{@agent.name}?",
27
+ message: "This permanently removes the agent and ALL of its data — every thread, message, attachment, responsibility, access rule, and tool-use record. This cannot be undone. To keep the history but stop the agent instead, archive it.",
28
+ action_url: agent_path(@agent),
29
+ action_text: "Delete agent")
@@ -0,0 +1,4 @@
1
+ An inference run failed and could not be recovered.
2
+
3
+ Error: <%= @error_class %>: <%= @error_message %>
4
+ Correlation ID: <%= @correlation_id || '(none)' %>
@@ -31,6 +31,6 @@
31
31
  = render("protege/shared/confirm_dialog",
32
32
  id: "remove-domain",
33
33
  title: "Remove #{@email_domain.domain}?",
34
- message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by a persona, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
34
+ message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by an agent, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
35
35
  action_url: email_domain_path(@email_domain),
36
36
  action_text: "Remove domain")
@@ -2,9 +2,9 @@
2
2
  = page_header('About Protege')
3
3
 
4
4
  div class="space-y-4 text-sm leading-relaxed mb-10"
5
- p Protege is an email-native AI agent toolkit. Your personas receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
5
+ p Protege is an email-native AI agent toolkit. Your agents receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
6
6
 
7
- p To get started, configure at least one email domain with valid DNS records, create a persona with a routable email address, and set up your provider credentials. Once a persona is active and your MTA is running, it will process inbound email automatically.
7
+ p To get started, configure at least one email domain with valid DNS records, create an agent with a routable email address, and set up your provider credentials. Once an agent is active and your MTA is running, it will process inbound email automatically.
8
8
 
9
9
  p Protege is a toolkit for developers, not an out-of-the-box solution. Every part of the pipeline — providers, resolvers, tools, hooks — is an extension surface you implement. The #{ui_link("documentation", "https://docs.protege.bot", target: "_blank")} covers each surface in detail with examples and guides.
10
10
 
@@ -13,14 +13,14 @@
13
13
  = heading_2("Extension surfaces")
14
14
 
15
15
  = grid_container(cols: 2) do
16
- = grid_card("Personas", "The agent identity that receives and responds to email. Each persona has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/personas")
17
-
18
- = grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/providers")
19
-
20
- = grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/resolvers")
21
-
22
- = grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/tools")
23
-
24
- = grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/hooks")
25
-
26
- = grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/gateway")
16
+ = grid_card("Agents", "The addressable identity that receives and responds to email. Each agent has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/reference/agents")
17
+
18
+ = grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/extensions/providers")
19
+
20
+ = grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/extensions/resolvers")
21
+
22
+ = grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/extensions/tools")
23
+
24
+ = grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/extensions/hooks-and-events")
25
+
26
+ = grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/architecture/gateway")
@@ -4,13 +4,13 @@
4
4
  = form_with url:, method:, scope: :responsibility, class: "space-y-5" do |f|
5
5
  = form_errors(responsibility)
6
6
 
7
- = form_select(f, :persona_id, all_personas.map { |p| [p.name, p.id] }, label: "Persona", prompt: "Choose a persona")
7
+ = form_select(f, :agent_id, all_agents.map { |p| [p.name, p.id] }, label: "Agent", prompt: "Choose an agent")
8
8
 
9
9
  = form_field(f, :name, placeholder: "e.g. Morning digest")
10
10
 
11
11
  = form_field(f, :schedule, label: "Schedule", placeholder: "0 9 * * 1-5", hint: "A 5-field cron expression (minute hour day-of-month month day-of-week) in the app timezone — e.g. \"0 9 * * 1-5\" is 9am on weekdays, \"*/15 * * * *\" every 15 minutes.")
12
12
 
13
- = form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The persona's responsibility_resolvers chain supplies the system context around it.")
13
+ = form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The agent's responsibility_resolvers chain supplies the system context around it.")
14
14
 
15
15
  - unless responsibility.new_record?
16
16
  = form_checkbox(f, :active, label: "Active")