protege 0.1.0.alpha.2 → 0.1.0.alpha.7

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +21 -0
  3. data/Rakefile +1 -3
  4. data/app/assets/builds/protege.css +76 -83
  5. data/app/controllers/concerns/protege/agent_scoped.rb +22 -0
  6. data/app/controllers/concerns/protege/agent_toolkit_scoped.rb +22 -0
  7. data/app/controllers/protege/access_rules_controller.rb +13 -13
  8. data/app/controllers/protege/agent_toolkit_rules_controller.rb +62 -0
  9. data/app/controllers/protege/agent_toolkits_controller.rb +44 -0
  10. data/app/controllers/protege/agents/agent_toolkits_controller.rb +65 -0
  11. data/app/controllers/protege/agents_controller.rb +118 -0
  12. data/app/controllers/protege/archives_controller.rb +18 -18
  13. data/app/controllers/protege/home_controller.rb +1 -1
  14. data/app/controllers/protege/messages_controller.rb +1 -1
  15. data/app/controllers/protege/replies_controller.rb +3 -3
  16. data/app/controllers/protege/responsibilities_controller.rb +7 -7
  17. data/app/controllers/protege/threads_controller.rb +4 -4
  18. data/app/controllers/protege/toolkits_controller.rb +84 -0
  19. data/app/helpers/protege/agents_helper.rb +75 -0
  20. data/app/helpers/protege/application_helper.rb +3 -2
  21. data/app/helpers/protege/components/badges_helper.rb +11 -2
  22. data/app/helpers/protege/components/forms_helper.rb +47 -12
  23. data/app/helpers/protege/components/typography_helper.rb +1 -1
  24. data/app/helpers/protege/messages_helper.rb +2 -2
  25. data/app/helpers/protege/responsibilities_helper.rb +2 -2
  26. data/app/helpers/protege/threads_helper.rb +11 -11
  27. data/app/helpers/protege/toolkits_helper.rb +66 -0
  28. data/app/hooks/protege/event_logger_hook.rb +8 -8
  29. data/app/jobs/protege/console_inference_job.rb +8 -8
  30. data/app/jobs/protege/inference_job.rb +11 -11
  31. data/app/jobs/protege/responsibility_job.rb +10 -10
  32. data/app/mailboxes/protege/agent_mailbox.rb +54 -29
  33. data/app/mailers/protege/alert_mailer.rb +41 -0
  34. data/app/mailers/protege/application_mailer.rb +9 -8
  35. data/app/models/concerns/protege/broadcastable_agent.rb +50 -0
  36. data/app/models/concerns/protege/broadcastable_toolkit.rb +49 -0
  37. data/app/models/concerns/protege/toolable.rb +108 -0
  38. data/app/models/protege/access_rule.rb +12 -12
  39. data/app/models/protege/{persona.rb → agent.rb} +45 -45
  40. data/app/models/protege/agent_toolkit.rb +50 -0
  41. data/app/models/protege/agent_toolkit_rule.rb +25 -0
  42. data/app/models/protege/application_record.rb +1 -1
  43. data/app/models/protege/email_thread.rb +16 -13
  44. data/app/models/protege/message.rb +36 -20
  45. data/app/models/protege/responsibility.rb +12 -12
  46. data/app/models/protege/tool_use.rb +6 -6
  47. data/app/models/protege/toolkit.rb +115 -0
  48. data/app/models/protege/trace.rb +1 -1
  49. data/app/providers/protege/open_router_provider.rb +17 -0
  50. data/app/resolvers/README.md +4 -4
  51. data/app/resolvers/protege/load_file_resolver.rb +5 -5
  52. data/app/resolvers/protege/load_text_resolver.rb +3 -3
  53. data/app/resolvers/protege/thread_history_resolver.rb +1 -1
  54. data/app/services/protege/message_search.rb +2 -2
  55. data/app/services/protege/system_toolkits.rb +67 -0
  56. data/app/tools/protege/create_file_tool.rb +2 -0
  57. data/app/tools/protege/read_attachment_tool.rb +3 -1
  58. data/app/tools/protege/search_emails_tool.rb +15 -13
  59. data/app/tools/protege/send_email_tool.rb +60 -18
  60. data/app/tools/protege/web_fetch_tool.rb +2 -0
  61. data/app/tools/protege/web_search_tool.rb +3 -1
  62. data/app/views/layouts/mailer.text.erb +1 -0
  63. data/app/views/protege/access_rules/create.turbo_stream.slim +3 -3
  64. data/app/views/protege/agent_toolkit_rules/create.turbo_stream.slim +6 -0
  65. data/app/views/protege/agent_toolkits/_gate_rule.html.slim +7 -0
  66. data/app/views/protege/agent_toolkits/_gate_rule_form.html.slim +8 -0
  67. data/app/views/protege/agent_toolkits/_gate_rules.html.slim +19 -0
  68. data/app/views/protege/agent_toolkits/show.html.slim +17 -0
  69. data/app/views/protege/{personas → agents}/_access_rule.html.slim +1 -1
  70. data/app/views/protege/{personas → agents}/_access_rule_form.html.slim +1 -1
  71. data/app/views/protege/{personas → agents}/_access_rules.html.slim +5 -5
  72. data/app/views/protege/agents/_actions.html.slim +15 -0
  73. data/app/views/protege/agents/_agent_sidebar_item.html.slim +5 -0
  74. data/app/views/protege/agents/_agent_toolkit.html.slim +7 -0
  75. data/app/views/protege/agents/_agent_toolkit_form.html.slim +7 -0
  76. data/app/views/protege/agents/_form.html.slim +25 -0
  77. data/app/views/protege/agents/_sidebar.html.slim +11 -0
  78. data/app/views/protege/agents/_toolkits.html.slim +19 -0
  79. data/app/views/protege/agents/agent_toolkits/create.turbo_stream.slim +6 -0
  80. data/app/views/protege/agents/edit.html.slim +7 -0
  81. data/app/views/protege/agents/index.html.slim +6 -0
  82. data/app/views/protege/agents/new.html.slim +7 -0
  83. data/app/views/protege/agents/show.html.slim +29 -0
  84. data/app/views/protege/alert_mailer/inference_failed.text.erb +4 -0
  85. data/app/views/protege/email_domains/show.html.slim +1 -1
  86. data/app/views/protege/home/show.html.slim +13 -13
  87. data/app/views/protege/responsibilities/_form.html.slim +2 -2
  88. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +1 -1
  89. data/app/views/protege/responsibilities/new.html.slim +1 -1
  90. data/app/views/protege/responsibilities/show.html.slim +2 -2
  91. data/app/views/protege/responsibility_runs/show.html.slim +1 -1
  92. data/app/views/protege/shared/_header.html.slim +2 -1
  93. data/app/views/protege/shared/_hotkeys.html.slim +15 -11
  94. data/app/views/protege/threads/new.html.slim +2 -2
  95. data/app/views/protege/toolkits/_form.html.slim +13 -0
  96. data/app/views/protege/toolkits/_sidebar.html.slim +11 -0
  97. data/app/views/protege/toolkits/_toolkit_sidebar_item.html.slim +5 -0
  98. data/app/views/protege/toolkits/edit.html.slim +7 -0
  99. data/app/views/protege/toolkits/index.html.slim +6 -0
  100. data/app/views/protege/toolkits/new.html.slim +7 -0
  101. data/app/views/protege/toolkits/show.html.slim +13 -0
  102. data/config/routes.rb +12 -1
  103. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +1 -1
  104. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +1 -1
  105. data/db/migrate/20260728140000_scope_message_and_thread_uniqueness_per_persona.rb +16 -0
  106. data/db/migrate/20260812000001_create_protege_toolkits.rb +37 -0
  107. data/db/migrate/20260815000001_remove_disabled_tool_ids_from_personas.rb +21 -0
  108. data/db/migrate/20260815000002_add_key_to_protege_toolkits.rb +22 -0
  109. data/db/migrate/20260815100000_rename_personas_to_agents.rb +24 -0
  110. data/lib/generators/protege/agent/agent_generator.rb +35 -0
  111. data/lib/generators/protege/{persona/templates/persona.rb.tt → agent/templates/agent.rb.tt} +9 -9
  112. data/lib/generators/protege/extension_naming.rb +1 -1
  113. data/lib/generators/protege/hook/templates/hook.rb.tt +2 -2
  114. data/lib/generators/protege/install/install_generator.rb +16 -11
  115. data/lib/generators/protege/install/templates/initializer.rb.tt +17 -6
  116. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +2 -2
  117. data/lib/generators/protege/resolver/resolver_generator.rb +2 -2
  118. data/lib/generators/protege/resolver/templates/resolver.rb.tt +4 -4
  119. data/lib/generators/protege/tool/templates/tool.rb.tt +7 -5
  120. data/lib/protege/configuration.rb +43 -8
  121. data/lib/protege/engine.rb +3 -1
  122. data/lib/protege/errors/tool_not_available_error.rb +7 -6
  123. data/lib/protege/events/event.rb +2 -2
  124. data/lib/protege/events/inference_chunk_event.rb +1 -1
  125. data/lib/protege/events/inference_completed_event.rb +1 -1
  126. data/lib/protege/events/inference_failed_event.rb +1 -1
  127. data/lib/protege/events/inference_generated_event.rb +1 -1
  128. data/lib/protege/events/inference_max_turns_reached_event.rb +1 -1
  129. data/lib/protege/events/inference_started_event.rb +1 -1
  130. data/lib/protege/events/loop_run_completed_event.rb +1 -1
  131. data/lib/protege/events/loop_run_enqueued_event.rb +1 -1
  132. data/lib/protege/events/loop_run_failed_event.rb +1 -1
  133. data/lib/protege/events/loop_run_started_event.rb +1 -1
  134. data/lib/protege/events/tool_call_completed_event.rb +1 -1
  135. data/lib/protege/events/tool_call_failed_event.rb +1 -1
  136. data/lib/protege/events/tool_call_started_event.rb +1 -1
  137. data/lib/protege/events/tool_calls_received_event.rb +1 -1
  138. data/lib/protege/extensions/hook.rb +1 -0
  139. data/lib/protege/extensions/hook_mixin.rb +1 -1
  140. data/lib/protege/extensions/manifest.rb +67 -0
  141. data/lib/protege/extensions/manifested.rb +81 -0
  142. data/lib/protege/extensions/provider.rb +1 -0
  143. data/lib/protege/extensions/provider_mixin.rb +9 -17
  144. data/lib/protege/extensions/resolver.rb +2 -1
  145. data/lib/protege/extensions/resolver_mixin.rb +2 -2
  146. data/lib/protege/extensions/tool.rb +1 -0
  147. data/lib/protege/extensions/tool_mixin.rb +16 -40
  148. data/lib/protege/gateway/access_control.rb +19 -19
  149. data/lib/protege/gateway/access_policy.rb +2 -2
  150. data/lib/protege/gateway/mail/outbound.rb +22 -5
  151. data/lib/protege/gateway.rb +48 -37
  152. data/lib/protege/loop/scheduler.rb +1 -1
  153. data/lib/protege/orchestrator/context.rb +8 -8
  154. data/lib/protege/orchestrator/harness.rb +26 -24
  155. data/lib/protege/orchestrator/reply_context.rb +3 -3
  156. data/lib/protege/orchestrator/reply_harness.rb +8 -8
  157. data/lib/protege/orchestrator/resolver_chain.rb +3 -3
  158. data/lib/protege/orchestrator/responsibility_context.rb +4 -4
  159. data/lib/protege/orchestrator/responsibility_harness.rb +8 -8
  160. data/lib/protege/orchestrator.rb +7 -7
  161. data/lib/protege/subscribers/alerter.rb +117 -0
  162. data/lib/protege/subscribers/tracing.rb +1 -1
  163. data/lib/protege/version.rb +1 -1
  164. data/lib/protege.rb +2 -2
  165. data/lib/tasks/protege_tasks.rake +12 -0
  166. metadata +66 -23
  167. data/app/controllers/concerns/protege/persona_scoped.rb +0 -22
  168. data/app/controllers/protege/personas_controller.rb +0 -118
  169. data/app/helpers/protege/personas_helper.rb +0 -90
  170. data/app/models/concerns/protege/broadcastable_persona.rb +0 -50
  171. data/app/models/concerns/protege/tool_scoped.rb +0 -88
  172. data/app/views/protege/personas/_actions.html.slim +0 -15
  173. data/app/views/protege/personas/_form.html.slim +0 -29
  174. data/app/views/protege/personas/_persona_sidebar_item.html.slim +0 -5
  175. data/app/views/protege/personas/_sidebar.html.slim +0 -11
  176. data/app/views/protege/personas/edit.html.slim +0 -7
  177. data/app/views/protege/personas/index.html.slim +0 -6
  178. data/app/views/protege/personas/new.html.slim +0 -7
  179. data/app/views/protege/personas/show.html.slim +0 -37
  180. data/lib/generators/protege/persona/persona_generator.rb +0 -35
