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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in tool that turns content the agent authored into a stored file it can later attach to an
5
+ # email. Subclasses +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id
6
+ # +:create_file+); when the model calls it, the harness routes the arguments to +#use+.
7
+ #
8
+ # The file is written to Active Storage as a standalone +ActiveStorage::Blob+ (no owner record) and
9
+ # the tool returns its +blob_id+. That id is the currency the +send_email+ tool accepts, so the flow
10
+ # is: author content here, get a blob id, then reference it from +send_email+'s +attachments+. Keeping
11
+ # storage and sending as two steps lets the agent create several files, review them via
12
+ # +read_attachment+, and attach any subset.
13
+ #
14
+ # The LLM only ever supplies *text*; the engine never asks it for binary bytes. The +format+ enum is
15
+ # therefore the whole capability surface — each maps to a content type the authored text is stored
16
+ # under. A format that needs a real transform (e.g. HTML->PDF) is a later addition; today every
17
+ # format stores the text as-is.
18
+ class CreateFileTool < Protege::Tool
19
+ # Supported formats mapped to the content type the authored text is stored under. The keys are the
20
+ # +format+ enum; the format name is also the file extension. New text formats are one row here.
21
+ FORMATS = {
22
+ csv: 'text/csv',
23
+ txt: 'text/plain',
24
+ md: 'text/markdown',
25
+ html: 'text/html',
26
+ json: 'application/json',
27
+ svg: 'image/svg+xml'
28
+ }.freeze
29
+
30
+ description <<~DESC.strip
31
+ Create a file from content you write, and get back a blob id you can attach to an email.
32
+
33
+ Use this when the user wants a document — a CSV of data, an HTML or Markdown report, a JSON
34
+ export, an SVG chart. Write the full file content yourself in the chosen format; it is stored as
35
+ a file and you receive its id. To send it, call send_email with that id in attachments (you can
36
+ create several files and attach any of them). You can also review a file you made with
37
+ read_attachment before sending.
38
+
39
+ Supported formats: csv, txt, md, html, json, svg. Provide the content as text — for csv write the
40
+ rows, for html/svg write the markup, for json write the JSON.
41
+ DESC
42
+
43
+ input_schema(
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ required: %w[format filename content],
47
+ properties: {
48
+ format: {
49
+ type: 'string',
50
+ enum: FORMATS.keys.map(&:to_s),
51
+ description: 'The file format. Determines the content type and extension.'
52
+ },
53
+ filename: {
54
+ type: 'string',
55
+ description: 'The base filename (the correct extension is applied automatically).'
56
+ },
57
+ content: {
58
+ type: 'string',
59
+ description: 'The complete file content you authored, as text in the chosen format.'
60
+ }
61
+ }
62
+ )
63
+
64
+ # Render the authored content to a stored blob and return its id.
65
+ #
66
+ # @param context [Protege::Orchestrator::Context] the per-run context (unused — a blob has no owner)
67
+ # @param format [String] one of {FORMATS}'s keys
68
+ # @param filename [String] the base filename; normalized to carry the format's extension
69
+ # @param content [String] the file content the model authored
70
+ # @return [Protege::Result] success carrying +blob_id+ and metadata, or a failure (unknown format
71
+ # or a size-limit breach)
72
+ def use(context:, format:, filename:, content:)
73
+ content_type = FORMATS[format.to_sym]
74
+ return failure(reason: "unknown format #{format.inspect}") unless content_type
75
+
76
+ bytes = content.to_s.b
77
+ violation = Protege.configuration.attachment_policy.violation(byte_sizes: [bytes.bytesize])
78
+ return failure(reason: violation) if violation
79
+
80
+ blob = ActiveStorage::Blob.create_and_upload!(
81
+ io: StringIO.new(bytes),
82
+ filename: with_extension(filename, format),
83
+ content_type:
84
+ )
85
+
86
+ success(
87
+ blob_id: blob.id,
88
+ filename: blob.filename.to_s,
89
+ byte_size: blob.byte_size,
90
+ content_type:
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ # Normalize +filename+ to carry the format's extension, replacing a wrong or missing one.
97
+ #
98
+ # @param filename [String] the base filename supplied by the model
99
+ # @param format [String, Symbol] the chosen format (its name is the extension)
100
+ # @return [String] the filename ending in +.<format>+
101
+ def with_extension(filename, format)
102
+ base = File.basename(filename.to_s.strip)
103
+ base = base.delete_suffix(File.extname(base)) unless File.extname(base).empty?
104
+ base = 'file' if base.empty?
105
+
106
+ "#{base}.#{format}"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in tool that reads a stored file by its blob id. Subclasses +Protege::Tool+, so the harness
5
+ # publishes it in the LLM's tool catalog (id +:read_attachment+).
6
+ #
7
+ # The agent learns a file's blob id from the +ThreadHistoryResolver+/inbound note ("…, id: 123") or
8
+ # from +create_file+. Resolution is by blob id (see +Protege::StoredFile.find+); persona scoping is
9
+ # deferred to a later pass (see the blob-currency spec §9), so any known blob id currently resolves.
10
+ # Behaviour by type:
11
+ #
12
+ # - **text-like** (text/*, json, xml, csv) → the text content is returned in the result.
13
+ # - **image / PDF** → returned as a "review" marker carrying only the blob id (no bytes); the harness
14
+ # rebuilds it as a vision part and injects it on the next user turn, because OpenAI-schema tool
15
+ # results can't carry images. The model then sees the actual image/PDF.
16
+ # - **anything else** (video, archives) → a failure; it can't be read.
17
+ class ReadAttachmentTool < Protege::Tool
18
+ description <<~DESC.strip
19
+ Read a file by the id shown in the conversation history (e.g. "id: 123") or returned by
20
+ create_file.
21
+
22
+ Text files (txt, csv, markdown, json, xml) are returned to you as text. Images and PDFs are
23
+ shown to you directly to view. Other types (such as video or archives) can't be read.
24
+ DESC
25
+
26
+ input_schema(
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ required: %w[blob_id],
30
+ properties: {
31
+ blob_id: {
32
+ type: 'integer',
33
+ description: 'The id of the file to read, as shown in the conversation history.'
34
+ }
35
+ }
36
+ )
37
+
38
+ # Resolve the file by blob id and read it according to its type.
39
+ #
40
+ # @param context [Protege::Orchestrator::Context] the per-run context (unused — resolution is by id)
41
+ # @param blob_id [Integer] the blob id from the conversation history or +create_file+
42
+ # @return [Protege::Result] text content, a review marker (image/PDF), or a failure
43
+ def use(context:, blob_id:)
44
+ file = Protege::StoredFile.find(blob_id)
45
+ return missing_failure(blob_id) unless file
46
+
47
+ if file.text?
48
+ text_result(file)
49
+ elsif file.viewable?
50
+ review_result(file)
51
+ else
52
+ unviewable_failure(file)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Success carrying the file's text content.
59
+ #
60
+ # @param file [Protege::StoredFile] the resolved file
61
+ # @return [Protege::Result] the text result
62
+ def text_result(file)
63
+ success(
64
+ filename: file.filename,
65
+ content_type: file.content_type,
66
+ content: file.download
67
+ )
68
+ end
69
+
70
+ # Success marking the file for visual review — only the blob id is carried, never the bytes; the
71
+ # harness injects the image/PDF as a vision part on the next user turn.
72
+ #
73
+ # @param file [Protege::StoredFile] the resolved file
74
+ # @return [Protege::Result] the review marker
75
+ def review_result(file)
76
+ success(
77
+ blob_id: file.id,
78
+ review: true,
79
+ filename: file.filename,
80
+ content_type: file.content_type,
81
+ note: "Showing #{file.filename} below for you to review."
82
+ )
83
+ end
84
+
85
+ # Failure for a blob id that matches no stored file.
86
+ #
87
+ # @param blob_id [Integer] the requested blob id
88
+ # @return [Protege::Result] the not-found failure
89
+ def missing_failure(blob_id)
90
+ failure(reason: "no file ##{blob_id}")
91
+ end
92
+
93
+ # Failure for a file whose type can't be read (e.g. video, archive).
94
+ #
95
+ # @param file [Protege::StoredFile] the resolved file
96
+ # @return [Protege::Result] the unviewable failure
97
+ def unviewable_failure(file)
98
+ failure(reason: "attachment #{file.filename} (#{file.content_type}) can't be read")
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in archive-search tool — the way an agent looks back across its own correspondence beyond
5
+ # the current thread. Subclasses +Protege::Tool+, so the harness publishes it in the LLM's tool
6
+ # catalog (id +:search_emails+); when the model emits a +search_emails+ call, the harness routes the
7
+ # arguments to +#use+.
8
+ #
9
+ # The search is always scoped to the active persona's own messages (via +Persona#messages+) — an
10
+ # agent can never read another persona's mail. A single free-text query is matched, case-insensitively,
11
+ # against both the participants (from/to/cc) and the content (subject/body); results come back newest
12
+ # first. Each result carries the message's *full* body — a truncated body could cut the very text that
13
+ # matched, so it is returned whole. The caller may cap the result count with +limit+ (default
14
+ # {DEFAULT_LIMIT}, hard-capped at {MAX_LIMIT}); the default is small precisely because bodies are full.
15
+ class SearchEmailsTool < Protege::Tool
16
+ # Result count when the caller does not specify a +limit+. Kept small because each result carries
17
+ # the full message body.
18
+ DEFAULT_LIMIT = 5
19
+
20
+ # Absolute ceiling on the result count, regardless of the requested +limit+.
21
+ MAX_LIMIT = 50
22
+
23
+ # Columns scanned by the query: the participant addresses first, then the subject and bodies.
24
+ SEARCHABLE_COLUMNS = %w[
25
+ from_address to_addresses cc_addresses subject text_body html_body
26
+ ].freeze
27
+
28
+ description <<~DESC.strip
29
+ Search your own email archive for past messages — use this to recall earlier conversations that
30
+ are not part of the thread you are currently replying to. Only your own sent and received mail is
31
+ searched; you cannot see other agents' mail.
32
+
33
+ The query is matched against both participants (from / to / cc addresses) and content (subject and
34
+ body), so you can search by who was involved ("acme.co", "bob@acme.co") or by what was discussed
35
+ ("Q3 invoice", "renewal"). Matching messages are returned newest first, each with its full body and
36
+ a list of any attachments (filename, type, size, and id) — use an id with read_attachment to view
37
+ one or with send_email to re-attach it.
38
+
39
+ Provide a single query string. Optionally set limit to cap the number of results (default 5,
40
+ maximum 50) — keep it small, since every result includes the whole message body. Narrow the query
41
+ rather than raising the limit when you get too many hits.
42
+ DESC
43
+
44
+ input_schema(
45
+ type: 'object',
46
+ additionalProperties: false,
47
+ required: %w[query],
48
+ properties: {
49
+ query: {
50
+ type: 'string',
51
+ description: 'Free-text search matched against participants (from/to/cc) and content ' \
52
+ '(subject/body).'
53
+ },
54
+ limit: {
55
+ type: 'integer',
56
+ description: 'Maximum number of results to return. Defaults to 5; capped at 50.'
57
+ }
58
+ }
59
+ )
60
+
61
+ # Search the active persona's archive and return the matching messages as concise summaries.
62
+ #
63
+ # @param context [Protege::Orchestrator::Context] exposes the active +persona+
64
+ # @param query [String] the free-text query supplied by the model
65
+ # @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
66
+ # @return [Protege::Result] success carrying +count+ and the shaped +results+, or a failure when
67
+ # the query is blank
68
+ def use(context:, query:, limit: nil)
69
+ return failure(reason: 'query cannot be empty') if query.to_s.strip.empty?
70
+
71
+ hits = matches(persona: context.persona, query:, limit:)
72
+
73
+ success(count: hits.size, results: hits.map { result_hash(_1) })
74
+ end
75
+
76
+ private
77
+
78
+ # Build and run the scoped, wildcard-escaped LIKE query, newest first.
79
+ #
80
+ # @param persona [Protege::Persona] the persona whose messages are searched
81
+ # @param query [String] the raw query supplied by the model
82
+ # @param limit [Integer, nil] the requested cap, clamped into 1..{MAX_LIMIT}
83
+ # @return [Array<Protege::Message>] the matching messages, newest first
84
+ def matches(persona:, query:, limit:)
85
+ count = (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
86
+ escaped = ActiveRecord::Base.sanitize_sql_like(query.strip.downcase)
87
+ clause = SEARCHABLE_COLUMNS.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
88
+
89
+ persona.messages
90
+ .with_attached_attachments
91
+ .where(clause, term: "%#{escaped}%")
92
+ .order(sent_at: :desc)
93
+ .limit(count)
94
+ .to_a
95
+ end
96
+
97
+ # Shape a single message into a result summary. The body is returned in full (never truncated) so
98
+ # the matched text is always present, and any attachments are listed so the model can pull one into
99
+ # view with +read_attachment+ or re-send it with +send_email+ by its id.
100
+ #
101
+ # @param message [Protege::Message] the matched message
102
+ # @return [Hash] the summary the model reasons over
103
+ def result_hash(message)
104
+ base = {
105
+ message_id: message.message_id,
106
+ thread_id: message.email_thread.thread_id,
107
+ direction: message.direction,
108
+ from_address: message.from_address,
109
+ to_addresses: message.to_addresses,
110
+ subject: message.subject,
111
+ sent_at: message.sent_at.iso8601,
112
+ body: message.readable_body
113
+ }
114
+
115
+ summaries = attachment_summaries(message)
116
+ summaries.empty? ? base : base.merge(attachments: summaries)
117
+ end
118
+
119
+ # One-line summaries (filename, type, human size, blob id) for every attachment on the message,
120
+ # built from the shared +Protege::StoredFile+ wrapper so search matches the format (and the blob
121
+ # id) used in thread history and +read_attachment+.
122
+ #
123
+ # @param message [Protege::Message] the matched message
124
+ # @return [Array<String>] the per-attachment summaries (empty when the message has none)
125
+ def attachment_summaries(message)
126
+ message.attachments.map { Protege::StoredFile.new(_1.blob).summary }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in email tool — the way an agent sends mail, in one of two modes. Subclasses
5
+ # +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:send_email+);
6
+ # when the model emits a +send_email+ call, the harness routes the arguments to +#use+.
7
+ #
8
+ # +From+ is always enforced to the persona's +email_address+ — the agent cannot impersonate another
9
+ # sender. The +mode+ argument selects behavior:
10
+ #
11
+ # * +"reply"+ — continue the current conversation. +Subject+ and threading (+In-Reply-To+ +
12
+ # +References+) are derived from the inbound message; the subject is the *thread's* subject, +Re:+
13
+ # prefixed, NOT a value the model chooses (Gmail and others require a matching subject to thread, so
14
+ # a model-invented subject would split the conversation). The original sender is always a recipient;
15
+ # the model may add more via +to+/+cc+/+bcc+.
16
+ # * +"new"+ — start a fresh conversation. The model supplies +to+ and +subject+ (and optional
17
+ # +cc+/+bcc+); no threading headers are set.
18
+ #
19
+ # Assembly is delegated to +Protege::Gateway.build_outbound+. The agent may attach files by
20
+ # referencing blob ids — the ids shown in the conversation history, or returned by +create_file+ —
21
+ # resolved through +Protege::StoredFile.find+ and checked against the configured
22
+ # +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; persona scoping is a
23
+ # deferred pass — see the blob-currency spec §9.)
24
+ class SendEmailTool < Protege::Tool
25
+ description <<~DESC.strip
26
+ YOU MUST USE THIS TOOL FOR EVERY RESPONSE. This is the ONLY way to communicate with the user.
27
+ They will never receive plain text in your assistant message If you want them to read something,#{' '}
28
+ you must use this send_email tool.#{' '}
29
+
30
+ Set mode:
31
+
32
+ - "reply": continue the current conversation. The subject and threading are kept automatically and
33
+ the original sender is included; add more recipients with to/cc/bcc if you need to.
34
+ - "new": start a new email. Provide to and subject.
35
+
36
+ Always provide body. cc and bcc are optional in both modes. Attach files with attachments — a list
37
+ of blob ids (e.g. the "id: 123" shown next to a file in the conversation, or the id returned by
38
+ create_file).
39
+ DESC
40
+
41
+ input_schema(
42
+ type: 'object',
43
+ additionalProperties: false,
44
+ required: %w[mode body],
45
+ properties: {
46
+ mode: {
47
+ type: 'string',
48
+ enum: %w[reply new],
49
+ description: '"reply" to continue the current conversation, or "new" to start a new email.'
50
+ },
51
+ body: { type: 'string', description: 'Plain text body.' },
52
+ to: {
53
+ type: 'array',
54
+ items: { type: 'string' },
55
+ description: 'Recipients. Required for "new"; for "reply", added beyond the original sender.'
56
+ },
57
+ cc: {
58
+ type: 'array',
59
+ items: { type: 'string' },
60
+ description: 'Optional carbon-copy recipients.'
61
+ },
62
+ bcc: {
63
+ type: 'array',
64
+ items: { type: 'string' },
65
+ description: 'Optional blind-carbon-copy recipients.'
66
+ },
67
+ subject: {
68
+ type: 'string',
69
+ description: 'Subject for a "new" email. Ignored when replying (the thread subject is kept).'
70
+ },
71
+ attachments: {
72
+ type: 'array',
73
+ items: { type: 'integer' },
74
+ description: 'Optional blob ids (from the conversation history or create_file) to attach.'
75
+ }
76
+ }
77
+ )
78
+
79
+ # Build the outbound mail for the requested mode and hand it to the gateway for delivery.
80
+ #
81
+ # @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +persona+, and +deliver+
82
+ # @param mode [String] +"reply"+ or +"new"+
83
+ # @param body [String] plain-text body supplied by the model
84
+ # @param to [Array<String>] recipients (required for +"new"+; additions for +"reply"+)
85
+ # @param cc [Array<String>] carbon-copy recipients
86
+ # @param bcc [Array<String>] blind-carbon-copy recipients
87
+ # @param subject [String, nil] subject for a +"new"+ email; ignored when replying
88
+ # @param attachments [Array<Integer>] blob ids to attach (from history or +create_file+)
89
+ # @return [Protege::Result] success carrying the sent message's id, or a failure (unknown blob,
90
+ # breached limits, nothing to reply to, or a new email missing +to+/+subject+)
91
+ def use(context:, mode:, body:, to: [], cc: [], bcc: [], subject: nil, attachments: [])
92
+ files = resolve_attachments(attachments)
93
+ return files if files.is_a?(Protege::Result) # a failure short-circuits
94
+
95
+ mail = build_mail(
96
+ context:, mode:, to:, cc:, bcc:, subject:, body:, attachments: files.map(&:to_mail_attachment)
97
+ )
98
+ return mail if mail.is_a?(Protege::Result) # a validation failure short-circuits
99
+
100
+ mail.encoded
101
+ # Hand the blobs to the gateway so the persisted outbound message attaches them by reference
102
+ # (no re-upload) — the mail carries the bytes only for transport.
103
+ context.deliver(mail:, attachments: files.map(&:blob))
104
+
105
+ success(message_id: mail.message_id)
106
+ end
107
+
108
+ private
109
+
110
+ # Build the outbound mail for the requested mode.
111
+ #
112
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
113
+ # @param mode [String] +"reply"+ or +"new"+
114
+ # @param to [Array<String>] recipients
115
+ # @param cc [Array<String>] carbon-copy recipients
116
+ # @param bcc [Array<String>] blind-carbon-copy recipients
117
+ # @param subject [String, nil] subject for a new email
118
+ # @param body [String] the plain-text body
119
+ # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
120
+ # @return [Mail::Message, Protege::Result] the assembled mail, or a failure
121
+ def build_mail(context:, mode:, to:, cc:, bcc:, subject:, body:, attachments:)
122
+ case mode
123
+ when 'reply' then reply_mail(context, to:, cc:, bcc:, body:, attachments:)
124
+ when 'new' then new_thread_mail(context.persona, to:, cc:, bcc:, subject:, body:, attachments:)
125
+ else failure(reason: %(unknown mode #{mode.inspect}; use "reply" or "new"))
126
+ end
127
+ end
128
+
129
+ # Construct a threaded reply: subject and threading from the inbound message, the original sender
130
+ # plus any added recipients.
131
+ #
132
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
133
+ # @param to [Array<String>] recipients to add beyond the original sender
134
+ # @param cc [Array<String>] carbon-copy recipients
135
+ # @param bcc [Array<String>] blind-carbon-copy recipients
136
+ # @param body [String] the plain-text body
137
+ # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
138
+ # @return [Mail::Message, Protege::Result] the assembled reply, or a failure when there is no inbound
139
+ def reply_mail(context, to:, cc:, bcc:, body:, attachments:)
140
+ inbound = context.message
141
+ return failure(reason: 'there is no message to reply to; send a "new" email instead') unless inbound
142
+
143
+ Protege::Gateway.build_outbound(
144
+ from: context.persona.email_address,
145
+ to: ([inbound.from_address] + addresses(to)).uniq,
146
+ cc: addresses(cc),
147
+ bcc: addresses(bcc),
148
+ subject: ensure_re_prefix(inbound.subject),
149
+ body:,
150
+ in_reply_to: inbound.message_id,
151
+ references: build_references(inbound),
152
+ attachments:
153
+ )
154
+ end
155
+
156
+ # Construct a new-thread email: the model supplies the recipients and subject; no threading headers.
157
+ #
158
+ # @param persona [Protege::Persona] the sending persona (enforced From)
159
+ # @param to [Array<String>] recipients (at least one required)
160
+ # @param cc [Array<String>] carbon-copy recipients
161
+ # @param bcc [Array<String>] blind-carbon-copy recipients
162
+ # @param subject [String, nil] the subject (required)
163
+ # @param body [String] the plain-text body
164
+ # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
165
+ # @return [Mail::Message, Protege::Result] the assembled mail, or a failure when +to+/+subject+ is missing
166
+ def new_thread_mail(persona, to:, cc:, bcc:, subject:, body:, attachments:)
167
+ recipients = addresses(to)
168
+ return failure(reason: 'a new email needs at least one "to" recipient') if recipients.empty?
169
+ return failure(reason: 'a new email needs a subject') if subject.to_s.strip.empty?
170
+
171
+ Protege::Gateway.build_outbound(
172
+ from: persona.email_address,
173
+ to: recipients,
174
+ cc: addresses(cc),
175
+ bcc: addresses(bcc),
176
+ subject:,
177
+ body:,
178
+ attachments:
179
+ )
180
+ end
181
+
182
+ # Normalize a recipients argument to a clean array of address strings (blank entries dropped).
183
+ #
184
+ # @param value [String, Array<String>, nil] the raw recipients
185
+ # @return [Array<String>] the cleaned addresses
186
+ def addresses(value)
187
+ Array(value).map { |address| address.to_s.strip }.reject(&:empty?)
188
+ end
189
+
190
+ # Resolve blob ids to the stored files they name, limit-checked.
191
+ #
192
+ # @param ids [Array<Integer>] the requested blob ids
193
+ # @return [Array<Protege::StoredFile>, Protege::Result] the resolved files, or a failure +Result+
194
+ # when a blob id is unknown or the selection breaches the limits
195
+ def resolve_attachments(ids)
196
+ resolved = Array(ids).map { |id| [id, Protege::StoredFile.find(id)] }
197
+
198
+ missing_id = first_missing_id(resolved)
199
+ return failure(reason: "no file ##{missing_id}") if missing_id
200
+
201
+ files = resolved.map(&:last)
202
+ violation = limit_violation(files)
203
+ return failure(reason: violation) if violation
204
+
205
+ files
206
+ end
207
+
208
+ # The first requested blob id that resolved to no file, or +nil+ when all were found.
209
+ #
210
+ # @param resolved [Array<Array(Integer, Protege::StoredFile, nil)>] id → file pairs
211
+ # @return [Integer, nil] the missing id, or nil
212
+ def first_missing_id(resolved)
213
+ resolved.find { |_id, file| file.nil? }&.first
214
+ end
215
+
216
+ # The configured attachment-limit breach for the selected files, or +nil+ when within limits.
217
+ #
218
+ # @param files [Array<Protege::StoredFile>] the resolved files
219
+ # @return [String, nil] the limit-breach reason, or nil
220
+ def limit_violation(files)
221
+ Protege.configuration.attachment_policy.violation(byte_sizes: files.map(&:byte_size))
222
+ end
223
+
224
+ # Assemble the +References+ chain, extending the inbound chain with the inbound's own id.
225
+ #
226
+ # @param inbound [Protege::Message] the message being replied to
227
+ # @return [Array<String>] the de-duplicated references chain
228
+ def build_references(inbound)
229
+ (inbound.references_header.to_s.split + [inbound.message_id]).compact.uniq
230
+ end
231
+
232
+ # Ensure the subject carries a single +Re:+ prefix.
233
+ #
234
+ # @param subject [String] the raw subject line
235
+ # @return [String] the subject, prefixed with +Re:+ unless it already starts with one
236
+ def ensure_re_prefix(subject)
237
+ subject.to_s.strip.match?(/\Are:/i) ? subject : "Re: #{subject}"
238
+ end
239
+ end
240
+ end