protege 0.1.0.alpha.2 → 0.1.0.alpha.8

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 (181) 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/dev_setup.rb +110 -0
  55. data/app/services/protege/message_search.rb +2 -2
  56. data/app/services/protege/system_toolkits.rb +80 -0
  57. data/app/tools/protege/create_file_tool.rb +2 -0
  58. data/app/tools/protege/read_attachment_tool.rb +3 -1
  59. data/app/tools/protege/search_emails_tool.rb +15 -13
  60. data/app/tools/protege/send_email_tool.rb +60 -18
  61. data/app/tools/protege/web_fetch_tool.rb +2 -0
  62. data/app/tools/protege/web_search_tool.rb +3 -1
  63. data/app/views/layouts/mailer.text.erb +1 -0
  64. data/app/views/protege/access_rules/create.turbo_stream.slim +3 -3
  65. data/app/views/protege/agent_toolkit_rules/create.turbo_stream.slim +6 -0
  66. data/app/views/protege/agent_toolkits/_gate_rule.html.slim +7 -0
  67. data/app/views/protege/agent_toolkits/_gate_rule_form.html.slim +8 -0
  68. data/app/views/protege/agent_toolkits/_gate_rules.html.slim +19 -0
  69. data/app/views/protege/agent_toolkits/show.html.slim +17 -0
  70. data/app/views/protege/{personas → agents}/_access_rule.html.slim +1 -1
  71. data/app/views/protege/{personas → agents}/_access_rule_form.html.slim +1 -1
  72. data/app/views/protege/{personas → agents}/_access_rules.html.slim +5 -5
  73. data/app/views/protege/agents/_actions.html.slim +15 -0
  74. data/app/views/protege/agents/_agent_sidebar_item.html.slim +5 -0
  75. data/app/views/protege/agents/_agent_toolkit.html.slim +7 -0
  76. data/app/views/protege/agents/_agent_toolkit_form.html.slim +7 -0
  77. data/app/views/protege/agents/_form.html.slim +25 -0
  78. data/app/views/protege/agents/_sidebar.html.slim +11 -0
  79. data/app/views/protege/agents/_toolkits.html.slim +19 -0
  80. data/app/views/protege/agents/agent_toolkits/create.turbo_stream.slim +6 -0
  81. data/app/views/protege/agents/edit.html.slim +7 -0
  82. data/app/views/protege/agents/index.html.slim +6 -0
  83. data/app/views/protege/agents/new.html.slim +7 -0
  84. data/app/views/protege/agents/show.html.slim +29 -0
  85. data/app/views/protege/alert_mailer/inference_failed.text.erb +4 -0
  86. data/app/views/protege/email_domains/show.html.slim +1 -1
  87. data/app/views/protege/home/show.html.slim +13 -13
  88. data/app/views/protege/responsibilities/_form.html.slim +2 -2
  89. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +1 -1
  90. data/app/views/protege/responsibilities/new.html.slim +1 -1
  91. data/app/views/protege/responsibilities/show.html.slim +2 -2
  92. data/app/views/protege/responsibility_runs/show.html.slim +1 -1
  93. data/app/views/protege/shared/_header.html.slim +2 -1
  94. data/app/views/protege/shared/_hotkeys.html.slim +15 -11
  95. data/app/views/protege/threads/new.html.slim +2 -2
  96. data/app/views/protege/toolkits/_form.html.slim +13 -0
  97. data/app/views/protege/toolkits/_sidebar.html.slim +11 -0
  98. data/app/views/protege/toolkits/_toolkit_sidebar_item.html.slim +5 -0
  99. data/app/views/protege/toolkits/edit.html.slim +7 -0
  100. data/app/views/protege/toolkits/index.html.slim +6 -0
  101. data/app/views/protege/toolkits/new.html.slim +7 -0
  102. data/app/views/protege/toolkits/show.html.slim +13 -0
  103. data/config/routes.rb +12 -1
  104. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +1 -1
  105. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +1 -1
  106. data/db/migrate/20260728140000_scope_message_and_thread_uniqueness_per_persona.rb +16 -0
  107. data/db/migrate/20260812000001_create_protege_toolkits.rb +37 -0
  108. data/db/migrate/20260815000001_remove_disabled_tool_ids_from_personas.rb +21 -0
  109. data/db/migrate/20260815000002_add_key_to_protege_toolkits.rb +22 -0
  110. data/db/migrate/20260815100000_rename_personas_to_agents.rb +24 -0
  111. data/lib/generators/protege/agent/agent_generator.rb +35 -0
  112. data/lib/generators/protege/{persona/templates/persona.rb.tt → agent/templates/agent.rb.tt} +9 -9
  113. data/lib/generators/protege/extension_naming.rb +1 -1
  114. data/lib/generators/protege/hook/templates/hook.rb.tt +2 -2
  115. data/lib/generators/protege/install/install_generator.rb +19 -11
  116. data/lib/generators/protege/install/templates/initializer.rb.tt +17 -6
  117. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +2 -2
  118. data/lib/generators/protege/resolver/resolver_generator.rb +2 -2
  119. data/lib/generators/protege/resolver/templates/resolver.rb.tt +4 -4
  120. data/lib/generators/protege/tool/templates/tool.rb.tt +7 -5
  121. data/lib/protege/configuration.rb +43 -8
  122. data/lib/protege/engine.rb +3 -1
  123. data/lib/protege/errors/tool_not_available_error.rb +7 -6
  124. data/lib/protege/events/event.rb +2 -2
  125. data/lib/protege/events/inference_chunk_event.rb +1 -1
  126. data/lib/protege/events/inference_completed_event.rb +1 -1
  127. data/lib/protege/events/inference_failed_event.rb +1 -1
  128. data/lib/protege/events/inference_generated_event.rb +1 -1
  129. data/lib/protege/events/inference_max_turns_reached_event.rb +1 -1
  130. data/lib/protege/events/inference_started_event.rb +1 -1
  131. data/lib/protege/events/loop_run_completed_event.rb +1 -1
  132. data/lib/protege/events/loop_run_enqueued_event.rb +1 -1
  133. data/lib/protege/events/loop_run_failed_event.rb +1 -1
  134. data/lib/protege/events/loop_run_started_event.rb +1 -1
  135. data/lib/protege/events/tool_call_completed_event.rb +1 -1
  136. data/lib/protege/events/tool_call_failed_event.rb +1 -1
  137. data/lib/protege/events/tool_call_started_event.rb +1 -1
  138. data/lib/protege/events/tool_calls_received_event.rb +1 -1
  139. data/lib/protege/extensions/hook.rb +1 -0
  140. data/lib/protege/extensions/hook_mixin.rb +1 -1
  141. data/lib/protege/extensions/manifest.rb +67 -0
  142. data/lib/protege/extensions/manifested.rb +81 -0
  143. data/lib/protege/extensions/provider.rb +1 -0
  144. data/lib/protege/extensions/provider_mixin.rb +9 -17
  145. data/lib/protege/extensions/resolver.rb +2 -1
  146. data/lib/protege/extensions/resolver_mixin.rb +2 -2
  147. data/lib/protege/extensions/tool.rb +1 -0
  148. data/lib/protege/extensions/tool_mixin.rb +16 -40
  149. data/lib/protege/gateway/access_control.rb +19 -19
  150. data/lib/protege/gateway/access_policy.rb +2 -2
  151. data/lib/protege/gateway/mail/outbound.rb +22 -5
  152. data/lib/protege/gateway.rb +48 -37
  153. data/lib/protege/loop/scheduler.rb +1 -1
  154. data/lib/protege/orchestrator/context.rb +8 -8
  155. data/lib/protege/orchestrator/harness.rb +26 -24
  156. data/lib/protege/orchestrator/reply_context.rb +3 -3
  157. data/lib/protege/orchestrator/reply_harness.rb +8 -8
  158. data/lib/protege/orchestrator/resolver_chain.rb +3 -3
  159. data/lib/protege/orchestrator/responsibility_context.rb +4 -4
  160. data/lib/protege/orchestrator/responsibility_harness.rb +8 -8
  161. data/lib/protege/orchestrator.rb +7 -7
  162. data/lib/protege/subscribers/alerter.rb +117 -0
  163. data/lib/protege/subscribers/tracing.rb +1 -1
  164. data/lib/protege/version.rb +1 -1
  165. data/lib/protege.rb +2 -2
  166. data/lib/tasks/protege_tasks.rake +40 -0
  167. metadata +67 -23
  168. data/app/controllers/concerns/protege/persona_scoped.rb +0 -22
  169. data/app/controllers/protege/personas_controller.rb +0 -118
  170. data/app/helpers/protege/personas_helper.rb +0 -90
  171. data/app/models/concerns/protege/broadcastable_persona.rb +0 -50
  172. data/app/models/concerns/protege/tool_scoped.rb +0 -88
  173. data/app/views/protege/personas/_actions.html.slim +0 -15
  174. data/app/views/protege/personas/_form.html.slim +0 -29
  175. data/app/views/protege/personas/_persona_sidebar_item.html.slim +0 -5
  176. data/app/views/protege/personas/_sidebar.html.slim +0 -11
  177. data/app/views/protege/personas/edit.html.slim +0 -7
  178. data/app/views/protege/personas/index.html.slim +0 -6
  179. data/app/views/protege/personas/new.html.slim +0 -7
  180. data/app/views/protege/personas/show.html.slim +0 -37
  181. data/lib/generators/protege/persona/persona_generator.rb +0 -35
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Maintains the engine's *system* toolkits — the two toolkit records the framework guarantees exist,
5
+ # identified by a stable +key+ (see +Toolkit+):
6
+ #
7
+ # * **All Tools** (+all_tools+) — always holds every registered tool. Its membership is engine-owned:
8
+ # each sync rewrites it to the live tool manifest, so a tool added in code shows up automatically and
9
+ # an operator's edits don't stick.
10
+ # * **Default Tools** (+default_tools+) — auto-attached to every new agent (see +Toolable+). The engine
11
+ # only *seeds* it on first creation — with every engine built-in tool, so a fresh agent starts with
12
+ # the full generic baseline (reply, search, web, files) — and thereafter its membership is the
13
+ # operator's to curate, so later syncs leave it alone.
14
+ #
15
+ # Sync is idempotent and safe to run repeatedly. It runs like any other seed data — not at boot (writing
16
+ # to the DB from an initializer is an anti-pattern): the +protege:toolkits:sync+ rake task on deploy and
17
+ # the host's +db/seeds.rb+ in development. Both eager-load the app first so the tool manifest is complete
18
+ # (rake tasks don't eager-load by default), which is what makes All Tools capture every registered tool.
19
+ class SystemToolkits
20
+ class << self
21
+ # Ensure both system toolkits exist and refresh All Tools' membership.
22
+ #
23
+ # @return [void]
24
+ def sync!
25
+ new.sync!
26
+ end
27
+ end
28
+
29
+ # Ensure both system toolkits exist and refresh All Tools' membership.
30
+ #
31
+ # @return [void]
32
+ def sync!
33
+ sync_all_tools
34
+ ensure_default_tools
35
+ end
36
+
37
+ private
38
+
39
+ # Create or update the All Tools toolkit so it holds every registered tool.
40
+ #
41
+ # @return [void]
42
+ def sync_all_tools
43
+ toolkit = Toolkit.find_or_initialize_by(key: Toolkit::ALL_TOOLS_KEY)
44
+
45
+ toolkit.name = 'All Tools'
46
+ toolkit.description = 'Automatically managed — always contains every registered tool, refreshed ' \
47
+ 'on each deploy. Its members are not editable. Attach it to an agent to ' \
48
+ 'grant the full tool catalogue.'
49
+ toolkit.member_tool_ids = Manifest.registered(:tool).map { |entry| entry.id.to_s }
50
+ toolkit.save!
51
+ end
52
+
53
+ # Ensure the Default Tools toolkit exists. On first creation it is seeded with every engine built-in
54
+ # tool — the generic baseline (reply, search, web, files) that makes a new agent immediately useful,
55
+ # deliberately overlapping All Tools — while host-defined tools stay out of the seed (granting your
56
+ # own tools is an explicit act). On later syncs its membership is left untouched (operator-owned).
57
+ #
58
+ # @return [void]
59
+ def ensure_default_tools
60
+ toolkit = Toolkit.find_or_initialize_by(key: Toolkit::DEFAULT_TOOLS_KEY)
61
+ return unless toolkit.new_record?
62
+
63
+ toolkit.name = 'Default Tools'
64
+ toolkit.description = 'Attached to every new agent automatically. Curate its members to set the ' \
65
+ 'baseline tools an agent starts with.'
66
+ toolkit.member_tool_ids = engine_tool_ids
67
+ toolkit.save!
68
+ end
69
+
70
+ # The ids of the tools the engine itself ships — manifest entries whose class lives under the
71
+ # +Protege::+ namespace, distinguishing built-ins from host-defined tools.
72
+ #
73
+ # @return [Array<String>] the engine built-in tool ids
74
+ def engine_tool_ids
75
+ Manifest.registered(:tool)
76
+ .select { |entry| entry.klass.module_parent == Protege }
77
+ .map { |entry| entry.id.to_s }
78
+ end
79
+ end
80
+ end
@@ -40,6 +40,8 @@ module Protege
40
40
  rows, for html/svg write the markup, for json write the JSON.
