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
@@ -6,10 +6,12 @@ module Protege
6
6
  # their description and JSON Schema via the class-level DSL, and implement +#use(context:, **input)+.
7
7
  #
8
8
  # Registration is automatic: every +Tool+ subclass is discoverable via +Tool.registered+
9
- # (resolved from +Tool.descendants+, so it survives Zeitwerk reloads), and the +Harness+
10
- # enumerates that list to build the LLM-visible tool catalog. Tools have no +protege_id+ DSL — the
11
- # id is derived from the class basename with the +Tool+ suffix stripped, then snake_cased
12
- # (+SendEmailTool+ → +:send_email+, +HTTPFetchTool+ → +:http_fetch+).
9
+ # (resolved from +Tool.descendants+, so it survives Zeitwerk reloads). Availability is not — the
10
+ # +Harness+ builds the LLM-visible catalog from the agent's attached toolkits
11
+ # (+Agent#available_tools_for+, fail-closed), so a registered tool reaches the model only through a
12
+ # toolkit that contains it. Tools have no +protege_id+ DSL — the id is derived from the class
13
+ # basename with the +Tool+ suffix stripped, then snake_cased (+SendEmailTool+ → +:send_email+,
14
+ # +HTTPFetchTool+ → +:http_fetch+).
13
15
  #
14
16
  # Tools must return a +Protege::Result+ from +#use+ — construct one via +Result.success(**data)+ /
15
17
  # +Result.failure(reason:, **data)+ (or the +#success+/+#failure+ helpers). The +Harness+ serializes
@@ -81,17 +83,17 @@ module Protege
81
83
  # +Protege::InvalidToolResultError+, rather than blowing up later when the +Harness+
82
84
  # calls +#success?+ on the bad value.
83
85
  #
84
- # Enforcement of tool scoping lives here: a call to any tool outside the persona's effective scope
85
- # (its code grant minus +disabled_tool_ids+, read from +context.persona+) is refused before +#use+
86
- # runs. Advertising a narrowed catalogue in the request isn't enough — a tool disabled mid-thread
87
- # stays visible to the model in earlier turns and could still be called — so dispatch is the
88
- # backstop.
86
+ # Enforcement of tool scoping lives here: a call to any tool outside the agent's effective scope
87
+ # for this run (the tools its attached toolkits expose to the run's sender, read from
88
+ # +context.agent+) is refused before +#use+ runs. Advertising a narrowed catalogue in the request
89
+ # isn't enough — a tool that left scope mid-thread stays visible to the model in earlier turns and
90
+ # could still be called — so dispatch is the backstop.
89
91
  #
90
92
  # @param tool_call [Object] the tool call carrying name and input
91
93
  # @param context [Protege::Orchestrator::Context] the per-delivery tool context
92
94
  # @return [Protege::ToolResult] the call paired with its result
93
95
  def invoke(tool_call, context:)
94
- if context.persona.available_tool_ids.exclude?(tool_call.name.to_sym)
96
+ if context.agent.available_tool_ids_for(context:).exclude?(tool_call.name.to_sym)
95
97
  return failed_tool_result(tool_call:,
96
98
  error: Protege::ToolNotAvailableError.new(name: tool_call.name))
97
99
  end
@@ -170,21 +172,11 @@ module Protege
170
172
  Result.failure(reason:, **data)
171
173
  end
172
174
 
173
- # Class-level DSL mixed into every tool class via +Tool.included+.
175
+ # Class-level DSL mixed into every tool class via +Tool.included+. The tool's +id+, +description+, and
176
+ # +display_name+ come from the shared +Extensions::Manifested+ concern (a tool's +id+ is its class
177
+ # basename with the +Tool+ suffix stripped, e.g. +SendEmailTool → :send_email+); only +input_schema+,
178
+ # which describes call-time input rather than manifest metadata, lives here.
174
179
  module ClassMethods
175
- # Declare or read the natural-language description shown to the LLM in the tool catalog.
176
- #
177
- # With an argument, sets the description; with no argument, returns the previously declared
178
- # value.
179
- #
180
- # @param text [String, nil] the description to set, or nil to read
181
- # @return [String, nil] the current description
182
- def description(text = nil)
183
- return @description if text.nil?
184
-
185
- @description = text
186
- end
187
-
188
180
  # Declare or read the JSON Schema describing this tool's input parameters.