@@ -7,7 +7,7 @@ module Protege
7
7
  # UI, the inbox, and the +ThreadHistoryResolver+ resolver all read from this model. The underlying
8
8
  # +ActionMailbox::InboundEmail+ is retained only as a raw delivery-pipeline artifact; application
9
9
  # logic never queries it directly. Every message belongs to exactly one +EmailThread+ and one
10
- # +Persona+, and inbound messages link back to the +InboundEmail+ they were ingested from.
10
+ # +Agent+, and inbound messages link back to the +InboundEmail+ they were ingested from.
11
11
  #
12
12
  # == Lifecycle (inbound)
13
13
  #
@@ -15,7 +15,7 @@ module Protege
15
15
  # processing → InferenceJob is running
16
16
  # processed → InferenceJob completed successfully
17
17
  # failed → InferenceJob raised; error captured in +last_processing_error+
18
- # bounced → no matching persona; handled in AgentMailbox before a job is queued
18
+ # bounced → no matching agent; handled in AgentMailbox before a job is queued
19
19
  #
20
20
  # == Lifecycle (outbound)
21
21
  #
@@ -28,7 +28,7 @@ module Protege
28
28
 
29
29
  # ── Associations ────────────────────────────────────────────────────────
30
30
 
31
- belongs_to :persona, class_name: 'Protege::Persona'
31
+ belongs_to :agent, class_name: 'Protege::Agent'
32
32
  belongs_to :email_thread, class_name: 'Protege::EmailThread', inverse_of: :messages
