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
@@ -3,12 +3,12 @@
3
3
  module Protege
4
4
  # View helpers for responsibility pages — status display and run-history formatting.
5
5
  module ResponsibilitiesHelper
6
- # Fetch every +Responsibility+ (persona eager-loaded) for the global sidebar listing. The model's
6
+ # Fetch every +Responsibility+ (agent eager-loaded) for the global sidebar listing. The model's
7
7
  # default scope already orders by name.
8
8
  #
9
9
  # @return [ActiveRecord::Relation<Protege::Responsibility>] all responsibilities
10
10
  def all_responsibilities
11
- Responsibility.includes(:persona)
11
+ Responsibility.includes(:agent)
12
12
  end
13
13
 
14
14
  # Render a status dot reflecting whether a responsibility is active.
@@ -11,18 +11,18 @@ module Protege
11
11
  EmailThread.order(last_message_at: :desc)
12
12
  end
13
13
 
14
- # List the active personas eligible for the compose form.
14
+ # List the active agents eligible for the compose form.
15
15
  #
16
- # @return [ActiveRecord::Relation<Protege::Persona>] personas eligible for selection
17
- def available_personas
18
- Persona.active
16
+ # @return [ActiveRecord::Relation<Protege::Agent>] agents eligible for selection
17
+ def available_agents
18
+ Agent.active
19
19
  end
20
20
 
21
- # Format the available personas as +[label, id]+ pairs for a select dropdown.
21
+ # Format the available agents as +[label, id]+ pairs for a select dropdown.
22
22
  #
23
23
  # @return [Array<Array(String, Integer)>] option label/value pairs
24
- def available_persona_options
25
- available_personas.map { |persona| ["#{persona.name} <#{persona.email_address}>", persona.id] }
24
+ def available_agent_options
25
+ available_agents.map { |agent| ["#{agent.name} <#{agent.email_address}>", agent.id] }
26
26
  end
27
27
 
28
28
  # Format a thread subject with its message count for sidebar display.
@@ -34,7 +34,7 @@ module Protege
34
34
  # @return [String] the subject, suffixed with the count when it exceeds one
35
35
  def thread_title(thread)
36
36
  subject = thread.subject.presence || '(no subject)'
37
- thread.message_count > 1 ? "#{thread.persona.name} - #{subject} (#{thread.message_count})" : subject
37
+ thread.message_count > 1 ? "#{thread.agent.name} - #{subject} (#{thread.message_count})" : subject
38
38
  end
39
39
 
40
40
  # Format the thread's last-activity date for sidebar display.
@@ -53,7 +53,7 @@ module Protege
53
53
  thread.latest_message&.text_body.to_s.lines.first.to_s.strip.presence || '—'
54
54
  end
55
55
 
56
- # Render the thread detail header — subject, persona, message count, last
56
+ # Render the thread detail header — subject, agent, message count, last
57
57
  # activity, and thread ID. An optional block renders actions beside the title.
58
58
  #
59
59
  # @param thread [Protege::EmailThread] the thread whose header to render
@@ -81,14 +81,14 @@ module Protege
81
81
  end
82
82
  end
83
83
 
84
- # Render the metadata row — persona, message count, and last activity.
84
+ # Render the metadata row — agent, message count, and last activity.
85
85
  #
86
86
  # @param thread [Protege::EmailThread] the thread whose metadata to render
87
87
  # @return [ActiveSupport::SafeBuffer] the metadata row markup
88
88
  def thread_header_meta(thread)
89
89
  tag.div(class: 'mt-1 flex items-center gap-3 text-xs', style: 'color: var(--text-muted)') do
90
90
  tag.span do
91
- 'Persona: '.html_safe + tag.span(thread.persona.name, class: 'font-medium')
91
+ 'Agent: '.html_safe + tag.span(thread.agent.name, class: 'font-medium')
92
92
  end +
93
93
  tag.span(pluralize(thread.message_count, 'message')) +
94
94
  thread_header_last_activity(thread)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for the toolkit dashboard. The member-tool checklist is built from the uniform extension
