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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # An agent's tools — sourced entirely from the toolkits attached to it. There is no code-declared grant
5
+ # and no per-agent disable list: attaching a toolkit equips the agent with its member tools, and the
6
+ # attachment (+AgentToolkit+) decides who may reach them. Resolution is *fail-closed* — an agent with
7
+ # no attached toolkit has no tools at all, so even replying (send_email is itself a tool) requires a
8
+ # toolkit that supplies it.
9
+ #
10
+ # Two questions, two methods. {#available_tools} is the gate-agnostic union across every attached toolkit
11
+ # — the at-a-glance catalogue for the dashboard. {#available_tools_for} narrows that to a single run: a
12
+ # tool is offered only when some attached toolkit that contains it admits the run — on a reply the inbound
13
+ # sender must pass that grant's gate; on a scheduled (senderless) run the grant must opt into proactive
14
+ # use (fail-closed by default). Both reconcile against the live registry (via the toolkit's
15
+ # +member_tools+), so a stale member id — naming a tool since deleted from the code — is simply skipped.
16
+ module Toolable
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ has_many :agent_toolkits, class_name: 'Protege::AgentToolkit', dependent: :destroy
21
+ has_many :toolkits, through: :agent_toolkits
22
+
23
+ after_create :attach_default_toolkit
24
+ end
25
+
26
+ # The tool classes this agent may use for a specific run — the members of every attached toolkit whose
27
+ # grant admits the run, deduped. This is the single source of truth called by both the advertised
28
+ # catalogue and the dispatch backstop, so what the model is offered and what it is allowed to call can
29
+ # never diverge.
30
+ #
31
+ # @param context [Protege::Orchestrator::Context] the run context (its +message+/sender drives gating)
32
+ # @return [Array<Class>] the tool classes offered for this run
33
+ def available_tools_for(context:)
34
+ tool_classes(admitted_grants(context:))
35
+ end
36
+
37
+ # The ids of {#available_tools_for} — the convenience the dispatch backstop checks.
38
+ #
39
+ # @param context [Protege::Orchestrator::Context] the run context
40
+ # @return [Array<Symbol>] the offered tool ids
41
+ def available_tool_ids_for(context:)
42
+ available_tools_for(context:).map(&:id)
43
+ end
44
+
45
+ # Every tool this agent is equipped with across all attached toolkits, gate-agnostic — the at-a-glance
46
+ # catalogue for the dashboard. Who may actually reach each tool is decided per run by
47
+ # {#available_tools_for}; this answers only "what could this agent ever use".
48
+ #
49
+ # @return [Array<Class>] the union of the attached toolkits' tool classes
50
+ def available_tools
51
+ tool_classes(attachments)
52
+ end
53
+
54
+ # The ids of {#available_tools} — a convenience for UI and tests.
55
+ #
56
+ # @return [Array<Symbol>] the equipped tool ids
57
+ def available_tool_ids
58
+ available_tools.map(&:id)
59
+ end
60
+
61
+ private
62
+
63
+ # The attachments that admit this run — each grant whose gate the run passes.
64
+ #
65
+ # @param context [Protege::Orchestrator::Context] the run context
66
+ # @return [Array<Protege::AgentToolkit>] the admitting grants
67
+ def admitted_grants(context:)
68
+ attachments.select { |grant| grant_admits?(grant, context) }
69
+ end
70
+
71
+ # Whether one grant admits this run: the inbound sender must pass the grant's gate, or — with no sender
72
+ # (a scheduled run) — the grant must opt into proactive use.
73
+ #
74
+ # @param grant [Protege::AgentToolkit] the attachment
75
+ # @param context [Protege::Orchestrator::Context] the run context
76
+ # @return [Boolean] true when the grant admits the run
77
+ def grant_admits?(grant, context)
78
+ sender = context.message&.from_address
79
+ sender ? grant.permits?(sender:) : grant.allow_proactive?
80
+ end
81
+
82
+ # The reconciled, deduped tool classes across a set of grants. Each toolkit's +member_tools+ already
83
+ # drops ids with no registered tool, so a stale member can never conjure or break a tool.
84
+ #
85
+ # @param grants [Enumerable<Protege::AgentToolkit>] the grants to draw tools from
86
+ # @return [Array<Class>] the deduped tool classes
87
+ def tool_classes(grants)
88
+ grants.flat_map(&:member_tools).uniq(&:id).map(&:klass)
89
+ end
90
+
91
+ # The agent's toolkit grants with their toolkits eager-loaded — the single source read by resolution.
92
+ #
93
+ # @return [ActiveRecord::Relation<Protege::AgentToolkit>] the attachments
94
+ def attachments
95
+ agent_toolkits.includes(:toolkit)
96
+ end
97
+
98
+ # Give a freshly created agent its baseline tools by attaching the Default Tools system toolkit (with
99
+ # an open gate). A no-op before that toolkit has been synced into existence, so it never blocks
100
+ # creation — the agent simply starts with no tools until a toolkit is attached.
101
+ #
102
+ # @return [void]
103
+ def attach_default_toolkit
104
+ default = Toolkit.default_tools
105
+ agent_toolkits.create!(toolkit: default) if default
106
+ end
107
+ end
108
+ end
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # One runtime allow/deny rule for a single persona — the dashboard-editable layer of the inbound
4
+ # One runtime allow/deny rule for a single agent — the dashboard-editable layer of the inbound
5
5
  # access-control guardrail. Each record carries a +kind+ (+allow+/+deny+) and an address +pattern+