189
181
  #
190
182
  # With an argument, sets the schema; with no argument, returns the previously declared value.
@@ -196,22 +188,6 @@ module Protege
196
188
 
197
189
  @input_schema = schema
198
190
  end
199
-
200
- # Return the tool's symbolic identifier, derived from the class basename with the +Tool+
201
- # suffix stripped and the rest snake_cased. Every concrete tool is named +<Purpose>Tool+ by
202
- # convention (so tool classes never collide with same-named models), yet the id the agent sees
203
- # stays clean. Memoized per class.
204
- #
205
- # @example
206
- # SendEmailTool # => :send_email
207
- # HTTPFetchTool # => :http_fetch
208
- # EchoTool # => :echo
209
- # Read2Tool # => :read2
210
- #
211
- # @return [Symbol] the tool id
212
- def id
213
- @id ||= name.demodulize.delete_suffix('Tool').underscore.to_sym
214
- end
215
191
  end
216
192
  end
217
193
  end
@@ -2,45 +2,45 @@
2
2
 
3
3
  module Protege
4
4
  module Gateway
5
- # The inbound access guardrail: composes every access-policy *layer* that applies to a persona and
6
- # answers the one question the Gateway asks — may this sender reach this persona? A gateway concern
5
+ # The inbound access guardrail: composes every access-policy *layer* that applies to an agent and
6
+ # answers the one question the Gateway asks — may this sender reach this agent? A gateway concern
7
7
  # (inbound mail authorization), it lives beside the other Gateway protocol pieces and is called from
8
8
  # the Gateway's inbound edge, +AgentMailbox+.
9
9
  #
10
10
  # Composition is by **intersection**: a sender is permitted only when *every* layer permits it
11
11
  # (logical AND). Because intersection is commutative there is no precedence to reason about — the
12
12
  # order of layers never changes the outcome — and the security property falls out for free: a layer
13
- # can only *narrow* the set of admitted senders, never widen it. So a per-persona rule can tighten
13
+ # can only *narrow* the set of admitted senders, never widen it. So a per-agent rule can tighten
14
14
  # who reaches that agent but can never punch through the committed org-wide ceiling.
15
15
  #
16
16
  # Two layers exist today — the committed global policy (+Protege.configuration.inbound_access+) and
17
- # the persona's runtime rules (+AccessRule.policy_for+). This composer is the single seam the rest of
18
- # the engine calls; adding a layer later (e.g. a per-persona-class committed policy) is a one-line
17
+ # the agent's runtime rules (+AccessRule.policy_for+). This composer is the single seam the rest of
18
+ # the engine calls; adding a layer later (e.g. a per-agent-class committed policy) is a one-line
19
19
  # change to {#policies}, never a change at the call sites.
20
20
  #
21
21
  # @example Gate an inbound sender
22
- # Gateway::AccessControl.for(persona:).permits?(address: mail.from.first)
22
+ # Gateway::AccessControl.for(agent:).permits?(address: mail.from.first)
23
23
  class AccessControl
24
- # @return [Protege::Persona] the persona the inbound mail is addressed to
25
- attr_reader :persona
24
+ # @return [Protege::Agent] the agent the inbound mail is addressed to
25
+ attr_reader :agent
26
26
 
27
27
  class << self
28
- # Build the access control for a persona. Factory mirror of +.new+ reading as a lookup.
28
+ # Build the access control for an agent. Factory mirror of +.new+ reading as a lookup.
29
29
  #
30
- # @param persona [Protege::Persona] the routed persona
30
+ # @param agent [Protege::Agent] the routed agent
31
31
  # @return [Protege::Gateway::AccessControl] the composed guardrail
32
- def for(persona:)
33
- new(persona:)
32
+ def for(agent:)
33
+ new(agent:)
34
34
  end
35
35
  end
36
36
 
37
- # @param persona [Protege::Persona] the routed persona
37
+ # @param agent [Protege::Agent] the routed agent
38
38
  # @return [void]
39
- def initialize(persona:)
40
- @persona = persona
39
+ def initialize(agent:)
40
+ @agent = agent
41
41
  end
