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,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Protege
6
+ module Orchestrator
7
+ # Multi-turn inference runner — the heart of the LOGI Orchestrator. Generates against the
8
+ # configured provider and dispatches tool calls until the model returns a tool-free response or
9
+ # +max_tool_turns+ is reached.
10
+ #
11
+ # This is the **abstract base** that owns the shared algorithm (event-wrapped tool loop, provider
12
+ # generation, tool dispatch, attachment review). What *drives* a run is left to subclasses, which
13
+ # each define one clean path: +ReplyHarness+ for a reactive run (an inbound email) and
14
+ # +ResponsibilityHarness+ for a proactive one (a scheduled Loop responsibility). The branch between
15
+ # them lives at the job boundary — the caller picks the subclass — so there are no run-kind
16
+ # conditionals in this loop. Subclasses fill the hooks: +#resolvers+, +#user_content+, +#context+,
17
+ # and (reply only) +#message+ / +#streaming?+.
18
+ #
19
+ # +Protege::Current.correlation_id+ must be set before calling so that emitted events carry the id
20
+ # automatically. Collaborates with +Inference+ (request building and generation), +Tool+ (dispatch
21
+ # and registry), the persona's +ResolverChain+ (context assembly), and the +Protege::*Event+ classes
22
+ # (observability).
23
+ #
24
+ # @example
25
+ # ReplyHarness.run(persona:, message:)
26
+ # ResponsibilityHarness.run(persona:, responsibility:)
27
+ #
28
+ # == Lifecycle
29
+ #
30
+ # 1. Emit +inference_started+ event.
31
+ # 2. Build the initial +Provider::Request+ from the opening user turn, registered tools, and
32
+ # resolver-contributed context messages.
33
+ # 3. Enter the tool loop — generate, dispatch any tool calls, extend the request with results,
34
+ # repeat until the model returns no tool calls or +max_tool_turns+ is exhausted.
35
+ # 4. Emit +inference_completed+ event and return the final response.
36
+ class Harness
37
+ class << self
38
+ # Instantiate the concrete harness and run it in one call. Keyword arguments are forwarded to
39
+ # the subclass initializer (e.g. +message:+ for +ReplyHarness+, +responsibility:+ for
40
+ # +ResponsibilityHarness+).
41
+ #
42
+ # @return [Object] the final +Provider::Response+
43
+ def run(**)
44
+ new(**).run
45
+ end
46
+ end
47
+
48
+ # @param persona [Protege::Persona] the persona handling the run
49
+ # @return [void]
50
+ def initialize(persona:)
51
+ @persona = persona
52
+ end
53
+
54
+ # Entry point. Emit lifecycle events around the tool loop.
55
+ #
56
+ # Always emits a terminal event — +inference_completed+ on success or +inference_failed+ when the
57
+ # loop raises — so subscribers (and stateful hooks keyed by correlation id) are never left
58
+ # waiting on a completion that never comes. The error is re-raised so the job's retry/discard
59
+ # policy still applies.
60
+ #
61
+ # @return [Object] the final +Provider::Response+
62
+ # @raise [StandardError] re-raised after +inference_failed+ is emitted
63
+ def run
64
+ InferenceStartedEvent.emit(persona:, message:)
65
+
66
+ result = run_tool_loop
67
+
68
+ InferenceCompletedEvent.emit(persona:, message:, result:)
69
+
70
+ result
71
+ rescue StandardError => e
72
+ InferenceFailedEvent.emit(persona:, message:, error: e)
73
+ raise
74
+ end
75
+
76
+ private
77
+
78
+ # @return [Protege::Persona] the persona handling the run
79
+ attr_reader :persona
80
+
81
+ # ── Kind-specific hooks (subclasses override) ─────────────────────────────
82
+
83
+ # The resolver chain that assembles this run's context messages.
84
+ # @return [Orchestrator::ResolverChain]
85
+ def resolvers
86
+ raise NotImplementedError, "#{self.class} must implement #resolvers"
87
+ end
88
+
89
+ # The per-run context shared by every tool and resolver invocation.
90
+ # @return [Orchestrator::Context]
91
+ def context
92
+ raise NotImplementedError, "#{self.class} must implement #context"
93
+ end
94
+
95
+ # An opening user turn for the harness to append, when a run carries a *live* trigger that isn't
96
+ # a stored record — the inbound email body for a reply. Defaults to none: assembling context
97
+ # (system prompts, history, and a scheduled run's instructions) is the resolver chain's job, not
98
+ # the harness's, so a subclass overrides this only for a live trigger it must inject itself.
99
+ #
100
+ # @return [String, Array<Object>, nil] the user-turn content, or nil to add nothing
101
+ def user_content
102
+ nil
103
+ end
104
+
105
+ # The inbound message, when this run answers one. Reactive runs override it; for scheduled runs
106
+ # it stays nil (events carry a nil message, and there is nothing to stream).
107
+ # @return [Object, nil]
108
+ def message
109
+ nil
110
+ end
111
+
112
+ # Whether to stream tokens to a connected console. Only the reactive console path streams.
113
+ # @return [Boolean]
114
+ def streaming?
115
+ false
116
+ end
117
+
118
+ # ── Shared loop ───────────────────────────────────────────────────────────
119
+
120
+ # Run the core generate → dispatch → extend loop.
121
+ #
122
+ # Runs up to +max_loop_iterations+ turns. Returns early when the model signals completion (no
123
+ # tool calls). On the final allowed turn, emits +max_turns_reached+ and breaks, returning
124
+ # whatever response the model last produced.
125
+ #
126
+ # @return [Object] the final +Provider::Response+
127
+ def run_tool_loop
128
+ final = nil
129
+ extended = build_initial_request
130
+
131
+ max_loop_iterations.times do |turn|
132
+ final = capture(turn:, request: extended) { run_generation(extended) }
133
+
134
+ return final if final.tool_calls.blank?
135
+
136
+ if turn >= (max_loop_iterations - 1)
137
+ InferenceMaxTurnsReachedEvent.emit(persona:, turn:, tool_calls: final.tool_calls)
138
+ break
139
+ end
140
+
141
+ ToolCallsReceivedEvent.emit(persona:, tool_calls: final.tool_calls)
142
+
143
+ tool_results = final.tool_calls.map { invoke_tool(_1) }
144
+ record_tool_round(turn:, response: final, tool_results:)
145
+ extended = Inference.extend_request(
146
+ previous: extended,
147
+ response: final,
148
+ tool_results:,
149
+ review_parts: review_parts_for(tool_results)
150
+ )
151
+ end
152
+
153
+ final
154
+ end
155
+
156
+ # Wrap one turn's generation for tracing. When tracing is off (the default) this is a bare
157
+ # pass-through — it yields and returns the response with zero overhead, publishing nothing. When
158
+ # tracing is on, it emits +InferenceGeneratedEvent+ (the seam the tracing subscriber snapshots).
159
+ # Gating the *emission* here — rather than always emitting and letting the subscriber decide —
160
+ # means the (potentially large) request payload is never assembled into an event unless a host
161
+ # opted in.
162
+ #
163
+ # @param turn [Integer] this generation's turn index within the run
164
+ # @param request [Object] the provider request being generated against
165
+ # @yieldreturn [Object] the provider response
166
+ # @return [Object] the provider response
167
+ def capture(turn:, request:)
168
+ return yield unless Protege.configuration.tracing[:enabled]
169
+
170
+ response = yield
171
+ emit_generated(turn:, request:, response:)
172
+ response
173
+ end
174
+
175
+ # Emit the per-turn trace event: the wire request (+Request#to_wire+ — the very serialization the
176
+ # API call uses, so the snapshot is exact), the response, and the inference settings that shaped it
177
+ # (+model+ + +settings+, pulled from the provider the harness resolves — not global config — so a
178
+ # future persona-level override is captured). Best-effort: any failure is logged and swallowed, so
179
+ # building or publishing the event can never abort a run whose side effects may already have run.
180
+ #
181
+ # @param turn [Integer] this generation's turn index
182
+ # @param request [Object] the provider request that was sent
183
+ # @param response [Object] the provider response it produced
184
+ # @return [void]
185
+ def emit_generated(turn:, request:, response:)
186
+ provider = Inference.provider
187
+
188
+ InferenceGeneratedEvent.emit(
189
+ turn:,
190
+ request: request.to_wire,
191
+ response: response.to_wire,
192
+ model: provider.model,
193
+ settings: provider.settings
194
+ )
195
+ rescue StandardError => e
196
+ Protege.configuration.logger.error(
197
+ "[Protege] failed to emit trace event (turn #{turn}): #{e.class}: #{e.message}"
198
+ )
199
+ end
200
+
201
+ # Run a single generation, streaming chunks to the browser when this run streams (console only).
202
+ #
203
+ # @param request [Object] the provider request to generate against
204
+ # @return [Object] the provider response
205
+ def run_generation(request)
206
+ if streaming?
207
+ Inference.generate_stream(request) do |chunk|
208
+ InferenceChunkEvent.emit(persona:, thread: message.email_thread, chunk:)
209
+ end
210
+ else
211
+ Inference.generate(request)
212
+ end
213
+ end
214
+
215
+ # Compute the loop bound. +max_tool_turns+ is how many tool-calling rounds to allow; the loop
216
+ # needs one extra iteration for the final tool-free generation.
217
+ #
218
+ # @return [Integer] the maximum number of loop iterations
219
+ def max_loop_iterations
220
+ Protege.configuration.max_tool_turns + 1
221
+ end
222
+
223
+ # Record one completed tool-calling round to the run's durable tool history.
224
+ #
225
+ # Recording is **best-effort**: it is an audit/memory side effect, not the run's mission, so any
226
+ # failure here is logged and swallowed. It must never abort the tool loop — the tools have already
227
+ # executed (a reply may have already been sent), and letting a persistence hiccup raise would kill
228
+ # the run and, under retry, re-execute those side effects. The actual write is delegated to
229
+ # +#persist_tool_round+, which subclasses override.
230
+ #
231
+ # @param turn [Integer] the tool-calling round index within this run
232
+ # @param response [Object] the assistant response carrying this round's tool calls
233
+ # @param tool_results [Array<Protege::ToolResult>] the paired results
234
+ # @return [void]
235
+ def record_tool_round(turn:, response:, tool_results:)
236
+ persist_tool_round(turn:, response:, tool_results:)
237
+ rescue StandardError => e
238
+ Protege.configuration.logger.error(
239
+ "[Protege] failed to record tool use (turn #{turn}): #{e.class}: #{e.message}"
240
+ )
241
+ end
242
+
243
+ # Persist this round's tool uses. Subclass hook — the base records nothing (a plain run keeps no
244
+ # tool history). +ReplyHarness+ and +ResponsibilityHarness+ override it.
245
+ #
246
+ # @param turn [Integer] the tool-calling round index within this run
247
+ # @param response [Object] the assistant response carrying this round's tool calls
248
+ # @param tool_results [Array<Protege::ToolResult>] the paired results
249
+ # @return [void]
250
+ def persist_tool_round(turn:, response:, tool_results:); end
251
+
252
+ # Dispatch a single tool call and emit the matching success/failure event.
253
+ #
254
+ # @param tool_call [Object] the tool call to dispatch
255
+ # @return [Protege::ToolResult] the call paired with its outcome
256
+ def invoke_tool(tool_call)
257
+ ToolCallStartedEvent.emit(persona:, tool_call:)
258
+
259
+ tool_result = ToolMixin.invoke(tool_call, context:)
260
+
261
+ if tool_result.result.success?
262
+ ToolCallCompletedEvent.emit(persona:, tool_call:, result: tool_result.result)
263
+ else
264
+ ToolCallFailedEvent.emit(persona:, tool_call:, error: tool_result.result.error)
265
+ end
266
+
267
+ tool_result
268
+ end
269
+
270
+ # Collect the vision parts for any tool result that asked to show the model a file (the
271
+ # +read_attachment+ tool on an image/PDF). The result carries only the blob id — never the bytes —
272
+ # so the part is rebuilt here by re-resolving the blob, keeping binary out of the serialized tool
273
+ # text. These are appended as a trailing user turn by +extend_request+.
274
+ #
275
+ # @param tool_results [Array<Protege::ToolResult>] the turn's tool results
276
+ # @return [Array<Object>] the vision parts to show the model
277
+ def review_parts_for(tool_results)
278
+ tool_results.filter_map do |tool_result|
279
+ data = tool_result.result.data
280
+ next unless data[:review]
281
+
282
+ file = Protege::StoredFile.find(data[:blob_id])
283
+ file&.to_part
284
+ end
285
+ end
286
+
287
+ # Assemble the first +Provider::Request+ from the opening user turn, the persona's effective
288
+ # (scoped) tool set, inference config, and any context messages contributed by resolvers.
289
+ #
290
+ # The catalogue is +persona.available_tools+ rather than the whole registry, so a persona's
291
+ # code-declared grant and the operator's +disabled_tool_ids+ override bound what the model is
292
+ # offered — on both the reply and the scheduled path, since both inherit this base method. Later
293
+ # turns re-use +previous.tools+ (see +Inference.extend_request+), so the scope holds for the run.
294
+ #
295
+ # @return [Object] the initial provider request
296
+ def build_initial_request
297
+ Inference.build_request(
298
+ user_content:,
299
+ tools: persona.available_tools,
300
+ context_messages: resolvers.run(context)
301
+ )
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # Context for a reactive run — one driven by an inbound email. Adds the +message+ to the shared
6
+ # +Context+ surface so tools and resolvers can reach the email being answered (its thread, sender,
7
+ # body, attachments). The +#responsibility+ accessor inherited from +Context+ stays nil.
8
+ class ReplyContext < Context
9
+ # @return [Object] the inbound message being processed
10
+ attr_reader :message
11
+
12
+ # @param persona [Protege::Persona] the persona handling the run
13
+ # @param message [Object] the inbound message being processed
14
+ # @return [void]
15
+ def initialize(persona:, message:)
16
+ @message = message
17
+ super(persona:)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # The reactive run — answering one inbound email. Runs the receiving persona's +message_resolvers+
6
+ # chain, opens the turn with the message body (plus any viewable attachments as vision parts), and
7
+ # streams tokens when the message came from the console. The +Harness+ base owns the tool loop;
8
+ # this subclass only supplies the reply-specific hooks.
9
+ class ReplyHarness < Harness
10
+ # @param persona [Protege::Persona] the persona that received the message
11
+ # @param message [Object] the inbound message to respond to
12
+ # @return [void]
13
+ def initialize(persona:, message:)
14
+ super(persona:)
15
+ @message = message
16
+ end
17
+
18
+ private
19
+
20
+ # @return [Object] the inbound message being answered (overrides the base nil)
21
+ attr_reader :message
22
+
23
+ # @return [Orchestrator::ResolverChain] the persona's reply chain
24
+ def resolvers
25
+ persona.message_resolvers
26
+ end
27
+
28
+ # @return [String, Array<Object>] the message body, or body-plus-attachment parts
29
+ def user_content
30
+ message.content_parts
31
+ end
32
+
33
+ # @return [Orchestrator::ReplyContext] the shared per-run context carrying the message
34
+ def context
35
+ @context ||= ReplyContext.new(persona:, message:)
36
+ end
37
+
38
+ # Persist this tool-calling round as +ToolUse+ rows owned by the inbound message, so a later turn's
39
+ # +ThreadHistoryResolver+ can replay the agent's prior tool calls and results instead of losing
40
+ # them. Grouped by +correlation_id+ (this run) and +turn+; each call is paired with its result. A
41
+ # reply always carries a correlation id (a +Harness+ precondition), so it is not guarded here.
42
+ #
43
+ # @param turn [Integer] the tool-calling round index within this run
44
+ # @param response [Object] the assistant response carrying this round's tool calls
45
+ # @param tool_results [Array<Protege::ToolResult>] the paired results
46
+ # @return [void]
47
+ def persist_tool_round(turn:, response:, tool_results:)
48
+ Protege::ToolUse.record_round(
49
+ source: message,
50
+ persona:,
51
+ run_id: Protege::Current.correlation_id,
52
+ turn_index: turn,
53
+ tool_calls: response.tool_calls,
54
+ tool_results:
55
+ )
56
+ end
57
+
58
+ # Stream only for console messages — SMTP mail has no connected client. Matched against the
59
+ # configured console address case-insensitively, since email addresses are not case-sensitive in
60
+ # practice and the stored +from_address+ preserves whatever case arrived.
61
+ #
62
+ # @return [Boolean] true when the message came from the console address
63
+ def streaming?
64
+ message&.from_address.to_s.casecmp?(Protege.configuration.console_address.to_s)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # Ordered list of resolver classes that build inference context for one persona. Configured at
6
+ # persona declaration time (via +Persona::Config#resolvers+) and run once per
7
+ # +Harness#build_initial_request+ call.
8
+ #
9
+ # Each resolver runs independently and returns +Array<ModelMessage>+ or +nil+. The chain
10
+ # concatenates all contributions in order — there is no wrapping or short-circuit (unlike
11
+ # middleware).
12
+ #
13
+ # Entries are stored as +[klass, args, kwargs, block]+ tuples rather than instances so that
14
+ # +remove(Klass)+ can locate and remove a specific resolver class without needing to match on
15
+ # constructor arguments. Each +run+ call creates fresh instances. A block passed to +use+/+prepend+
16
+ # is captured and forwarded to +klass.new+, so resolvers can take a loader block (e.g.
17
+ # +chain.use(Protege::LoadRecordResolver) { |ctx| Invoice.where(...) }+).
18
+ #
19
+ # @example
20
+ # chain = ResolverChain.new
21
+ # chain.use Protege::LoadFileResolver, 'app/personas/agent/PERSONA.md'
22
+ # chain.use(Protege::LoadRecordResolver) { |ctx| Account.find_by(email: ctx.message.from_address) }
23
+ # chain.use Protege::ThreadHistoryResolver
24
+ #
25
+ # messages = chain.run(resolve_ctx) # => Array<ModelMessage>
26
+ class ResolverChain
27
+ # @return [Array<Array>] read-only view of the registered +[klass, args, kwargs, block]+ entries
28
+ attr_reader :entries
29
+
30
+ # Initialize an empty chain.
31
+ #
32
+ # @return [void]
33
+ def initialize
34
+ @entries = []
35
+ end
36
+
37
+ # Append a resolver class to the end of the chain. Constructor args are stored alongside and
38
+ # forwarded to +klass.new+ at run time.
39
+ #
40
+ # @param klass [Class] the resolver class to register
41
+ # @param args [Array] positional constructor arguments forwarded at run time
42
+ # @param kwargs [Hash] keyword constructor arguments forwarded at run time
43
+ # @yield optional block forwarded to +klass.new+ (e.g. a loader for +LoadRecordResolver+)
44
+ # @return [self] the chain, for fluent chaining
45
+ def use(klass, *args, **kwargs, &block)
46
+ @entries << [klass, args, kwargs, block]
47
+ self
48
+ end
49
+
50
+ # Insert a resolver class at the front of the chain.
51
+ #
52
+ # @param klass [Class] the resolver class to register
53
+ # @param args [Array] positional constructor arguments forwarded at run time
54
+ # @param kwargs [Hash] keyword constructor arguments forwarded at run time
55
+ # @yield optional block forwarded to +klass.new+ (e.g. a loader for +LoadRecordResolver+)
56
+ # @return [self] the chain, for fluent chaining
57
+ def prepend(klass, *args, **kwargs, &block)
58
+ @entries.unshift([klass, args, kwargs, block])
59
+ self
60
+ end
61
+
62
+ # Remove every entry whose registered class equals +klass+.
63
+ #
64
+ # @param klass [Class] the resolver class to remove
65
+ # @return [self] the chain, for fluent chaining
66
+ def remove(klass)
67
+ @entries.reject! { |entry| entry.first == klass }
68
+ self
69
+ end
70
+
71
+ # Empty the chain.
72
+ #
73
+ # @return [self] the now-empty chain
74
+ def clear
75
+ @entries.clear
76
+ self
77
+ end
78
+
79
+ # Run every resolver in order, passing +context+ to each.
80
+ #
81
+ # +nil+ returns are silently skipped (coerced through +Array(...)+). Fresh resolver instances
82
+ # are constructed per call from the stored +[klass, args, kwargs, block]+ tuples.
83
+ #
84
+ # @param context [Protege::Orchestrator::Context] the per-delivery resolver context
85
+ # @return [Array<Object>] a flat array of +ModelMessage+ objects, empty when none contribute
86
+ def run(context)
87
+ @entries.flat_map do |klass, args, kwargs, block|
88
+ Array(klass.new(*args, **kwargs, &block).resolve(context:))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # Context for a proactive run — one driven by a scheduled +Responsibility+ (the Loop layer). Adds
6
+ # the +responsibility+ to the shared +Context+ surface so tools and resolvers can reach the duty
7
+ # being carried out (its prompt, schedule, owning persona). The +#message+ accessor inherited from
8
+ # +Context+ stays nil, so reply-only tools (e.g. +send_email+ in reply mode) degrade gracefully.
9
+ class ResponsibilityContext < Context
10
+ # @return [Protege::Responsibility] the responsibility being fulfilled
11
+ attr_reader :responsibility
12
+
13
+ # @param persona [Protege::Persona] the persona carrying out the responsibility
14
+ # @param responsibility [Protege::Responsibility] the scheduled duty being run
15
+ # @return [void]
16
+ def initialize(persona:, responsibility:)
17
+ @responsibility = responsibility
18
+ super(persona:)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # The proactive run — carrying out one scheduled +Responsibility+ (the Loop layer). Runs the
6
+ # persona's +responsibility_resolvers+ chain, which assembles the *entire* message list — including
7
+ # the responsibility's instructions (e.g. via +LoadTextResolver(role: :user) { ctx.responsibility.instructions }+).
8
+ # The harness injects no user turn of its own (it inherits the base +user_content+ default of nil):
9
+ # context assembly is the resolver chain's job. There is no inbound message and no streaming. The
10
+ # +Harness+ base owns the tool loop; this subclass only supplies the responsibility-specific hooks.
11
+ class ResponsibilityHarness < Harness
12
+ # @param persona [Protege::Persona] the persona carrying out the responsibility
13
+ # @param responsibility [Protege::Responsibility] the scheduled duty to run
14
+ # @param responsibility_run [Protege::ResponsibilityRun] the run record this execution belongs to
15
+ # @return [void]
16
+ def initialize(persona:, responsibility:, responsibility_run:)
17
+ super(persona:)
18
+ @responsibility = responsibility
19
+ @responsibility_run = responsibility_run
20
+ end
21
+
22
+ private
23
+
24
+ # @return [Protege::Responsibility] the responsibility being fulfilled
25
+ attr_reader :responsibility
26
+
27
+ # @return [Protege::ResponsibilityRun] the run record this execution belongs to
28
+ attr_reader :responsibility_run
29
+
30
+ # @return [Orchestrator::ResolverChain] the persona's scheduled-run chain
31
+ def resolvers
32
+ persona.responsibility_resolvers
33
+ end
34
+
35
+ # @return [Orchestrator::ResponsibilityContext] the shared per-run context carrying the responsibility
36
+ def context
37
+ @context ||= ResponsibilityContext.new(persona:, responsibility:)
38
+ end
39
+
40
+ # Persist this tool-calling round as +ToolUse+ rows owned by the +ResponsibilityRun+ — an audit
41
+ # trail of what the scheduled run did, beside the run's own status. Not replayed (a scheduled run
42
+ # has no thread history); grouped by +correlation_id+ (this run) and +turn+.
43
+ #
44
+ # @param turn [Integer] the tool-calling round index within this run
45
+ # @param response [Object] the assistant response carrying this round's tool calls
46
+ # @param tool_results [Array<Protege::ToolResult>] the paired results
47
+ # @return [void]
48
+ def persist_tool_round(turn:, response:, tool_results:)
49
+ Protege::ToolUse.record_round(
50
+ source: responsibility_run,
51
+ persona:,
52
+ run_id: Protege::Current.correlation_id,
53
+ turn_index: turn,
54
+ tool_calls: response.tool_calls,
55
+ tool_results:
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # LOGI "O" — the orchestrator layer. Owns the inference-running constructs that sit between the
5
+ # Gateway (which produces inbound messages and sends replies) and the Inference layer (which talks
6
+ # to providers).
7
+ #
8
+ # The +Harness+ is this layer's primary construct: given one inbound message, it builds a provider
9
+ # request, runs the multi-turn tool loop, and produces a final response. Supporting constructs in
10
+ # this namespace include the +Context+ value objects passed to extensions, the +Tool+ and
11
+ # +Resolver+ contracts, and the +ResolverChain+ that assembles inference context per persona.
12
+ #
13
+ # Module methods here are the cross-namespace entry points. Callers (jobs, the +Extension+ tool mixin)
14
+ # use these rather than reaching into +Harness+ subclasses or +ToolResult+/+Error+ directly.
15
+ module Orchestrator
16
+ extend self
17
+
18
+ # Run a reactive reply generation for an inbound message.
19
+ #
20
+ # @param persona [Protege::Persona] the receiving persona
21
+ # @param message [Protege::Message] the inbound message to answer
22
+ # @return [Object] the harness result
23
+ def reply(persona:, message:)
24
+ ReplyHarness.run(persona:, message:)
25
+ end
26
+
27
+ # Run a proactive generation for a scheduled responsibility.
28
+ #
29
+ # @param persona [Protege::Persona] the persona carrying out the duty
30
+ # @param responsibility [Protege::Responsibility] the scheduled duty
31
+ # @param responsibility_run [Protege::ResponsibilityRun] the run record being executed
32
+ # @return [Object] the harness result
33
+ def run_responsibility(persona:, responsibility:, responsibility_run:)
34
+ ResponsibilityHarness.run(persona:, responsibility:, responsibility_run:)
35
+ end
36
+ end
37
+ end