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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ # See +Protege+ in protege.rb for the root namespace overview. This file reopens the module to
4
+ # define the process-wide +Configuration+ object and its +Protege.configure+ accessors.
5
+ module Protege
6
+ # Holds the process-wide Protege configuration set via +Protege.configure+. A single instance is
7
+ # memoized on the +Protege+ module and read throughout the LOGI pipeline — the Orchestrator reads
8
+ # +max_tool_turns+, the Inference layer reads +provider_id+ (each provider reads its own model /
9
+ # sampling / credentials from its +providers+ slice), and the Gateway reads +console_address+.
10
+ # Mutated once at boot from a Rails initializer; treated as read-only thereafter (the Orchestrator
11
+ # even freezes it per delivery via +Context+).
12
+ #
13
+ # @example Configure in a Rails initializer
14
+ # Protege.configure do |config|
15
+ # config.provider_id = :openrouter
16
+ # config.providers = { openrouter: { model: 'anthropic/claude-sonnet-4-5' } }
17
+ # end
18
+ class Configuration
19
+ # Generic, real-looking browser User-Agent sent by the built-in web tools (+web_search+,
20
+ # +web_fetch+) by default. Deliberately unbranded — a plain Chrome string that blends in — since
21
+ # some origins reject or fingerprint unusual agents.
22
+ DEFAULT_HTTP_USER_AGENT =
23
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' \
24
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
25
+
26
+ # Symbolic id of the inference provider extension (e.g. +:openrouter+). The model, sampling, and
27
+ # credentials for that provider live in its {#providers} slice, not here.
28
+ attr_accessor :provider_id
29
+
30
+ # Maximum tool-calling rounds before returning the last response.
31
+ attr_accessor :max_tool_turns
32
+
33
+ # Email address representing the dashboard user in local conversations. Messages from the
34
+ # dashboard use this as their +from_address+, and outbound replies to this address skip SMTP
35
+ # delivery.
36
+ attr_accessor :console_address
37
+
38
+ # Brand shown in the dashboard nav header. Override to white-label the console, e.g.
39
+ # +config.nav_title = 'Acme Support'+. Defaults to +'🥚 Protege'+.
40
+ attr_accessor :nav_title
41
+
42
+ # User-Agent the built-in web tools (+web_search+, +web_fetch+) send on outbound HTTP. Defaults to
43
+ # {DEFAULT_HTTP_USER_AGENT} — a generic browser string, not a Protege-branded one. Override to adopt
44
+ # your host machine's own browser agent, e.g.
45
+ # +config.http_user_agent = ENV.fetch('HTTP_USER_AGENT', Protege::Configuration::DEFAULT_HTTP_USER_AGENT)+.
46
+ attr_accessor :http_user_agent
47
+
48
+ # Sets the logger used throughout the engine. Override in an initializer to route Protege output
49
+ # to a dedicated log target, e.g. +config.logger = Logger.new(Rails.root.join('log/protege.log'))+.
50
+ attr_writer :logger
51
+
52
+ # Destination directories the extension scaffold generators (+protege:tool+, +:resolver+, +:hook+,
53
+ # +:persona+, +:provider+) write into, each relative to the app root. Defaults to the conventional
54
+ # +app/tools+, +app/resolvers+, +app/hooks+, +app/personas+, +app/providers+. Override in the
55
+ # initializer if a host groups its extensions elsewhere (e.g. +config.tools_path = 'app/agents/tools'+);
56
+ # the target must stay under an autoloaded path so Rails still loads the extension.
57
+ attr_accessor :tools_path, :resolvers_path, :hooks_path, :personas_path, :providers_path
58
+
59
+ # Per-provider options, keyed by provider id — assign a plain Hash (see {#providers}).
60
+ attr_writer :providers
61
+
62
+ # The inbound attachment limits — assign a +Gateway.build_attachment_policy+.
63
+ attr_writer :attachment_policy
64
+
65
+ # Set the global inbound access policy — assign a +Gateway.build_access_policy+.
66
+ attr_writer :inbound_access
67
+
68
+ # Inference tracing options — assign a plain Hash, e.g. +config.tracing = { enabled: true }+ (see
69
+ # {#tracing}). Off by default; nothing is captured until a host opts in.
70
+ attr_writer :tracing
71
+
72
+ # Initialize configuration with engine defaults.
73
+ #
74
+ # @return [void]
75
+ def initialize
76
+ @nav_title = '🥚 Protege'
77
+ @console_address = 'console@protege.local'
78
+ @http_user_agent = DEFAULT_HTTP_USER_AGENT
79
+ @max_tool_turns = 8
80
+ @provider_id = nil
81
+ @logger = nil
82
+ @tools_path = 'app/tools'
83
+ @resolvers_path = 'app/resolvers'
84
+ @hooks_path = 'app/hooks'
85
+ @personas_path = 'app/personas'
86
+ @providers_path = 'app/providers'
87
+ @providers = nil
88
+ @attachment_policy = nil
89
+ @inbound_access = nil
90
+ @tracing = nil
91
+ end
92
+
93
+ # Return the configured logger, lazily resolving a default.
94
+ #
95
+ # Falls back to +Rails.logger+ when running inside a Rails application, or a plain stdout logger
96
+ # otherwise (useful in isolated tests and scripts).
97
+ #
98
+ # @return [Logger] the active logger
99
+ def logger
100
+ @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout)
101
+ end
102
+
103
+ # Per-provider options, a plain Hash keyed by provider id — the Rails-style config seam every
104
+ # provider (built-in or host-written) reads its settings from. Assigned wholesale in the initializer;
105
+ # assignment replaces the whole Hash, so include every provider you configure:
106
+ #
107
+ # config.providers = {
108
+ # openrouter: { api_key: ENV['OPENROUTER_API_KEY'], base_url: '...' },
109
+ # my_llm: { api_key: ENV['MY_LLM_KEY'], region: 'us' }
110
+ # }
111
+ #
112
+ # Defaults to +{ openrouter: {} }+ so the built-in is harmless when unused. A provider reads its own
113
+ # slice via {#provider_options} (keyed by its +protege_id+). Values are opaque to the engine — a
114
+ # host-written provider may put any keys it likes under its own id and read them back here.
115
+ #
116
+ # @return [Hash{Symbol=>Hash}] per-provider options keyed by provider id
117
+ def providers
118
+ @providers ||= { openrouter: {} }
119
+ end
120
+
121
+ # The options Hash for one provider, by id — the seam a +Protege::Provider+ reads its settings from
122
+ # (typically +provider_options(self.class.id)+). Nil-safe: returns an empty Hash when the provider
123
+ # has no configured slice.
124
+ #
125
+ # @param id [Symbol] the provider id (its +protege_id+)
126
+ # @return [Hash] that provider's options, or +{}+ when unconfigured
127
+ def provider_options(id)
128
+ providers[id] || {}
129
+ end
130
+
131
+ # The inbound attachment limits, assigned in the initializer via
132
+ # +config.attachment_policy = Gateway.build_attachment_policy(...)+. Defaults to the engine's
133
+ # standard limits (10 MB/attachment, 10/message, 25 MB total). Consulted by the inbound mailbox and
134
+ # the +send_email+ tool.
135
+ #
136
+ # @return [Protege::Gateway::AttachmentPolicy] the attachment policy
137
+ def attachment_policy
138
+ @attachment_policy ||= Gateway.build_attachment_policy
139
+ end
140
+
141
+ # The global inbound access policy — the committed, org-wide ceiling on which senders may reach
142
+ # *any* persona. This is the static layer of the access-control guardrail; the runtime,
143
+ # per-persona layer lives in +Protege::AccessRule+ records, and +AccessControl+ intersects the two
144
+ # (each layer can only narrow, never widen).
145
+ #
146
+ # Defaults to a bare permit-everyone policy, built lazily on first read so an unconfigured engine
147
+ # imposes no constraint.
148
+ #
149
+ # @example Restrict every persona to the company domain
150
+ # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
151
+ #
152
+ # @return [Protege::Gateway::AccessPolicy] the configured policy, or a permit-everyone default
153
+ def inbound_access
154
+ @inbound_access || Gateway.build_access_policy
155
+ end
156
+
157
+ # Inference tracing options, a plain Hash — the on/off seam for durable turn snapshots. Assigned
158
+ # wholesale in the initializer (like {#providers}); assignment replaces the whole Hash. Defaults to
159
+ # +{ enabled: false }+, so an unconfigured engine records nothing. When +enabled+ is truthy, the
160
+ # harness emits a per-turn +InferenceGeneratedEvent+ that the tracing subscriber snapshots into
161
+ # +Protege::Trace+ rows.
162
+ #
163
+ # @example Turn tracing on
164
+ # config.tracing = { enabled: true }
165
+ #
166
+ # @return [Hash] the tracing options, defaulting to +{ enabled: false }+
167
+ def tracing
168
+ @tracing ||= { enabled: false }
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ # Rails-layer runtime dependencies used by the engine's +app/+ (views, controllers, helpers): Slim
6
+ # templating for the +.slim+ views, and Pagy for dashboard pagination. Required here, with the Engine,
7
+ # rather than in +protege.rb+ (which loads only the plain-Ruby core entry points).
8
+ require 'slim-rails'
9
+ require 'pagy'
10
+ require 'pagy/extras/overflow'
11
+
12
+ module Protege
13
+ # Rails engine that mounts Protege into a host application. This is the boot seam of the LOGI
14
+ # pipeline: it wires inbound email to the Gateway via Action Mailbox routing, exposes the engine's
15
+ # +lib/+ to autoloading so Orchestrator/Inference constructs resolve lazily, shares route helpers
16
+ # into the host's view context for Turbo broadcasts, and installs the inference stream broadcaster
17
+ # and engine migrations. It runs an isolated +Protege+ namespace so host and engine constants never
18
+ # collide.
19
+ class Engine < ::Rails::Engine
20
+ isolate_namespace Protege
21
+
22
+ # Expose engine lib/ to Rails autoloading (Harness, Provider, etc.). lib/ is autoload-only — not
23
+ # eager loaded — so Zeitwerk never tries to map filenames like version.rb to class constants.
24
+ config.autoload_paths << File.expand_path('..', __dir__)
25
+
26
+ # Add engine migrations to the host app so bin/rails db:migrate picks them up.
27
+ initializer 'protege.migrations' do |app|
28
+ config.paths['db/migrate'].expanded.each do |path|
29
+ app.config.paths['db/migrate'] << path
30
+ end
31
+ end
32
+
33
+ # Clamp an out-of-range +?page=+ to the last page rather than raising +Pagy::OverflowError+ (via the
34
+ # +overflow+ extra required above), so a stale or hand-typed page number degrades gracefully. We use
35
+ # only Pagy's lightweight metadata object and render our own sidebar footer markup.
36
+ initializer 'protege.pagy' do
37
+ Pagy::DEFAULT[:overflow] = :last_page
38
+ end
39
+
40
+ # Keep non-autoloadable lib/ subdirs out of Zeitwerk. Generators are loaded by Rails' generator
41
+ # system (and their +Protege::Generators::+ constants don't match the lib-rooted path Zeitwerk would
42
+ # expect); tasks are .rake. Apps would use +config.autoload_lib(ignore:)+, but that's unavailable
43
+ # for engines, so ignore explicitly — the form the autoloading guide documents for this case.
44
+ initializer 'protege.ignore_lib_non_autoloadable', before: :setup_main_autoloader do
45
+ Rails.autoloaders.main.ignore(lib_path('generators'),
46
+ lib_path('tasks'))
47
+ end
48
+
49
+ # Collapse the directories that merely group top-level constants so their files resolve to flat
50
+ # constants without a namespace segment: +errors/timeout_error.rb+ → +Protege::TimeoutError+ (not
51
+ # +Protege::Errors::TimeoutError+), +types/result.rb+ → +Protege::Result+, +extensions/tool.rb+ →
52
+ # +Protege::Tool+, +events/inference_started_event.rb+ → +Protege::InferenceStartedEvent+. The
53
+ # directories organize the files; +collapse+ keeps the constants flat.
54
+ initializer 'protege.collapse_shared', before: :setup_main_autoloader do
55
+ Rails.autoloaders.main.collapse lib_path('protege/errors')
56
+ Rails.autoloaders.main.collapse lib_path('protege/events')
57
+ Rails.autoloaders.main.collapse lib_path('protege/extensions')
58
+ Rails.autoloaders.main.collapse lib_path('protege/types')
59
+ end
60
+
61
+ # Route inbound Action Mailbox email addressed to a Protege domain to Protege::AgentMailbox — the
62
+ # Gateway's inbound edge. Scoped by +Gateway.claims?+ (matches a registered +EmailDomain+) rather
63
+ # than a catch-all, so mounting Protege into a host that also uses Action Mailbox never hijacks the
64
+ # host's other inbound mail; non-Protege mail falls through to the host's own routing.
65
+ initializer 'protege.action_mailbox_routing' do
66
+ ActiveSupport.on_load(:action_mailbox) do
67
+ routing ->(inbound_email) { Protege::Gateway.claims?(inbound_email:) } => 'protege/agent'
68
+ end
69
+ end
70
+
71
+ # Add the engine's route helpers to the host app's view context so partials rendered outside a request
72
+ # (e.g. Turbo broadcasts via ApplicationController.render) can resolve helpers like +thread_path+ without
73
+ # explicit host-app configuration.
74
+ initializer 'protege.add_route_helpers' do
75
+ ActiveSupport.on_load(:action_controller) do
76
+ helper Protege::Engine.routes.url_helpers
77
+ end
78
+ end
79
+
80
+ # Expose engine route helpers to all controllers and view contexts once routes are fully
81
+ # initialised. This ensures partials rendered outside a request (e.g. Turbo broadcasts via
82
+ # ApplicationController.render) can resolve helpers like +thread_path+ without explicit host-app
83
+ # configuration, and installs the engine's event subscribers (all under +Protege::Subscribers+): the
84
+ # introspection broadcaster (console activity feed), the hook dispatcher (fans events to host hooks —
85
+ # its dispatch reads +HookDispatcher+'s registry live, so reloaded hooks are picked up without
86
+ # re-subscribing), and tracing. Each exposes the +install!+ contract and subscribes once here.
87
+ config.after_initialize do
88
+ Protege::IntrospectionBroadcaster.install!
89
+ Protege::Subscribers::HookDispatcher.install!
90
+ Protege::Subscribers::Tracing.install!
91
+ end
92
+
93
+ # Return the absolute path to a file in the engine's lib/ directory. Used by the engine's rake tasks
94
+ # to locate templates and other static assets.
95
+ #
96
+ # @param rel_path [String] the relative path from lib/
97
+ # @return [String]
98
+ def lib_path(rel_path)
99
+ lib = File.expand_path('..', __dir__)
100
+ File.join(lib, rel_path)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider rejected the request as malformed
5
+ class BadRequestError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider rejected the request as malformed')
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # provider contract violated
5
+ class ContractViolationError < Error
6
+ def initialize(detail = nil) = super(detail || 'provider contract violated')
7
+ end
8
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for every Protege error. Rescue +Protege::Error+ to catch anything the engine raises
5
+ # without knowing the specific subclass; the concrete errors are flat top-level constants
6
+ # (+Protege::TimeoutError+, +Protege::ToolNotFoundError+, …) defined in +lib/protege/errors/+ (a
7
+ # collapsed autoload dir, so they read as +Protege::+ rather than +Protege::Errors::+).
8
+ #
9
+ # Errors are ownerless, pipeline-wide types, so they live at the top level (next to +Result+ and
10
+ # +Event+) rather than inside the Loop/Orchestrator/Gateway/Inference behavior namespaces.
11
+ class Error < StandardError
12
+ # Provider failures a later attempt may clear — a job's +retry_on+ list.
13
+ TRANSIENT = [
14
+ InternalError,
15
+ RateLimitedError,
16
+ TimeoutError,
17
+ UnavailableError
18
+ ].freeze
19
+
20
+ # Provider failures retrying cannot help — a job's +discard_on+ list.
21
+ PERMANENT = [
22
+ BadRequestError,
23
+ ContractViolationError,
24
+ InvalidModelError,
25
+ ResponseParseFailedError,
26
+ UnauthorizedError,
27
+ UnsupportedCapabilityError
28
+ ].freeze
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Wraps a plain-string reason so callers that expect an Exception on +Result#error+ always get one.
5
+ # Built automatically by +Result.failure+ when handed a String rather than an Exception.
6
+ class GenericError < Error; end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider reported an internal error
5
+ class InternalError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider reported an internal error')
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the configured model is not recognized by the provider
5
+ class InvalidModelError < Error
6
+ def initialize(detail = nil) = super(detail || 'the configured model is not recognized by the provider')
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # A tool's +#use+ returned something other than a +Protege::Result+. Tools must build their return
5
+ # value via +Result.success+ / +Result.failure+.
6
+ class InvalidToolResultError < Error
7
+ def initialize(tool:, got:) = super("#{tool}#use must return a Protege::Result, got #{got}")
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider is rate limiting requests
5
+ class RateLimitedError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider is rate limiting requests')
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # A +LoadFileResolver+'s configured path does not exist — a developer misconfiguration (the path is
5
+ # author-supplied), so we fail loudly rather than silently contributing nothing.
6
+ class ResolverFileNotFoundError < Error
7
+ def initialize(path:, absolute:) = super("no file at #{absolute} (from path #{path.inspect})")
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider response could not be parsed
5
+ class ResponseParseFailedError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider response could not be parsed')
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider request timed out
5
+ class TimeoutError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider request timed out')
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The model called a registered tool that is not in the current persona's scope — its code grant
5
+ # minus +disabled_tool_ids+ (see +Protege::ToolScoped+). Distinct from +ToolNotFoundError+ (the tool
6
+ # does not exist at all): here the tool exists but is withheld from this persona, so dispatch refuses
7
+ # it before +#use+ runs and the model gets a serialized failure to self-correct from. Guards the case
8
+ # where a tool disabled mid-thread is still visible to the model in earlier turns.
9
+ class ToolNotAvailableError < Error
10
+ def initialize(name:) = super("tool :#{name} is not available to this persona")
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The model requested a tool whose name is not in +ToolMixin.registered+. Distinct from runtime
5
+ # errors raised inside +#use+, which the harness wraps into the tool result so the model self-corrects.
6
+ class ToolNotFoundError < Error
7
+ def initialize(name:) = super("no tool registered for :#{name}")
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider rejected authentication
5
+ class UnauthorizedError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider rejected authentication')
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider is unavailable
5
+ class UnavailableError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider is unavailable')
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # the provider/model does not support the requested capability
5
+ class UnsupportedCapabilityError < Error
6
+ def initialize(detail = nil) = super(detail || 'the provider/model does not support the requested capability')
7
+ end
8
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for every Protege event — both the typed, nil-safe wrapper handed to subscribers and the
5
+ # class-level publish/subscribe surface. Concrete events are one flat class each (+InferenceStartedEvent+,
6
+ # +ToolCallCompletedEvent+, …) in +lib/protege/events/+ (a collapsed autoload dir, so they read as
7
+ # +Protege::+ rather than +Protege::Events::+), mirroring the +Protege::*Error+ hierarchy.
8
+ #
9
+ # Each subclass declares its +ActiveSupport::Notifications+ topic with the +channel+ class macro (the
10
+ # same class-attribute style as a provider's +protege_id+) and writes a plain reader per payload key.
11
+ # Publishing and subscribing go through the subclass:
12
+ #
13
+ # Protege::InferenceCompletedEvent.emit(persona:, message:, result:) # engine internals
14
+ # Protege::InferenceCompletedEvent.subscribe { |event| event.result } # host / broadcaster
15
+ #
16
+ # +correlation_id+ is injected automatically from +Protege::Current+ into every emitted event — set it
17
+ # once in +AgentMailbox+ and it rides through the whole processing run. A subscriber's block receives a
18
+ # frozen instance of the specific event class, so +event.persona+ / +event.result+ read the payload
19
+ # with named, nil-safe accessors (and +event[:key]+ reaches anything without a dedicated reader).
20
+ class Event
21
+ # @return [Hash] the underlying payload hash
22
+ attr_reader :payload
23
+
24
+ # Wrap a raw notification payload; frozen so subscribers can't mutate shared state.
25
+ #
26
+ # @param payload [Hash] the event payload as published by {emit}
27
+ # @return [void]
28
+ def initialize(payload)
29
+ @payload = payload
30
+ freeze
31
+ end
32
+
33
+ # @return [String, nil] the per-run correlation id injected by {emit}
34
+ def correlation_id = @payload[:correlation_id]
35
+
36
+ # Read an arbitrary payload key not covered by a named reader.
37
+ #
38
+ # @param key [Symbol] the payload key
39
+ # @return [Object, nil] the value, or nil when absent
40
+ def [](key) = @payload[key]
41
+
42
+ class << self
43
+ # Declare (with an argument) or read (without) this event's +ActiveSupport::Notifications+ topic.
44
+ # Called in the subclass body — the same class-attribute idiom as a provider's +protege_id+.
45
+ #
46
+ # @param value [String, nil] the topic to set, or nil to read the declared one
47
+ # @return [String] the topic
48
+ def channel(value = nil)
49
+ value ? @channel = value : @channel
50
+ end
51
+
52
+ # Publish this event, injecting +correlation_id+ from +Protege::Current+ so callers never pass it.
53
+ #
54
+ # @param payload [Hash] the event's payload keyed by symbol
55
+ # @return [void]
56
+ def emit(**payload)
57
+ ActiveSupport::Notifications.instrument(channel,
58
+ correlation_id: Protege::Current.correlation_id,
59
+ **payload)
60
+ end
61
+
62
+ # Subscribe to this event, yielding a typed instance of this class (not the raw payload hash).
63
+ #
64
+ # @yieldparam event [Protege::Event] the wrapped event
65
+ # @return [Object] the subscription handle returned by +ActiveSupport::Notifications+
66
+ def subscribe(&block)
67
+ ActiveSupport::Notifications.subscribe(channel) do |_name, _start, _finish, _id, payload|
68
+ block.call new(payload)
69
+ end
70
+ end
71
+
72
+ # Every concrete event class, in fire order. Built lazily on first call (at boot, by
73
+ # +Subscribers::HookDispatcher.install!+), so each subclass is autoloaded only when referenced
74
+ # here — after this base and its +channel+ macro are defined — keeping +lib/+ autoload-only with
75
+ # no load-order constraint.
76
+ #
77
+ # @return [Array<Class>] the event classes
78
+ def all
79
+ @all ||= [
80
+ InferenceStartedEvent,
81
+ InferenceGeneratedEvent,
82
+ InferenceCompletedEvent,
83
+ InferenceFailedEvent,
84
+ ToolCallsReceivedEvent,
85
+ ToolCallStartedEvent,
86
+ ToolCallCompletedEvent,
87
+ ToolCallFailedEvent,
88
+ InferenceMaxTurnsReachedEvent,
89
+ InferenceChunkEvent,
90
+ LoopRunEnqueuedEvent,
91
+ LoopRunStartedEvent,
92
+ LoopRunCompletedEvent,
93
+ LoopRunFailedEvent
94
+ ].freeze
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired for each token chunk during streaming inference.
5
+ class InferenceChunkEvent < Event
6
+ channel 'protege.inference.chunk'
7
+
8
+ def persona = self[:persona]
9
+ def thread = self[:thread]
10
+ def chunk = self[:chunk]
11
+
12
+ # The decoded text of the streamed chunk, if any.
13
+ #
14
+ # @return [String, nil] the chunk's +:content+ as a string, or nil when there is no chunk
15
+ def chunk_content = chunk && chunk[:content].to_s
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired after the tool loop returns a final, tool-free response.
5
+ class InferenceCompletedEvent < Event
6
+ channel 'protege.inference.completed'
7
+
8
+ def persona = self[:persona]
9
+ def message = self[:message]
10
+ def result = self[:result]
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when a run raises before completing.
5
+ class InferenceFailedEvent < Event
6
+ channel 'protege.inference.failed'
7
+
8
+ def persona = self[:persona]
9
+ def message = self[:message]
10
+ def error = self[:error]
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired after every single provider generation within the tool loop — once per turn, including the
5
+ # final tool-free turn. Unlike +InferenceCompletedEvent+ (one per run, carrying only the final
6
+ # result), this carries the wire +request+ as sent, the +response+ it produced, and the inference
7
+ # settings (+model+ + +settings+) that shaped it — everything a trace needs to be a training example,
8
+ # and nothing else (no persona/provider identity). This is the seam the tracing subscriber listens on;
9
+ # +correlation_id+ (from +Protege::Current+) and +turn+ group the turns of one run for dedup.
10
+ class InferenceGeneratedEvent < Event
11
+ channel 'protege.inference.generated'
12
+
13
+ def turn = self[:turn]
14
+ def request = self[:request]
15
+ def response = self[:response]
16
+ def model = self[:model]
17
+ def settings = self[:settings]
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when the tool loop hits the max turn count without completing.
5
+ class InferenceMaxTurnsReachedEvent < Event
6
+ channel 'protege.inference.max_turns_reached'
7
+
8
+ def persona = self[:persona]
9
+ def turn = self[:turn]
10
+ def tool_calls = self[:tool_calls]
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired at the start of the tool loop, before the first provider call.
5
+ class InferenceStartedEvent < Event
6
+ channel 'protege.inference.started'
7
+
8
+ def persona = self[:persona]
9
+ def message = self[:message]
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when a scheduled responsibility run completes successfully (the Loop layer).
5
+ class LoopRunCompletedEvent < Event
6
+ channel 'protege.loop.run.completed'
7
+
8
+ def name = self[:name]
9
+ def persona_name = self[:persona_name]
10
+ end
11
+ end