6
- # (an exact address or a +*+ wildcard such as +*@company.co+); together a persona's rules fold into
6
+ # (an exact address or a +*+ wildcard such as +*@company.co+); together an agent's rules fold into
7
7
  # a +Gateway::AccessPolicy+ via {.policy_for}.
8
8
  #
9
9
  # This is the *narrowing* layer: +AccessControl+ intersects it with the committed global policy
10
- # (+Protege.configuration.inbound_access+), so a persona's rules can only further restrict who may
11
- # reach that agent — never widen past the org-wide ceiling. A persona with no rules imposes no
10
+ # (+Protege.configuration.inbound_access+), so an agent's rules can only further restrict who may
11
+ # reach that agent — never widen past the org-wide ceiling. An agent with no rules imposes no
12
12
  # constraint of its own.
13
13
  class AccessRule < ApplicationRecord
14
14
  # ── Associations ────────────────────────────────────────────────────────
15
15
 
16
- belongs_to :persona, class_name: 'Protege::Persona'
16
+ belongs_to :agent, class_name: 'Protege::Agent'
17
17
 
18
18
  # ── Attributes ──────────────────────────────────────────────────────────
19
19
 
@@ -28,17 +28,17 @@ module Protege
28
28
  # ── Class methods ───────────────────────────────────────────────────────
29
29
 
30
30
  class << self
31
- # Fold one persona's rules into a single +Gateway::AccessPolicy+.
31
+ # Fold one agent's rules into a single +Gateway::AccessPolicy+.
32
32
  #
33
- # Groups the persona's +allow+/+deny+ patterns into the policy's lists; the policy then derives
34
- # its own default (an allow-list implies default-deny). A persona with no rules yields a bare
33
+ # Groups the agent's +allow+/+deny+ patterns into the policy's lists; the policy then derives
34
+ # its own default (an allow-list implies default-deny). An agent with no rules yields a bare
35
35
  # +Gateway::AccessPolicy+, which permits everyone and so drops out of the +AccessControl+
36
36
  # intersection.
37
37
  #
38
- # @param persona [Protege::Persona] the persona whose rules to fold
39
- # @return [Protege::Gateway::AccessPolicy] the persona's runtime access policy
40
- def policy_for(persona)
41
- grouped = where(persona:).group_by(&:kind)
38
+ # @param agent [Protege::Agent] the agent whose rules to fold
39
+ # @return [Protege::Gateway::AccessPolicy] the agent's runtime access policy
40
+ def policy_for(agent)
41
+ grouped = where(agent:).group_by(&:kind)
42
42
 
