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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Inference
5
+ module Provider
6
+ # One normalized generation request handed to a provider's +#generate+ method — the conversation
7
+ # only: the ordered messages plus the tool schemas to expose. Model, sampling, and credentials are
8
+ # not carried here; each provider reads those from its own +config.providers+ slice.
9
+ #
10
+ # +messages+ is the complete ordered message array for this
11
+ # generation call — resolver context messages (role: :system,
12
+ # :user, :assistant) prepended by the harness, followed by the
13
+ # current inbound as a +:user+ message. Providers handle
14
+ # role: :system messages according to their API requirements
15
+ # (OpenRouter normalises for all underlying models; a direct
16
+ # Anthropic provider would extract system messages into its
17
+ # top-level +system:+ parameter).
18
+ #
19
+ # == Wire serialization
20
+ #
21
+ # +#to_wire+ renders the request into the standard (OpenAI-compatible) +{ messages:, tools: }+
22
+ # body. This lives here — not on each provider — because it is the *same* serialization the API
23
+ # call needs and a trace needs: +OpenRouterProvider#generate+ builds its payload by merging
24
+ # model/sampling onto +to_wire+, and the tracing subscriber snapshots +to_wire+ directly. Keeping
25
+ # it in one place means a trace records the exact conversation that was sent, never a re-derivation.
26
+ Request = Data.define(
27
+ :messages,
28
+ :tools
29
+ ) do
30
+ # @param messages [Array<Protege::ModelMessage>] the ordered message array for this generation call
31
+ # @param tools [Array<Protege::Inference::Tool>] the tools available to the model (default empty)
32
+ def initialize(messages:, tools: [])
33
+ super
34
+ end
35
+
36
+ # Render this request as the standard chat-completions body: the serialized message array and the
37
+ # function-tool schemas. Model, sampling, and credentials are the provider's to add.
38
+ #
39
+ # @return [Hash] +{ messages: Array<Hash>, tools: Array<Hash> }+ (tools omitted when none)
40
+ def to_wire
41
+ {
42
+ messages: messages.map { |message| wire_message(message) },
43
+ tools: tools.presence&.map { |tool| wire_tool(tool) }
44
+ }.compact
45
+ end
46
+
47
+ private
48
+
49
+ # Serialize one message: role, content (a joined string when all-text, else typed blocks), and
50
+ # any tool linkage (an assistant turn's +tool_calls+, a tool result's +tool_call_id+).
51
+ #
52
+ # @param message [Protege::ModelMessage] the message to serialize
53
+ # @return [Hash] the wire message
54
+ def wire_message(message)
55
+ body = { role: message.role.to_s, content: wire_content(message.parts) }
56
+ body[:tool_call_id] = message.tool_call_id if message.tool_call_id
57
+ body[:tool_calls] = message.tool_calls.map(&:to_wire) if message.tool_calls
58
+ body
59
+ end
60
+
61
+ # Render message parts as content: a plain joined string when every part is text (the common
62
+ # case), otherwise an array of typed content blocks for a multimodal message.
63
+ #
64
+ # @param parts [Array<Object>] the normalized message parts
65
+ # @return [String, Array<Hash>] joined text, or an array of content blocks
66
+ def wire_content(parts)
67
+ return parts.map(&:text).join if parts.all?(TextPart)
68
+
69
+ parts.map { |part| wire_part(part) }
70
+ end
71
+
72
+ # Serialize one content part into a typed content block, with attachment bytes inline as a data
73
+ # URI (so the request — and any trace of it — is self-contained).
74
+ #
75
+ # @param part [Object] a TextPart, ImagePart, or DocumentPart
76
+ # @return [Hash] the typed content block (+text+, +image_url+, or +file+)
77
+ def wire_part(part)
78
+ case part
79
+ when TextPart
80
+ { type: 'text', text: part.text }
81
+ when ImagePart
82
+ { type: 'image_url', image_url: { url: data_uri(part.media_type, part.data) } }
83
+ when DocumentPart
84
+ { type: 'file', file: { filename: part.filename, file_data: data_uri(part.media_type, part.data) } }
85
+ end
86
+ end
87
+
88
+ # Build a base64 data URI for an inline content block.
89
+ #
90
+ # @param media_type [String] the MIME type
91
+ # @param data [String] the Base64-encoded bytes
92
+ # @return [String] the +data:+ URI
93
+ def data_uri(media_type, data)
94
+ "data:#{media_type};base64,#{data}"
95
+ end
96
+
97
+ # Serialize a tool definition into the function-tool schema advertised to the model.
98
+ #
99
+ # @param tool [Protege::Tool] the tool to advertise
100
+ # @return [Hash] the function-typed tool descriptor
101
+ def wire_tool(tool)
102
+ {
103
+ type: 'function',
104
+ function: { name: tool.id.to_s, description: tool.description, parameters: tool.input_schema }
105
+ }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Protege
6
+ module Inference
7
+ module Provider
8
+ # One normalized generation result — Protege's **internal** representation of a model turn.
9
+ #
10
+ # Providers never construct this: they return the wire **response hash** (an OpenAI assistant
11
+ # message + finish reason — see the providers doc), and Protege wraps it here via {from_wire} at
12
+ # the Inference↔Provider seam. The harness and tracing read this object.
13
+ #
14
+ # +text+: assistant text (nil when the turn is tool calls only).
15
+ # +tool_calls+: +Array<ToolCall>+ (empty when none were requested).
16
+ # +finish_reason+: provider-reported reason (e.g. +'stop'+, +'tool_calls'+, +'length'+).
17
+ Response = Data.define(
18
+ :text,
19
+ :tool_calls,
20
+ :finish_reason
21
+ ) do
22
+ # Build a Response from a provider's wire response hash:
23
+ # +{ content:, tool_calls: [ {id:, function: {name:, arguments: <JSON string>}} ], finish_reason: }+.
24
+ #
25
+ # @param hash [Hash] the provider's response hash (symbol keys)
26
+ # @return [Response] the wrapped response
27
+ def self.from_wire(hash)
28
+ new(
29
+ text: hash[:content],
30
+ tool_calls: Array(hash[:tool_calls]).map { |call| ToolCall.from_wire(call) },
31
+ finish_reason: hash[:finish_reason]
32
+ )
33
+ end
34
+
35
+ # Render the model's output as a compact wire snapshot — the completion a trace trains toward:
36
+ # assistant text, any tool calls (in the shared function-call shape), and the finish reason.
37
+ # Mirrors +Request#to_wire+; the tracing subscriber stores this verbatim. Nil/empty fields drop.
38
+ #
39
+ # @return [Hash] the response snapshot
40
+ def to_wire
41
+ {
42
+ text:,
43
+ tool_calls: tool_calls.presence&.map(&:to_wire),
44
+ finish_reason:
45
+ }.compact
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Protege
6
+ module Inference
7
+ module Provider
8
+ # One tool call emitted by the model in a +Response+ (Protege's internal representation).
9
+ #
10
+ # +id+: provider-assigned identifier used to correlate the eventual tool-result message back to
11
+ # this call.
12
+ # +name+: snake_case tool name to invoke.
13
+ # +input+: parsed input hash matching the tool's input schema.
14
+ ToolCall = Data.define(
15
+ :id,
16
+ :name,
17
+ :input
18
+ ) do
19
+ # Build a ToolCall from a function-call wire hash:
20
+ # +{ id:, type: "function", function: { name:, arguments: <JSON string> } }+ — the shape the
21
+ # model emits in the response hash. +arguments+ is JSON-decoded into +input+.
22
+ #
23
+ # @param hash [Hash] the function-call wire hash (symbol keys)
24
+ # @return [ToolCall] the parsed call
25
+ def self.from_wire(hash)
26
+ function = hash[:function] || {}
27
+ new(id: hash[:id], name: function[:name], input: parse_arguments(function[:arguments]))
28
+ end
29
+
30
+ # Decode the JSON +arguments+ string into an input hash (empty when blank).
31
+ #
32
+ # @param raw [String, nil] the JSON-encoded arguments
33
+ # @return [Hash] the parsed input
34
+ def self.parse_arguments(raw)
35
+ raw.present? ? JSON.parse(raw) : {}
36
+ end
37
+
38
+ # Render this call in the standard function-call wire shape, with JSON-encoded arguments — the
39
+ # same serialization a request's assistant turn and a trace's response snapshot both use.
40
+ #
41
+ # @return [Hash] +{ id:, type: 'function', function: { name:, arguments: } }+
42
+ def to_wire
43
+ {
44
+ id:,
45
+ type: 'function',
46
+ function: { name:, arguments: input.to_json }
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Namespace for all inference concerns — providers, requests,
5
+ # responses, and the value objects they exchange.
6
+ #
7
+ # Module methods here are the cross-namespace entry points. Other namespaces (Orchestrator, Gateway)
8
+ # and the Rails layer call these rather than reaching into +Provider::*+ internals directly.
9
+ module Inference
10
+ extend self
11
+
12
+ # Build a +Provider::Request+ for the first turn of a generation.
13
+ #
14
+ # The message list is the resolver-supplied +context_messages+. When +user_content+ is given, it
15
+ # is appended as a trailing +role: :user+ message — used by the reply path to inject the live
16
+ # inbound email body (a stripped string, or multimodal parts when it has attachments). When
17
+ # +user_content+ is +nil+ the request is exactly the resolver messages: a scheduled run assembles
18
+ # its whole turn — instructions included — through its resolver chain, so the harness injects
19
+ # nothing. The request carries only the conversation and the tool classes; the active provider reads
20
+ # its own model / sampling / credentials from +config.providers+.
21
+ #
22
+ # @param user_content [String, Array<Object>, nil] the trailing user turn, or nil to add none.
23
+ # @param tools [Array<Class>] tool classes to expose for this generation.
24
+ # @param context_messages [Array<ModelMessage>] the resolver-assembled message list.
25
+ # @return [Provider::Request] the assembled first-turn request.
26
+ def build_request(user_content: nil, tools: [], context_messages: [])
27
+ messages = Array(context_messages)
28
+ messages += [ModelMessage.new(role: :user, content: user_content)] unless user_content.nil?
29
+
30
+ Provider::Request.new(messages:, tools:)
31
+ end
32
+
33
+ # Build the next-turn +Provider::Request+ by appending the assistant turn and tool results.
34
+ #
35
+ # Used by the Orchestrator tool loop after the model requested tools: it appends the assistant
36
+ # +ModelMessage+ (carrying its +tool_calls+) followed by one +role: :tool+ message per
37
+ # result, then re-issues the request with the same tools as +previous+ (model and sampling come
38
+ # from the provider's config on each turn). Each tool result's payload is correlated back via
39
+ # +tool_call.id+ and serialized as JSON.
40
+ #
41
+ # When +review_parts+ are given (a tool asked to show the model an image/PDF it can't return in a
42
+ # text-only tool result), a final +role: :user+ message carrying those vision parts is appended
43
+ # after the tool messages — the only place the OpenAI schema lets visual content reach the model.
44
+ #
45
+ # @param previous [Provider::Request] the request that produced +response+.
46
+ # @param response [Provider::Response] the assistant response containing the requested tool
47
+ # calls.
48
+ # @param tool_results [Array<Protege::ToolResult>] one result per executed tool call.
49
+ # @param review_parts [Array<Object>] vision parts to show the model on a trailing user turn.
50
+ # @return [Provider::Request] the request for the next tool-loop turn.
51
+ def extend_request(previous:, response:, tool_results:, review_parts: [])
52
+ assistant_msg = ModelMessage.new(
53
+ role: :assistant,
54
+ content: response.text || '',
55
+ tool_calls: response.tool_calls
56
+ )
57
+
58
+ tool_messages = tool_results.map do |tr|
59
+ ModelMessage.new(
60
+ role: :tool,
61
+ tool_call_id: tr.tool_call.id,
62
+ content: tr.result.to_h.to_json
63
+ )
64
+ end
65
+
66
+ messages = previous.messages + [assistant_msg] + tool_messages
67
+ messages += [review_message(review_parts)] if review_parts.any?
68
+
69
+ Provider::Request.new(messages:, tools: previous.tools)
70
+ end
71
+
72
+ # Instantiate the provider class matching the configured +provider_id+.
73
+ #
74
+ # Resolves against +ProviderMixin.registered+ (every +Provider+ descendant) by symbolic
75
+ # id, so the lookup stays correct across Zeitwerk reloads.
76
+ #
77
+ # @return [ProviderMixin] a fresh provider instance for the configured id.
78
+ # @raise [Protege::ContractViolationError] when no registered provider declares the configured id.
79
+ def provider
80
+ id = Protege.configuration.provider_id
81
+ klass = ProviderMixin.registered.find { |k| k.id == id }
82
+
83
+ raise Protege::ContractViolationError, "no provider registered for #{id.inspect}" unless klass
84
+
85
+ klass.new
86
+ end
87
+
88
+ # Generate against the configured provider — the seam that keeps the provider contract hash-based.
89
+ # Hands the provider the wire request hash (+Request#to_wire+) and wraps its returned response hash
90
+ # back into a +Provider::Response+, so providers never touch Protege's internal value types.
91
+ #
92
+ # @param request [Provider::Request] the request to generate against
93
+ # @return [Provider::Response] the wrapped response
94
+ def generate(request)
95
+ Provider::Response.from_wire(provider.generate(request.to_wire))
96
+ end
97
+
98
+ # Stream a generation against the configured provider, wrapping the final response hash as {generate}
99
+ # does. Yielded chunks pass through untouched.
100
+ #
101
+ # @param request [Provider::Request] the request to generate against
102
+ # @yieldparam chunk [Hash] a +{ type:, content: }+ token chunk
103
+ # @return [Provider::Response] the wrapped final response
104
+ def generate_stream(request, &)
105
+ Provider::Response.from_wire(provider.generate_stream(request.to_wire, &))
106
+ end
107
+
108
+ private
109
+
110
+ # Build the trailing +role: :user+ message that shows the model the attachments it asked to review.
111
+ #
112
+ # @param parts [Array<Object>] the vision parts to show
113
+ # @return [ModelMessage] the review user message
114
+ def review_message(parts)
115
+ ModelMessage.new(
116
+ role: :user,
117
+ content: [TextPart.new(text: 'Here are the attachments you asked to review:'), *parts]
118
+ )
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fugit'
4
+
5
+ module Protege
6
+ module Loop
7
+ # Domain wrapper over a standard 5-field cron expression — the only knob a responsibility's
8
+ # schedule exposes. Backed by +Fugit::Cron+ so raw cron parsing never scatters through the engine
9
+ # (the gateway/wrapper convention), and deliberately answers just one question: does a given
10
+ # minute match? The Loop scheduler ticks every minute, so there is no "next occurrence" maths to do.
11
+ #
12
+ # The expression is interpreted in the **application timezone** (+Time.zone+) by pinning Fugit to
13
+ # that zone's IANA name, so "0 9 * * *" means 9am app-time regardless of the host's system clock.
14
+ class Schedule
15
+ class << self
16
+ # Whether +expression+ parses as a valid cron string. Never raises — safe for validations.
17
+ #
18
+ # @param expression [String, nil] the cron expression to check
19
+ # @return [Boolean] true when parseable
20
+ def valid?(expression)
21
+ new(expression).valid?
22
+ end
23
+ end
24
+
25
+ # @return [String] the raw cron expression as given
26
+ attr_reader :expression
27
+
28
+ # @param expression [String, nil] a standard 5-field cron expression (e.g. "0 9 * * 1-5")
29
+ # @return [void]
30
+ def initialize(expression)
31
+ @expression = expression.to_s.strip
32
+ @cron = Fugit::Cron.parse(zoned(@expression))
33
+ end
34
+
35
+ # @return [Boolean] true when the expression parsed as valid cron
36
+ def valid?
37
+ !@cron.nil?
38
+ end
39
+
40
+ # Whether +time+ falls on this schedule, at minute granularity. Seconds are zeroed before the
41
+ # match because a 5-field cron fires at second zero, while the tick runs a few seconds into the
42
+ # minute.
43
+ #
44
+ # @param time [Time] the moment to test (typically +Time.current+)
45
+ # @return [Boolean] true when this minute is a scheduled minute
46
+ def due?(time)
47
+ return false unless valid?
48
+
49
+ @cron.match?(time.change(sec: 0, usec: 0))
50
+ end
51
+
52
+ private
53
+
54
+ # Append the application timezone's IANA name so the cron is interpreted in app-time. Blank input
55
+ # is passed through unchanged so it parses to +nil+ (and reads as invalid).
56
+ #
57
+ # @param expr [String] the raw cron expression
58
+ # @return [String] the expression pinned to the application timezone
59
+ def zoned(expr)
60
+ return expr if expr.empty?
61
+
62
+ "#{expr} #{(Time.zone || ActiveSupport::TimeZone['UTC']).tzinfo.name}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Loop
5
+ # The dispatch coordinator behind the Loop layer's every-minute clock. Each tick it finds the
6
+ # responsibilities due this minute and tells each to dispatch itself; it owns *which* responsibilities
7
+ # fire, while +Responsibility#dispatch!+ owns *how* (recording a run, enqueuing the job).
8
+ #
9
+ # Intentionally minimal: no overlap guard, no concurrency cap, no stale recovery, no minute-dedup.
10
+ # The recurring tick fires once per minute and the queue adapter bounds parallelism; a failed run is
11
+ # recorded and simply waits for its next scheduled minute.
12
+ class Scheduler
13
+ class << self
14
+ # Dispatch every responsibility due now.
15
+ #
16
+ # @return [void]
17
+ def dispatch
18
+ new.dispatch
19
+ end
20
+ end
21
+
22
+ # Tell each active, due responsibility to dispatch itself.
23
+ #
24
+ # @return [void]
25
+ def dispatch
26
+ Protege::Responsibility.active.with_active_persona.find_each do |responsibility|
27
+ responsibility.dispatch! if responsibility.due?(now)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # The single moment every responsibility this tick is evaluated against, captured once so a tick
34
+ # spanning a minute boundary still tests everyone against the same minute.
35
+ #
36
+ # @return [Time]
37
+ def now
38
+ @now ||= Time.current
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # LOGI "L" — the loop layer. Owns scheduled, proactive work: deciding which responsibilities are due
5
+ # on each clock tick and dispatching their runs. The +Scheduler+ drives dispatch and +Schedule+ parses
6
+ # and evaluates cron expressions.
7
+ #
8
+ # Class methods on this module are the cross-namespace entry points. Callers (the recurring tick job,
9
+ # the +Responsibility+ model) use these rather than reaching into +Scheduler+ / +Schedule+ directly.
10
+ module Loop
11
+ extend self
12
+
13
+ # Dispatch a run for every responsibility due right now. Called once per clock tick.
14
+ #
15
+ # @return [void]
16
+ def dispatch
17
+ Scheduler.dispatch
18
+ end
19
+
20
+ # Report whether a cron expression is well-formed — for model validation.
21
+ #
22
+ # @param expression [String] the cron expression
23
+ # @return [Boolean] true when parseable
24
+ def schedule_valid?(expression)
25
+ Schedule.valid?(expression)
26
+ end
27
+
28
+ # Report whether a cron expression is due at a given time.
29
+ #
30
+ # @param expression [String] the cron expression
31
+ # @param at [Time] the moment to test
32
+ # @return [Boolean] true when due at +at+
33
+ def schedule_due?(expression, at:)
34
+ Schedule.new(expression).due?(at)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Orchestrator
5
+ # Base context shared by both kinds of run. Carries the per-run runtime data every tool and
6
+ # resolver needs — persona, a frozen config snapshot, logger, correlation id — plus the
7
+ # +#deliver+ affordance for sending mail through the Gateway, without exposing mutable framework
8
+ # state. Frozen after construction so one instance is safely shared across every tool/resolver
9
+ # invocation in a run.
10
+ #
11
+ # The thing that *drove* the run differs by kind, so it is exposed through two accessors that both
12
+ # default to +nil+ here and are each overridden by exactly one subclass: +ReplyContext+ sets
13
+ # +#message+ (reactive, an inbound email) and +ResponsibilityContext+ sets +#responsibility+
14
+ # (proactive, a scheduled Loop run). Tools and resolvers branch on whichever is present.
15
+ class Context
16
+ # @return [Protege::Persona] the persona handling this run
17
+ # @return [Protege::Configuration] the frozen process configuration snapshot
18
+ # @return [Logger] the engine logger
19
+ # @return [String, nil] the correlation id threaded through this run's events
20
+ attr_reader :persona, :config, :logger, :correlation_id
21
+
22
+ # Capture the shared runtime data and freeze. Subclasses set their own subject ivar *before*
23
+ # calling +super+, since this constructor freezes the instance.
24
+ #
25
+ # @param persona [Protege::Persona] the persona handling this run
26
+ # @return [void]
27
+ def initialize(persona:)
28
+ @persona = persona
29
+ @logger = Protege.configuration.logger
30
+ # Snapshot a frozen *copy* so the context is immutable without freezing the process-wide
31
+ # configuration (which boot-time code and tests still mutate).
32
+ @config = Protege.configuration.dup.freeze
33
+ @correlation_id = Protege::Current.correlation_id
34
+
35
+ freeze
36
+ end
37
+
38
+ # The inbound message that drove this run, or nil when none did (overridden by +ReplyContext+).
39
+ #
40
+ # @return [Object, nil]
41
+ def message
42
+ nil
43
+ end
44
+
45
+ # The responsibility that drove this run, or nil when none did (overridden by
46
+ # +ResponsibilityContext+).
47
+ #
48
+ # @return [Protege::Responsibility, nil]
49
+ def responsibility
50
+ nil
51
+ end
52
+
53
+ # Deliver an outbound mail via the Gateway, handling transport, thread resolution, and message
54
+ # persistence under the current persona. Any +attachments+ (blobs already in storage) are attached
55
+ # to the persisted outbound message by reference, so a file is never re-uploaded on send.
56
+ #
57
+ # @param mail [Object] the outbound mail to deliver
58
+ # @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound message
59
+ # @return [Object] the Gateway delivery outcome
60
+ def deliver(mail:, attachments: [])
61
+ Gateway.deliver(mail:, persona:, attachments:)
62
+ end
63
+ end
64
+ end
65
+ end