5
+ # manifest; the allow/deny gate is edited as +ToolkitRule+ child rows (see the toolkit-rules partials).
6
+ # The overview helpers keep computed display values (tool names, proactive/count labels) out of the
7
+ # templates.
8
+ module ToolkitsHelper
9
+ # Every +Toolkit+ for the global sidebar listing, ordered by name.
10
+ #
11
+ # @return [ActiveRecord::Relation<Protege::Toolkit>] all toolkits
12
+ def all_toolkits
13
+ Toolkit.order(:name)
14
+ end
15
+
16
+ # The tool manifest entries offered as the member-tool checklist — the toolkit equivalent of
17
+ # +AgentsHelper#agent_tool_options+, built from the uniform extension manifest.
18
+ #
19
+ # @return [Array<Protege::Manifest::Entry>] the selectable tools
20
+ def toolkit_tool_options
21
+ Manifest.registered(:tool)
22
+ end
23
+
24
+ # The labelled summary rows shown in a toolkit's overview card.
25
+ #
26
+ # @param toolkit [Protege::Toolkit] the toolkit
27
+ # @return [Hash{String=>String}] the info-card rows
28
+ def toolkit_overview(toolkit)
29
+ { 'Tools' => toolkit_member_tool_names(toolkit) }
30
+ end
31
+
32
+ # A short "N tools" label for a toolkit listing.
33
+ #
34
+ # @param toolkit [Protege::Toolkit] the toolkit
35
+ # @return [String] the pluralized tool count
36
+ def toolkit_tool_count_label(toolkit)
37
+ pluralize(toolkit.member_tool_ids.size, 'tool')
38
+ end
39
+
40
+ # The badge colour for a gate rule — green for allow, red for deny.
41
+ #
42
+ # @param rule [Protege::AgentToolkitRule] the rule
43
+ # @return [Symbol] +:green+ or +:red+
44
+ def gate_rule_kind_color(rule)
45
+ rule.allow? ? :green : :red
46
+ end
47
+
48
+ # The toolkits not yet attached to an agent — the options for its attach form.
49
+ #
50
+ # @param agent [Protege::Agent] the agent
51
+ # @return [ActiveRecord::Relation<Protege::Toolkit>] the attachable toolkits, by name
52
+ def attachable_toolkits(agent)
53
+ Toolkit.where.not(id: agent.toolkit_ids).order(:name)
54
+ end
55
+
56
+ private
57
+
58
+ # The toolkit's member-tool display names, comma-joined, or "None".
59
+ #
60
+ # @param toolkit [Protege::Toolkit] the toolkit
61
+ # @return [String] the joined tool names
62
+ def toolkit_member_tool_names(toolkit)
63
+ toolkit.member_tools.map(&:display_name).join(', ').presence || 'None'
64
+ end
65
+ end
66
+ end
@@ -9,14 +9,14 @@ module Protege
9
9
  # at boot; each +on+ block receives a typed +Protege::Event+ and runs isolated, so a logging
10
10
  # error can never break inference. As a built-in it is always active.
11
11
  class EventLoggerHook < Protege::Hook
12
- # Log the start of an inference run for a persona.
12
+ # Log the start of an inference run for an agent.
13
13
  on InferenceStartedEvent do |event|
14
- log "[#{ts}] #{cid(event)} INF START persona=#{event.persona&.name}"
14
+ log "[#{ts}] #{cid(event)} INF START agent=#{event.agent&.name}"
15
15
  end
16
16
 
17
- # Log the completion of an inference run for a persona.
17
+ # Log the completion of an inference run for an agent.
18
18
  on InferenceCompletedEvent do |event|
19
- log "[#{ts}] #{cid(event)} INF DONE persona=#{event.persona&.name}"
19
+ log "[#{ts}] #{cid(event)} INF DONE agent=#{event.agent&.name}"
20
20
  end
21
21
 
22
22
  # Log the set of tool calls the model requested in a single turn.
@@ -52,22 +52,22 @@ module Protege
52
52
 
53
53
  # Log a scheduled responsibility run being enqueued — pending, awaiting a worker (the Loop layer).
54
54
  on LoopRunEnqueuedEvent do |event|
55
- log "[#{ts}] #{cid(event)} LOOP PENDING #{event.name} persona=#{event.persona_name}"
55
+ log "[#{ts}] #{cid(event)} LOOP PENDING #{event.name} agent=#{event.agent_name}"
56
56
  end
57
57
 
58
58
  # Log the start of a scheduled responsibility run (the Loop layer).