42
42
 
43
- # Decide whether a sender may reach this persona, intersecting all applicable layers.
43
+ # Decide whether a sender may reach this agent, intersecting all applicable layers.
44
44
  #
45
45
  # @param address [String] the raw sender address
46
46
  # @return [Boolean] true only when every layer permits the sender
@@ -50,15 +50,15 @@ module Protege
50
50
 
51
51
  private
52
52
 
53
- # The ordered (order-immaterial) list of policy layers that apply to this persona.
53
+ # The ordered (order-immaterial) list of policy layers that apply to this agent.
54
54
  #
55
55
  # Add a layer here — and only here — to extend the guardrail.
56
56
  #
57
- # @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the persona's runtime policy
57
+ # @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the agent's runtime policy
58
58
  def policies
59
59
  [
60
60
  Protege.configuration.inbound_access,
61
- AccessRule.policy_for(persona)
61
+ AccessRule.policy_for(agent)
62
62
  ]
63
63
  end
64
64
  end
@@ -6,7 +6,7 @@ module Protege
6
6
  # sender address is permitted, given an allow-list and a deny-list of address patterns. A Gateway
7
7
  # value type (inbound authorization) — built through the +Gateway.build_access_policy+ factory,
8
8
  # composed by +Gateway::AccessControl+, and assigned as the global ceiling via +config.inbound_access+
9
- # or per-persona by +AccessRule.policy_for+:
9
+ # or per-agent by +AccessRule.policy_for+:
10
10
  #
11
11
  # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
12
12
  #
@@ -19,7 +19,7 @@ module Protege
19
19
  # Patterns are matched against the sender's tag-stripped, lowercased +local@domain+ routing key (so
20
20
  # +ceo+finance@Company.CO+ matches a +ceo@company.co+ rule), and support a single +*+ wildcard
21
21
  # (e.g. +*@company.co+). This object knows nothing about layering; composing several policies into
22
- # the org→persona guardrail is +AccessControl+'s job.
22
+ # the org→agent guardrail is +AccessControl+'s job.
23
23
  #
24
24
  # @example The identity policy — a bare +build_access_policy+ permits everyone (empty lists derive a
25
25
  # default-allow), so it drops out of the +AccessControl+ intersection as a no-op.
@@ -15,7 +15,7 @@ module Protege
15
15
  #
16
16
  # @example A threaded reply
17
17
  # Outbound.build(
18
- # from: persona.email_address,
18
+ # from: agent.email_address,
19
19
  # to: inbound.from_address,
20
20
  # subject: 'Re: Hello',
21
21
  # body: 'Thanks!',
@@ -37,6 +37,9 @@ module Protege
37
37
  # Strings are split on whitespace, Arrays/References normalized per id.
38
38
  # @param attachments [Array<Mail::Attachment>] files to attach; when any are present the message
39
39
  # is assembled as multipart with the body as a text part.
40
+ # @param hops [Integer, nil] the +X-Protege-Recursion+ hop count to stamp — agent-sent mail
41
+ # passes it (see +Gateway::RECURSION_HEADER+); +nil+ (operator/console mail) leaves the
42
+ # header off entirely.
40
43
  # @return [::Mail::Message] the assembled message.
41
44
  def build(
42
45
  from:,
@@ -47,7 +50,8 @@ module Protege
47
50
  bcc: [],
48
51
  in_reply_to: nil,
49
52
  references: nil,
50
- attachments: []
53
+ attachments: [],
54
+ hops: nil
51
55
  )
52
56
  new(
53
57
  from:,
@@ -58,7 +62,8 @@ module Protege
58
62
  bcc:,
59
63
  in_reply_to:,
60
64
  references:,
61
- attachments:
65
+ attachments:,
66
+ hops:
62
67
  ).message
63
68
  end
64
69
  end
@@ -75,7 +80,8 @@ module Protege
75
80
  bcc: [],
76
81
  in_reply_to: nil,
77
82
  references: nil,
78
- attachments: []
83
+ attachments: [],
84
+ hops: nil
79
85
  )
80
86
  @from = from
81
87
  @to = to
@@ -86,6 +92,7 @@ module Protege
86
92
  @in_reply_to = in_reply_to