41
41
  DESC
42
42
 
43
+ summary 'Create a file (CSV, HTML, Markdown, JSON…) to attach to an email.'
44
+
43
45
  input_schema(
44
46
  type: 'object',
45
47
  additionalProperties: false,
@@ -5,7 +5,7 @@ module Protege
5
5
  # publishes it in the LLM's tool catalog (id +:read_attachment+).
6
6
  #
7
7
  # The agent learns a file's blob id from the +ThreadHistoryResolver+/inbound note ("…, id: 123") or
8
- # from +create_file+. Resolution is by blob id (see +Protege::StoredFile.find+); persona scoping is
8
+ # from +create_file+. Resolution is by blob id (see +Protege::StoredFile.find+); agent scoping is
9
9
  # deferred to a later pass (see the blob-currency spec §9), so any known blob id currently resolves.
10
10
  # Behaviour by type:
11
11
  #
@@ -23,6 +23,8 @@ module Protege
23
23
  shown to you directly to view. Other types (such as video or archives) can't be read.
24
24
  DESC
25
25
 
26
+ summary 'Read a file from the conversation by its id.'
27
+
26
28
  input_schema(
27
29
  type: 'object',
28
30
  additionalProperties: false,
@@ -6,8 +6,8 @@ module Protege
6
6
  # catalog (id +:search_emails+); when the model emits a +search_emails+ call, the harness routes the
7
7
  # arguments to +#use+.
8
8
  #
9
- # The search is always scoped to the active persona's own messages (via +Persona#messages+) — an
10
- # agent can never read another persona's mail. A single free-text query is matched, case-insensitively,
9
+ # The search is always scoped to the active agent's own messages (via +Agent#messages+) — an
10
+ # agent can never read another agent's mail. A single free-text query is matched, case-insensitively,
11
11
  # against both the participants (from/to/cc) and the content (subject/body); results come back newest
12
12
  # first. Each result carries the message's *full* body — a truncated body could cut the very text that
13
13
  # matched, so it is returned whole. The caller may cap the result count with +limit+ (default
@@ -41,6 +41,8 @@ module Protege
41
41
  rather than raising the limit when you get too many hits.
42
42
  DESC
43
43
 
44
+ summary 'Search the agent\'s own email archive for past messages.'
45
+
44
46
  input_schema(
45
47
  type: 'object',
46
48
  additionalProperties: false,
@@ -58,9 +60,9 @@ module Protege
58
60
  }
59
61
  )
60
62
 
61
- # Search the active persona's archive and return the matching messages as concise summaries.
63
+ # Search the active agent's archive and return the matching messages as concise summaries.
62
64
  #
63
- # @param context [Protege::Orchestrator::Context] exposes the active +persona+
65
+ # @param context [Protege::Orchestrator::Context] exposes the active +agent+
64
66
  # @param query [String] the free-text query supplied by the model
65
67
  # @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
66
68
  # @return [Protege::Result] success carrying +count+ and the shaped +results+, or a failure when
@@ -68,7 +70,7 @@ module Protege
68
70
  def use(context:, query:, limit: nil)
69
71
  return failure(reason: 'query cannot be empty') if query.to_s.strip.empty?
70
72
 
71
- hits = matches(persona: context.persona, query:, limit:)
73
+ hits = matches(agent: context.agent, query:, limit:)
72
74
 
73
75
  success(count: hits.size, results: hits.map { result_hash(_1) })
74
76
  end
@@ -77,21 +79,21 @@ module Protege
77
79
 
78
80
  # Build and run the scoped, wildcard-escaped LIKE query, newest first.
79
81
  #
80
- # @param persona [Protege::Persona] the persona whose messages are searched
82
+ # @param agent [Protege::Agent] the agent whose messages are searched
81
83
  # @param query [String] the raw query supplied by the model
82
84
  # @param limit [Integer, nil] the requested cap, clamped into 1..{MAX_LIMIT}
83
85
  # @return [Array<Protege::Message>] the matching messages, newest first
84
- def matches(persona:, query:, limit:)
86
+ def matches(agent:, query:, limit:)
85
87
  count = (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
86
88
  escaped = ActiveRecord::Base.sanitize_sql_like(query.strip.downcase)
87
89
  clause = SEARCHABLE_COLUMNS.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
88
90
 
89
- persona.messages
90
- .with_attached_attachments
91
- .where(clause, term: "%#{escaped}%")
92
- .order(sent_at: :desc)
93
- .limit(count)
94
- .to_a
91
+ agent.messages
92
+ .with_attached_attachments
93
+ .where(clause, term: "%#{escaped}%")
94
+ .order(sent_at: :desc)
95
+ .limit(count)
96
+ .to_a
95
97
  end
96
98
 
97
99
  # Shape a single message into a result summary. The body is returned in full (never truncated) so
@@ -5,7 +5,7 @@ module Protege
5
5
  # +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:send_email+);
6
6
  # when the model emits a +send_email+ call, the harness routes the arguments to +#use+.
7
7
  #
8
- # +From+ is always enforced to the persona's +email_address+ — the agent cannot impersonate another
8
+ # +From+ is always enforced to the agent's +email_address+ — the agent cannot impersonate another
9
9
  # sender. The +mode+ argument selects behavior:
10
10
  #
11
11
  # * +"reply"+ — continue the current conversation. +Subject+ and threading (+In-Reply-To+ +
@@ -19,7 +19,7 @@ module Protege
19
19
  # Assembly is delegated to +Protege::Gateway.build_outbound+. The agent may attach files by
20
20
  # referencing blob ids — the ids shown in the conversation history, or returned by +create_file+ —
21
21
  # resolved through +Protege::StoredFile.find+ and checked against the configured
22
- # +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; persona scoping is a
22
+ # +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; agent scoping is a
23
23
  # deferred pass — see the blob-currency spec §9.)
24
24
  class SendEmailTool < Protege::Tool
25
25
  description <<~DESC.strip
@@ -38,6 +38,8 @@ module Protege
38
38
  create_file).
39
39
  DESC
40
40
 
41
+ summary 'Send an email — the agent\'s only way to reply to or reach someone.'
42
+
41
43
  input_schema(
42
44
  type: 'object',
43
45
  additionalProperties: false,
@@ -78,7 +80,7 @@ module Protege
78
80
 
79
81
  # Build the outbound mail for the requested mode and hand it to the gateway for delivery.
80
82
  #
81
- # @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +persona+, and +deliver+
83
+ # @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +agent+, and +deliver+
82
84
  # @param mode [String] +"reply"+ or +"new"+
83
85
  # @param body [String] plain-text body supplied by the model
84
86
  # @param to [Array<String>] recipients (required for +"new"+; additions for +"reply"+)
@@ -109,7 +111,7 @@ module Protege
109
111
 
110
112
  # Build the outbound mail for the requested mode.
111
113
  #
112
- # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
114
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
113
115
  # @param mode [String] +"reply"+ or +"new"+
114
116
  # @param to [Array<String>] recipients
115
117
  # @param cc [Array<String>] carbon-copy recipients
@@ -121,17 +123,20 @@ module Protege
121
123
  def build_mail(context:, mode:, to:, cc:, bcc:, subject:, body:, attachments:)
122
124
  case mode
123
125
  when 'reply' then reply_mail(context, to:, cc:, bcc:, body:, attachments:)
124
- when 'new' then new_thread_mail(context.persona, to:, cc:, bcc:, subject:, body:, attachments:)
126
+ when 'new' then new_thread_mail(context.agent, to:, cc:, bcc:, subject:, body:, attachments:)
125
127
  else failure(reason: %(unknown mode #{mode.inspect}; use "reply" or "new"))
126
128
  end
127
129
  end
128
130
 
129
- # Construct a threaded reply: subject and threading from the inbound message, the original sender
130
- # plus any added recipients.
131
+ # Construct a threaded reply: subject and threading from the inbound message, and a reply-all
132
+ # recipient set that keeps the whole conversation intact. The original sender and other To recipients
133
+ # go in To; the original Cc recipients stay in Cc; the model may add more via to/cc/bcc. The agent's
134
+ # own address is dropped (it is the sender, not a recipient), and every list is de-duplicated by
135
+ # routing key so no one is Cc'd who is already in To.
131
136
  #
132
- # @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
133
- # @param to [Array<String>] recipients to add beyond the original sender
134
- # @param cc [Array<String>] carbon-copy recipients
137
+ # @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
138
+ # @param to [Array<String>] recipients to add beyond the carried-forward participants
139
+ # @param cc [Array<String>] carbon-copy recipients to add
135
140
  # @param bcc [Array<String>] blind-carbon-copy recipients
136
141
  # @param body [String] the plain-text body
137
142
  # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
@@ -140,22 +145,27 @@ module Protege
140
145
  inbound = context.message
141
146
  return failure(reason: 'there is no message to reply to; send a "new" email instead') unless inbound
142
147
 
148
+ own = context.agent.email_address
149
+ reply_to = merge_recipients([inbound.from_address, *inbound.to_addresses, *addresses(to)], excluding: [own])
150
+ reply_cc = merge_recipients([*inbound.cc_addresses, *addresses(cc)], excluding: [own, *reply_to])
151
+
143
152
  Protege::Gateway.build_outbound(
144
- from: context.persona.email_address,
145
- to: ([inbound.from_address] + addresses(to)).uniq,
146
- cc: addresses(cc),
153
+ from: own,
154
+ to: reply_to,
155
+ cc: reply_cc,
147
156
  bcc: addresses(bcc),
148
157
  subject: ensure_re_prefix(inbound.subject),
149
158
  body:,
150
159
  in_reply_to: inbound.message_id,
151
160
  references: build_references(inbound),
152
- attachments:
161
+ attachments:,
162
+ hops: inbound.recursion_hops + 1
153
163
  )
154
164
  end
155
165
 
156
166
  # Construct a new-thread email: the model supplies the recipients and subject; no threading headers.
157
167
  #
158
- # @param persona [Protege::Persona] the sending persona (enforced From)
168
+ # @param agent [Protege::Agent] the sending agent (enforced From)
159
169
  # @param to [Array<String>] recipients (at least one required)
160
170
  # @param cc [Array<String>] carbon-copy recipients
161
171
  # @param bcc [Array<String>] blind-carbon-copy recipients
@@ -163,19 +173,20 @@ module Protege
163
173
  # @param body [String] the plain-text body
164
174
  # @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
165
175
  # @return [Mail::Message, Protege::Result] the assembled mail, or a failure when +to+/+subject+ is missing
166
- def new_thread_mail(persona, to:, cc:, bcc:, subject:, body:, attachments:)
176
+ def new_thread_mail(agent, to:, cc:, bcc:, subject:, body:, attachments:)
167
177
  recipients = addresses(to)
168
178
  return failure(reason: 'a new email needs at least one "to" recipient') if recipients.empty?
169
179
  return failure(reason: 'a new email needs a subject') if subject.to_s.strip.empty?
170
180
 
171
181
  Protege::Gateway.build_outbound(
172
- from: persona.email_address,
182
+ from: agent.email_address,
173
183
  to: recipients,
174
184
  cc: addresses(cc),
175
185
  bcc: addresses(bcc),
176
186
  subject:,
177
187
  body:,
178
- attachments:
188
+ attachments:,
189
+ hops: 1
179
190
  )
180
191
  end
181
192
 
@@ -187,6 +198,37 @@ module Protege
187
198
  Array(value).map { |address| address.to_s.strip }.reject(&:empty?)
188
199
  end
189
200
 
201
+ # Build a reply recipient list from several sources, preserving each address's original form but
202
+ # collapsing duplicates and dropping any excluded address — compared by routing key (tag-stripped,
203
+ # lowercased) so casing and plus-tags don't defeat the de-dupe. This is what carries the original
204
+ # thread's participants onto a reply without doubling anyone up. Unparseable entries are dropped.
205
+ #
206
+ # @param candidates [Array<String>] the addresses to include, in priority order
207
+ # @param excluding [Array<String>] addresses to omit (e.g. the agent itself, or the To list)
208
+ # @return [Array<String>] the de-duplicated recipients
209
+ def merge_recipients(candidates, excluding: [])
210
+ seen = excluding.filter_map { |address| routing_key(address) }
211
+
212
+ candidates.each_with_object([]) do |address, kept|
213
+ key = routing_key(address)
214
+ next if key.nil? || seen.include?(key)
215
+
216
+ seen << key
217
+ kept << address
218
+ end
219
+ end
220
+
221
+ # The routing key (tag-stripped, lowercased +local@domain+) for an address, or +nil+ when it can't be
222
+ # parsed — so de-duping treats an unparseable address as droppable rather than raising.
223
+ #
224
+ # @param address [String] the raw address
225
+ # @return [String, nil] the routing key, or nil
226
+ def routing_key(address)
227
+ Protege::Gateway.routing_key(address)
228
+ rescue ArgumentError
229
+ nil
230
+ end
231
+
190
232
  # Resolve blob ids to the stored files they name, limit-checked.
191
233
  #
192
234
  # @param ids [Array<Integer>] the requested blob ids
@@ -43,6 +43,8 @@ module Protege
43
43
  internal/private network addresses are refused.
44
44
  DESC
45
45
 
46
+ summary 'Fetch a web page or text file by URL and return its readable contents.'
47
+
46
48
  input_schema(
47
49
  type: 'object',
48
50
  additionalProperties: false,
@@ -10,7 +10,7 @@ module Protege
10
10
  # It is a keyless, best-effort search: it scrapes DuckDuckGo's HTML endpoint and parses the results
11
11
  # page into a list of +{title, url, snippet}+ the agent can reason over and then +web_fetch+. Because
12
12
  # it scrapes an unofficial endpoint, it can change or be rate-limited — that trade-off buys a search
13
- # that needs no API key and is safe to expose to every persona. Richer or keyed search (Tavily,
13
+ # that needs no API key and is safe to expose to every agent. Richer or keyed search (Tavily,
14
14
  # Perplexity, Brave, …) belongs in a host-app tool, keyed from the host's own config.
15
15
  class WebSearchTool < Protege::Tool
16
16
  # Result count when the caller does not specify a +limit+.
@@ -35,6 +35,8 @@ module Protege
35
35
  limit when results are off.
36
36
  DESC
37
37
 
38
+ summary 'Search the web and get back a list of matching pages.'
39
+
38
40
  input_schema(
39
41
  type: 'object',
40
42
  additionalProperties: false,
@@ -0,0 +1 @@
1
+ <%= yield %>
@@ -1,6 +1,6 @@
1
1
  - if @access_rule.persisted?
2
- = turbo_stream.append "access_rules", partial: "protege/personas/access_rule", locals: { access_rule: @access_rule }
2
+ = turbo_stream.append "access_rules", partial: "protege/agents/access_rule", locals: { access_rule: @access_rule }
3
3
  = turbo_stream.remove "access_rules_empty"
4
- = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @persona.access_rules.build }
4
+ = turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @agent.access_rules.build }
5
5
  - else
6
- = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @access_rule }
6
+ = turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @access_rule }
@@ -0,0 +1,6 @@
1
+ - if @agent_toolkit_rule.persisted?
2
+ = turbo_stream.append "agent_toolkit_rules", partial: "protege/agent_toolkits/gate_rule", locals: { rule: @agent_toolkit_rule }
3
+ = turbo_stream.remove "agent_toolkit_rules_empty"
4
+ = turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit.agent_toolkit_rules.build }
5
+ - else
6
+ = turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit_rule }
@@ -0,0 +1,7 @@
1
+ tr id=dom_id(rule) style="border-top: 1px solid var(--border)"
2
+ td class="py-3 pr-4 align-middle"
3
+ = badge(rule.kind, gate_rule_kind_color(rule))
4
+ td class="py-3 pr-4 align-middle"
5
+ code class="font-mono break-all" = rule.pattern
6
+ td class="py-3 align-middle"
7
+ = destructive_link("Remove", agent_toolkit_agent_toolkit_rule_path(rule.agent_toolkit, rule), confirm: "Remove this rule?")
@@ -0,0 +1,8 @@
1
+ #gate_rule_form
2
+ = form_with model: rule, url: agent_toolkit_agent_toolkit_rules_path(agent_toolkit), scope: :agent_toolkit_rule, class: "space-y-3" do |f|
3
+ = form_errors(rule)
4
+ .flex.items-end.gap-3
5
+ = form_select(f, :kind, Protege::AgentToolkitRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
6
+ .flex-1
7
+ = form_field(f, :pattern, label: "Pattern", placeholder: "*@company.co")
8
+ = button("Add rule", type: :submit, class: "h-9")
@@ -0,0 +1,19 @@
1
+ / The grant's allow/deny sender gate — which correspondents may unlock this agent's copy of the
2
+ / toolkit's tools. Lists existing rules with a remove control and an add form; create/destroy update the
3
+ / table in place via Turbo Streams, falling back to a redirect for non-Turbo.
4
+
5
+ = card do
6
+ .space-y-4
7
+ = heading_2("Sender gate")
8
+ = hint("Allow or deny senders from unlocking these tools. With no rules, every sender is permitted.")
9
+
10
+ = render "gate_rule_form", agent_toolkit: agent_toolkit, rule: agent_toolkit.agent_toolkit_rules.build
11
+
12
+ table class="w-full text-sm"
13
+ tbody id="agent_toolkit_rules"
14
+ - if agent_toolkit.agent_toolkit_rules.any?
15
+ = render partial: "gate_rule", collection: agent_toolkit.agent_toolkit_rules.order(:kind, :pattern), as: :rule
16
+ - else
17
+ tr id="agent_toolkit_rules_empty"
18
+ td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
19
+ | No rules — every sender can unlock these tools.
@@ -0,0 +1,17 @@
1
+ = page_container do
2
+ = page_header(@agent_toolkit.name, "How #{@agent_toolkit.agent.name} may use this toolkit") do
3
+ .flex.items-center.gap-2
4
+ = ui_link("Edit toolkit", edit_toolkit_path(@agent_toolkit.toolkit), class: "px-3")
5
+ = ui_link("Back to agent", agent_path(@agent_toolkit.agent), class: "px-3")
6
+
7
+ .space-y-4
8
+ = info_card("Tools", toolkit_overview(@agent_toolkit.toolkit))
9
+
10
+ = card do
11
+ = heading_2("Scheduled runs")
12
+ = hint("Whether these tools may run on this agent's scheduled duties, where there is no sender to check.")
13
+ = form_with model: @agent_toolkit, url: agent_toolkit_path(@agent_toolkit), method: :patch, scope: :agent_toolkit, class: "mt-3 space-y-3" do |f|
14
+ = form_checkbox(f, :allow_proactive, label: "Allow on scheduled runs")
15
+ = button("Save", type: :submit)
16
+
17
+ = render("gate_rules", agent_toolkit: @agent_toolkit)
@@ -4,4 +4,4 @@ tr id=dom_id(access_rule) style="border-top: 1px solid var(--border)"
4
4
  td class="py-3 pr-4 align-middle"
5
5
  code class="font-mono break-all" = access_rule.pattern
6
6
  td class="py-3 align-middle"
7
- = destructive_link("Remove", persona_access_rule_path(access_rule.persona, access_rule), confirm: "Remove this rule?")
7
+ = destructive_link("Remove", agent_access_rule_path(access_rule.agent, access_rule), confirm: "Remove this rule?")
@@ -1,5 +1,5 @@
1
1
  #access_rule_form
2
- = form_with model: access_rule, url: persona_access_rules_path(persona), scope: :access_rule, class: "space-y-3" do |f|
2
+ = form_with model: access_rule, url: agent_access_rules_path(agent), scope: :access_rule, class: "space-y-3" do |f|
3
3
  = form_errors(access_rule)
4
4
  .flex.items-end.gap-3
5
5
  = form_select(f, :kind, Protege::AccessRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
@@ -1,18 +1,18 @@
1
- / Inbound access rules for a persona — the dashboard-editable, narrowing layer of the access
1
+ / Inbound access rules for an agent — the dashboard-editable, narrowing layer of the access
2
2
  / guardrail. Lists existing rules with a remove control and an add form. Create/destroy update the
3
- / table in place via Turbo Streams (the persona page survives), falling back to a redirect for non-Turbo.
3
+ / table in place via Turbo Streams (the agent page survives), falling back to a redirect for non-Turbo.
4
4
 
5
5
  = card do
6
6
  .space-y-4
7
7
  = heading_2("Inbound access rules")
8
8
  = hint("Allow or deny senders for this agent. Rules only narrow the global policy — they can never widen it.")
9
9
 
10
- = render "access_rule_form", persona: persona, access_rule: persona.access_rules.build
10
+ = render "access_rule_form", agent: agent, access_rule: agent.access_rules.build
11
11
 
12
12
  table class="w-full text-sm"
13
13
  tbody id="access_rules"
14
- - if persona.access_rules.any?
15
- = render partial: "access_rule", collection: persona.access_rules.order(:kind, :pattern), as: :access_rule
14
+ - if agent.access_rules.any?
15
+ = render partial: "access_rule", collection: agent.access_rules.order(:kind, :pattern), as: :access_rule
16
16
  - else
17
17
  tr id="access_rules_empty"
18
18
  td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
@@ -0,0 +1,15 @@
1
+ / The agent show page's action bar — Edit, Archive/Unarchive, and permanent Delete — plus an "Archived"
2
+ / badge when soft-removed. Wrapped in a dom_id so Archive/Unarchive can replace it in place over Turbo
3
+ / (see ArchivesController). The Delete button opens the confirm dialog rendered by the show template.
4
+ div id=dom_id(agent, :actions) class="flex items-center gap-3"
5
+ - if agent.archived?
6
+ = badge("Archived")
7
+
8
+ = ui_link("Edit", edit_agent_path(agent))
9
+
10
+ - if agent.archived?
11
+ = action_button("Unarchive", agent_archive_path(agent), method: :delete)
12
+ - else
13
+ = action_button("Archive", agent_archive_path(agent), method: :post, variant: :secondary)
14
+
15
+ = button("Delete", type: "button", variant: :danger, onclick: show_dialog("delete-agent"))
@@ -0,0 +1,5 @@
1
+ = sidebar_item(agent_label(agent), agent_path(agent),
2
+ detail: agent_status(agent),
3
+ subtitle: agent.email_address,
4
+ active: local_assigns[:active],
5
+ id: dom_id(agent))
@@ -0,0 +1,7 @@
1
+ tr id=dom_id(agent_toolkit) style="border-top: 1px solid var(--border)"
2
+ td class="w-full py-3 pr-6 align-middle font-medium"
3
+ = agent_toolkit.name
4
+ td class="py-3 align-middle"
5
+ .flex.items-center.justify-end.gap-4
6
+ = ui_link("Configure Access Settings", agent_toolkit_path(agent_toolkit), class: "whitespace-nowrap")
7
+ = destructive_link("Detach", agent_agent_toolkit_path(agent_toolkit.agent, agent_toolkit), confirm: "Detach this toolkit?", class: "whitespace-nowrap")
@@ -0,0 +1,7 @@
1
+ #agent_toolkit_form
2
+ = form_with model: agent_toolkit, url: agent_agent_toolkits_path(agent), scope: :agent_toolkit, class: "space-y-3" do |f|
3
+ = form_errors(agent_toolkit)
4
+ .flex.items-end.gap-3
5
+ .flex-1
6
+ = form_select(f, :toolkit_id, attachable_toolkits(agent).map { |t| [t.name, t.id] }, label: "Toolkit", prompt: "Choose a toolkit")
7
+ = button("Attach", type: :submit, class: "h-9")
@@ -0,0 +1,25 @@
1
+ - url = agent.new_record? ? agents_path : agent_path(agent)
2
+ - method = agent.new_record? ? :post : :patch
3
+
4
+ = form_with url:, method:, scope: :agent, class: "space-y-5" do |f|
5
+ = form_errors(agent)
6
+
7
+ = form_field(f, :name, placeholder: "e.g. alice")
8
+
9
+ = form_email_address(agent, available_email_domains)
10
+
11
+ - unless agent.new_record?
12
+ = hint("Changing the address only affects new mail — messages to the old address will no longer reach this agent. Existing threads and history are unaffected.")
13
+
14
+ - if agent.new_record?
15
+ = form_select(f, :type, available_agent_types.map { |k| [k.display_name, k.name] }, label: "Type")
16
+
17
+ = form_text_area(f, :instructions, \
18
+ label: "Instructions", \
19
+ rows: 10, \
20
+ hint: "The agent's system prompt — who it is and how it behaves. Only sent to the model when a resolver (such as LoadText) includes it in the agent's context.")
21
+
22
+ .flex.items-center.gap-3.pt-2
23
+ = button(agent.new_record? ? "Create agent" : "Save changes", type: :submit)
24
+
25
+ = ui_link("Cancel", agent.new_record? ? agents_path : agent_path(agent))
@@ -0,0 +1,11 @@
1
+ - pagy, agents = paginate(all_agents)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Agents (#{pagy.count})", new_path: new_agent_path)
5
+
6
+ = sidebar_stream("protege:agents", pagy: pagy)
7
+
8
+ = sidebar_body(agents, empty: "No agents yet", id: "agents_list") do |agent|
9
+ = render "agent_sidebar_item", agent: agent, active: local_assigns[:current] == agent
10
+
11
+ = sidebar_pagination(pagy)
@@ -0,0 +1,19 @@
1
+ / Toolkits attached to an agent — the sender-gated tool bundles it exposes. Lists attached toolkits with
2
+ / a detach control and an attach form. Attach/detach update the table in place via Turbo Streams (the
3
+ / agent page survives), falling back to a redirect for non-Turbo.
4
+
5
+ = card do
6
+ .space-y-4
7
+ = heading_2("Agent Toolkits")
8
+ = hint("Attach a toolkit to give this agent its tools, then use Configure Access Settings to set who may use them. Toolkits themselves are managed under Toolkits.")
9
+
10
+ = render "agent_toolkit_form", agent: agent, agent_toolkit: agent.agent_toolkits.build
11
+
12
+ table class="w-full text-sm"
13
+ tbody id="agent_toolkits"
14
+ - if agent.agent_toolkits.any?
15
+ = render partial: "agent_toolkit", collection: agent.agent_toolkits.includes(:toolkit), as: :agent_toolkit
16
+ - else
17
+ tr id="agent_toolkits_empty"
18
+ td colspan="2" class="py-6 text-center text-sm" style="color: var(--text-faint)"
19
+ | No toolkits attached — this agent has no tools yet.
@@ -0,0 +1,6 @@
1
+ - if @agent_toolkit.persisted?
2
+ = turbo_stream.append "agent_toolkits", partial: "protege/agents/agent_toolkit", locals: { agent_toolkit: @agent_toolkit }
3
+ = turbo_stream.remove "agent_toolkits_empty"
4
+ = turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent.agent_toolkits.build }
5
+ - else
6
+ = turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent_toolkit }
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @agent
3
+
4
+ = page_container do
5
+ = page_header("Edit #{@agent.name}")
6
+
7
+ = card { render "form", agent: @agent }
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select an agent from the sidebar, or &nbsp;
6
+ = ui_link("create a new one.", new_agent_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("New agent", "Specify the email address this agent receives mail at. It must match your MX records.")
6
+
7
+ = card { render "form", agent: @agent }