33
33
  # Optional: only inbound messages originate from an ActionMailbox record.
34
34
  belongs_to :inbound_email, class_name: 'ActionMailbox::InboundEmail', optional: true
@@ -64,8 +64,9 @@ module Protege
64
64
 
65
65
  # ── Validations ─────────────────────────────────────────────────────────
66
66
 
67
- # Message-ID is the dedup key; uniqueness keeps retries idempotent.
68
- validates :message_id, presence: true, uniqueness: true
67
+ # Message-ID is the dedup key; uniqueness is per agent so one email to several proteges records a
68
+ # message for each, while a single agent's retries stay idempotent.
69
+ validates :message_id, presence: true, uniqueness: { scope: :agent_id }
69
70
  validates :direction, presence: true
70
71
  validates :from_address, presence: true
71
72
  validates :sent_at, presence: true
@@ -86,19 +87,19 @@ module Protege
86
87
  # Ingest an +ActionMailbox::InboundEmail+ into a persisted, processing +Message+.
87
88
  #
88
89
  # Finds or creates the owning +EmailThread+, copies all mail-derived attributes onto the
89
- # record, and transitions it to +processing+. Keyed on +inbound_email_id+, so calling it
90
- # again for the same inbound email updates the existing record rather than duplicating it —
91
- # making it safe to invoke on job retry.
90
+ # record, and transitions it to +processing+. Keyed on +inbound_email_id+ *and* +agent+, so a
91
+ # retry for the same agent updates its existing record, while a different agent addressed by the
92
+ # same email gets its own — one inbound email maps to one message per handling agent.
92
93
  #
93
94
  # @param inbound_email [ActionMailbox::InboundEmail] the raw received email
94
- # @param persona [Protege::Persona] the persona the email routed to
95
+ # @param agent [Protege::Agent] the agent the email routed to
95
96
  # @return [Protege::Message] the saved message in the +processing+ state
96
- def ingest!(inbound_email:, persona:)
97
+ def ingest!(inbound_email:, agent:)
97
98
  mail = inbound_email.mail