87
93
  @references = references
88
94
  @attachments = attachments
95
+ @hops = hops
89
96
  end
90
97
 
91
98
  # Assemble the +::Mail::Message+ from the stored inputs.
@@ -103,12 +110,22 @@ module Protege
103
110
  m.in_reply_to = parent_id if parent_id
104
111
  m.references = reference_values if reference_values.any?
105
112
 
113
+ stamp_hops(m)
106
114
  assign_content(m)
107
115
  end
108
116
  end
109
117
 
110
118
  private
111
119
 
120
+ # Stamp the +X-Protege-Recursion+ hop count on agent-sent mail; a nil +hops+ (operator/console
121
+ # mail) leaves the header off so human-authored messages never look like an agent chain.
122
+ #
123
+ # @param mail [::Mail::Message] the message under construction
124
+ # @return [void]
125
+ def stamp_hops(mail)
126
+ mail.header[Gateway::RECURSION_HEADER] = @hops.to_s if @hops
127
+ end
128
+
112
129
  # Set the body on +mail+: a single +text/plain+ part when there are no attachments, otherwise a
113
130
  # multipart message whose first part is the plain-text body and whose remaining parts are the
114
131
  # attachments. Forcing a top-level +text/plain+ content type would break the multipart case, so
@@ -138,7 +155,7 @@ module Protege
138
155
 
139
156
  # The domain for the outbound Message-ID — the +From+ address's domain, so the id carries the
140
157
  # real sending domain (recipients like Gmail distrust a bogus domain and won't thread). A
141
- # persona (or the console) always sends from a full address, so a From with no domain is a
158
+ # agent (or the console) always sends from a full address, so a From with no domain is a
142
159
  # caller bug rather than something to paper over.
143
160
  #
144
161
  # @return [String] the sending domain.
@@ -17,6 +17,13 @@ module Protege
17
17
  autoload :AccessControl, 'protege/gateway/access_control'
18
18
  autoload :AttachmentPolicy, 'protege/gateway/attachment_policy'
19
19
 
20
+ # The hop-count header agent-sent mail carries so agent-to-agent reply loops stay bounded. Every
21
+ # email an agent sends is stamped: +1+ on fresh mail, the inbound value plus one on a reply. Human
22
+ # mail clients never echo a custom header back, so a conversation with a person always resets — only
23
+ # an unbroken chain of Protege-to-Protege replies accumulates hops, and the inbound edge
24
+ # (+AgentMailbox+) silently drops mail whose count reaches its limit.
25
+ RECURSION_HEADER = 'X-Protege-Recursion'
26
+
20
27
  extend self
21
28
 
22
29
  # Accept a message typed into the dashboard console.
@@ -27,18 +34,18 @@ module Protege
27
34
  # correlation id so events across the run can be traced.
28
35
  #
29
36
  # @param mail [::Mail::Message] the console-authored message.
30
- # @param persona [Persona] the persona receiving the message.
37
+ # @param agent [Agent] the agent receiving the message.
31
38
  # @param attachments [Array<ActiveStorage::Blob, ActiveStorage::Attachment, #original_filename, Hash>]
32
- # files for the persona to read. Each entry is normalized to a blob (see {#blob_for}) and attached by
39
+ # files for the agent to read. Each entry is normalized to a blob (see {#blob_for}) and attached by
33
40
  # reference (mirroring +deliver+) — an already-stored blob/attachment reuses its blob, an uploaded
34
41
  # file or a +{ filename:, content_type:, bytes: }+ Hash is uploaded once. Attached after +save!+ so
35
- # they are committed before the (delayed) job reads the message; the persona sees them via
42
+ # they are committed before the (delayed) job reads the message; the agent sees them via
36
43
  # +Message#content_parts+ / the +read_attachment+ tool.
37
44
  # @return [Message] the saved inbound message record.
38
- def accept_console_message(mail:, persona:, attachments: [])
45
+ def accept_console_message(mail:, agent:, attachments: [])
39
46
  correlation_id = Mail::MessageId.ensure(mail.message_id).value
