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,255 @@
1
+ # Resolvers (engine-internal reference)
2
+
3
+ > Engineering reference for the engine's built-in resolvers and the exact payload they produce.
4
+ > User-facing docs live in `site/developer-experience/extensions/resolvers.md`; this file is the
5
+ > precise contract for contributors working in the engine.
6
+
7
+ A **resolver** assembles the context the LLM sees *before* an inference run. Each persona declares an
8
+ ordered chain of resolvers (`message_resolvers` for replies, `responsibility_resolvers` for scheduled
9
+ runs); the harness runs the chain, concatenates every resolver's output, and hands the result to the
10
+ provider as the opening messages of the request.
11
+
12
+ Resolvers are **not** auto-discovered — they are added to a chain explicitly, and **order matters**
13
+ (the chain's output is concatenated in declaration order). Built-in resolvers live in
14
+ `protege/resolvers/` under this directory and subclass `Protege::Resolver`
15
+ (`engine/lib/protege/resolver_base.rb`).
16
+
17
+ ---
18
+
19
+ ## The contract
20
+
21
+ Every resolver implements one method:
22
+
23
+ ```ruby
24
+ def resolve(context:) # => ModelMessage | Array<ModelMessage> | nil
25
+ ```
26
+
27
+ - **Input:** a `Protege::Orchestrator::Context` (see [Context](#the-context)).
28
+ - **Output (this is the whole spec):** one **`ModelMessage`**, an **`Array`** of them, or **`nil`**
29
+ to contribute nothing — the chain coerces all three shapes. Build messages with the `message` helper
30
+ (`message(role:, content:, tool_call_id: nil, tool_calls: nil)`) rather than the raw constructor.
31
+
32
+ There is no other return type. Anything a resolver wants to put in front of the model — a system
33
+ prompt, prior conversation, retrieved records, tool history — is expressed as one or more
34
+ `ModelMessage` objects. That single object type is the payload every resolver produces and the
35
+ common currency the chain concatenates.
36
+
37
+ ---
38
+
39
+ ## The payload: `ModelMessage`
40
+
41
+ Defined in `engine/lib/protege/inference/provider/message.rb`. It is the *same* value object used
42
+ throughout the inference layer (requests and responses), so what a resolver emits is exactly what the
43
+ provider serializes onto the wire.
44
+
45
+ ```ruby
46
+ Message = Data.define(:role, :content, :tool_call_id, :tool_calls)
47
+ # initialize(role:, content:, tool_call_id: nil, tool_calls: nil)
48
+ ```
49
+
50
+ | Field | Type | Required | Meaning |
51
+ |----------------|-----------------------------------|----------|---------|
52
+ | `role` | `Symbol` | yes | One of `:system`, `:user`, `:assistant`, `:tool`. |
53
+ | `content` | `String` **or** `Array<Part>` | yes | Text, or an array of typed parts (multimodal). May be `""`. |
54
+ | `tool_call_id` | `String` | no | **Only** meaningful when `role: :tool` — correlates a tool result to its call. |
55
+ | `tool_calls` | `Array<ToolCall>` | no | **Only** meaningful when `role: :assistant` — tool calls the model is requesting. |
56
+
57
+ ### `content` normalization
58
+
59
+ `Message#parts` normalizes `content` to an array of parts:
60
+
61
+ ```ruby
62
+ def parts
63
+ content.is_a?(Array) ? content : [TextPart.new(text: content)]
64
+ end
65
+ ```
66
+
67
+ So a `String` is the text-only shorthand for `[TextPart.new(text: content)]`. Pass an `Array<Part>`
68
+ directly for multimodal content.
69
+
70
+ ### Part value objects
71
+
72
+ Under `engine/lib/protege/inference/provider/`:
73
+
74
+ | Part | Definition | Notes |
75
+ |----------------|---------------------------------------------|-------|
76
+ | `TextPart` | `Data.define(:text)` | Plain text. |
77
+ | `ImagePart` | `Data.define(:media_type, :data)` | `data` is **Base64** bytes; `media_type` e.g. `"image/png"`. Vision models read it. |
78
+ | `DocumentPart` | `Data.define(:filename, :media_type, :data)`| `data` is **Base64** bytes; used for PDFs. |
79
+
80
+ ### `ToolCall` (for `role: :assistant` messages)
81
+
82
+ `engine/lib/protege/inference/provider/tool_call.rb`:
83
+
84
+ ```ruby
85
+ ToolCall = Data.define(:id, :name, :input)
86
+ # id: String — provider-assigned, correlates the eventual :tool result
87
+ # name: String — snake_case tool name
88
+ # input: Hash — parsed arguments matching the tool's input schema
89
+ ```
90
+
91
+ ### Which shape goes with which role
92
+
93
+ | `role` | Typical `content` | `tool_calls` | `tool_call_id` | Emitted by |
94
+ |--------------|--------------------------|--------------|----------------|------------|
95
+ | `:system` | `String` (instructions) | — | — | `LoadFileResolver`, `LoadTextResolver`, `LoadRecordResolver` (default) |
96
+ | `:user` | `String` or `Array<Part>`| — | — | `ThreadHistoryResolver` (inbound turns); any loader with `role: :user` |
97
+ | `:assistant` | `String` (may be `""`) | `Array<ToolCall>` when replaying a tool round | — | `ThreadHistoryResolver` (outbound turns, and the tool-call turns) |
98
+ | `:tool` | `String` (result JSON) | — | `String` (the call id) | `ThreadHistoryResolver` (replayed tool results) |
99
+
100
+ > Every `:tool` message must be preceded by an `:assistant` message whose `tool_calls` include a
101
+ > matching `id`. `ThreadHistoryResolver` reconstructs these as a paired block, and the resolver chain
102
+ > preserves order — never interleave a lone `:tool` message.
103
+
104
+ ---
105
+
106
+ ## How the chain assembles the final payload
107
+
108
+ `Protege::Orchestrator::ResolverChain#run` (`engine/lib/protege/orchestrator/resolver_chain.rb`):
109
+
110
+ ```ruby
111
+ def run(context)
112
+ @entries.flat_map do |klass, args, kwargs, block|
113
+ Array(klass.new(*args, **kwargs, &block).resolve(context:))
114
+ end
115
+ end
116
+ ```
117
+
118
+ - Each resolver is instantiated fresh per run and `resolve(context:)` is called.
119
+ - Its return is wrapped in `Array(...)`: **`nil` → `[]`**, a single message → `[message]`, an array →
120
+ unchanged.
121
+ - `flat_map` concatenates every resolver's contribution into **one flat, ordered
122
+ `Array<ModelMessage>`** — in chain declaration order.
123
+
124
+ That array becomes `context_messages` in `Inference.build_request`
125
+ (`engine/lib/protege/inference.rb`):
126
+
127
+ ```ruby
128
+ def build_request(user_content: nil, tools: [], context_messages: [])
129
+ messages = Array(context_messages)
130
+ messages += [ModelMessage.new(role: :user, content: user_content)] unless user_content.nil?
131
+
132
+ Provider::Request.new(messages:, tools:)
133
+ end
134
+ ```
135
+
136
+ So the request's message list is, in order:
137
+
138
+ ```
139
+ [ …every resolver's messages, concatenated in chain order… ] + [ optional trailing :user turn ]
140
+ ```
141
+
142
+ - **Reply runs** pass the inbound email body (a `String`, or `Array<Part>` when it has attachments) as
143
+ `user_content`, appended as the closing `:user` message.
144
+ - **Scheduled (responsibility) runs** pass `user_content: nil` — the resolver chain assembles the
145
+ *entire* message list itself (including the responsibility's instructions, typically via
146
+ `LoadTextResolver`).
147
+
148
+ **The complete payload the resolvers produce together = the flat, ordered array of `ModelMessage`
149
+ objects above.** Nothing else. Get the roles, order, and (for tool turns) the assistant/tool pairing
150
+ right, and the request is well-formed.
151
+
152
+ ---
153
+
154
+ ## The context
155
+
156
+ `resolve(context:)` receives a `Protege::Orchestrator::Context`. Attributes available:
157
+
158
+ | Attribute | Type | Reply run | Scheduled run |
159
+ |------------------|-------------------------------|---------------------|---------------|
160
+ | `persona` | `Protege::Persona` | present | present |
161
+ | `config` | `Protege::Configuration` | present (frozen) | present (frozen) |
162
+ | `logger` | `Logger` | present | present |
163
+ | `correlation_id` | `String, nil` | present | present |
164
+ | `message` | `Protege::Message` | **present** | `nil` |
165
+ | `responsibility` | `Protege::Responsibility` | `nil` | **present** |
166
+
167
+ A resolver used in both chains must treat the field it doesn't expect as `nil` (e.g. guard
168
+ `context.message` before reading `.email_thread`). The context is frozen, so resolvers read from it —
169
+ they never mutate it.
170
+
171
+ ---
172
+
173
+ ## Built-in resolvers
174
+
175
+ All live in `app/resolvers/protege/` as flat `Protege::<Name>` constants (e.g.
176
+ `Protege::ThreadHistoryResolver`); a persona names one by that constant. Each returns a `ModelMessage`,
177
+ an array of them, or `nil`.
178
+
179
+ ### `ThreadHistoryResolver`
180
+
181
+ Replays a thread's prior conversation **and the agent's tool use between turns**, so the agent has
182
+ memory across exchanges.
183
+
184
+ - **Constructor:** `initialize(limit: 50, include_tool_calls: true)`
185
+ - `limit` — most-recent-N prior messages (chronological); `nil` for all.
186
+ - `include_tool_calls` — weave recorded `ToolUse` rows back in (default `true`).
187
+ - **Returns:** `nil` when the thread has no prior content; otherwise an ordered array of:
188
+ - one message per prior message — `role: :user` (inbound) or `:assistant` (outbound), `content:`
189
+ the readable body (plain text, falling back to stripped HTML) plus an attachment note when present;
190
+ the *current* inbound message is excluded (it arrives as the trailing `:user` turn).
191
+ - for each prior turn's tool use (when `include_tool_calls`): one `:assistant` message with
192
+ `content: ''` and `tool_calls: Array<ToolCall>`, followed by one `:tool` message per result
193
+ (`tool_call_id:` set, `content:` the result JSON truncated to `REPLAY_RESULT_LIMIT = 4_000` chars).
194
+ - **Reads:** `context.message.email_thread` (returns `nil` if there is no thread/message).
195
+
196
+ ### `LoadFileResolver`
197
+
198
+ Reads a file and contributes it as one message. General-purpose (system prompt, knowledge, examples).
199
+
200
+ - **Constructor:** `initialize(path = nil, role: :system, &block)` — supply **exactly one** of `path`
201
+ or a block returning a path (per delivery). Relative paths resolve against `Rails.root`; absolute
202
+ paths are used verbatim.
203
+ - **Returns:** `nil` when the file is blank; otherwise one `ModelMessage` (role:, content: <file>).
204
+ - **Raises:** `Protege::ResolverFileNotFoundError` when the file is missing (a missing
205
+ prompt is a misconfiguration — fail loud). Developer-wired; never agent input.
206
+
207
+ ### `LoadRecordResolver`
208
+
209
+ Loads ActiveRecord record(s) and contributes them as one message — the DB counterpart to `LoadFile`.
210
+
211
+ - **Constructor:** `initialize(role: :system, serialize: nil, &loader)` — the **required** `loader`
212
+ block receives the context and returns record(s) (`ActiveRecord::Base`, a `Relation`, an `Array`, or
213
+ `nil`). `serialize` is a `Symbol` (a method on each record returning text), a `Proc` (called with each
214
+ record), or `nil` for pretty-printed JSON (`JSON.pretty_generate(record.as_json)`).
215
+ - **Returns:** `nil` when the loader yields nothing (empty is a normal runtime state); otherwise one
216
+ `ModelMessage` (role:, content: <records joined by "\n\n">).
217
+
218
+ ### `LoadTextResolver`
219
+
220
+ The computed-text primitive: a block returns a `String`, contributed as one message. The clean way to
221
+ inject a persona's DB-column system prompt.
222
+
223
+ - **Constructor:** `initialize(role: :system, &block)` — the **required** block receives the context and
224
+ returns the text.
225
+ - **Returns:** `nil` when the block returns blank/`nil` (so an unset column adds nothing); otherwise one
226
+ `ModelMessage` (role:, content: <stripped text>).
227
+
228
+ ---
229
+
230
+ ## Writing a resolver
231
+
232
+ Subclass `Protege::Resolver`, implement `resolve(context:)`, and build messages with the `message`
233
+ helper — the resolver counterpart of `Tool`'s `success`/`failure`, so you never construct `ModelMessage`
234
+ by hand. Return one message, an array of them, or `nil`:
235
+
236
+ ```ruby
237
+ class CustomerContextResolver < Protege::Resolver
238
+ def resolve(context:)
239
+ customer = Customer.find_by(email: context.message&.from_address)
240
+ return nil unless customer
241
+
242
+ message(role: :system, content: "You are speaking with #{customer.name} (plan: #{customer.plan}).")
243
+ end
244
+ end
245
+ ```
246
+
247
+ `message(role:, content:, tool_call_id: nil, tool_calls: nil)` returns one
248
+ `ModelMessage`; the chain coerces a lone message, an array, or `nil`.
249
+
250
+ Rules of thumb:
251
+ - Return `nil` (not `[]` — both work, but `nil` is the idiom) when there's nothing to contribute.
252
+ - Pick the `role` deliberately; default to `:system` for injected knowledge.
253
+ - Only emit `:assistant`-with-`tool_calls` + paired `:tool` messages if you're replaying a real tool
254
+ exchange; keep them adjacent and correctly `tool_call_id`-matched.
255
+ - Read the context; never mutate it (it's frozen).
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # General-purpose resolver that reads a file and contributes its contents as one context message.
5
+ # It is deliberately *not* named for a use case: the developer decides what the file is — a system
6
+ # prompt, a knowledge document, few-shot examples — by choosing the +role+ of the emitted message.
7
+ #
8
+ # The path is given either statically or as a block that receives the resolver context, so it can
9
+ # be derived from the inbound message or persona (e.g. a per-persona prompt file). Path resolution
10
+ # mirrors ordinary shell intuition, so the same resolver works in development and in any production
11
+ # deployment:
12
+ # - a relative path (+config/personas/agent.md+, +./agent.md+) resolves against the host app root
13
+ # (+Rails.root+) — i.e. a file deployed with the app, which is reliably readable everywhere.
14
+ # - an absolute path (+/data/prompts/agent.md+) is used as-is, so an attached volume or mounted
15
+ # secret just needs its fully-qualified path.
16
+ #
17
+ # LoadFileResolver is a *developer-wired* resolver, never a model-callable tool — the path is author-trusted
18
+ # code, so even absolute reads carry the same trust as any code in the persona. A missing file is a
19
+ # misconfiguration and raises +Protege::ResolverFileNotFoundError+; a blank file contributes
20
+ # nothing (returns +nil+).
21
+ #
22
+ # resolvers do |chain|
23
+ # chain.use Protege::LoadFileResolver, 'config/personas/agent.md'
24
+ # chain.use(Protege::LoadFileResolver) { |ctx| "config/personas/#{ctx.persona.name.parameterize}.md" }
25
+ # end
26
+ class LoadFileResolver < Protege::Resolver
27
+ # @param path [String, nil] file to read; relative paths resolve against +Rails.root+, absolute
28
+ # paths are used verbatim. Omit when supplying a block instead.
29
+ # @param role [Symbol] role for the emitted message (+:system+ for a prompt, +:user+/+:assistant+
30
+ # to seed conversation); defaults to +:system+
31
+ # @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
32
+ # @yieldreturn [String] the path to read, computed per delivery
33
+ # @raise [ArgumentError] unless exactly one of +path+ or a block is given
34
+ def initialize(path = nil, role: :system, &block)
35
+ super()
36
+ raise ArgumentError, 'LoadFileResolver requires exactly one of a path or a block' unless path.nil? ^ block.nil?
37
+
38
+ @path = path
39
+ @role = role
40
+ @block = block
41
+ end
42
+
43
+ # Read the file and emit its contents as a single message, or +nil+ when the file is blank.
44
+ #
45
+ # @param context [Protege::Orchestrator::Context] the per-delivery context
46
+ # @return [Protege::ModelMessage, nil] one message, or +nil+ when blank
47
+ # @raise [Protege::ResolverFileNotFoundError] when the path does not exist
48
+ def resolve(context:)
49
+ content = read(path_for(context))
50
+ return nil if content.blank?
51
+
52
+ message(role: @role, content:)
53
+ end
54
+
55
+ private
56
+
57
+ # The path for this delivery: the block's return value when given a block, else the static path.
58
+ #
59
+ # @param context [Protege::Orchestrator::Context] the per-delivery context
60
+ # @return [String] the (unexpanded) path to read
61
+ def path_for(context)
62
+ @block ? @block.call(context) : @path
63
+ end
64
+
65
+ # Read the resolved path, translating a missing file into a domain error.
66
+ #
67
+ # @param path [String] the (unexpanded) path to read
68
+ # @return [String] the file contents
69
+ # @raise [Protege::ResolverFileNotFoundError] when the path does not exist
70
+ def read(path)
71
+ absolute = resolved_path(path)
72
+ File.read(absolute)
73
+ rescue Errno::ENOENT
74
+ raise Protege::ResolverFileNotFoundError.new(path:, absolute:)
75
+ end
76
+
77
+ # Expand a path: absolute paths pass through, relative paths anchor to +Rails.root+.
78
+ #
79
+ # @param path [String] the (unexpanded) path
80
+ # @return [String] the absolute filesystem path to read
81
+ def resolved_path(path)
82
+ str = path.to_s
83
+ str.start_with?('/') ? str : Rails.root.join(str).to_s
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # General-purpose resolver that loads ActiveRecord record(s) and contributes them as one context
5
+ # message — the database counterpart to LoadFileResolver. Like LoadFileResolver, it is deliberately not named for a
6
+ # use case: the developer decides what the records mean (account state, open invoices, reference
7
+ # data) and what message +role+ carries them.
8
+ #
9
+ # The single primitive is a **loader block** that returns record(s). It receives the resolver
10
+ # context, so loading can key off the inbound message — e.g. find the account that emailed in. A
11
+ # static query is just a block that ignores the context. We intentionally do not offer a
12
+ # +model:+/+where:+ DSL: it would be strictly less capable than the block (no scopes, joins, or
13
+ # +find+ vs +where+) and would collide column names with resolver options.
14
+ #
15
+ # Serialization is per-record and always produces text. By default each record is rendered as
16
+ # pretty-printed JSON; +serialize:+ overrides that with a Symbol (a method on the record returning
17
+ # text) or a +Proc+ (called with the record, returning text). The rendered records are joined into
18
+ # one message. A +nil+ or empty result contributes nothing (returns +nil+), since "no matching
19
+ # rows" is a normal runtime state — unlike LoadFileResolver's missing-file misconfiguration.
20
+ #
21
+ # resolvers do |chain|
22
+ # chain.use(Protege::LoadRecordResolver) { |ctx| Account.find_by(email: ctx.message.from_address) }
23
+ # chain.use(Protege::LoadRecordResolver, serialize: :to_llm_markdown) { Setting.active }
24
+ # end
25
+ class LoadRecordResolver < Protege::Resolver
26
+ # Separator between each record's text rendering when more than one is loaded.
27
+ RECORD_SEPARATOR = "\n\n"
28
+
29
+ # @param role [Symbol] role for the emitted message; defaults to +:system+
30
+ # @param serialize [Symbol, Proc, nil] per-record serializer returning text — a method name to
31
+ # call on each record, or a +Proc+ receiving each record; defaults to pretty-printed JSON
32
+ # @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
33
+ # @yieldreturn [ActiveRecord::Base, ActiveRecord::Relation, Array, nil] the record(s) to load
34
+ # @raise [ArgumentError] when no loader block is given
35
+ def initialize(role: :system, serialize: nil, &loader)
36
+ super()
37
+ raise ArgumentError, 'LoadRecordResolver requires a block returning record(s)' unless loader
38
+
39
+ @role = role
40
+ @serialize = serialize
41
+ @loader = loader
42
+ end
43
+
44
+ # Run the loader and emit its record(s) as one message, or +nil+ when nothing is loaded.
45
+ #
46
+ # @param context [Protege::Orchestrator::Context] the per-delivery context
47
+ # @return [Protege::ModelMessage, nil] one message, or +nil+ when empty
48
+ def resolve(context:)
49
+ records = Array(@loader.call(context))
50
+ return nil if records.empty?
51
+
52
+ content = render(records)
53
+ message(role: @role, content:)
54
+ end
55
+
56
+ private
57
+
58
+ # Render the records into the message string: serialize each to text, then join. Every
59
+ # serializer returns text (that is the contract), so no per-value coercion is needed.
60
+ #
61
+ # @param records [Array] the loaded records
62
+ # @return [String] the message content
63
+ def render(records)
64
+ records.map { serialize_record(_1) }.join(RECORD_SEPARATOR)
65
+ end
66
+
67
+ # Serialize one record to text. A serializer always returns a String: a Symbol names a method on
68
+ # the record, a +Proc+ is called with the record, and the default is pretty-printed JSON.
69
+ #
70
+ # @param record [Object] the record to serialize
71
+ # @return [String] the record's text representation
72
+ def serialize_record(record)
73
+ case @serialize
74
+ when Symbol then record.public_send(@serialize)
75
+ when Proc then @serialize.call(record)
76
+ else JSON.pretty_generate(record.as_json)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # General-purpose resolver that runs a block and contributes its returned String as one context
5
+ # message — the computed-text sibling of +LoadFileResolver+ (text from a file) and +LoadRecordResolver+ (text from
6
+ # records). The block receives the resolver context, so the text can be derived from the persona or
7
+ # the inbound message. The canonical use is a persona's system prompt held in a column:
8
+ #
9
+ # resolvers do |chain|
10
+ # chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
11
+ # end
12
+ #
13
+ # +role+ chooses how the text enters the conversation (+:system+ for a prompt, +:user+/+:assistant+
14
+ # to seed turns) and defaults to +:system+. A +nil+ or blank result contributes nothing (returns
15
+ # +nil+), so an unset column simply adds no message.
16
+ class LoadTextResolver < Protege::Resolver
17
+ # @param role [Symbol] role for the emitted message; defaults to +:system+
18
+ # @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
19
+ # @yieldreturn [String, nil] the text to contribute
20
+ # @raise [ArgumentError] when no block is given
21
+ def initialize(role: :system, &block)
22
+ super()
23
+ raise ArgumentError, 'LoadTextResolver requires a block returning text' unless block
24
+
25
+ @role = role
26
+ @block = block
27
+ end
28
+
29
+ # Run the block and emit its text as a single message, or +nil+ when the result is blank.
30
+ #
31
+ # @param context [Protege::Orchestrator::Context] the per-delivery context
32
+ # @return [Protege::ModelMessage, nil] one message, or +nil+ when blank
33
+ def resolve(context:)
34
+ content = @block.call(context).to_s.strip
35
+ return nil if content.empty?
36
+
37
+ message(role: @role, content:)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in resolver that replays a thread's prior messages so the agent has conversational
5
+ # memory across exchanges. For each persisted message in the current message's thread it
6
+ # contributes an ordered +ModelMessage+ — inbound mapped to +:user+, outbound to
7
+ # +:assistant+ — with the current inbound message excluded (it arrives as the final +:user+
8
+ # message through the normal harness flow).
9
+ #
10
+ # Each turn is the message's +Protege::Message#to_llm_text+: a standard header block (From / To / Cc /
11
+ # Date / Subject, always rendered) followed by the body — plain text, or the tag-stripped HTML
12
+ # fallback — and, when the message carries attachments, the note the app builds listing each (filename,
13
+ # content type, size, and the attachment id the +read_attachment+ / +send_email+ tools accept). So the
14
+ # agent knows who wrote each turn and when, not just what was said, and an attachment-only message
15
+ # still contributes. A message with neither a body nor attachments is skipped.
16
+ #
17
+ # It also replays the agent's **tool use** between turns: the +ToolUse+ rows recorded during each
18
+ # prior run are woven back in right after the inbound message that triggered them, as the same
19
+ # structured messages a live run builds — one +:assistant+ message carrying the round's +tool_calls+,
20
+ # followed by one +:tool+ message per result. Without this the agent would forget, on the next turn,
21
+ # what it searched, fetched, or read earlier. Each replayed result is truncated to
22
+ # +REPLAY_RESULT_LIMIT+ so past tool output can't dominate the context. Pass +include_tool_calls:
23
+ # false+ to replay only the plain message turns.
24
+ #
25
+ # Returns +nil+ when the thread has no prior content.
26
+ #
27
+ # By default it replays only the most recent +DEFAULT_LIMIT+ prior messages (still in chronological
28
+ # order) — a bounded verbatim window that pairs well with a running summary recapping everything
29
+ # older, and a guard against unbounded context on long threads. Pass +limit:+ to change the window, or
30
+ # +limit: nil+ to replay the entire thread.
31
+ #
32
+ # resolvers { |chain| chain.use Protege::ThreadHistoryResolver } # most recent DEFAULT_LIMIT
33
+ # resolvers { |chain| chain.use Protege::ThreadHistoryResolver, limit: 10 } # most recent 10
34
+ # resolvers { |chain| chain.use Protege::ThreadHistoryResolver, limit: nil } # the entire thread
35
+ class ThreadHistoryResolver < Protege::Resolver
36
+ # Most-recent prior messages replayed when no +limit:+ is given.
37
+ DEFAULT_LIMIT = 50
38
+
39
+ # Maximum characters of each prior tool result replayed into history.
40
+ REPLAY_RESULT_LIMIT = 4_000
41
+
42
+ # @param limit [Integer, nil] keep only the most recent N prior messages; +nil+ keeps all
43
+ # @param include_tool_calls [Boolean] weave prior tool calls/results back into the history
44
+ def initialize(limit: DEFAULT_LIMIT, include_tool_calls: true)
45
+ super()
46
+ @limit = limit
47
+ @include_tool_calls = include_tool_calls
48
+ end
49
+
50
+ # Build the thread's prior messages — and the tool use between them — as ordered provider messages.
51
+ #
52
+ # @param context [Protege::Orchestrator::Context] exposes the current +message+
53
+ # @return [Array<Protege::ModelMessage>, nil] ordered history, or +nil+ when empty
54
+ def resolve(context:)
55
+ thread = context.message.email_thread
56
+ return nil unless thread
57
+
58
+ prior = thread.messages
59
+ .with_attached_attachments
60
+ .where.not(message_id: context.message.message_id)
61
+ .order(:sent_at)
62
+ prior = prior.includes(:tool_uses) if @include_tool_calls # eager-load each turn's tool history
63
+ prior = prior.last(@limit) if @limit # most recent N, still ascending by sent_at
64
+
65
+ prior.flat_map { |message| [history_message(message), *tool_turns(message)] }
66
+ .compact.presence
67
+ end
68
+
69
+ private
70
+
71
+ # Rebuild a message's tool activity as structured provider messages: per tool-calling round, one
72
+ # +:assistant+ message carrying the round's calls, then one +:tool+ message per (truncated) result.
73
+ # Reads the eager-loaded +tool_uses+ association and orders it in memory, so no extra query runs.
74
+ #
75
+ # @param message [Protege::Message] the message whose recorded tool use is replayed
76
+ # @return [Array<Protege::ModelMessage>] the reconstructed turns (empty when none)
77
+ def tool_turns(message)
78
+ return [] unless @include_tool_calls
79
+
80
+ message.tool_uses
81
+ .sort_by { |use| [use.run_id.to_s, use.turn_index, use.position] }
82
+ .group_by { |use| [use.run_id, use.turn_index] }
83
+ .flat_map { |_round, rows| round_messages(rows) }
84
+ end
85
+
86
+ # Rebuild one tool-calling round: the +:assistant+ message carrying its calls, then one +:tool+
87
+ # message per (truncated) result.
88
+ #
89
+ # @param rows [Array<Protege::ToolUse>] the round's tool uses, in call order
90
+ # @return [Array<Protege::ModelMessage>] the assistant turn followed by its results
91
+ def round_messages(rows)
92
+ assistant = message(role: :assistant, content: '', tool_calls: rows.map(&:to_provider_tool_call))
93
+
94
+ results = rows.map do |row|
95
+ message(role: :tool, tool_call_id: row.tool_call_id, content: row.replay_result(limit: REPLAY_RESULT_LIMIT))
96
+ end
97
+
98
+ [assistant, *results]
99
+ end
100
+
101
+ # Map one stored message to a provider message, or +nil+ when it carries no usable content.
102
+ #
103
+ # The turn's content is the message's {Protege::Message#to_llm_text} — the standard header
104
+ # (sender/recipients/subject/timestamp) plus body and any attachment note — so a replayed turn is
105
+ # self-describing. Skips a message with neither a body nor attachments (nothing worth replaying);
106
+ # an attachment-only turn still contributes.
107
+ #
108
+ # @param record [Protege::Message] the stored message
109
+ # @return [Protege::ModelMessage, nil]
110
+ def history_message(record)
111
+ return nil if record.readable_body.blank? && !record.attachments.attached?
112
+
113
+ message(role: record.inbound? ? :user : :assistant, content: record.to_llm_text)
114
+ end
115
+ end
116
+ end