protege 0.1.0.alpha.1

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 (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Runs one inbound email through the full Protege inference pipeline (the "I" of LOGI).
5
+ #
6
+ # Enqueued by +AgentMailbox+ once persona routing succeeds. The job re-hydrates the raw email and
7
+ # persona from their IDs, ingests a +Message+, runs the Orchestrator's +Harness+, and records the
8
+ # outcome on the message. IDs (not objects) are passed across the async boundary so the queue
9
+ # adapter can serialize and safely retry the job across process restarts.
10
+ #
11
+ # The +correlation_id+ argument carries the trace token set in +AgentMailbox+; restoring it into
12
+ # +Protege::Current+ at the top of +perform+ keeps events consistent from HTTP ingress through
13
+ # inference. Failures are captured on the message before re-raising so the retry machinery and the
14
+ # UI both have full context.
15
+ class InferenceJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ # Process one inbound email end to end: ingest, run inference, record the result.
19
+ #
20
+ # @param inbound_email_id [Integer] id of the +ActionMailbox::InboundEmail+ to process
21
+ # @param persona_id [Integer] primary key of the +Protege::Persona+; STI loads the subclass
22
+ # @param correlation_id [String] the RFC 2822 Message-ID set by +AgentMailbox+ for tracing
23
+ # @return [void]
24
+ # @raise [StandardError] re-raised after being recorded on the message, to trigger retries
25
+ def perform(inbound_email_id:, persona_id:, correlation_id:)
26
+ restore_correlation_id(correlation_id)
27
+
28
+ # Re-hydrate the raw email and the target persona from their IDs.
29
+ # IDs are passed rather than objects so Solid Queue can serialize
30
+ # the job and retry it safely across process restarts.
31
+ inbound_email = ActionMailbox::InboundEmail.find(inbound_email_id)
32
+ persona = Persona.find(persona_id)
33
+
34
+ # Parse the raw email, find-or-create the EmailThread, and persist
35
+ # a Protege::Message record with processing_status: :processing.
36
+ # Returns the saved message so we can update its lifecycle below.
37
+ message = Message.ingest!(inbound_email:, persona:)
38
+
39
+ process(message:, persona:)
40
+ rescue StandardError => e
41
+ # Capture the error on the message record before re-raising so the
42
+ # job can be inspected and manually re-queued without losing context.
43
+ message&.mark_failed!(e)
44
+ raise
45
+ end
46
+
47
+ private
48
+
49
+ # Run the message through the inference loop and record success.
50
+ #
51
+ # @param message [Protege::Message] the message to process
52
+ # @param persona [Protege::Persona] the persona handling it
53
+ # @return [void]
54
+ def process(message:, persona:)
55
+ Orchestrator.reply(persona:, message:)
56
+
57
+ message.mark_processed!
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Pushes the current mail provisioning manifest to the self-hosted MTA accessory. Enqueued from
5
+ # +EmailDomain+'s +after_commit+ so a domain save (create/update/destroy) isn't coupled to the
6
+ # accessory's availability — the push happens just after the record commits, and its retry covers a
7
+ # momentarily-down MTA.
8
+ #
9
+ # A no-op in provider mode: +Gateway.push_mail_provisioning+ returns early when no accessory is
10
+ # configured (no +MAIL_RELAY_HOST+), so this job is harmless when there is nothing to provision.
11
+ class MailProvisioningJob < ApplicationJob
12
+ queue_as :default
13
+
14
+ # Push the whole domain manifest to the mail accessory's control listener.
15
+ #
16
+ # @return [void]
17
+ def perform
18
+ Gateway.push_mail_provisioning
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Carries out one scheduled responsibility run — the Loop layer's worker (the "L" of LOGI driving the
5
+ # "I"). Enqueued by +Responsibility#dispatch!+ with the id of a +pending+ +ResponsibilityRun+; it
6
+ # advances that run to running, runs the persona through +ResponsibilityHarness+, and records the
7
+ # outcome on the run. The run id (not the object) is passed across the async boundary so the queue
8
+ # adapter can serialize the job.
9
+ #
10
+ # There is no inbound request to inherit a correlation id from, so the job mints a fresh UUID and
11
+ # restores it into +Protege::Current+ at the top of +perform+; the run snapshots it onto its own
12
+ # +correlation_id+ when it starts, tying the run record to the events it emits. Unlike the inbound
13
+ # jobs, a failure is recorded but **not re-raised**: a responsibility is cron-driven, so the next
14
+ # scheduled minute is its natural retry — letting ActiveJob rerun it would double-fire. (The harness
15
+ # still emits +inference_failed+, so failure hooks observe it.)
16
+ class ResponsibilityJob < ApplicationJob
17
+ queue_as :default
18
+
19
+ # Carry out one scheduled run: advance the run, run inference, record the result.
20
+ #
21
+ # @param run_id [Integer] id of the pending +ResponsibilityRun+ to carry out
22
+ # @return [void]
23
+ def perform(run_id:)
24
+ responsibility_run = ResponsibilityRun.eager_load(responsibility: :persona).find(run_id)
25
+ # Adopt the trace id minted at dispatch (Responsibility#dispatch!) so the whole run lifecycle
26
+ # shares it; fall back to a fresh one for a run created some other way (e.g. in a test).
27
+ restore_correlation_id(responsibility_run.correlation_id || SecureRandom.uuid)
28
+
29
+ responsibility = responsibility_run.responsibility
30
+ persona = responsibility.persona
31
+
32
+ process(responsibility_run:, responsibility:, persona:)
33
+ rescue StandardError => e
34
+ # Record the failure without re-raising — the cron schedule, not ActiveJob, drives the retry.
35
+ responsibility_run&.mark_failed!(e)
36
+ emit_loop_run_failed(responsibility_run, e)
37
+ end
38
+
39
+ private
40
+
41
+ # Advance the run through its lifecycle around one +ResponsibilityHarness+ run.
42
+ #
43
+ # @param responsibility_run [Protege::ResponsibilityRun] the run to carry out
44
+ # @param responsibility [Protege::Responsibility] the responsibility to handle
45
+ # @param persona [Protege::Persona] the persona handling the responsibility
46
+ # @return [void]
47
+ def process(responsibility_run:, responsibility:, persona:)
48
+ responsibility_run.start!
49
+ LoopRunStartedEvent.emit(name: responsibility.name, persona_name: persona.name)
50
+
51
+ Orchestrator.run_responsibility(persona:, responsibility:, responsibility_run:)
52
+
53
+ responsibility_run.mark_completed!
54
+ LoopRunCompletedEvent.emit(name: responsibility.name, persona_name: persona.name)
55
+ end
56
+
57
+ # Emit +loop_run_failed+ for a recorded failure — omitted when the run never loaded (e.g. the
58
+ # +find+ itself raised), since there is no responsibility to name.
59
+ #
60
+ # @param responsibility_run [Protege::ResponsibilityRun, nil] the failed run, if loaded
61
+ # @param error [Exception] the failure
62
+ # @return [void]
63
+ def emit_loop_run_failed(responsibility_run, error)
64
+ return unless responsibility_run
65
+
66
+ responsibility = responsibility_run.responsibility
67
+ LoopRunFailedEvent.emit(name: responsibility.name, persona_name: responsibility.persona.name, error:)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The Loop layer's clock — a thin recurring job the host registers to run every minute (Solid Queue
5
+ # recurring, alongside any other recurring tasks). Each tick hands off to +Loop.dispatch+, which
6
+ # enqueues a +ResponsibilityJob+ for every responsibility due that minute. ActiveJob has no native
7
+ # recurring, so the recurrence itself is the host queue adapter's concern; the engine only ships this
8
+ # job class and the dispatch logic.
9
+ class ResponsibilityTickJob < ApplicationJob
10
+ queue_as :default
11
+
12
+ # Dispatch every responsibility due now.
13
+ #
14
+ # @return [void]
15
+ def perform
16
+ Loop.dispatch
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Inbound edge of the Gateway: routes every received email toward the inference pipeline.
5
+ #
6
+ # Action Mailbox delivers parsed mail here, exposing +mail+ and +inbound_email+ as instance
7
+ # methods and calling {#process} as the single entry point. The mailbox resolves the target
8
+ # persona by recipient address, then hands off to +Gateway.accept_smtp_message+, which enqueues
9
+ # +InferenceJob+. The inbound Message-ID is forwarded as the correlation ID so the trace token
10
+ # survives the async boundary and is restored inside the job.
11
+ #
12
+ # == Recursion guard
13
+ #
14
+ # Outbound mail carries an +X-Protege-Recursion+ budget header. Any inbound email whose budget has
15
+ # reached zero (or below) is silently dropped, preventing infinite loops when one persona replies
16
+ # to another. Unroutable mail (no matching persona) is bounced with an unrouted notice instead.
17
+ #
18
+ # == Access guardrail
19
+ #
20
+ # Once routed, the sender is checked against the persona's inbound access guardrail
21
+ # (+Gateway::AccessControl+): the committed global ceiling intersected with the persona's runtime rules. A
22
+ # sender that fails any layer is bounced with an access-denied notice and never reaches inference.
23
+ class AgentMailbox < ActionMailbox::Base
24
+ # Route the inbound email: drop on exhausted recursion budget, bounce when unrouted or when the
25
+ # sender fails the access guardrail, else accept.
26
+ #
27
+ # @return [void]
28
+ def process
29
+ return handle_recursion_limit if recursion_limit_reached?
30
+ return bounce_with_unrouted if persona.nil?
31
+ return bounce_with_access_denied unless sender_permitted?
32
+ return bounce_with_attachment_error if attachment_violation
33
+
34
+ Gateway.accept_smtp_message(inbound_email:, persona:)
35
+ end
36
+
37
+ private
38
+
39
+ # Report whether the sender may reach the routed persona, per the access guardrail.
40
+ #
41
+ # @return [Boolean] true when every access layer permits the sender
42
+ def sender_permitted?
43
+ Gateway.permits?(persona:, address: mail.from.first)
44
+ end
45
+
46
+ # Resolve and memoise the persona for the email's primary recipient.
47
+ #
48
+ # Uses +Persona.lookup+, which strips plus-tag subaddresses before matching.
49
+ #
50
+ # @return [Protege::Persona, nil] the routed persona, or nil when none matches
51
+ def persona
52
+ @persona ||= Protege::Persona.lookup(mail.to.first)
53
+ end
54
+
55
+ # The attachment-limit breach for this email, or nil when its attachments are within the limits.
56
+ #
57
+ # Memoised (including the nil result) so +#process+ checks once and the bounce can reuse the reason.
58
+ #
59
+ # @return [String, nil] the limit-breach reason, or nil
60
+ def attachment_violation
61
+ @attachment_violation ||= begin
62
+ sizes = mail.attachments.map { _1.decoded.bytesize }
63
+ Protege.configuration.attachment_policy.violation(byte_sizes: sizes)
64
+ end
65
+ end
66
+
67
+ # Report whether the +X-Protege-Recursion+ budget has been exhausted.
68
+ #
69
+ # @return [Boolean] true when the header is present and its value is zero or below
70
+ def recursion_limit_reached?
71
+ header = mail.header['X-Protege-Recursion']
72
+ header.present? && header.value.to_i <= 0
73
+ end
74
+
75
+ # Silently drop the message when the recursion budget is exhausted — no reply, no error.
76
+ #
77
+ # @return [void]
78
+ def handle_recursion_limit; end
79
+
80
+ # Bounce an unroutable email with an unrouted notice.
81
+ #
82
+ # Swallows any error raised while building or sending the bounce so a broken bounce template can
83
+ # never prevent the mailbox from completing cleanly — but logs it so the failure is visible rather
84
+ # than silently lost.
85
+ #
86
+ # @return [void]
87
+ def bounce_with_unrouted
88
+ bounce_with ApplicationMailer.new.unrouted_bounce(mail)
89
+ rescue StandardError => e
90
+ Protege.configuration.logger.error("[Protege] failed to send unrouted bounce: #{e.class}: #{e.message}")
91
+ nil
92
+ end
93
+
94
+ # Bounce an email whose sender failed the access guardrail with an access-denied notice.
95
+ #
96
+ # Swallows any error raised while building or sending the bounce (logging it) for the same reason
97
+ # as +bounce_with_unrouted+: a broken bounce template must never prevent the mailbox from
98
+ # completing cleanly.
99
+ #
100
+ # @return [void]
101
+ def bounce_with_access_denied
102
+ bounce_with ApplicationMailer.new.access_denied_bounce(mail)
103
+ rescue StandardError => e
104
+ Protege.configuration.logger.error("[Protege] failed to send access-denied bounce: #{e.class}: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Bounce an email whose attachments breached the configured limits, with the breach reason.
109
+ #
110
+ # Swallows any error raised while building or sending the bounce (logging it) for the same reason
111
+ # as the other bounces: a broken bounce template must never prevent the mailbox from completing.
112
+ #
113
+ # @return [void]
114
+ def bounce_with_attachment_error
115
+ bounce_with ApplicationMailer.new.attachment_rejected_bounce(mail, reason: attachment_violation)
116
+ rescue StandardError => e
117
+ Protege.configuration.logger.error(
118
+ "[Protege] failed to send attachment-rejected bounce: #{e.class}: #{e.message}"
119
+ )
120
+ nil
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base mailer for the Protege engine and home of the Gateway's bounce notices.
5
+ #
6
+ # Sits at the outbound edge of the Gateway. +AgentMailbox+ returns these notices to Action Mailbox's
7
+ # +bounce_with+ when an inbound email can't be accepted — no matching persona ({#unrouted_bounce}) or
8
+ # a sender rejected by the access guardrail ({#access_denied_bounce}). The default +from:+ address is
9
+ # a deliberate placeholder: a host application should override it through
10
+ # +config.action_mailer.default_options+ in an initializer, or per-mailer, so outbound mail
11
+ # originates from a domain it actually controls.
12
+ class ApplicationMailer < ActionMailer::Base
13
+ # Placeholder sender; override in the host app so mail comes from a domain you control.
14
+ default from: 'from@example.com'
15
+ layout 'mailer'
16
+
17
+ # Bounce an inbound email that matched no persona back to its sender.
18
+ #
19
+ # Returned to +AgentMailbox#bounce_with_unrouted+, which hands it to Action Mailbox's +bounce_with+
20
+ # for delivery. The body is built inline (no template/layout) so a bounce can never fail to render.
21
+ #
22
+ # @param inbound [::Mail::Message] the undeliverable inbound email
23
+ # @return [Mail::Message] the bounce notice addressed to the original sender
24
+ def unrouted_bounce(inbound)
25
+ mail(
26
+ to: inbound.from,
27
+ subject: 'Undeliverable: no such recipient',
28
+ body: "Your message could not be delivered: there is no agent at this address.\n"
29
+ )
30
+ end
31
+
32
+ # Bounce an inbound email whose sender was rejected by the inbound access guardrail.
33
+ #
34
+ # Returned to +AgentMailbox#bounce_with_access_denied+. Like {#unrouted_bounce}, the body is inline
35
+ # so rendering can't fail. The wording is deliberately neutral — it states the message was not
36
+ # accepted without disclosing the policy that rejected it.
37
+ #
38
+ # @param inbound [::Mail::Message] the rejected inbound email
39
+ # @return [Mail::Message] the bounce notice addressed to the original sender
40
+ def access_denied_bounce(inbound)
41
+ mail(
42
+ to: inbound.from,
43
+ subject: 'Undeliverable: message rejected',
44
+ body: "Your message could not be delivered: this recipient is not accepting mail from you.\n"
45
+ )
46
+ end
47
+
48
+ # Bounce an inbound email whose attachments breached the configured limits.
49
+ #
50
+ # Returned to +AgentMailbox#bounce_with_attachment_rejected+. Like the other bounces, the body is
51
+ # inline so rendering can't fail. The +reason+ (from +Gateway::AttachmentPolicy+) is included so the sender
52
+ # knows what to fix — it discloses only the limit, not anything sensitive.
53
+ #
54
+ # @param inbound [::Mail::Message] the rejected inbound email
55
+ # @param reason [String] the limit-breach reason from +Gateway::AttachmentPolicy#violation+
56
+ # @return [Mail::Message] the bounce notice addressed to the original sender
57
+ def attachment_rejected_bounce(inbound, reason:)
58
+ mail(
59
+ to: inbound.from,
60
+ subject: 'Undeliverable: attachment rejected',
61
+ body: "Your message could not be delivered: #{reason}.\n"
62
+ )
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +EmailDomain+ records — keeping the Domains sidebar
5
+ # live: the row is appended on create, replaced on update, and removed on destroy. First-page viewers
6
+ # subscribe via +turbo_stream_from "protege:email_domains"+ (see +SidebarHelper#sidebar_stream+).
7
+ module BroadcastableEmailDomain
8
+ extend ActiveSupport::Concern
9
+
10
+ # Turbo Stream channel for all +EmailDomain+ records.
11
+ # @return [String]
12
+ STREAM = 'protege:email_domains'
13
+
14
+ included do
15
+ after_create_commit :broadcast_email_domain_created
16
+ after_update_commit :broadcast_email_domain_updated
17
+ after_destroy_commit :broadcast_email_domain_removed
18
+ end
19
+
20
+ # Append this domain's row to the Domains sidebar on creation.
21
+ #
22
+ # @return [void]
23
+ def broadcast_email_domain_created
24
+ broadcast_append_to STREAM,
25
+ target: 'email_domains_list',
26
+ partial: 'protege/email_domains/email_domain_sidebar_item',
27
+ locals: { email_domain: self }
28
+ end
29
+
30
+ # Replace this domain's sidebar row in place when its attributes change.
31
+ #
32
+ # @return [void]
33
+ def broadcast_email_domain_updated
34
+ broadcast_replace_to STREAM,
35
+ target: ActionView::RecordIdentifier.dom_id(self),
36
+ partial: 'protege/email_domains/email_domain_sidebar_item',
37
+ locals: { email_domain: self }
38
+ end
39
+
40
+ # Remove this domain's sidebar row on destruction.
41
+ #
42
+ # @return [void]
43
+ def broadcast_email_domain_removed
44
+ broadcast_remove_to STREAM,
45
+ target: ActionView::RecordIdentifier.dom_id(self)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +Message+ records.
5
+ #
6
+ # Mixed into +Message+, this concern wires Active Record commit callbacks to Turbo Streams so the
7
+ # web UI reflects mail activity live, with no polling. On create it prepends the new message to
8
+ # the open thread view and asks the owning +EmailThread+ (via +BroadcastableThread+) to refresh
9
+ # its inbox row. On update it replaces the message partial in place, so later attribute changes
10
+ # (e.g. an edited body) appear immediately. (Run liveness — reasoning and tool use — is not shown on
11
+ # the message; it streams into the console's introspection panel.)
12
+ module BroadcastableMessage
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ after_create_commit :broadcast_message_created
17
+ after_update_commit :broadcast_message_updated
18
+ end
19
+
20
+ private
21
+
22
+ # Broadcast a newly created message into the thread view and refresh its inbox row.
23
+ #
24
+ # Prepends the message partial to the thread's messages target (newest first), then delegates
25
+ # to the thread to update its sidebar row.
26
+ #
27
+ # @return [void]
28
+ def broadcast_message_created
29
+ broadcast_prepend_to email_thread,
30
+ target: ActionView::RecordIdentifier.dom_id(email_thread, :messages),
31
+ partial: 'protege/messages/message',
32
+ locals: { message: self }
33
+
34
+ email_thread.broadcast_thread_updated
35
+ end
36
+
37
+ # Broadcast an in-place replacement of this message's partial when its attributes change.
38
+ #
39
+ # @return [void]
40
+ def broadcast_message_updated
41
+ broadcast_replace_to email_thread,
42
+ target: ActionView::RecordIdentifier.dom_id(self),
43
+ partial: 'protege/messages/message',
44
+ locals: { message: self }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +Persona+ records — keeping the Personas sidebar
5
+ # live: the row is appended on create, replaced on update, and removed on destroy. First-page viewers
6
+ # subscribe via +turbo_stream_from "protege:personas"+ (see +SidebarHelper#sidebar_stream+).
7
+ module BroadcastablePersona
8
+ extend ActiveSupport::Concern
9
+
10
+ # Turbo Stream channel for all +Persona+ records.
11
+ # @return [String]
12
+ STREAM = 'protege:personas'
13
+
14
+ included do
15
+ after_create_commit :broadcast_persona_created
16
+ after_update_commit :broadcast_persona_updated
17
+ after_destroy_commit :broadcast_persona_removed
18
+ end
19
+
20
+ private
21
+
22
+ # Append this persona's row to the Personas sidebar on creation.
23
+ #
24
+ # @return [void]
25
+ def broadcast_persona_created
26
+ broadcast_append_to STREAM,
27
+ target: 'personas_list',
28
+ partial: 'protege/personas/persona_sidebar_item',
29
+ locals: { persona: self }
30
+ end
31
+
32
+ # Replace this persona's sidebar row in place when its attributes change.
33
+ #
34
+ # @return [void]
35
+ def broadcast_persona_updated
36
+ broadcast_replace_to STREAM,
37
+ target: ActionView::RecordIdentifier.dom_id(self),
38
+ partial: 'protege/personas/persona_sidebar_item',
39
+ locals: { persona: self }
40
+ end
41
+
42
+ # Remove this persona's sidebar row on destruction.
43
+ #
44
+ # @return [void]
45
+ def broadcast_persona_removed
46
+ broadcast_remove_to STREAM,
47
+ target: ActionView::RecordIdentifier.dom_id(self)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +Responsibility+ records — keeping the (global)
5
+ # Responsibilities sidebar live: the row is appended on create, replaced on update, and removed on
6
+ # destroy. First-page viewers subscribe via +turbo_stream_from "protege:responsibilities"+ (see
7
+ # +SidebarHelper#sidebar_stream+).
8
+ module BroadcastableResponsibility
9
+ extend ActiveSupport::Concern
10
+
11
+ # Turbo Stream channel for all +Responsibility+ records.
12
+ # @return [String]
13
+ STREAM = 'protege:responsibilities'
14
+
15
+ included do
16
+ after_create_commit :broadcast_responsibility_created
17
+ after_update_commit :broadcast_responsibility_updated
18
+ after_destroy_commit :broadcast_responsibility_removed
19
+ end
20
+
21
+ private
22
+
23
+ # Append this duty's row to the Responsibilities sidebar on creation.
24
+ #
25
+ # @return [void]
26
+ def broadcast_responsibility_created
27
+ broadcast_append_to STREAM,
28
+ target: 'responsibilities_list',
29
+ partial: 'protege/responsibilities/responsibility_sidebar_item',
30
+ locals: { responsibility: self }
31
+ end
32
+
33
+ # Replace this duty's sidebar row in place when its attributes change.
34
+ #
35
+ # @return [void]
36
+ def broadcast_responsibility_updated
37
+ broadcast_replace_to STREAM,
38
+ target: ActionView::RecordIdentifier.dom_id(self),
39
+ partial: 'protege/responsibilities/responsibility_sidebar_item',
40
+ locals: { responsibility: self }
41
+ end
42
+
43
+ # Remove this duty's sidebar row on destruction.
44
+ #
45
+ # @return [void]
46
+ def broadcast_responsibility_removed
47
+ broadcast_remove_to STREAM,
48
+ target: ActionView::RecordIdentifier.dom_id(self)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +EmailThread+ records — keeping the inbox sidebar
5
+ # live as threads come, change, and go.
6
+ #
7
+ # On create the thread's row is prepended to the list (newest-first); on update its row is replaced
8
+ # in place (also invoked directly by +BroadcastableMessage+ when a new message arrives, so the row
9
+ # reflects the latest activity and counts); on destroy the row is removed. First-page viewers subscribe
10
+ # via +turbo_stream_from "protege:threads"+ (see +SidebarHelper#sidebar_stream+).
11
+ module BroadcastableThread
12
+ extend ActiveSupport::Concern
13
+
14
+ # Turbo Stream channel for all +EmailThread+ records.
15
+ # @return [String]
16
+ STREAM = 'protege:threads'
17
+
18
+ included do
19
+ after_create_commit :broadcast_thread_created
20
+ after_update_commit :broadcast_thread_updated
21
+ after_destroy_commit :broadcast_thread_removed
22
+ end
23
+
24
+ # Prepend this thread's row to the inbox list on creation.
25
+ #
26
+ # @return [void]
27
+ def broadcast_thread_created
28
+ broadcast_prepend_to STREAM,
29
+ target: 'threads_list',
30
+ partial: 'protege/threads/thread',
31
+ locals: { thread: self }
32
+ end
33
+
34
+ # Broadcast an in-place replacement of this thread's inbox-sidebar row.
35
+ #
36
+ # Public by design so +BroadcastableMessage+ can trigger the refresh after a new message saves,
37
+ # not only on the thread's own attribute changes.
38
+ #
39
+ # @return [void]
40
+ def broadcast_thread_updated
41
+ broadcast_replace_to STREAM,
42
+ target: ActionView::RecordIdentifier.dom_id(self),
43
+ partial: 'protege/threads/thread',
44
+ locals: { thread: self }
45
+ end
46
+
47
+ # Remove this thread's row from the inbox list on destruction.
48
+ #
49
+ # @return [void]
50
+ def broadcast_thread_removed
51
+ broadcast_remove_to STREAM,
52
+ target: ActionView::RecordIdentifier.dom_id(self)
53
+ end
54
+ end
55
+ end