98
- thread = EmailThread.find_or_create_for(mail:, persona:)
99
+ thread = EmailThread.find_or_create_for(mail:, agent:)
99
100
 
100
- find_or_initialize_by(inbound_email_id: inbound_email.id).tap do |record|
101
- record.assign_from_mail(mail:, thread:, persona:, inbound_email:)
101
+ find_or_initialize_by(inbound_email_id: inbound_email.id, agent:).tap do |record|
102
+ record.assign_from_mail(mail:, thread:, agent:, inbound_email:)
102
103
  record.start_processing!
103
104
  end
104
105
  end
@@ -111,14 +112,14 @@ module Protege
111
112
  # @param mail [Mail::Message] the parsed email to copy attributes from
112
113
  # @param direction [Symbol, String] +:inbound+ or +:outbound+
113
114
  # @param thread [Protege::EmailThread] the owning thread
114
- # @param persona [Protege::Persona] the persona the message belongs to
115
+ # @param agent [Protege::Agent] the agent the message belongs to
115
116
  # @param inbound_email [ActionMailbox::InboundEmail, nil] the source record, if any
116
117
  # @param attach_from_mail [Boolean] rebuild attachments from the mail's parts (true for inbound,
117
118
  # whose files arrive as bytes); pass false for outbound, where the caller attaches known blobs.
118
119
  # @return [Protege::Message] an unsaved message with attributes assigned
119
- def build_from_mail(mail:, direction:, thread:, persona:, inbound_email: nil, attach_from_mail: true)
120
+ def build_from_mail(mail:, direction:, thread:, agent:, inbound_email: nil, attach_from_mail: true)
120
121
  new.tap do |record|
121
- record.assign_from_mail(mail:, thread:, persona:, inbound_email:, attach_from_mail:)
122
+ record.assign_from_mail(mail:, thread:, agent:, inbound_email:, attach_from_mail:)
122
123
  record.direction = direction
123
124
  end
124
125
  end
@@ -237,20 +238,20 @@ module Protege
237
238
 
238
239
  # Copy every RFC 2822-derived attribute from a +Mail::Message+ onto this record.
239
240
  #
240
- # Shared by both {.ingest!} and {.build_from_mail}. Sets the thread/persona associations and
241
+ # Shared by both {.ingest!} and {.build_from_mail}. Sets the thread/agent associations and
241
242
  # then delegates the header, body, and threading field groups to private helpers so the work
242
243
  # stays within Active Record's metrics budget. Does not save.
243
244
  #
244
245
  # @param mail [Mail::Message] the parsed email to read attributes from
245
246
  # @param thread [Protege::EmailThread] the owning thread
246
- # @param persona [Protege::Persona] the persona the message belongs to
247
+ # @param agent [Protege::Agent] the agent the message belongs to
247
248
  # @param inbound_email [ActionMailbox::InboundEmail, nil] the source record, if any
248
249
  # @param attach_from_mail [Boolean] rebuild attachments from the mail's MIME parts; true for inbound
249
250
  # (files arrive as bytes), false for outbound (the caller attaches the known blobs by reference).
250
251
  # @return [void]
251
- def assign_from_mail(mail:, thread:, persona:, inbound_email: nil, attach_from_mail: true)
252
+ def assign_from_mail(mail:, thread:, agent:, inbound_email: nil, attach_from_mail: true)
252
253
  self.email_thread = thread
253
- self.persona = persona
254
+ self.agent = agent
254
255
  self.inbound_email = inbound_email
255
256
 
256
257
  assign_envelope_from(mail)
@@ -299,6 +300,21 @@ module Protege
299
300
  )
300
301
  end
301
302
 
303
+ # How many agent hops this message has already taken, read from the linked raw inbound email's
304
+ # +X-Protege-Recursion+ header. Zero for human mail (mail clients never echo the header back) and
305
+ # when the raw email is gone (Action Mailbox incinerates it eventually) — either way the chain
306
+ # resets, which is the intended behavior. A reply to this message stamps this count plus one (see
307
+ # +Gateway::RECURSION_HEADER+).
308
+ #
309
+ # @return [Integer] the inbound hop count, or 0 when untracked
310
+ def recursion_hops
311
+ raw = inbound_email&.mail
312
+ return 0 unless raw
313
+
314
+ field = raw.header[Gateway::RECURSION_HEADER]
315
+ field ? field.value.to_i : 0
316
+ end
317
+
302
318
  private
303
319
 
304
320
  # Assemble {#to_llm_text} from the standard header, the readable body (when present), and an optional
@@ -1,29 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # A persona's standing, cron-scheduled duty — the record behind the Loop layer (the "L" of LOGI).
4
+ # An agent's standing, cron-scheduled duty — the record behind the Loop layer (the "L" of LOGI).
5
5
  #
6
6
  # Where an inbound email drives the reactive path, a responsibility drives the *proactive* one: on
7
- # its +schedule+ (a 5-field cron string) the Loop scheduler runs the owning persona's
7
+ # its +schedule+ (a 5-field cron string) the Loop scheduler runs the owning agent's
8
8
  # +responsibility_resolvers+ chain, seeded with this responsibility's +instructions+ as the opening user
9
- # turn. The persona decides what to do from there (typically sending mail via +send_email+).
9
+ # turn. The agent decides what to do from there (typically sending mail via +send_email+).
10
10
  #
11
- # Responsibilities are lean, dashboard-managed records — not a developer subclass; the per-persona
12
- # resolver chain lives on the +Persona+, so a responsibility is just "which persona, what task, how
11
+ # Responsibilities are lean, dashboard-managed records — not a developer subclass; the per-agent
12
+ # resolver chain lives on the +Agent+, so a responsibility is just "which agent, what task, how
13
13
  # often." Each execution is tracked by a +ResponsibilityRun+.