59
59
  on LoopRunStartedEvent do |event|
60
- log "[#{ts}] #{cid(event)} LOOP START #{event.name} persona=#{event.persona_name}"
60
+ log "[#{ts}] #{cid(event)} LOOP START #{event.name} agent=#{event.agent_name}"
61
61
  end
62
62
 
63
63
  # Log the successful completion of a scheduled responsibility run.
64
64
  on LoopRunCompletedEvent do |event|
65
- log "[#{ts}] #{cid(event)} LOOP DONE #{event.name} persona=#{event.persona_name}"
65
+ log "[#{ts}] #{cid(event)} LOOP DONE #{event.name} agent=#{event.agent_name}"
66
66
  end
67
67
 
68
68
  # Log a failed scheduled responsibility run with its error class, message, and short backtrace.
69
69
  on LoopRunFailedEvent do |event|
70
- log "[#{ts}] #{cid(event)} LOOP FAIL #{event.name} persona=#{event.persona_name} " \
70
+ log "[#{ts}] #{cid(event)} LOOP FAIL #{event.name} agent=#{event.agent_name} " \
71
71
  "#{event.error&.class}: #{event.error&.message}\n #{error_backtrace(event.error)}"
72
72
  end
73
73
 
@@ -13,7 +13,7 @@ module Protege
13
13
  # == Lifecycle
14
14
  #
15
15
  # 1. Restore the correlation ID for event tracing.
16
- # 2. Find the +Message+ and +Persona+ from their IDs.
16
+ # 2. Find the +Message+ and +Agent+ from their IDs.
17
17
  # 3. Transition the message to +processing+.
18
18
  # 4. Run the inference loop (+Harness+).
19
19
  # 5. Mark the message +processed+ on success, +failed+ on error.
@@ -23,17 +23,17 @@ module Protege
23
23
  # Process one pre-persisted console message end to end.
24
24
  #
25
25
  # @param message_id [Integer] primary key of the pre-persisted +Message+ record
26
- # @param persona_id [Integer] primary key of the +Persona+; STI loads the correct subclass
26
+ # @param agent_id [Integer] primary key of the +Agent+; STI loads the correct subclass
27
27
  # @param correlation_id [String] trace token forwarded from the console request
28
28
  # @return [void]
29
29
  # @raise [StandardError] re-raised after being recorded on the message, to trigger retries
30
- def perform(message_id:, persona_id:, correlation_id:)
30
+ def perform(message_id:, agent_id:, correlation_id:)
31
31
  restore_correlation_id(correlation_id)
32
32
 
33
33
  message = Message.find(message_id)
34
- persona = Persona.find(persona_id)
34
+ agent = Agent.find(agent_id)
35
35
 
36
- process(message:, persona:)
36
+ process(message:, agent:)
37
37
  rescue StandardError => e
38
38
  message&.mark_failed!(e)
39
39
  raise
@@ -44,12 +44,12 @@ module Protege
44
44
  # Run the message through the inference loop and record success.
45
45
  #
46
46
  # @param message [Protege::Message] the message to process
47
- # @param persona [Protege::Persona] the persona handling it
47
+ # @param agent [Protege::Agent] the agent handling it
48
48
  # @return [void]
49
- def process(message:, persona:)
49
+ def process(message:, agent:)
50
50
  message.start_processing!
51
51
 
52
- Orchestrator.reply(persona:, message:)
52
+ Orchestrator.reply(agent:, message:)
53
53
 
54
54
  message.mark_processed!
55
55
  end
@@ -3,8 +3,8 @@
3
3
  module Protege
4
4
  # Runs one inbound email through the full Protege inference pipeline (the "I" of LOGI).
5
5
  #
6
- # Enqueued by +AgentMailbox+ once persona routing succeeds. The job re-hydrates the raw email and
7
- # persona from their IDs, ingests a +Message+, runs the Orchestrator's +Harness+, and records the
6
+ # Enqueued by +AgentMailbox+ once agent routing succeeds. The job re-hydrates the raw email and
7
+ # agent from their IDs, ingests a +Message+, runs the Orchestrator's +Harness+, and records the
8
8
  # outcome on the message. IDs (not objects) are passed across the async boundary so the queue
9
9
  # adapter can serialize and safely retry the job across process restarts.
