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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when a scheduled responsibility run is recorded pending and enqueued, before a worker picks
5
+ # it up (the Loop layer).
6
+ class LoopRunEnqueuedEvent < Event
7
+ channel 'protege.loop.run.enqueued'
8
+
9
+ def name = self[:name]
10
+ def persona_name = self[:persona_name]
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when a scheduled responsibility run raises (the Loop layer).
5
+ class LoopRunFailedEvent < Event
6
+ channel 'protege.loop.run.failed'
7
+
8
+ def name = self[:name]
9
+ def persona_name = self[:persona_name]
10
+ def error = self[:error]
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when a scheduled responsibility run begins (the Loop layer).
5
+ class LoopRunStartedEvent < Event
6
+ channel 'protege.loop.run.started'
7
+
8
+ def name = self[:name]
9
+ def persona_name = self[:persona_name]
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired after a tool call returns a successful result.
5
+ class ToolCallCompletedEvent < Event
6
+ channel 'protege.inference.tool_call.completed'
7
+
8
+ def persona = self[:persona]
9
+ def tool_call = self[:tool_call]
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 tool call raises an unhandled exception.
5
+ class ToolCallFailedEvent < Event
6
+ channel 'protege.inference.tool_call.failed'
7
+
8
+ def persona = self[:persona]
9
+ def tool_call = self[:tool_call]
10
+ def error = self[:error]
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired immediately before a tool call is dispatched.
5
+ class ToolCallStartedEvent < Event
6
+ channel 'protege.inference.tool_call.started'
7
+
8
+ def persona = self[:persona]
9
+ def tool_call = self[:tool_call]
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Fired when the provider returns tool calls in a single turn.
5
+ class ToolCallsReceivedEvent < Event
6
+ channel 'protege.inference.tool_calls.received'
7
+
8
+ def persona = self[:persona]
9
+ def tool_calls = self[:tool_calls]
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for hooks — the surface host apps use to react to engine lifecycle events. Host apps
5
+ # subclass +Protege::Hook+ and declare handlers with the class-level +on+ DSL; each handler block
6
+ # receives a typed +Protege::Event+. The declaration contract (the +on+/+stateful+ DSL) lives in
7
+ # +HookMixin+, which this includes; the runtime that wires those declarations to the event bus and
8
+ # runs them (isolated dispatch, stateful-instance lifecycle) is +Protege::Subscribers::HookDispatcher+,
9
+ # installed once at boot.
10
+ class Hook
11
+ include HookMixin
12
+ end
13
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Contract mixin for hooks — the surface host apps use to *declare* reactions to engine lifecycle
5
+ # events. A concrete hook subclasses +Protege::Hook+ (which includes this module) and declares
6
+ # handlers with the class-level +on+ DSL, naming the event classes it cares about; each handler block
7
+ # receives an instance of that +Protege::Event+ subclass.
8
+ #
9
+ # This module is *declaration only*. +on+ merely records the block in the class's +subscriptions+
10
+ # data, so a Zeitwerk reload just rebuilds that data. *Activation* — subscribing to the event bus and
11
+ # running handlers — is a separate concern owned by +Protege::Subscribers::HookDispatcher+, which
12
+ # reads this contract (+subscriptions+, +stateful?+) off each hook class when an event fires.
13
+ #
14
+ # Handler blocks run (by the dispatcher) via +instance_exec+ on a hook instance, so instance helper
15
+ # methods — and, for +stateful+ hooks, instance variables across a run — are available inside them.
16
+ #
17
+ # @example
18
+ # class EventLoggerHook < Protege::Hook
19
+ # on Protege::InferenceStartedEvent do |event|
20
+ # log "START #{event.persona&.name}"
21
+ # end
22
+ #
23
+ # on Protege::ToolCallStartedEvent, Protege::ToolCallCompletedEvent do |event|
24
+ # log "TOOL #{event.tool_call&.name}"
25
+ # end
26
+ #
27
+ # private
28
+ #
29
+ # def log(line) = Protege.configuration.logger.info(line)
30
+ # end
31
+ module HookMixin
32
+ # Extend the includer with +ClassMethods+ so the +on+/+stateful+ DSL becomes available.
33
+ #
34
+ # @param base [Class] the class including +Hook+
35
+ # @return [void]
36
+ def self.included(base)
37
+ base.extend(ClassMethods)
38
+ end
39
+
40
+ # Class-level DSL mixed into every hook class via +Hook.included+.
41
+ module ClassMethods
42
+ # Declare a handler for one or more event classes.
43
+ #
44
+ # Pure declaration: records the block in +subscriptions+; the event bus is wired separately by
45
+ # +Protege::Subscribers::HookDispatcher+. Each argument must be a known +Protege::Event+ subclass.
46
+ # Listing several registers the same block under each. A single event may carry several handlers;
47
+ # they run in declaration order. Each block is invoked with the event instance via +instance_exec+.
48
+ #
49
+ # @param event_classes [Array<Class>] the event classes to handle (e.g. +Protege::InferenceStartedEvent+)
50
+ # @param block [Proc] the handler, receiving the event instance
51
+ # @return [void]
52
+ # @raise [ArgumentError] when an argument is not a known event class
53
+ def on(*event_classes, &block)
54
+ event_classes.each do |event_class|
55
+ unless subscriptions.key?(event_class)
56
+ raise ArgumentError, "unknown event #{event_class.inspect} (known: #{Protege::Event.all.join(', ')})"
57
+ end
58
+
59
+ subscriptions[event_class] << block
60
+ end
61
+ end
62
+
63
+ # Declare that this hook keeps state across a single run.
64
+ #
65
+ # A stateful hook reuses one instance per +correlation_id+ for the lifetime of a run, so instance
66
+ # variables set in one handler are visible to later handlers; the instance is evicted when the run
67
+ # ends. Stateless hooks (the default) get a fresh instance per event.
68
+ #
69
+ # @return [void]
70
+ def stateful
71
+ @stateful = true
72
+ end
73
+
74
+ # @return [Boolean] true when this hook was declared +stateful+
75
+ def stateful?
76
+ @stateful || false
77
+ end
78
+
79
+ # The event subscriptions, keyed by every known event class.
80
+ #
81
+ # Pre-seeded with one entry per +Protege::Event.all+ (each an empty array), so the map is also the
82
+ # set of valid +on+ targets. Handler blocks accumulate in declaration order.
83
+ #
84
+ # @return [Hash{Class => Array<Proc>}] each known event class mapped to its handler blocks
85
+ def subscriptions
86
+ @subscriptions ||= Protege::Event.all.index_with { [] }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for LLM providers. Host apps subclass +Protege::Provider+, declare their id with
5
+ # +protege_id :symbol+, and implement +#generate(request)+ (optionally +#generate_stream+). The
6
+ # provider contract lives in +ProviderMixin+, which this includes; subclasses are discovered via
7
+ # +Provider.descendants+ (see +ProviderMixin.registered+). The request/response value objects it
8
+ # exchanges are the +Inference::Provider::*+ types.
9
+ class Provider
10
+ include ProviderMixin
11
+ end
12
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Marker mixin for LLM provider extensions.
5
+ #
6
+ # A concrete provider class subclasses +Protege::Provider+ (which includes this module) and
7
+ # declares its symbolic identifier via the +protege_id+ class macro:
8
+ #
9
+ # class OpenRouterProvider < Protege::Provider
10
+ # protege_id :openrouter
11
+ #
12
+ # def initialize(api_key:); @api_key = api_key; end
13
+ # def generate(request); ...; end
14
+ # end
15
+ #
16
+ # Providers are referenced by id (the symbol passed to +protege_id+). On include, the class is
17
+ # auto-tracked in +Provider.registered+; the registry uses this list to map a configured
18
+ # +provider_id+ to its class without scanning ObjectSpace or requiring explicit registration calls.
19
+ #
20
+ # The generation request/response value objects it exchanges live in the +Inference::Provider+
21
+ # namespace (+Inference::Provider::Request+, +::Response+, …).
22
+ #
23
+ # Contract:
24
+ #
25
+ # def self.id; end # returns the Symbol declared above (provided by the DSL)
26
+ # def generate(request); end # Request -> Response, raises a Protege::*Error on failure
27
+ module ProviderMixin
28
+ class << self
29
+ # Extend the including class with the +protege_id+/+id+ DSL.
30
+ #
31
+ # @param base [Class] the class that included +Provider+.
32
+ # @return [void]
33
+ def included(base)
34
+ base.extend(ClassMethods)
35
+ end
36
+
37
+ # List every concrete provider class.
38
+ #
39
+ # Reads from +Provider.descendants+ so the registry survives Zeitwerk reloads without a
40
+ # manual registration step.
41
+ #
42
+ # @return [Array<Class>] all provider subclasses currently loaded.
43
+ def registered
44
+ Provider.descendants
45
+ end
46
+ end
47
+
48
+ # Generate a response for +request+ (abstract).
49
+ #
50
+ # The provider contract is **hash in, hash out**: +request+ is the standard (OpenAI-compatible)
51
+ # request hash — +{ messages:, tools: }+ — and the return is the response hash —
52
+ # +{ content:, tool_calls:, finish_reason: }+ (see the providers doc). The provider adds its own
53
+ # model/sampling/credentials from config, and raises a +Protege::Error+ subclass on transport, auth,
54
+ # or parse failure. Providers never touch Protege's internal value types. Concrete providers must
55
+ # override this; the default body exists only to give a clear failure when one forgets to.
56
+ #
57
+ # @param _request [Hash] the request hash (unused in the default).
58
+ # @return [Hash] never returns; always raises.
59
+ # @raise [NotImplementedError] always, naming the class that failed to implement +#generate+.
60
+ def generate(_request)
61
+ raise NotImplementedError,
62
+ "#{self.class} must implement #generate(request_hash) -> response hash"
63
+ end
64
+
65
+ # Generate a response, yielding incremental chunks as they arrive.
66
+ #
67
+ # The default implementation does not truly stream: it calls +#generate+ and yields the whole
68
+ # text as one +:text+ chunk (only when non-blank), then returns the response hash. Providers with
69
+ # native streaming should override this to yield finer-grained chunks.
70
+ #
71
+ # @param request [Hash] the request hash.
72
+ # @yield [chunk] each streamed chunk.
73
+ # @yieldparam chunk [Hash] +{ type: :text | :thinking | :tool_call, content: String }+.
74
+ # @return [Hash] the final, complete response hash.
75
+ def generate_stream(request, &)
76
+ response = generate(request)
77
+ yield({ type: :text, content: response[:content] }) if response[:content].present?
78
+ response
79
+ end
80
+
81
+ # The model id this provider targets, read from its own +config.providers+ slice (+:model+).
82
+ # Public so the harness can record which model produced a turn (for tracing); resolved per call, so
83
+ # a future persona-level override is reflected here. Providers may treat a missing model as a hard
84
+ # error at generation time — that enforcement is theirs, not this soft reader's.
85
+ #
86
+ # @return [String, nil] the configured model id, or nil when unset
87
+ def model
88
+ Protege.configuration.provider_options(self.class.id)[:model]
89
+ end
90
+
91
+ # The sampling settings this provider applies beyond the model — the generation knobs (temperature,
92
+ # max tokens, …) that shape the output and so matter for training/reproducibility. The default is
93
+ # empty; a provider overrides this to expose its own knobs. Captured alongside +#model+ in a trace.
94
+ #
95
+ # @return [Hash] the sampling settings (empty by default)
96
+ def settings
97
+ {}
98
+ end
99
+
100
+ # Class-level DSL provided by the mixin.
101
+ module ClassMethods
102
+ # Declare the symbolic identifier this provider responds to.
103
+ #
104
+ # Call inside the class body; the configured +provider_id+ references the provider by this symbol.
105
+ #
106
+ # @param symbol [Symbol] the provider's unique identifier (e.g. +:openrouter+).
107
+ # @return [Symbol] the stored identifier.
108
+ def protege_id(symbol)
109
+ @protege_id = symbol
110
+ end
111
+
112
+ # Return the declared symbolic id.
113
+ #
114
+ # @return [Symbol] the id set via +protege_id+.
115
+ # @raise [Protege::ContractViolationError] when the class never called +protege_id+.
116
+ def id
117
+ @protege_id || raise(
118
+ Protege::ContractViolationError,
119
+ "#{self} must declare its id with `protege_id :<symbol>` inside the class body"
120
+ )
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for resolvers — the Orchestrator's mechanism for assembling inference context (system
5
+ # prompts, history, retrieved knowledge). Host apps subclass +Protege::Resolver+ and implement
6
+ # +#resolve(context:)+, returning an +Array<Protege::ModelMessage>+ or nil. The contract lives
7
+ # in +ResolverMixin+, which this includes. Unlike tools, resolvers are not auto-registered — they are
8
+ # added explicitly to a persona's +ResolverChain+.
9
+ class Resolver
10
+ include ResolverMixin
11
+ end
12
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Contract mixin for resolver classes. A resolver receives a +Context+ and returns an
5
+ # +Array<Protege::ModelMessage>+ (one or more messages to prepend to the inference request)
6
+ # or +nil+ to contribute nothing. Resolvers are the Orchestrator's mechanism for assembling
7
+ # inference context — system prompts, conversation history, retrieved knowledge.
8
+ #
9
+ # Unlike +Tool+, resolvers are NOT auto-registered. They are explicitly added to a persona's
10
+ # +ResolverChain+ at declaration time. Host resolvers subclass +Protege::Resolver+, which
11
+ # includes this module.
12
+ #
13
+ # The role of returned messages is the developer's choice:
14
+ # - +role: :system+ → contributes to the system prompt
15
+ # - +role: :user+ / +:assistant+ → contributes to conversation history
16
+ #
17
+ # Build messages with the +#message+ helper rather than constructing +Protege::ModelMessage+ by hand
18
+ # — the resolver counterpart of +Tool+'s +success+/+failure+ sugar:
19
+ #
20
+ # @example
21
+ # class MyResolver < Protege::Resolver
22
+ # def resolve(context:)
23
+ # message(role: :system, content: "You are #{context.persona.name}.")
24
+ # end
25
+ # end
26
+ module ResolverMixin
27
+ # Produce context messages for the inference request. Concrete resolvers must override.
28
+ #
29
+ # @param context [Protege::Orchestrator::Context] the per-delivery resolver context
30
+ # @return [Array<Object>, nil] messages to prepend, or nil to contribute nothing
31
+ # @raise [NotImplementedError] always, when not overridden by the including class
32
+ def resolve(context:)
33
+ raise NotImplementedError,
34
+ "#{self.class} must implement #resolve(context:) -> Array<ModelMessage> | nil"
35
+ end
36
+
37
+ # Build one context message to contribute from +#resolve+, without constructing
38
+ # +Protege::ModelMessage+ by hand — the resolver counterpart of +Tool+'s +success+/+failure+ sugar.
39
+ #
40
+ # Return the message directly (a lone message) or collect several in an array; the +ResolverChain+
41
+ # coerces either shape and skips a +nil+ return, so a resolver that contributes nothing just
42
+ # returns +nil+.
43
+ #
44
+ # @example a system prompt
45
+ # message(role: :system, content: summary.to_prompt)
46
+ #
47
+ # @param role [Symbol] +:system+ (prompt), +:user+ / +:assistant+ (history), or +:tool+ (a
48
+ # replayed tool result)
49
+ # @param content [String, Array] the text, or multimodal parts (+TextPart+ / +ImagePart+ /
50
+ # +DocumentPart+)
51
+ # @param tool_call_id [String, nil] correlates a +role: :tool+ message back to its originating call
52
+ # @param tool_calls [Array, nil] the tool calls carried by a +role: :assistant+ message
53
+ # @return [Protege::ModelMessage] the assembled message
54
+ def message(role:, content:, tool_call_id: nil, tool_calls: nil)
55
+ Protege::ModelMessage.new(role:, content:, tool_call_id:, tool_calls:)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base class for harness tools — the surface the agent uses to take action. Host apps subclass
5
+ # +Protege::Tool+, declare +description+/+input_schema+ via the class DSL, and implement
6
+ # +#use(context:, **input)+ returning a +Protege::Result+. The tool contract (registry, dispatch, the
7
+ # DSL, and the +#success+/+#failure+ helpers) lives in +ToolMixin+, which this includes; every subclass
8
+ # is auto-discovered via +Tool.descendants+ (see +ToolMixin.registered+).
9
+ class Tool
10
+ include ToolMixin
11
+ end
12
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Contract mixin for harness tools — the surface the agent uses to take action within the LOGI
5
+ # Orchestrator. Concrete tools subclass +Protege::Tool+ (which includes this module), declare
6
+ # their description and JSON Schema via the class-level DSL, and implement +#use(context:, **input)+.
7
+ #
8
+ # Registration is automatic: every +Tool+ subclass is discoverable via +Tool.registered+
9
+ # (resolved from +Tool.descendants+, so it survives Zeitwerk reloads), and the +Harness+
10
+ # enumerates that list to build the LLM-visible tool catalog. Tools have no +protege_id+ DSL — the
11
+ # id is derived from the class basename with the +Tool+ suffix stripped, then snake_cased
12
+ # (+SendEmailTool+ → +:send_email+, +HTTPFetchTool+ → +:http_fetch+).
13
+ #
14
+ # Tools must return a +Protege::Result+ from +#use+ — construct one via +Result.success(**data)+ /
15
+ # +Result.failure(reason:, **data)+ (or the +#success+/+#failure+ helpers). The +Harness+ serializes
16
+ # the result to JSON for the LLM's tool message, so a failed result lets the model self-correct.
17
+ #
18
+ # @example A concrete tool
19
+ # class SendEmailTool < Protege::Tool
20
+ # description 'Sends an email reply to the inbound sender.'
21
+ #
22
+ # input_schema(
23
+ # type: 'object',
24
+ # required: %w[subject body],
25
+ # properties: {
26
+ # subject: { type: 'string' },
27
+ # body: { type: 'string' }
28
+ # }
29
+ # )
30
+ #
31
+ # def use(context:, subject:, body:)
32
+ # # ...
33
+ # end
34
+ # end
35
+ #
36
+ # == Contract
37
+ #
38
+ # def self.id; end # Symbol derived from class basename
39
+ # def self.description; end # String declared via the DSL
40
+ # def self.input_schema; end # Hash declared via the DSL (JSON Schema)
41
+ # def use(context:, **input); end # Context + parsed input → Protege::Result
42
+ module ToolMixin
43
+ class << self
44
+ # Extend the includer with +ClassMethods+ so the class-level DSL becomes available.
45
+ #
46
+ # @param base [Class] the class including +Tool+
47
+ # @return [void]
48
+ def included(base)
49
+ base.extend(ClassMethods)
50
+ end
51
+
52
+ # List all concrete tool classes.
53
+ #
54
+ # Resolved via +Tool.descendants+ so the registry survives Zeitwerk reloads without manual
55
+ # bookkeeping. Anonymous classes (nil name) are excluded.
56
+ #
57
+ # @return [Array<Class>] the registered tool classes
58
+ def registered
59
+ Tool.descendants.reject { _1.name.nil? }
60
+ end
61
+
62
+ # Find a tool instance by its string id.
63
+ #
64
+ # @param name [String] the tool id to look up
65
+ # @return [Object] a fresh instance of the matching tool class
66
+ # @raise [Protege::ToolNotFoundError] when no tool matches +name+
67
+ def find!(name)
68
+ klass = registered.find { _1.id.to_s == name }
69
+ raise Protege::ToolNotFoundError.new(name:) unless klass
70
+
71
+ klass.new
72
+ end
73
+
74
+ # Look up a tool by name, execute it, and pair the call with its outcome.
75
+ #
76
+ # Both failure modes resolve to a failed +Result+ so the caller (the +Harness+) never sees a
77
+ # raw error — the model gets a serialized failure it can self-correct from:
78
+ #
79
+ # * an exception raised inside +#use+ (or by +find!+) is caught, and
80
+ # * a +#use+ that returns a non-+Result+ violates the tool contract and is reported as
81
+ # +Protege::InvalidToolResultError+, rather than blowing up later when the +Harness+
82
+ # calls +#success?+ on the bad value.
83
+ #
84
+ # Enforcement of tool scoping lives here: a call to any tool outside the persona's effective scope
85
+ # (its code grant minus +disabled_tool_ids+, read from +context.persona+) is refused before +#use+
86
+ # runs. Advertising a narrowed catalogue in the request isn't enough — a tool disabled mid-thread
87
+ # stays visible to the model in earlier turns and could still be called — so dispatch is the
88
+ # backstop.
89
+ #
90
+ # @param tool_call [Object] the tool call carrying name and input
91
+ # @param context [Protege::Orchestrator::Context] the per-delivery tool context
92
+ # @return [Protege::ToolResult] the call paired with its result
93
+ def invoke(tool_call, context:)
94
+ if context.persona.available_tool_ids.exclude?(tool_call.name.to_sym)
95
+ return failed_tool_result(tool_call:,
96
+ error: Protege::ToolNotAvailableError.new(name: tool_call.name))
97
+ end
98
+
99
+ tool = find!(tool_call.name)
100
+ input = (tool_call.input || {}).symbolize_keys
101
+ result = tool.use(context:, **input)
102
+
103
+ return Protege::ToolResult.new(tool_call:, result:) if result.is_a?(Result)
104
+
105
+ failed_tool_result(tool_call:,
106
+ error: Protege::InvalidToolResultError.new(tool: tool.class, got: result.class))
107
+ rescue StandardError => e
108
+ failed_tool_result(tool_call:, error: e)
109
+ end
110
+
111
+ private
112
+
113
+ # Build a failed +ToolResult+, logging the full error for operators.
114
+ #
115
+ # The agent only ever receives the serialized (backtrace-truncated) failure via the tool
116
+ # message; the complete error and backtrace are written to the log here so debugging context
117
+ # is never lost.
118
+ #
119
+ # @param tool_call [Object] the tool call that failed
120
+ # @param error [Exception] the captured error or contract violation
121
+ # @return [Protege::ToolResult] a failed result wrapping +error+
122
+ def failed_tool_result(tool_call:, error:)
123
+ Protege.configuration.logger.error(
124
+ "[Protege] tool #{tool_call.name} failed: #{error.class}: #{error.message}\n" \
125
+ "#{error.backtrace&.join("\n")}"
126
+ )
127
+ Protege::ToolResult.new(tool_call:,
128
+ result: Result.failure(reason: error))
129
+ end
130
+ end
131
+
132
+ # Execute the tool's work. Concrete tools must override.
133
+ #
134
+ # @param context [Protege::Orchestrator::Context] the per-delivery tool context
135
+ # @param _input [Hash] the parsed tool input keyed by symbol
136
+ # @return [Protege::Result] the outcome of the tool's work
137
+ # @raise [NotImplementedError] always, when not overridden by the including class
138
+ def use(context:, **_input)
139
+ raise NotImplementedError,
140
+ "#{self.class} must implement #use(context:, **input) -> Protege::Result"
141
+ end
142
+
143
+ # Build a successful +Protege::Result+.
144
+ #
145
+ # Call from +#use+ to signal the tool completed its work. +data+ becomes the payload the LLM sees
146
+ # in the tool-result message.
147
+ #
148
+ # @example
149
+ # success(message_id: reply.message_id)
150
+ #
151
+ # @param data [Hash] arbitrary payload keyed by symbol
152
+ # @return [Protege::Result] a successful result
153
+ def success(**data)
154
+ Result.success(**data)
155
+ end
156
+
157
+ # Build a failed +Protege::Result+.
158
+ #
159
+ # Call from +#use+ to signal the tool could not complete its work. +reason+ may be a String or an
160
+ # Exception; strings are wrapped in +Protege::GenericError+. +data+ is merged into the payload.
161
+ #
162
+ # @example
163
+ # failure(reason: 'recipient looks invalid', to: to)
164
+ # failure(reason: e, transport: 'smtp')
165
+ #
166
+ # @param reason [String, Exception] the failure cause
167
+ # @param data [Hash] arbitrary payload keyed by symbol
168
+ # @return [Protege::Result] a failed result
169
+ def failure(reason:, **data)
170
+ Result.failure(reason:, **data)
171
+ end
172
+
173
+ # Class-level DSL mixed into every tool class via +Tool.included+.
174
+ module ClassMethods
175
+ # Declare or read the natural-language description shown to the LLM in the tool catalog.
176
+ #
177
+ # With an argument, sets the description; with no argument, returns the previously declared
178
+ # value.
179
+ #
180
+ # @param text [String, nil] the description to set, or nil to read
181
+ # @return [String, nil] the current description
182
+ def description(text = nil)
183
+ return @description if text.nil?
184
+
185
+ @description = text
186
+ end
187
+
188
+ # Declare or read the JSON Schema describing this tool's input parameters.
189
+ #
190
+ # With an argument, sets the schema; with no argument, returns the previously declared value.
191
+ #
192
+ # @param schema [Hash, nil] the JSON Schema to set, or nil to read
193
+ # @return [Hash, nil] the current input schema
194
+ def input_schema(schema = nil)
195
+ return @input_schema if schema.nil?
196
+
197
+ @input_schema = schema
198
+ end
199
+
200
+ # Return the tool's symbolic identifier, derived from the class basename with the +Tool+
201
+ # suffix stripped and the rest snake_cased. Every concrete tool is named +<Purpose>Tool+ by
202
+ # convention (so tool classes never collide with same-named models), yet the id the agent sees
203
+ # stays clean. Memoized per class.
204
+ #
205
+ # @example
206
+ # SendEmailTool # => :send_email
207
+ # HTTPFetchTool # => :http_fetch
208
+ # EchoTool # => :echo
209
+ # Read2Tool # => :read2
210
+ #
211
+ # @return [Symbol] the tool id
212
+ def id
213
+ @id ||= name.demodulize.delete_suffix('Tool').underscore.to_sym
214
+ end
215
+ end
216
+ end
217
+ end