40
- thread = EmailThread.find_or_create_for(mail:, persona:)
41
- message = Message.build_from_mail(mail:, direction: :inbound, thread:, persona:)
47
+ thread = EmailThread.find_or_create_for(mail:, agent:)
48
+ message = Message.build_from_mail(mail:, direction: :inbound, thread:, agent:)
42
49
  message.processing_status = :pending
43
50
  message.save!
44
51
 
@@ -52,13 +59,13 @@ module Protege
52
59
  ConsoleInferenceJob
53
60
  .set(wait: 1.second)
54
61
  .perform_later(message_id: message.id,
55
- persona_id: persona.id,
62
+ agent_id: agent.id,
56
63
  correlation_id:)
57
64
 
58
65
  message
59
66
  end
60
67
 
61
- # Send a message to a persona from the host application — the reverse of tools/resolvers.
68
+ # Send a message to an agent from the host application — the reverse of tools/resolvers.
62
69
  #
63
70
  # Where tools and resolvers let the agent reach *into* the app, this lets ordinary application code
64
71
  # (a service object, controller, or state machine) hand the agent something to act on:
@@ -68,12 +75,12 @@ module Protege
68
75
  #
69
76
  # It is a thin convenience over +build_outbound+ + +accept_console_message+ — the exact sequence the
70
77
  # dashboard console uses — so the brief flows through the same "inbound message starts a thread" path
71
- # (+ReplyHarness+ and the persona's +message_resolvers+). +to+ is an address, not a record: it is
72
- # resolved through +Persona.lookup+ (honouring case and +tag+ subaddressing), so a caller can pass a
73
- # literal address or +persona.email_address+ without loading the record first.
78
+ # (+ReplyHarness+ and the agent's +message_resolvers+). +to+ is an address, not a record: it is
79
+ # resolved through +Agent.lookup+ (honouring case and +tag+ subaddressing), so a caller can pass a
80
+ # literal address or +agent.email_address+ without loading the record first.
74
81
  #
75
82
  # Fire-and-forget: it returns the persisted inbound +Message+ for reference/tracing, not an LLM
76
- # response — the agent's actions (any mail it chooses to send) are the outcome. The persona reads it,
83
+ # response — the agent's actions (any mail it chooses to send) are the outcome. The agent reads it,
77
84
  # reasons, and uses its (scoped) tools; there is nothing it must reply to.
78
85
  #
79
86
  # Attachments meet the host where its file already is — a stored blob/attachment (e.g.
@@ -82,42 +89,42 @@ module Protege
82
89
  # generated bytes, uploaded once). So a caller never constructs a +Gateway::Mail::Attachment+ by hand.
83
90
  # See {#blob_for} for the full set of accepted shapes.
84
91
  #
85
- # @param to [String] the recipient persona's address (or +persona.email_address+).
92
+ # @param to [String] the recipient agent's address (or +agent.email_address+).
86
93
  # @param subject [String, nil] the message subject (the thread's title).
87
- # @param body [String] the brief the persona acts on.
88
- # @param attachments [Array] files for the persona to read; see {#accept_console_message} / {#blob_for}.
94
+ # @param body [String] the brief the agent acts on.
95
+ # @param attachments [Array] files for the agent to read; see {#accept_console_message} / {#blob_for}.
89
96
  # @return [Message] the saved inbound message record.
90
- # @raise [ArgumentError] when no active persona owns +to+, or an attachment is an unsupported shape.
97
+ # @raise [ArgumentError] when no active agent owns +to+, or an attachment is an unsupported shape.
91
98
  def message(to:, body:, subject: nil, attachments: [])
92
- persona = Persona.lookup(to)
93
- raise ArgumentError, "no active persona for address: #{to.inspect}" unless persona
99
+ agent = Agent.lookup(to)
100
+ raise ArgumentError, "no active agent for address: #{to.inspect}" unless agent
94
101
 
95
102
  mail = build_outbound(from: Protege.configuration.console_address,
96
- to: persona.email_address,
103
+ to: agent.email_address,
97
104
  subject:,
98
105
  body:)
99
106
 
100
- accept_console_message(mail:, persona:, attachments:)
107
+ accept_console_message(mail:, agent:, attachments:)
101
108
  end
102
109
 
103
110
  # Accept an inbound email arriving via SMTP / Action Mailbox.
104
111
  #
105
112
  # Unlike the console path, ingestion (persisting the +Message+, building the thread) happens