14
14
  class Responsibility < ApplicationRecord
15
15
  self.table_name = 'protege_responsibilities'
16
16
 
17
17
  # ── Concerns ──────────────────────────────────────────────────────────────
18
18
 
19
- # Keep the persona's Responsibilities sidebar live (append/replace/remove).
19
+ # Keep the agent's Responsibilities sidebar live (append/replace/remove).
20
20
  include BroadcastableResponsibility
21
21
 
22
22
  # ── Associations ──────────────────────────────────────────────────────────
23
23
 
24
- # The persona that carries out this duty; its +responsibility_resolvers+ chain assembles context
24
+ # The agent that carries out this duty; its +responsibility_resolvers+ chain assembles context
25
25
  # and its identity is the enforced +From+ on any mail the run sends.
26
- belongs_to :persona, class_name: 'Protege::Persona'
26
+ belongs_to :agent, class_name: 'Protege::Agent'
27
27
 
28
28
  # Every execution of this responsibility, newest interesting for the dashboard; destroyed with it.
29
29
  has_many :responsibility_runs,
@@ -48,14 +48,14 @@ module Protege
48
48
  # Only responsibilities currently enabled to fire.
49
49
  scope :active, -> { where(active: true) }
50
50
 
51
- # Only responsibilities whose persona is active (not archived) — archiving a persona stops its
51
+ # Only responsibilities whose agent is active (not archived) — archiving an agent stops its
52
52
  # scheduled runs. The scheduler intersects this with {.active}.
53
- scope :with_active_persona, -> { joins(:persona).merge(Protege::Persona.active) }
53
+ scope :with_active_agent, -> { joins(:agent).merge(Protege::Agent.active) }
54
54
 
55
55
  # ── Instance methods ────────────────────────────────────────────────────
56
56
 
57
57
  # Whether this responsibility should fire at +time+ — active and its cron matches that minute.
58
- # The scheduler calls this each tick (over the {.with_active_persona} set, so an archived persona's
58
+ # The scheduler calls this each tick (over the {.with_active_agent} set, so an archived agent's
59
59
  # duties are already excluded); there is no precomputed next-run, only minute matching.
60
60
  #
61
61
  # @param time [Time] the moment to test (the tick's +Time.current+)
@@ -78,7 +78,7 @@ module Protege
78
78
  run = responsibility_runs.create!(status: :pending, correlation_id: SecureRandom.uuid)
79
79
  Protege::Current.correlation_id = run.correlation_id
80
80
 
81
- LoopRunEnqueuedEvent.emit(name:, persona_name: persona.name)
81
+ LoopRunEnqueuedEvent.emit(name:, agent_name: agent.name)
82
82
  ResponsibilityJob.perform_later(run_id: run.id)
83
83
  run
84
84
  end
@@ -13,7 +13,7 @@ module Protege
13
13
  # result (the tool loop produces them 1:1). +run_id+ groups one harness run, +turn_index+ the
14
14
  # tool-calling round within it, and +position+ the order within a round (an assistant turn may request
15
15
  # several calls at once). +tool_call_id+ correlates the call to its result when history is replayed.
16
- # +persona_id+ is denormalized for per-persona queries.
16
+ # +agent_id+ is denormalized for per-agent queries.
17
17
  class ToolUse < ApplicationRecord
18
18
  # Upper bound on a stored +arguments+/+result+ JSON payload, so a huge tool result (e.g. a large
19
19
  # +web_fetch+ body) can't bloat the row. Replay truncates further still (see +#replay_result+).
@@ -24,8 +24,8 @@ module Protege
24
24
 
25
25
  # ── Associations ────────────────────────────────────────────────────────
26
26
 
27
- belongs_to :source, polymorphic: true
28
- belongs_to :persona, class_name: 'Protege::Persona'
27
+ belongs_to :source, polymorphic: true
28
+ belongs_to :agent, class_name: 'Protege::Agent'
29
29
 
30
30
  # ── Validations ─────────────────────────────────────────────────────────
31
31
 
@@ -78,18 +78,18 @@ module Protege
78
78
  # finds the existing rows instead of duplicating them.
79
79
  #
80
80
  # @param source [Protege::Message, Protege::ResponsibilityRun] the run's owning record
81
- # @param persona [Protege::Persona] the persona handling the run
81
+ # @param agent [Protege::Agent] the agent handling the run
82
82
  # @param run_id [String, nil] id grouping this harness run
83
83
  # @param turn_index [Integer] the tool-calling round within the run (0-based)
84
84
  # @param tool_calls [Array<Protege::Inference::Provider::ToolCall>] the calls the model requested
85
85
  # @param tool_results [Array<Protege::ToolResult>] the paired results
86
86
  # @return [Array<Protege::ToolUse>] the recorded rows
87
- def record_round(source:, persona:, run_id:, turn_index:, tool_calls:, tool_results:)
87
+ def record_round(source:, agent:, run_id:, turn_index:, tool_calls:, tool_results:)
88
88
  transaction do
89
89
  tool_calls.each_with_index.map do |call, position|
90
90
  result = tool_results[position]
91
91
  find_or_create_by!(source:, run_id:, turn_index:, position:) do |use|
92
- use.persona = persona
92
+ use.agent = agent
93
93
  use.tool_call_id = call.id
94
94
  use.tool_name = call.name