10
10
  #
@@ -18,25 +18,25 @@ module Protege
18
18
  # Process one inbound email end to end: ingest, run inference, record the result.
19
19
  #
20
20
  # @param inbound_email_id [Integer] id of the +ActionMailbox::InboundEmail+ to process
21
- # @param persona_id [Integer] primary key of the +Protege::Persona+; STI loads the subclass
21
+ # @param agent_id [Integer] primary key of the +Protege::Agent+; STI loads the subclass
22
22
  # @param correlation_id [String] the RFC 2822 Message-ID set by +AgentMailbox+ for tracing
23
23
  # @return [void]
24
24
  # @raise [StandardError] re-raised after being recorded on the message, to trigger retries
25
- def perform(inbound_email_id:, persona_id:, correlation_id:)
25
+ def perform(inbound_email_id:, agent_id:, correlation_id:)
26
26
  restore_correlation_id(correlation_id)
27
27
 
28
- # Re-hydrate the raw email and the target persona from their IDs.
28
+ # Re-hydrate the raw email and the target agent from their IDs.
29
29
  # IDs are passed rather than objects so Solid Queue can serialize
30
30
  # the job and retry it safely across process restarts.
31
31
  inbound_email = ActionMailbox::InboundEmail.find(inbound_email_id)
32
- persona = Persona.find(persona_id)
32
+ agent = Agent.find(agent_id)
33
33
 
34
34
  # Parse the raw email, find-or-create the EmailThread, and persist
35
35
  # a Protege::Message record with processing_status: :processing.
36
36
  # Returns the saved message so we can update its lifecycle below.
37
- message = Message.ingest!(inbound_email:, persona:)
37
+ message = Message.ingest!(inbound_email:, agent:)
38
38
 
39
- process(message:, persona:)
39
+ process(message:, agent:)
40
40
  rescue StandardError => e
41
41
  # Capture the error on the message record before re-raising so the
42
42
  # job can be inspected and manually re-queued without losing context.
@@ -49,10 +49,10 @@ module Protege
49
49
  # Run the message through the inference loop and record success.
50
50
  #
51
51
  # @param message [Protege::Message] the message to process
52
- # @param persona [Protege::Persona] the persona handling it
52
+ # @param agent [Protege::Agent] the agent handling it
53
53
  # @return [void]
54
- def process(message:, persona:)
55
- Orchestrator.reply(persona:, message:)
54
+ def process(message:, agent:)
55
+ Orchestrator.reply(agent:, message:)
56
56
 
57
57
  message.mark_processed!
58
58
  end
@@ -3,7 +3,7 @@
3
3
  module Protege
4
4
  # Carries out one scheduled responsibility run — the Loop layer's worker (the "L" of LOGI driving the
5
5
  # "I"). Enqueued by +Responsibility#dispatch!+ with the id of a +pending+ +ResponsibilityRun+; it
6
- # advances that run to running, runs the persona through +ResponsibilityHarness+, and records the
6
+ # advances that run to running, runs the agent through +ResponsibilityHarness+, and records the
7
7
  # outcome on the run. The run id (not the object) is passed across the async boundary so the queue
8
8
  # adapter can serialize the job.
9
9
  #
@@ -21,15 +21,15 @@ module Protege
21
21
  # @param run_id [Integer] id of the pending +ResponsibilityRun+ to carry out
22
22
  # @return [void]
23
23
  def perform(run_id:)
24
- responsibility_run = ResponsibilityRun.eager_load(responsibility: :persona).find(run_id)
24
+ responsibility_run = ResponsibilityRun.eager_load(responsibility: :agent).find(run_id)
25
25
  # Adopt the trace id minted at dispatch (Responsibility#dispatch!) so the whole run lifecycle
26
26
  # shares it; fall back to a fresh one for a run created some other way (e.g. in a test).
27
27
  restore_correlation_id(responsibility_run.correlation_id || SecureRandom.uuid)
28
28
 
29
29
  responsibility = responsibility_run.responsibility
30
- persona = responsibility.persona
30
+ agent = responsibility.agent
31
31
 
32
- process(responsibility_run:, responsibility:, persona:)
32
+ process(responsibility_run:, responsibility:, agent:)
33
33
  rescue StandardError => e