43
43
  Gateway.build_access_policy(
44
44
  allow: Array(grouped['allow']).map(&:pattern),
@@ -1,54 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # Active Record base class for every Protege persona — the agent identity behind an email address.
4
+ # Active Record base class for every Protege agent — the agent identity behind an email address.
5
5
  #
6
- # Personas are single-table-inheritance (STI) records: each concrete agent is a subclass defined
7
- # in the host application and persisted in +protege_personas+, discriminated by the +type+ column.
8
- # A persona owns the conversations addressed to it (+EmailThread+, +Message+) and declares the
6
+ # Agents are single-table-inheritance (STI) records: each concrete agent is a subclass defined
7
+ # in the host application and persisted in +protege_agents+, discriminated by the +type+ column.
8
+ # An agent owns the conversations addressed to it (+EmailThread+, +Message+) and declares the
9
9
  # resolver chain that the Orchestrator runs to build the inference context for each turn.
10
10
  #
11
11
  # The +email_address+ is operator-chosen and must line up with the host's MX records. Inbound
12
- # routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the persona
12
+ # routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the agent
13
13
  # whose canonical address is +alice@example.com+ (see {.lookup}).
14
14
  #
15
- # @example Define a persona in the host application
16
- # class Agent < Protege::Persona
17
- # self.display_name = 'AI Agent'
15
+ # @example Define an agent in the host application
16
+ # class ExecutiveAgent < Protege::Agent
17
+ # self.display_name = 'Executive Agent'
18
18
  #
19
19
  # resolvers do |chain|
20
20
  # chain.use Protege::ThreadHistoryResolver
21
21
  # end
22
22
  # end
23
- class Persona < ApplicationRecord
24
- self.table_name = 'protege_personas'
23
+ class Agent < ApplicationRecord
24
+ self.table_name = 'protege_agents'
25
25
 
26
- # Keep the Personas sidebar live (append/replace/remove).
27
- include BroadcastablePersona
26
+ # Keep the Agents sidebar live (append/replace/remove).
27
+ include BroadcastableAgent
28
28
 
29
- # Narrow the tool catalogue per persona: the code-declared +tools+ grant minus the operator's
30
- # +disabled_tool_ids+ override (see +ToolScoped+).
31
- include ToolScoped
29
+ # The agent's tools, sourced entirely from the toolkits attached to it and gated per attachment —
30
+ # there is no code-declared grant (see +Toolable+, +Toolkit+, +AgentToolkit+).
31
+ include Toolable
32
32
 
33
33
  # ── Associations ──────────────────────────────────────────────────────────
34
34
 
35
35
  with_options dependent: :destroy do
36
- # The persona's runtime inbound access rules — the dashboard-editable, narrowing layer of the
37
- # access-control guardrail (see +AccessRule+ and +AccessControl+). Destroyed with the persona.
36
+ # The agent's runtime inbound access rules — the dashboard-editable, narrowing layer of the
37
+ # access-control guardrail (see +AccessRule+ and +AccessControl+). Destroyed with the agent.
38
38
  has_many :access_rules, class_name: 'Protege::AccessRule'
39
39
 
40
- # Every email the persona has sent or received. Each +Message+ carries a denormalised
41
- # +persona_id+, so this is the scoping seam for per-persona reads such as the +SearchEmailsTool+
42
- # tool. Destroyed with the persona.
40
+ # Every email the agent has sent or received. Each +Message+ carries a denormalised
41
+ # +agent_id+, so this is the scoping seam for per-agent reads such as the +SearchEmailsTool+
42
+ # tool. Destroyed with the agent.
43
43
  has_many :messages, class_name: 'Protege::Message'
44
44
 
45
- # The persona's standing, cron-scheduled duties — the Loop layer (see +Responsibility+). Dashboard
46
- # CRUD is nested under the persona; runs the duty's +responsibility_resolvers+ chain. Destroyed
47
- # with the persona.
45
+ # The agent's standing, cron-scheduled duties — the Loop layer (see +Responsibility+). Dashboard
46
+ # CRUD is nested under the agent; runs the duty's +responsibility_resolvers+ chain. Destroyed
47
+ # with the agent.
48
48
  has_many :responsibilities, class_name: 'Protege::Responsibility'
49
49
 
50
- # Every conversation addressed to the persona. Carries a +persona_id+ FK with no DB-level cascade,
51
- # so it must be destroyed with the persona (its messages cascade in turn) — otherwise a permanent
50
+ # Every conversation addressed to the agent. Carries a +agent_id+ FK with no DB-level cascade,
51
+ # so it must be destroyed with the agent (its messages cascade in turn) — otherwise a permanent
52
52
  # delete would orphan threads / raise a foreign-key error.
53
53
  has_many :email_threads, class_name: 'Protege::EmailThread'
54
54
  end
@@ -69,10 +69,10 @@ module Protege
69
69
 
70
70
  default_scope { order(:name) }
71
71
 
72
- # Only personas currently enabled to receive and answer mail (i.e. not archived).
72
+ # Only agents currently enabled to receive and answer mail (i.e. not archived).
73
73
  scope :active, -> { where(archived_at: nil) }
74
74
 
75
- # Personas that have been archived (soft-removed): kept for history, out of routing.
75
+ # Agents that have been archived (soft-removed): kept for history, out of routing.
76
76
  scope :archived, -> { where.not(archived_at: nil) }
77
77
 
78
78
  # ── Display name ────────────────────────────────────────────────────────
@@ -84,17 +84,17 @@ module Protege
84
84
  # ── Class methods ───────────────────────────────────────────────────────
85
85
 
86
86
  class << self
87
- # Return the human-readable label for this persona subclass.
87
+ # Return the human-readable label for this agent subclass.
88
88
  #
89
89
  # Falls back to the demodulized class name when no explicit label has been set, so an
90
- # unconfigured +Foo::Agent+ subclass reads as +"Agent"+.
90
+ # unconfigured +SupportAgent+ subclass reads as +"SupportAgent"+.
91
91
  #
92
92
  # @return [String, nil] the configured label, or the demodulized class name
93
93
  def display_name
94
94
  _display_name || name&.demodulize
95
95
  end
96
96
 
97
- # Set the human-readable label for this persona subclass.
97
+ # Set the human-readable label for this agent subclass.
98
98
  #
99
99
  # @param val [String] the label to display in the UI
100
100
  # @return [String] the assigned label
@@ -130,7 +130,7 @@ module Protege
130
130
  # Return the resolver chain that assembles context for scheduled responsibility runs — the
131
131
  # proactive path (the Loop layer) — optionally mutating it in a block.
132
132
  #
133
- # Held separately from the reply chain so a persona can present different context when acting on
133
+ # Held separately from the reply chain so an agent can present different context when acting on
134
134
  # its own initiative (e.g. a task prompt, no inbound thread) than when answering mail. Like the
135
135
  # reply chain it is lazily built and owned per subclass, never shared with the parent.
136
136
  #
@@ -153,15 +153,15 @@ module Protege
153
153
  self
154
154
  end
155
155
 
156
- # Find the active persona that owns the given address, honouring subaddressing.
156
+ # Find the active agent that owns the given address, honouring subaddressing.
157
157
  #
158
158
  # Parses the address through +Gateway::Mail::Address+ and matches its +routing_key+ (the
159
159
  # tag-stripped, lowercased +local@domain+) against the canonical +email_address+. So
160
- # +Alice+thread9@Example.com+ resolves to the persona at +alice@example.com+. Returns nil for
160
+ # +Alice+thread9@Example.com+ resolves to the agent at +alice@example.com+. Returns nil for
161
161
  # blank or malformed input.
162
162
  #
163
163
  # @param address [String, nil] the recipient address from an inbound email
164
- # @return [Protege::Persona, nil] the matching active persona, or nil when none routes
164
+ # @return [Protege::Agent, nil] the matching active agent, or nil when none routes
165
165
  def lookup(address)
166
166
  active.find_by(email_address: Gateway.routing_key(address))
167
167
  rescue ArgumentError
@@ -171,52 +171,52 @@ module Protege
171
171
 
172
172
  # ── Instance methods ────────────────────────────────────────────────────
173
173
 
174
- # Return this persona's resolver chains by delegating to the class-level chains — the reply chain
174
+ # Return this agent's resolver chains by delegating to the class-level chains — the reply chain
175
175
  # (+message_resolvers+) and the scheduled-run chain (+responsibility_resolvers+).
176
176
  #
177
- # @return [Orchestrator::ResolverChain] the chain declared on this persona's subclass
177
+ # @return [Orchestrator::ResolverChain] the chain declared on this agent's subclass
178
178
  delegate :message_resolvers, :responsibility_resolvers, to: :class
179
179
 
180
180
  # ── Archive state ─────────────────────────────────────────────────────────
181
181
  # +active+/+archived+ are derived from +archived_at+ (no boolean column): +active?+ is what inbound
182
- # routing (+.active+ scope, {.lookup}) and the status dot read, so archiving a persona removes it from
182
+ # routing (+.active+ scope, {.lookup}) and the status dot read, so archiving an agent removes it from
183
183
  # service while keeping all its history.
184
184
 
185
- # Whether the persona is active (not archived).
185
+ # Whether the agent is active (not archived).
186
186
  #
187
187
  # @return [Boolean]
188
188
  def active?
189
189
  archived_at.nil?
190
190
  end
191
191
 
192
- # Whether the persona has been archived (soft-removed): out of routing + scheduled runs, data kept.
192
+ # Whether the agent has been archived (soft-removed): out of routing + scheduled runs, data kept.
193
193
  #
194
194
  # @return [Boolean]
195
195
  def archived?
196
196
  archived_at.present?
197
197
  end
198
198
 
199
- # Archive the persona — stops inbound routing and scheduled responsibility runs; data is preserved.
199
+ # Archive the agent — stops inbound routing and scheduled responsibility runs; data is preserved.
200
200
  #
201
201
  # @return [void]
202
202
  def archive!
203
203
  update!(archived_at: Time.current)
204
204
  end
205
205
 
206
- # Return an archived persona to active service.
206
+ # Return an archived agent to active service.
207
207
  #
208
208
  # @return [void]
209
209
  def unarchive!
210
210
  update!(archived_at: nil)
211
211
  end
212
212
 
213
- # Find one of this persona's own message attachments by its Active Storage attachment id.
213
+ # Find one of this agent's own message attachments by its Active Storage attachment id.
214
214
  #
215
- # Scopes the lookup to attachments whose record is one of *this* persona's messages. The file tools
216
- # now resolve by *blob* id (see +Protege::StoredFile.find+), so this persona-scoped lookup is
215
+ # Scopes the lookup to attachments whose record is one of *this* agent's messages. The file tools
216
+ # now resolve by *blob* id (see +Protege::StoredFile.find+), so this agent-scoped lookup is
217
217
  # currently unused by them — it is retained to become the scoping filter for the deferred
218
218
  # authorization pass (see the blob-currency spec §9). Returns +nil+ when the id is unknown or
219
- # belongs to a different persona.
219
+ # belongs to a different agent.
220
220
  #
221
221
  # @param id [Integer, String] the +ActiveStorage::Attachment+ id
222
222
  # @return [ActiveStorage::Attachment, nil] the scoped attachment, or nil
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The grant binding one +Toolkit+ to one +Agent+ — and where the toolkit's permission lives. Giving a
5
+ # toolkit to an agent is not just "these tools are available"; it is "these tools are available to the
6
+ # senders I allow here". So the attachment carries the sender gate (its +agent_toolkit_rules+ folded
7
+ # into a +Gateway::AccessPolicy+, mirroring +AccessRule+) and +allow_proactive+ (whether the tools may
8
+ # run on this agent's scheduled, senderless runs). Two agents can attach the same toolkit and gate it
9
+ # differently.
10
+ class AgentToolkit < ApplicationRecord
11
+ # ── Associations ────────────────────────────────────────────────────────
12
+
13
+ belongs_to :agent, class_name: 'Protege::Agent'
14
+ belongs_to :toolkit, class_name: 'Protege::Toolkit'
15
+
16
+ has_many :agent_toolkit_rules, class_name: 'Protege::AgentToolkitRule', dependent: :destroy
17
+
18
+ # ── Validations ─────────────────────────────────────────────────────────
19
+
20
+ # A toolkit is attached to an agent at most once.
21
+ validates :toolkit_id, uniqueness: { scope: :agent_id }
22
+
23
+ # ── Delegation ────────────────────────────────────────────────────────────
24
+
25
+ # The tools this grant exposes come from the toolkit.
26
+ delegate :covers?, :member_tools, :name, to: :toolkit
27
+
28
+ # ── Instance methods ──────────────────────────────────────────────────────
29
+
30
+ # The sender gate for this grant as a policy, folded from its rules through the Gateway factory (never
31
+ # constructed by hand), mirroring +AccessRule.policy_for+. A grant with no rules permits everyone.
32
+ #
33
+ # @return [Protege::Gateway::AccessPolicy] the grant's gate
34
+ def access_policy
35
+ grouped = agent_toolkit_rules.group_by(&:kind)
36
+ Gateway.build_access_policy(
37
+ allow: Array(grouped['allow']).map(&:pattern),
38
+ deny: Array(grouped['deny']).map(&:pattern)
39
+ )
40
+ end
41
+
42
+ # Whether a sender may unlock this grant's tools, per its gate.
43
+ #
44
+ # @param sender [String] the inbound sender's address
45
+ # @return [Boolean] true when the gate permits the sender
46
+ def permits?(sender:)
47
+ access_policy.permits?(address: sender)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One allow/deny address rule for a toolkit grant's sender gate — the dashboard-editable pattern that
5
+ # decides which correspondents can unlock this agent's copy of a toolkit's tools. Belongs to the
6
+ # attachment (+AgentToolkit+), not the toolkit, so each grant is gated independently. Mirrors
7
+ # +AccessRule+: each row carries a +kind+ (+allow+/+deny+) and an address +pattern+ (an exact address or
8
+ # a +*+ wildcard such as +*@company.co+); the grant folds its rules into a +Gateway::AccessPolicy+ via
9
+ # {AgentToolkit#access_policy}.
10
+ class AgentToolkitRule < ApplicationRecord
11
+ # ── Associations ────────────────────────────────────────────────────────
12
+
13
+ belongs_to :agent_toolkit, class_name: 'Protege::AgentToolkit'
14
+
15
+ # ── Attributes ──────────────────────────────────────────────────────────
16
+
17
+ # Whether this rule admits (+allow+) or blocks (+deny+) matching senders.
18
+ enum :kind, { allow: 'allow', deny: 'deny' }
19
+
20
+ # ── Validations ─────────────────────────────────────────────────────────
21
+
22
+ # A rule with no pattern matches nothing and is meaningless; the address pattern is required.
23
+ validates :pattern, presence: true
24
+ end
25
+ end
@@ -3,7 +3,7 @@
3
3
  module Protege
4
4
  # Abstract base class for every Active Record model in the Protege engine.
5
5
  #
6
- # Every persisted model — +Persona+, +Message+, +EmailThread+, +EmailDomain+ — inherits
6
+ # Every persisted model — +Agent+, +Message+, +EmailThread+, +EmailDomain+ — inherits
7
7
  # from this class rather than +ActiveRecord::Base+ directly, mirroring the convention a host
8
8
  # Rails application uses for its own +ApplicationRecord+. Keeping a dedicated base class gives
9
9
  # the engine a single seam for shared concerns (connection handling, common scopes, callbacks)
@@ -5,7 +5,7 @@ module Protege
5
5
  #
6
6
  # A thread is identified by its canonical +thread_id+: the RFC 2822 Message-ID of the conversation
7
7
  # root. For a reply that is the first entry of the References chain (falling back to In-Reply-To);
8
- # for a brand-new conversation it is the message's own Message-ID. Threads belong to one +Persona+
8
+ # for a brand-new conversation it is the message's own Message-ID. Threads belong to one +Agent+
9
9
  # and carry denormalised counters (+message_count+, +last_message_at+) that +Message+ keeps fresh
10
10
  # so the inbox can sort and display without aggregating on every render.
11
11
  class EmailThread < ApplicationRecord
@@ -15,7 +15,7 @@ module Protege
15
15
 
16
16
  # ── Associations ────────────────────────────────────────────────────────
17
17
 
18
- belongs_to :persona, class_name: 'Protege::Persona'
18
+ belongs_to :agent, class_name: 'Protege::Agent'
19
19
 
20
20
  # Destroying a thread removes its messages; inverse lets Message keep counters in sync. Each message
21
21
  # in turn destroys its own recorded tool uses (see +Message+), so a thread's tool history is torn
@@ -24,15 +24,16 @@ module Protege
24
24
 
25
25
  # ── Scopes ──────────────────────────────────────────────────────────────
26
26
 
27
- # Eager-load persona and messages, most-recently-active first — the inbox listing query.
28
- scope :for_inbox, -> { includes(:persona, :messages).order(last_message_at: :desc) }
27
+ # Eager-load agent and messages, most-recently-active first — the inbox listing query.
28
+ scope :for_inbox, -> { includes(:agent, :messages).order(last_message_at: :desc) }
29
29
 
30
30
  # ── Validations ─────────────────────────────────────────────────────────
31
31
 
32
32
  with_options presence: true do
33
- # The canonical Message-ID must be unique so each conversation maps to one thread.
34
- validates :thread_id, uniqueness: true
35
- validates :persona
33
+ # The canonical Message-ID is unique per agent: each agent maps a conversation to its own thread,
34
+ # so one email to several proteges yields one thread each rather than colliding on a shared id.
35
+ validates :thread_id, uniqueness: { scope: :agent_id }
36
+ validates :agent
36
37
  end
37
38
 
38
39
  # ── Instance methods ─────────────────────────────────────────────────────
@@ -61,17 +62,19 @@ module Protege
61
62
  Gateway.canonical_message_id(mail.in_reply_to.presence || mail.message_id)
62
63
  end
63
64
 
64
- # Find or create the thread for the given mail and persona.
65
+ # Find or create the thread for the given mail and agent.
65
66
  #
66
- # On creation, seeds the persona, subject, and last-activity timestamp from the mail.
67
+ # On creation, seeds the agent, subject, and last-activity timestamp from the mail.
68
+ #
69
+ # Scoped to the agent: the same conversation reaching two agents resolves to a separate thread
70
+ # for each, since +thread_id+ is unique only within an agent.
67
71
  #
68
72
  # @param mail [Mail::Message] the email being threaded
69
- # @param persona [Protege::Persona] the persona that owns the conversation
73
+ # @param agent [Protege::Agent] the agent that owns the conversation
70
74
  # @return [Protege::EmailThread] the existing or newly created thread
71
75
  # @raise [ActiveRecord::RecordInvalid] when a new thread fails validation
72
- def find_or_create_for(mail:, persona:)
73
- find_or_create_by!(thread_id: thread_id_for(mail)) do |t|
74
- t.persona = persona
76
+ def find_or_create_for(mail:, agent:)
77
+ find_or_create_by!(thread_id: thread_id_for(mail), agent:) do |t|
75
78
  t.subject = mail.subject.to_s
76
79
  t.last_message_at = Time.current
77
80
  end