95
95
  use.arguments = clip(call.input.to_json, limit: STORED_PAYLOAD_LIMIT)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # A toolkit — the first "Binding" of the extension framework. A global, dashboard-managed, reusable
5
+ # bundle of tools (referenced by their manifest ids) and nothing more: it says *what* tools travel
6
+ # together, not *who* may use them. Permission is a property of the attachment — see +AgentToolkit+ —
7
+ # so the same toolkit given to two agents can be gated differently, with no tool-list duplication.
8
+ #
9
+ # Member tools are a +member_tool_ids+ JSON id list (a checklist over the tool manifest), reconciled
10
+ # against the live manifest at read time so a stale id (a tool since removed from the code) is skipped.
11
+ #
12
+ # A toolkit may be a *system* toolkit — one the engine guarantees exists, identified by a stable +key+
13
+ # rather than its (renameable) display name (see +Protege::SystemToolkits+). The +all_tools+ system kit
14
+ # is auto-maintained to hold every registered tool; +default_tools+ is auto-attached to each new agent.
15
+ # System toolkits can't be deleted from the dashboard. User-created toolkits leave +key+ nil.
16
+ class Toolkit < ApplicationRecord
17
+ # The stable key of the auto-maintained toolkit holding every registered tool.
18
+ ALL_TOOLS_KEY = 'all_tools'
19
+
20
+ # The stable key of the toolkit auto-attached to every new agent.
21
+ DEFAULT_TOOLS_KEY = 'default_tools'
22
+
23
+ # ── Concerns ────────────────────────────────────────────────────────────
24
+
25
+ # Keep the Toolkits sidebar live (append/replace/remove).
26
+ include BroadcastableToolkit
27
+
28
+ # ── Serialization ─────────────────────────────────────────────────────────
29
+
30
+ serialize :member_tool_ids, coder: JSON
31
+
32
+ # ── Associations ────────────────────────────────────────────────────────
33
+
34
+ with_options dependent: :destroy do
35
+ has_many :agent_toolkits, class_name: 'Protege::AgentToolkit'
36
+ end
37
+ has_many :agents, through: :agent_toolkits
38
+
39
+ # ── Callbacks ─────────────────────────────────────────────────────────────
40
+
41
+ before_validation :normalize_member_tool_ids
42
+ before_destroy :protect_system_toolkit
43
+
44
+ # ── Validations ─────────────────────────────────────────────────────────
45
+
46
+ validates :name, presence: true, uniqueness: true
47
+ validates :key, uniqueness: true, allow_nil: true
48
+
49
+ # ── Lookups ─────────────────────────────────────────────────────────────
50
+
51
+ class << self
52
+ # The auto-maintained "All Tools" system toolkit, or nil before it has been synced.
53
+ #
54
+ # @return [Protege::Toolkit, nil]
55
+ def all_tools
56
+ find_by(key: ALL_TOOLS_KEY)
57
+ end
58
+
59
+ # The "Default Tools" system toolkit auto-attached to new agents, or nil before it has been synced.
60
+ #
61
+ # @return [Protege::Toolkit, nil]
62
+ def default_tools
63
+ find_by(key: DEFAULT_TOOLS_KEY)
64
+ end
65
+ end
66
+
67
+ # ── Instance methods ──────────────────────────────────────────────────────
68
+
69
+ # Whether this is an engine-managed system toolkit (carries a +key+) — such toolkits can't be deleted,
70
+ # and +all_tools+ has its membership maintained by the engine rather than the operator.
71
+ #
72
+ # @return [Boolean] true when the toolkit is keyed
73
+ def system?
74
+ key.present?
75
+ end
76
+
77
+ # Whether this toolkit includes a given tool id.
78
+ #
79
+ # @param tool_id [Symbol, String] the tool id to test
80
+ # @return [Boolean] true when the id is a member
81
+ def covers?(tool_id)
82
+ member_tool_ids.map(&:to_sym).include?(tool_id.to_sym)
83
+ end
84
+
85
+ # The member tools that still exist, as manifest entries — stale ids (naming a removed tool) are
86
+ # dropped, so a toolkit tolerates a member whose tool has since been deleted from the code.
87
+ #
88
+ # @return [Array<Protege::Manifest::Entry>] the live member tools
89
+ def member_tools
90
+ ids = member_tool_ids.map(&:to_sym)
91
+ Manifest.registered(:tool).select { |entry| ids.include?(entry.id) }
92
+ end
93
+
94
+ private
95
+
96
+ # Coerce +member_tool_ids+ to a clean, deduped array of non-blank strings — so the checklist column
97
+ # stays tidy however it was assigned (symbols, blanks, duplicates).
98
+ #
99
+ # @return [void]
100
+ def normalize_member_tool_ids
101
+ self.member_tool_ids = Array(member_tool_ids).map(&:to_s).reject(&:blank?).uniq
102
+ end
103
+
104
+ # Halt destruction of a system toolkit — the engine depends on it existing (it would be recreated on
105
+ # the next sync anyway). User toolkits (no key) delete normally.
106
+ #
107
+ # @return [void]
108
+ def protect_system_toolkit
109
+ return unless system?
110
+
111
+ errors.add(:base, 'System toolkits cannot be deleted.')
112
+ throw :abort
113
+ end
114
+ end
115
+ end
@@ -9,7 +9,7 @@ module Protege
9
9
  # enough to *replay* against the *current* prompts.
10
10
  #
11
11
  # Deliberately **isolated and minimal**: no associations, no foreign keys, and no identity columns