34
34
  # Record the failure without re-raising — the cron schedule, not ActiveJob, drives the retry.
35
35
  responsibility_run&.mark_failed!(e)
@@ -42,16 +42,16 @@ module Protege
42
42
  #
43
43
  # @param responsibility_run [Protege::ResponsibilityRun] the run to carry out
44
44
  # @param responsibility [Protege::Responsibility] the responsibility to handle
45
- # @param persona [Protege::Persona] the persona handling the responsibility
45
+ # @param agent [Protege::Agent] the agent handling the responsibility
46
46
  # @return [void]
47
- def process(responsibility_run:, responsibility:, persona:)
47
+ def process(responsibility_run:, responsibility:, agent:)
48
48
  responsibility_run.start!
49
- LoopRunStartedEvent.emit(name: responsibility.name, persona_name: persona.name)
49
+ LoopRunStartedEvent.emit(name: responsibility.name, agent_name: agent.name)
50
50
 
51
- Orchestrator.run_responsibility(persona:, responsibility:, responsibility_run:)
51
+ Orchestrator.run_responsibility(agent:, responsibility:, responsibility_run:)
52
52
 
53
53
  responsibility_run.mark_completed!
54
- LoopRunCompletedEvent.emit(name: responsibility.name, persona_name: persona.name)
54
+ LoopRunCompletedEvent.emit(name: responsibility.name, agent_name: agent.name)
55
55
  end
56
56
 
57
57
  # Emit +loop_run_failed+ for a recorded failure — omitted when the run never loaded (e.g. the
@@ -64,7 +64,7 @@ module Protege
64
64
  return unless responsibility_run
65
65
 
66
66
  responsibility = responsibility_run.responsibility
67
- LoopRunFailedEvent.emit(name: responsibility.name, persona_name: responsibility.persona.name, error:)
67
+ LoopRunFailedEvent.emit(name: responsibility.name, agent_name: responsibility.agent.name, error:)
68
68
  end
69
69
  end
70
70
  end
@@ -4,52 +4,76 @@ module Protege
4
4
  # Inbound edge of the Gateway: routes every received email toward the inference pipeline.
5
5
  #
6
6
  # Action Mailbox delivers parsed mail here, exposing +mail+ and +inbound_email+ as instance
7
- # methods and calling {#process} as the single entry point. The mailbox resolves the target
8
- # persona by recipient address, then hands off to +Gateway.accept_smtp_message+, which enqueues
9
- # +InferenceJob+. The inbound Message-ID is forwarded as the correlation ID so the trace token
10
- # survives the async boundary and is restored inside the job.
7
+ # methods and calling {#process} as the single entry point. The mailbox resolves every agent
8
+ # addressed in the +To:+ header, then hands each off to +Gateway.accept_smtp_message+, which enqueues
9
+ # an +InferenceJob+ per agent — so an email to several proteges is handled by each independently. The
10
+ # inbound Message-ID is forwarded as the correlation ID so the trace token survives the async boundary
11
+ # and is restored inside the job.
11
12
  #
12
13
  # == Recursion guard
13
14
  #
14
- # Outbound mail carries an +X-Protege-Recursion+ budget header. Any inbound email whose budget has
15
- # reached zero (or below) is silently dropped, preventing infinite loops when one persona replies
16
- # to another. Unroutable mail (no matching persona) is bounced with an unrouted notice instead.
15
+ # Agent-sent mail carries an +X-Protege-Recursion+ hop-count header (+Gateway::RECURSION_HEADER+):
16
+ # +1+ on fresh mail, the inbound count plus one on a reply. A human mail client never echoes the
17
+ # header back, so conversations with people don't accumulate — only an unbroken Protege-to-Protege
18
+ # reply chain does. Any inbound email whose count has reached the configured limit
19
+ # (+config.recursion_limit+, default 50) is silently dropped, preventing infinite loops when one agent
20
+ # replies to another. Unroutable mail (no matching agent) is bounced with an unrouted notice instead.
17
21
  #
18
22
  # == Access guardrail
19
23
  #