106
- # inside +InferenceJob+; this method only enqueues that job with the persona and the inbound
113
+ # inside +InferenceJob+; this method only enqueues that job with the agent and the inbound
107
114
  # email reference, carrying the mail's message id as the correlation id.
108
115
  #
109
116
  # @param inbound_email [ActionMailbox::InboundEmail] the ingress email record.
110
- # @param persona [Persona] the persona the message was routed to.
117
+ # @param agent [Agent] the agent the message was routed to.
111
118
  # @return [void]
112
- def accept_smtp_message(inbound_email:, persona:)
119
+ def accept_smtp_message(inbound_email:, agent:)
113
120
  InferenceJob.perform_later(
114
- persona_id: persona.id,
121
+ agent_id: agent.id,
115
122
  inbound_email_id: inbound_email.id,
116
123
  correlation_id: Mail::MessageId.ensure(inbound_email.mail.message_id).value
117
124
  )
118
125
  end
119
126
 
120
- # Deliver an outbound message on behalf of a persona and record it.
127
+ # Deliver an outbound message on behalf of an agent and record it.
121
128
  #
122
129
  # Sends over SMTP via +mail.deliver!+ unless the recipient is the local console address, in
123
130
  # which case the conversation stays entirely in-app. Either way the message is persisted as an
@@ -129,18 +136,18 @@ module Protege
129
136
  # uploaded once and thereafter only referenced.
130
137
  #
131
138
  # @param mail [::Mail::Message] the outbound mail to deliver.
132
- # @param persona [Persona] the sending persona.
139
+ # @param agent [Agent] the sending agent.
133
140
  # @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound record by reference.
134
141
  # @return [Message] the saved outbound message record.
135
- def deliver(mail:, persona:, attachments: [])
142
+ def deliver(mail:, agent:, attachments: [])
136
143
  deliver_over_transport(mail) unless local_delivery?(mail)
137
144
 
138
- thread = EmailThread.find_or_create_for(mail:, persona:)
145
+ thread = EmailThread.find_or_create_for(mail:, agent:)
139
146
 
140
147
  Message.build_from_mail(
141
148
  mail:,
142
149
  thread:,
143
- persona:,
150
+ agent:,
144
151
  direction: :outbound,
145
152
  attach_from_mail: false
146
153
  ).tap do |record|
@@ -162,7 +169,7 @@ module Protege
162
169
 
163
170
  # Build an inbound access policy — one layer of the sender-authorization guardrail. The public way
164
171
  # to construct an +AccessPolicy+, whether the global ceiling (+config.inbound_access+) or a
165
- # per-persona layer (+AccessRule.policy_for+). +build_+ marks it as an instance factory, not a
172
+ # per-agent layer (+AccessRule.policy_for+). +build_+ marks it as an instance factory, not a
166
173
  # persisted record. Keyword arguments are forwarded (+allow:+, +deny:+, +default_decision:+).
167
174
  #
168
175
  # @return [Protege::Gateway::AccessPolicy] the composed policy layer
@@ -215,24 +222,28 @@ module Protege
215
222
  Mail::Address.parse(address).routing_key
216
223
  end
217
224
 
218
- # Report whether a sender may reach a persona, per the persona's inbound access guardrail.
225
+ # Report whether a sender may reach an agent, per the agent's inbound access guardrail.
219
226
  #
220
- # @param persona [Persona] the routed persona
227
+ # @param agent [Agent] the routed agent
221
228
  # @param address [String, nil] the sender's address
222
229
  # @return [Boolean] true when every access layer permits the sender
223
- def permits?(persona:, address:)
224
- AccessControl.for(persona:).permits?(address:)
230
+ def permits?(agent:, address:)
231
+ AccessControl.for(agent:).permits?(address:)
225
232
  end
226
233
 
227
234
  # Report whether an inbound email is addressed to a domain Protege receives for — the Action
228
235
  # Mailbox routing predicate. Domain-scoped on purpose: the engine claims only mail for its own
229
236
  # domains and leaves everything else to the host's own mailbox routing, so mounting Protege never