12
- # (persona/provider) — a trace records only what affects training, plus a human review verdict. The
12
+ # (agent/provider) — a trace records only what affects training, plus a human review verdict. The
13
13
  # table can be truncated or dropped without touching anything else. +correlation_id+ + +turn_index+
14
14
  # group the turns of one run for retry-safe dedup. Written only by the tracing subscriber
15
15
  # (+Protege::Subscribers::Tracing+); reviewed and labeled in the dashboard.
@@ -125,6 +125,8 @@ module Protege
125
125
  post = build_stream_post(uri, payload)
126
126
 
127
127
  http.request(post) do |response|
128
+ raise_for_stream_status(response)
129
+
128
130
  buffer = ''
129
131
 
130
132
  response.read_body do |chunk|
@@ -137,6 +139,21 @@ module Protege
137
139
  end
138
140
  end
139
141
 
142
+ # Surface a non-2xx streaming response as the matching +Protege::Error+, mirroring +raise_for_status+
143
+ # for the non-streaming path. A streamed error (e.g. HTTP 402 insufficient credits) arrives as a normal
144
+ # response whose body is the JSON error, not an SSE event stream — without this it would be parsed as
145
+ # empty tokens and swallowed, so the run would silently produce no output and no failure event.
146
+ #
147
+ # @param response [Net::HTTPResponse] the streaming response, before its body is consumed as SSE
148
+ # @return [void]
149
+ # @raise [Protege::Error] the error class matching a non-2xx status
150
+ def raise_for_stream_status(response)
151
+ code = response.code.to_i
152
+ return if (200..299).cover?(code)
153
+
154
+ raise_for_status(Struct.new(:status, :body).new(code, response.read_body))
155
+ end
156
+
140
157
  # Construct the Net::HTTP transport for an SSE request.
141
158
  #
142
159
  # @param uri [URI] the fully resolved chat-completions endpoint
@@ -4,7 +4,7 @@
4
4
  > User-facing docs live in `site/developer-experience/extensions/resolvers.md`; this file is the
5
5
  > precise contract for contributors working in the engine.
6
6
 
7
- A **resolver** assembles the context the LLM sees *before* an inference run. Each persona declares an
7
+ A **resolver** assembles the context the LLM sees *before* an inference run. Each agent declares an
8
8
  ordered chain of resolvers (`message_resolvers` for replies, `responsibility_resolvers` for scheduled
9
9
  runs); the harness runs the chain, concatenates every resolver's output, and hands the result to the
10
10
  provider as the opening messages of the request.
@@ -157,7 +157,7 @@ right, and the request is well-formed.
157
157
 
158
158
  | Attribute | Type | Reply run | Scheduled run |
159
159
  |------------------|-------------------------------|---------------------|---------------|
160
- | `persona` | `Protege::Persona` | present | present |
160
+ | `agent` | `Protege::Agent` | present | present |
161
161
  | `config` | `Protege::Configuration` | present (frozen) | present (frozen) |
162
162
  | `logger` | `Logger` | present | present |
163
163
  | `correlation_id` | `String, nil` | present | present |
@@ -173,7 +173,7 @@ they never mutate it.
173
173
  ## Built-in resolvers
174
174
 