20
- # Once routed, the sender is checked against the persona's inbound access guardrail
21
- # (+Gateway::AccessControl+): the committed global ceiling intersected with the persona's runtime rules. A
22
- # sender that fails any layer is bounced with an access-denied notice and never reaches inference.
24
+ # Each addressed agent is checked against its own inbound access guardrail
25
+ # (+Gateway::AccessControl+): the committed global ceiling intersected with that agent's runtime rules.
26
+ # The email is delivered only to the agents that permit the sender; if none do, it is bounced with an
27
+ # access-denied notice and never reaches inference.
23
28
  class AgentMailbox < ActionMailbox::Base
24
- # Route the inbound email: drop on exhausted recursion budget, bounce when unrouted or when the
25
- # sender fails the access guardrail, else accept.
29
+ # Route the inbound email: drop on exhausted recursion budget, bounce when no addressed agent
30
+ # matches, bounce when the sender may reach none of them, bounce on an attachment breach, else accept
31
+ # it for every addressed agent the sender is permitted to reach (one email to several proteges is
32
+ # handled by each independently).
26
33
  #
27
34
  # @return [void]
28
35
  def process
29
36
  return handle_recursion_limit if recursion_limit_reached?
30
- return bounce_with_unrouted if persona.nil?
31
- return bounce_with_access_denied unless sender_permitted?
37
+ return bounce_with_unrouted if agents.empty?
38
+ return bounce_with_access_denied if permitted_agents.empty?
32
39
  return bounce_with_attachment_error if attachment_violation
33
40
 
34
- Gateway.accept_smtp_message(inbound_email:, persona:)
41
+ permitted_agents.each { |agent| Gateway.accept_smtp_message(inbound_email:, agent:) }
35
42
  end
36
43
 
37
44
  private
38
45
 
39
- # Report whether the sender may reach the routed persona, per the access guardrail.
46
+ # Every active agent addressed on the email — the proteges it is for. Scans both +To:+ and +Cc:+
47
+ # (so a protege reached only as a Cc recipient, e.g. on a reply-all, is still handled), preserving
48
+ # order and collapsing duplicates. +Agent.lookup+ strips plus-tag subaddresses before matching, and
49
+ # scanning all recipients (not just the first) is what lets a protege be reached wherever it appears.
40
50
  #
41
- # @return [Boolean] true when every access layer permits the sender
42
- def sender_permitted?
43
- Gateway.permits?(persona:, address: mail.from.first)
51
+ # @return [Array<Protege::Agent>] the matched agents, possibly empty
52
+ def agents
53
+ @agents ||= recipient_addresses.filter_map { |address| Protege::Agent.lookup(address) }.uniq
44
54
  end
45
55
 
46
- # Resolve and memoise the persona for the email's primary recipient.
56
+ # Every address the email was sent to, across +To:+ and +Cc:+.
47
57
  #
48
- # Uses +Persona.lookup+, which strips plus-tag subaddresses before matching.
58
+ # @return [Array<String>] the combined recipient addresses
59
+ def recipient_addresses
60
+ Array(mail.to) + Array(mail.cc)
61
+ end
62
+
63
+ # The addressed agents the sender is actually permitted to reach, per each one's access guardrail.
64
+ # An email may be delivered to some proteges and withheld from others on the same +To:+ line.
49
65
  #
50
- # @return [Protege::Persona, nil] the routed persona, or nil when none matches
51
- def persona
52
- @persona ||= Protege::Persona.lookup(mail.to.first)
66
+ # @return [Array<Protege::Agent>] the permitted subset of {agents}
67
+ def permitted_agents
68
+ @permitted_agents ||= agents.select { |agent| sender_permitted_for?(agent) }
69
+ end
70
+
71
+ # Report whether the sender may reach one agent, per its access guardrail.
72
+ #
73
+ # @param agent [Protege::Agent] the addressed agent
74
+ # @return [Boolean] true when every access layer permits the sender
75
+ def sender_permitted_for?(agent)
76
+ Gateway.permits?(agent:, address: mail.from.first)
53
77
  end
54
78
 
55
79
  # The attachment-limit breach for this email, or nil when its attachments are within the limits.
@@ -64,15 +88,16 @@ module Protege
64
88
  end
65
89
  end
66
90
 
67
- # Report whether the +X-Protege-Recursion+ budget has been exhausted.
91
+ # Report whether the +X-Protege-Recursion+ hop count has reached the configured limit.
68
92
  #