230
- # hijacks a host's other inbound mail. Per-persona/subaddress validation stays in +AgentMailbox+.
237
+ # hijacks a host's other inbound mail. Per-agent/subaddress validation stays in +AgentMailbox+.
238
+ #
239
+ # Considers both To and Cc: an agent reached only as a Cc recipient (e.g. on a reply-all) is a
240
+ # legitimate participant and must be claimed, matching how the rest of the system treats Cc.
231
241
  #
232
242
  # @param inbound_email [ActionMailbox::InboundEmail] the received email
233
- # @return [Boolean] true when any recipient's domain is one of ours
243
+ # @return [Boolean] true when any To/Cc recipient's domain is one of ours
234
244
  def claims?(inbound_email:)
235
- Array(inbound_email.mail.to).any? { |address| EmailDomain.receives?(address) }
245
+ mail = inbound_email.mail
246
+ (Array(mail.to) + Array(mail.cc)).any? { |address| EmailDomain.receives?(address) }
236
247
  end
237
248
 
238
249
  # ── MTA provisioning (push) ────────────────────────────────────────────────
@@ -23,7 +23,7 @@ module Protege
23
23
  #
24
24
  # @return [void]
25
25
  def dispatch
26
- Protege::Responsibility.active.with_active_persona.find_each do |responsibility|
26
+ Protege::Responsibility.active.with_active_agent.find_each do |responsibility|
27
27
  responsibility.dispatch! if responsibility.due?(now)
28
28
  end
29
29
  end
@@ -3,7 +3,7 @@
3
3
  module Protege
4
4
  module Orchestrator
5
5
  # Base context shared by both kinds of run. Carries the per-run runtime data every tool and
6
- # resolver needs — persona, a frozen config snapshot, logger, correlation id — plus the
6
+ # resolver needs — agent, a frozen config snapshot, logger, correlation id — plus the
7
7
  # +#deliver+ affordance for sending mail through the Gateway, without exposing mutable framework
8
8
  # state. Frozen after construction so one instance is safely shared across every tool/resolver
9
9
  # invocation in a run.
@@ -13,19 +13,19 @@ module Protege
13
13
  # +#message+ (reactive, an inbound email) and +ResponsibilityContext+ sets +#responsibility+
14
14
  # (proactive, a scheduled Loop run). Tools and resolvers branch on whichever is present.
15
15
  class Context
16
- # @return [Protege::Persona] the persona handling this run
16
+ # @return [Protege::Agent] the agent handling this run
17
17
  # @return [Protege::Configuration] the frozen process configuration snapshot
18
18
  # @return [Logger] the engine logger
19
19
  # @return [String, nil] the correlation id threaded through this run's events
20
- attr_reader :persona, :config, :logger, :correlation_id
20
+ attr_reader :agent, :config, :logger, :correlation_id
21
21
 
22
22
  # Capture the shared runtime data and freeze. Subclasses set their own subject ivar *before*
23
23
  # calling +super+, since this constructor freezes the instance.
24
24
  #
25
- # @param persona [Protege::Persona] the persona handling this run
25
+ # @param agent [Protege::Agent] the agent handling this run
26
26
  # @return [void]
27
- def initialize(persona:)
28
- @persona = persona
27
+ def initialize(agent:)
28
+ @agent = agent
29
29
  @logger = Protege.configuration.logger
30
30
  # Snapshot a frozen *copy* so the context is immutable without freezing the process-wide
31
31
  # configuration (which boot-time code and tests still mutate).
@@ -51,14 +51,14 @@ module Protege
51
51
  end
52
52
 
53
53
  # Deliver an outbound mail via the Gateway, handling transport, thread resolution, and message
54
- # persistence under the current persona. Any +attachments+ (blobs already in storage) are attached
54
+ # persistence under the current agent. Any +attachments+ (blobs already in storage) are attached
55
55
  # to the persisted outbound message by reference, so a file is never re-uploaded on send.
56
56
  #
57
57
  # @param mail [Object] the outbound mail to deliver
58
58
  # @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound message
59
59
  # @return [Object] the Gateway delivery outcome
60
60
  def deliver(mail:, attachments: [])
61
- Gateway.deliver(mail:, persona:, attachments:)
61
+ Gateway.deliver(mail:, agent:, attachments:)
62
62
  end
63
63
  end
64
64
  end