175
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`,
176
+ `Protege::ThreadHistoryResolver`); an agent names one by that constant. Each returns a `ModelMessage`,
177
177
  an array of them, or `nil`.
178
178
 
179
179
  ### `ThreadHistoryResolver`
@@ -218,7 +218,7 @@ Loads ActiveRecord record(s) and contributes them as one message — the DB coun
218
218
  ### `LoadTextResolver`
219
219
 
220
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.
221
+ inject an agent's DB-column system prompt.
222
222
 
223
223
  - **Constructor:** `initialize(role: :system, &block)` — the **required** block receives the context and
224
224
  returns the text.
@@ -6,22 +6,22 @@ module Protege
6
6
  # prompt, a knowledge document, few-shot examples — by choosing the +role+ of the emitted message.
7
7
  #
8
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
9
+ # be derived from the inbound message or agent (e.g. a per-agent prompt file). Path resolution
10
10
  # mirrors ordinary shell intuition, so the same resolver works in development and in any production
11
11
  # deployment:
12
- # - a relative path (+config/personas/agent.md+, +./agent.md+) resolves against the host app root
12
+ # - a relative path (+config/agents/agent.md+, +./agent.md+) resolves against the host app root
13
13
  # (+Rails.root+) — i.e. a file deployed with the app, which is reliably readable everywhere.
14
14
  # - an absolute path (+/data/prompts/agent.md+) is used as-is, so an attached volume or mounted
15
15
  # secret just needs its fully-qualified path.
16
16
  #
17
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
18
+ # code, so even absolute reads carry the same trust as any code in the agent. A missing file is a
19
19
  # misconfiguration and raises +Protege::ResolverFileNotFoundError+; a blank file contributes
20
20
  # nothing (returns +nil+).
21
21
  #
22
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" }
23
+ # chain.use Protege::LoadFileResolver, 'config/agents/agent.md'
24
+ # chain.use(Protege::LoadFileResolver) { |ctx| "config/agents/#{ctx.agent.name.parameterize}.md" }
25
25
  # end
26
26
  class LoadFileResolver < Protege::Resolver
27
27
  # @param path [String, nil] file to read; relative paths resolve against +Rails.root+, absolute
@@ -3,11 +3,11 @@
3
3
  module Protege
4
4
  # General-purpose resolver that runs a block and contributes its returned String as one context
5
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:
6
+ # records). The block receives the resolver context, so the text can be derived from the agent or
7
+ # the inbound message. The canonical use is an agent's system prompt held in a column:
8
8
  #
9
9
  # resolvers do |chain|
10
- # chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
10
+ # chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.agent.instructions }
11
11
  # end
12
12
  #
13
13
  # +role+ chooses how the text enters the conversation (+:system+ for a prompt, +:user+/+:assistant+
@@ -90,7 +90,7 @@ module Protege
90
90
  # @return [Array<Protege::ModelMessage>] the assistant turn followed by its results
91
91
  def round_messages(rows)
92
92
  assistant = message(role: :assistant, content: '', tool_calls: rows.map(&:to_provider_tool_call))
93
-
93
+
94
94
  results = rows.map do |row|
95
95
  message(role: :tool, tool_call_id: row.tool_call_id, content: row.replay_result(limit: REPLAY_RESULT_LIMIT))
96
96
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # Cross-persona email search behind the console's search page — a PORO service object (not an
5
- # ActiveRecord model, and not the persona-scoped +search_emails+ agent tool).
4
+ # Cross-agent email search behind the console's search page — a PORO service object (not an
5
+ # ActiveRecord model, and not the agent-scoped +search_emails+ agent tool).
6
6
  #
7
7
  # Each field narrows the +Message+ relation: +from+ scans the sender, +recipients+ spans the
8
8
  # to/cc/bcc lists, +subject+ and +body+ (text + html) scan their columns, and +attachments+ matches
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Maintains the engine's *system* toolkits — the two toolkit records the framework guarantees exist,
5
+ # identified by a stable +key+ (see +Toolkit+):
6
+ #
7
+ # * **All Tools** (+all_tools+) — always holds every registered tool. Its membership is engine-owned:
8
+ # each sync rewrites it to the live tool manifest, so a tool added in code shows up automatically and
9
+ # an operator's edits don't stick.
10
+ # * **Default Tools** (+default_tools+) — auto-attached to every new agent (see +Toolable+). The engine
11
+ # only *seeds* it (with +send_email+, so a fresh agent can at least reply) on first creation;
12
+ # thereafter its membership is the operator's to curate, so later syncs leave it alone.
13
+ #
14
+ # Sync is idempotent and safe to run repeatedly. It runs like any other seed data — not at boot (writing
15
+ # to the DB from an initializer is an anti-pattern): the +protege:toolkits:sync+ rake task on deploy and
16
+ # the host's +db/seeds.rb+ in development. Both eager-load the app first so the tool manifest is complete
17
+ # (rake tasks don't eager-load by default), which is what makes All Tools capture every registered tool.
18
+ class SystemToolkits
19
+ class << self
20
+ # Ensure both system toolkits exist and refresh All Tools' membership.
21
+ #
22
+ # @return [void]
23
+ def sync!
24
+ new.sync!
25
+ end
26
+ end
27
+
28
+ # Ensure both system toolkits exist and refresh All Tools' membership.
29
+ #
30
+ # @return [void]
31
+ def sync!
32
+ sync_all_tools
33
+ ensure_default_tools
34
+ end
35
+
36
+ private
37
+
38
+ # Create or update the All Tools toolkit so it holds every registered tool.
39
+ #
40
+ # @return [void]
41
+ def sync_all_tools
42
+ toolkit = Toolkit.find_or_initialize_by(key: Toolkit::ALL_TOOLS_KEY)
43
+
44
+ toolkit.name = 'All Tools'
45
+ toolkit.description = 'Automatically managed — always contains every registered tool, refreshed ' \
46
+ 'on each deploy. Its members are not editable. Attach it to an agent to ' \
47
+ 'grant the full tool catalogue.'
48
+ toolkit.member_tool_ids = Manifest.registered(:tool).map { |entry| entry.id.to_s }
49
+ toolkit.save!
50
+ end
51
+
52
+ # Ensure the Default Tools toolkit exists. On first creation it is seeded with +send_email+ so a new
53
+ # agent is immediately able to reply; on later syncs its membership is left untouched (operator-owned).
54
+ #
55
+ # @return [void]
56
+ def ensure_default_tools
57
+ toolkit = Toolkit.find_or_initialize_by(key: Toolkit::DEFAULT_TOOLS_KEY)
58
+ return unless toolkit.new_record?
59
+
60
+ toolkit.name = 'Default Tools'
61
+ toolkit.description = 'Attached to every new agent automatically. Curate its members to set the ' \
62
+ 'baseline tools an agent starts with.'
63
+ toolkit.member_tool_ids = ['send_email']
64
+ toolkit.save!
65
+ end
66
+ end
67
+ end
@@ -40,6 +40,8 @@ module Protege
40
40
  rows, for html/svg write the markup, for json write the JSON.
41
41
  DESC
42
42
 
43
+ summary 'Create a file (CSV, HTML, Markdown, JSON…) to attach to an email.'
44
+
43
45
  input_schema(
44
46
  type: 'object',
45
47
  additionalProperties: false,
@@ -5,7 +5,7 @@ module Protege
5
5
  # publishes it in the LLM's tool catalog (id +:read_attachment+).
6
6
  #
7
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
8
+ # from +create_file+. Resolution is by blob id (see +Protege::StoredFile.find+); agent scoping is
9
9
  # deferred to a later pass (see the blob-currency spec §9), so any known blob id currently resolves.
10
10
  # Behaviour by type:
11
11
  #
@@ -23,6 +23,8 @@ module Protege
23
23
  shown to you directly to view. Other types (such as video or archives) can't be read.
24
24
  DESC
25
25
 
26
+ summary 'Read a file from the conversation by its id.'
27
+
26
28
  input_schema(
27
29
  type: 'object',
28
30
  additionalProperties: false,