69
- # @return [Boolean] true when the header is present and its value is zero or below
93
+ # @return [Boolean] true when the header is present and its count is at or above
94
+ # +config.recursion_limit+
70
95
  def recursion_limit_reached?
71
- header = mail.header['X-Protege-Recursion']
72
- header.present? && header.value.to_i <= 0
96
+ header = mail.header[Gateway::RECURSION_HEADER]
97
+ header.present? && header.value.to_i >= Protege.configuration.recursion_limit
73
98
  end
74
99
 
75
- # Silently drop the message when the recursion budget is exhausted — no reply, no error.
100
+ # Silently drop the message when the recursion hop count is exhausted — no reply, no error.
76
101
  #
77
102
  # @return [void]
78
103
  def handle_recursion_limit; end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Operational alert mail — emailed to the platform admin when an inference run fails and cannot be
5
+ # recovered. A sibling of the Gateway's bounce notices ({ApplicationMailer}): both are operational
6
+ # mail the engine originates itself, not agent conversation (so neither goes through
7
+ # +Gateway.deliver+, which is agent/thread-scoped). Sent by +Protege::Subscribers::Alerter+ off the
8
+ # +InferenceFailedEvent+; the recipients and sender come from +config.failure_alerts+.
9
+ #
10
+ # The alert is deliberately trigger-agnostic — it carries only the error and the run's correlation id,
11
+ # not agent/message specifics, so the same notice serves agent-based and non-agent failures
12
+ # alike (the correlation id is the thread back to the full trace). The body renders from
13
+ # +alert_mailer/inference_failed.text.erb+ through the engine's +mailer+ text layout — the engine's
14
+ # first template-rendered mail (the bounce notices use inline bodies).
15
+ class AlertMailer < ApplicationMailer
16
+ # Alert the configured recipients that an inference run failed.
17
+ #
18
+ # +:to+ and +:from+ are read from +config.failure_alerts+. There is no valid default sender — a
19
+ # deliverable +:from+ must be an address at a registered +EmailDomain+ (so the self-hosted MTA
20
+ # DKIM-signs it), so the +Alerter+ only invokes this mailer once +:from+ is configured; it is passed
21
+ # through verbatim here rather than defaulted.
22
+ #
23
+ # @param error_class [String] the error's class name (also names the subject)
24
+ # @param error_message [String] the error's message
25
+ # @param correlation_id [String, nil] the run's correlation id, for tracing the failure
26
+ # @return [Mail::Message] the alert addressed to the configured recipients
27
+ def inference_failed(error_class:, error_message:, correlation_id:)
28
+ @error_class = error_class
29
+ @error_message = error_message
30
+ @correlation_id = correlation_id
31
+
32
+ alerts = Protege.configuration.failure_alerts
33
+
34
+ mail(
35
+ from: alerts[:from],
36
+ to: alerts[:to],
37
+ subject: "[Protege] Inference failed: #{error_class}"
38
+ )
39
+ end
40
+ end
41
+ end
@@ -4,17 +4,18 @@ module Protege
4
4
  # Base mailer for the Protege engine and home of the Gateway's bounce notices.
5
5
  #
6
6
  # Sits at the outbound edge of the Gateway. +AgentMailbox+ returns these notices to Action Mailbox's
7
- # +bounce_with+ when an inbound email can't be accepted — no matching persona ({#unrouted_bounce}) or
8
- # a sender rejected by the access guardrail ({#access_denied_bounce}). The default +from:+ address is
9
- # a deliberate placeholder: a host application should override it through
10
- # +config.action_mailer.default_options+ in an initializer, or per-mailer, so outbound mail
11
- # originates from a domain it actually controls.
7
+ # +bounce_with+ when an inbound email can't be accepted — no matching agent ({#unrouted_bounce}) or
8
+ # a sender rejected by the access guardrail ({#access_denied_bounce}). The sender is the host's
9
+ # app-wide default (+config.action_mailer.default_options+) when one is set, falling back to a
10
+ # deliberate placeholder — set the app-wide default so outbound mail originates from a domain the
11
+ # host actually controls.
12
12
  class ApplicationMailer < ActionMailer::Base
13
- # Placeholder sender; override in the host app so mail comes from a domain you control.
14
- default from: 'from@example.com'
13
+ # Resolved lazily per mail so the host's +config.action_mailer.default_options+ wins even though
14
+ # this subclass declares its own default (a literal here would shadow the app-wide setting).
15
+ default from: -> { ActionMailer::Base.default[:from] || 'from@example.com' }
15
16
  layout 'mailer'
16
17
 
17
- # Bounce an inbound email that matched no persona back to its sender.
18
+ # Bounce an inbound email that matched no agent back to its sender.
18
19
  #
19
20
  # Returned to +AgentMailbox#bounce_with_unrouted+, which hands it to Action Mailbox's +bounce_with+
20
21
  # for delivery. The body is built inline (no template/layout) so a bounce can never fail to render.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +Agent+ records — keeping the Agents sidebar
5
+ # live: the row is appended on create, replaced on update, and removed on destroy. First-page viewers
6
+ # subscribe via +turbo_stream_from "protege:agents"+ (see +SidebarHelper#sidebar_stream+).
7
+ module BroadcastableAgent
8
+ extend ActiveSupport::Concern
9
+
10
+ # Turbo Stream channel for all +Agent+ records.
11
+ # @return [String]
12
+ STREAM = 'protege:agents'
13
+
14
+ included do
15
+ after_create_commit :broadcast_agent_created
16
+ after_update_commit :broadcast_agent_updated
17
+ after_destroy_commit :broadcast_agent_removed
18
+ end
19
+
20
+ private
21
+
22
+ # Append this agent's row to the Agents sidebar on creation.
23
+ #
24
+ # @return [void]
25
+ def broadcast_agent_created
26
+ broadcast_append_to STREAM,
27
+ target: 'agents_list',
28
+ partial: 'protege/agents/agent_sidebar_item',
29
+ locals: { agent: self }
30
+ end
31
+
32
+ # Replace this agent's sidebar row in place when its attributes change.
33
+ #
34
+ # @return [void]
35
+ def broadcast_agent_updated
36
+ broadcast_replace_to STREAM,
37
+ target: ActionView::RecordIdentifier.dom_id(self),
38
+ partial: 'protege/agents/agent_sidebar_item',
39
+ locals: { agent: self }
40
+ end
41
+
42
+ # Remove this agent's sidebar row on destruction.
43
+ #
44
+ # @return [void]
45
+ def broadcast_agent_removed
46
+ broadcast_remove_to STREAM,
47
+ target: ActionView::RecordIdentifier.dom_id(self)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives real-time Turbo Stream broadcasting for +Toolkit+ records — keeping the (global) Toolkits
5
+ # sidebar live: the row is appended on create, replaced on update, and removed on destroy. First-page
6
+ # viewers subscribe via +turbo_stream_from "protege:toolkits"+ (see +SidebarHelper#sidebar_stream+).
7
+ module BroadcastableToolkit
8
+ extend ActiveSupport::Concern
9
+
10
+ # Turbo Stream channel for all +Toolkit+ records.
11
+ # @return [String]
12
+ STREAM = 'protege:toolkits'
13
+
14
+ included do
15
+ after_create_commit :broadcast_toolkit_created
16
+ after_update_commit :broadcast_toolkit_updated
17
+ after_destroy_commit :broadcast_toolkit_removed
18
+ end
19
+
20
+ private
21
+
22
+ # Append this toolkit's row to the Toolkits sidebar on creation.
23
+ #
24
+ # @return [void]
25
+ def broadcast_toolkit_created
26
+ broadcast_append_to STREAM,
27
+ target: 'toolkits_list',
28
+ partial: 'protege/toolkits/toolkit_sidebar_item',
29
+ locals: { toolkit: self }
30
+ end
31
+
32
+ # Replace this toolkit's sidebar row in place when its attributes change.
33
+ #
34
+ # @return [void]
35
+ def broadcast_toolkit_updated
36
+ broadcast_replace_to STREAM,
37
+ target: ActionView::RecordIdentifier.dom_id(self),
38
+ partial: 'protege/toolkits/toolkit_sidebar_item',
39
+ locals: { toolkit: self }
40
+ end
41
+
42
+ # Remove this toolkit's sidebar row on destroy.
43
+ #
44
+ # @return [void]
45
+ def broadcast_toolkit_removed
46
+ broadcast_remove_to STREAM, target: ActionView::RecordIdentifier.dom_id(self)
47
+ end
48
+ end
49
+ end