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
@@ -1,5 +1,5 @@
1
1
  = sidebar_item(responsibility.name, responsibility_path(responsibility),
2
2
  detail: responsibility_status(responsibility),
3
- subtitle: "#{responsibility.persona.name} · #{responsibility.schedule}",
3
+ subtitle: "#{responsibility.agent.name} · #{responsibility.schedule}",
4
4
  active: local_assigns[:active],
5
5
  id: dom_id(responsibility))
@@ -2,6 +2,6 @@
2
2
  = render "sidebar"
3
3
 
4
4
  = page_container do
5
- = page_header("New responsibility", "A scheduled duty a persona carries out on a cron schedule.")
5
+ = page_header("New responsibility", "A scheduled duty an agent carries out on a cron schedule.")
6
6
 
7
7
  = card { render "form", responsibility: @responsibility }
@@ -2,13 +2,13 @@
2
2
  = render "sidebar", current: @responsibility
3
3
 
4
4
  = page_container do
5
- = page_header(@responsibility.name, "#{@responsibility.persona.name} · #{@responsibility.schedule}") do
5
+ = page_header(@responsibility.name, "#{@responsibility.agent.name} · #{@responsibility.schedule}") do
6
6
  div
7
7
  = ui_link("Edit", edit_responsibility_path(@responsibility), class: 'px-3')
8
8
  = inline_button("Run now", responsibility_runs_path, params: { responsibility_run: { responsibility_id: @responsibility.id } })
9
9
 
10
10
  .space-y-4
11
- = info_card("Schedule", { "Cron" => @responsibility.schedule, "Persona" => @responsibility.persona.name, "Active" => (@responsibility.active? ? "Yes" : "No") })
11
+ = info_card("Schedule", { "Cron" => @responsibility.schedule, "Agent" => @responsibility.agent.name, "Active" => (@responsibility.active? ? "Yes" : "No") })
12
12
 
13
13
  = card do
14
14
  = heading_2("Instructions")
@@ -1,5 +1,5 @@
1
1
  = page_container do
2
- = page_header("Run · #{@responsibility.name}", @responsibility.persona.name) do
2
+ = page_header("Run · #{@responsibility.name}", @responsibility.agent.name) do
3
3
  = ui_link("Back", responsibility_path(@responsibility))
4
4
 
5
5
  .space-y-4
@@ -4,8 +4,9 @@ header class="flex h-14 flex-shrink-0 items-center justify-between px-5" style="
4
4
 
5
5
  nav class="flex items-center gap-1"
6
6
  = nav_item("Inbox", protege.threads_path, request.path)
7
+ = nav_item("Agents", protege.agents_path, request.path)
8
+ = nav_item("Toolkits", protege.toolkits_path, request.path)
7
9
  = nav_item("Responsibilities", protege.responsibilities_path, request.path)
8
- = nav_item("Personas", protege.personas_path, request.path)
9
10
  = nav_item("Domains", protege.email_domains_path, request.path)
10
11
  = nav_item("Traces", protege.traces_path, request.path)
11
12
 
@@ -3,19 +3,21 @@
3
3
  /
4
4
  / Registry — the single source of truth for app-wide combos. Each is a hidden link keys.js activates on
5
5
  / its combo; it navigates via the same route helper the visible UI uses, so a global command works from
6
- / anywhere, independent of what's on screen. Nav combos are numbered by position (1..5, left to right)
6
+ / anywhere, independent of what's on screen. Nav combos are numbered by position (1..6, left to right)
7
7
  / rather than by initial — the "go to the Nth section" convention (⌘1..9 in browsers/editors). Rendered
8
8
  / after the page body, so a page-local hotkey reusing a combo is matched first and shadows the global one.
9
9
  div hidden=true aria-hidden="true"
10
10
  = link_to "Documentation", 'https://docs.protege.bot', data: { hotkey: "g 0" }, target: '_blank'
11
11
  = link_to "Inbox", protege.threads_path, data: { hotkey: "g 1" }
12
- = link_to "Responsibilities", protege.responsibilities_path, data: { hotkey: "g 2" }
13
- = link_to "Personas", protege.personas_path, data: { hotkey: "g 3" }
14
- = link_to "Domains", protege.email_domains_path, data: { hotkey: "g 4" }
15
- = link_to "Traces", protege.traces_path, data: { hotkey: "g 5" }
12
+ = link_to "Agents", protege.agents_path, data: { hotkey: "g 2" }
13
+ = link_to "Toolkits", protege.toolkits_path, data: { hotkey: "g 3" }
14
+ = link_to "Responsibilities", protege.responsibilities_path, data: { hotkey: "g 4" }
15
+ = link_to "Domains", protege.email_domains_path, data: { hotkey: "g 5" }
16
+ = link_to "Traces", protege.traces_path, data: { hotkey: "g 6" }
16
17
  = link_to "New Thread", protege.new_thread_path, data: { hotkey: "g c" }
18
+ = link_to "New Agent", protege.new_agent_path, data: { hotkey: "g a" }
19
+ = link_to "New Toolkit", protege.new_toolkit_path, data: { hotkey: "g k" }
17
20
  = link_to "New Responsibility", protege.new_responsibility_path, data: { hotkey: "g r" }
18
- = link_to "New Persona", protege.new_persona_path, data: { hotkey: "g p" }
19
21
  = link_to "New Email Domain", protege.new_email_domain_path, data: { hotkey: "g d" }
20
22
 
21
23
  / Help overlay, opened with "?" or the header's "?" button (keys.js just toggles it open/closed). This
@@ -26,16 +28,18 @@ div hidden=true aria-hidden="true"
26
28
  .mt-3.space-y-1
27
29
  = hotkey_section("Go to")
28
30
  = hotkey_row("Inbox", "g", "1")
29
- = hotkey_row("Responsibilities", "g", "2")
30
- = hotkey_row("Personas", "g", "3")
31
- = hotkey_row("Domains", "g", "4")
32
- = hotkey_row("Traces", "g", "5")
31
+ = hotkey_row("Agents", "g", "2")
32
+ = hotkey_row("Toolkits", "g", "3")
33
+ = hotkey_row("Responsibilities", "g", "4")
34
+ = hotkey_row("Domains", "g", "5")
35
+ = hotkey_row("Traces", "g", "6")
33
36
  = hotkey_row("Documentation", "g", "0")
34
37
 
35
38
  = hotkey_section("Create")
36
39
  = hotkey_row("New thread", "g", "c")
40
+ = hotkey_row("New agent", "g", "a")
41
+ = hotkey_row("New toolkit", "g", "k")
37
42
  = hotkey_row("New responsibility", "g", "r")
38
- = hotkey_row("New persona", "g", "p")
39
43
  = hotkey_row("New email domain", "g", "d")
40
44
 
41
45
  = hotkey_section("General")
@@ -2,11 +2,11 @@
2
2
  = render "protege/threads/sidebar", threads: inbox_threads
3
3
 
4
4
  = page_container do
5
- = page_header("New message", "Send a message to a persona to start a thread.")
5
+ = page_header("New message", "Send a message to an agent to start a thread.")
6
6
 
7
7
  = card do
8
8
  = form_with url: threads_path, scope: :thread, class: "space-y-5", multipart: true do |f|
9
- = form_select(f, :persona_id, available_persona_options, label: "To", prompt: "Select persona...")
9
+ = form_select(f, :agent_id, available_agent_options, label: "To", prompt: "Select agent...")
10
10
 
11
11
  = form_field(f, :subject, label: "Subject", placeholder: "Subject")
12
12
 
@@ -0,0 +1,13 @@
1
+ = form_with model: toolkit, class: "space-y-5" do |f|
2
+ = form_errors(toolkit)
3
+
4
+ = form_field(f, :name, placeholder: "e.g. Finance")
5
+
6
+ = form_text_area(f, :description, label: "Description", rows: 3, hint: "What this toolkit is for — shown only in the dashboard.")
7
+
8
+ - tool_id = ->(entry) { entry.id.to_s }
9
+ = form_checklist(f, :member_tool_ids, toolkit_tool_options, label: "Tools", value: tool_id, text: :display_name, description: :summary, hint: "The tools this toolkit bundles. Who may use them is set per agent when you attach it.")
10
+
11
+ .flex.items-center.gap-3.pt-2
12
+ = button("Save toolkit", type: :submit)
13
+ = ui_link("Cancel", toolkits_path)
@@ -0,0 +1,11 @@
1
+ - pagy, toolkits = paginate(all_toolkits)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Toolkits (#{pagy.count})", new_path: new_toolkit_path)
5
+
6
+ = sidebar_stream("protege:toolkits", pagy: pagy)
7
+
8
+ = sidebar_body(toolkits, empty: "No toolkits yet", id: "toolkits_list") do |toolkit|
9
+ = render "toolkit_sidebar_item", toolkit: toolkit, active: local_assigns[:current] == toolkit
10
+
11
+ = sidebar_pagination(pagy)
@@ -0,0 +1,5 @@
1
+ = sidebar_item(toolkit.name, toolkit_path(toolkit),
2
+ subtitle: toolkit_tool_count_label(toolkit),
3
+ detail: (badge("System", :yellow, size: :sm) if toolkit.system?),
4
+ active: local_assigns[:active],
5
+ id: dom_id(toolkit))
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @toolkit
3
+
4
+ = page_container do
5
+ = page_header("Edit #{@toolkit.name}")
6
+
7
+ = card { render "form", toolkit: @toolkit }
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select a toolkit from the sidebar, or  
6
+ = ui_link("create a new one.", new_toolkit_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("New toolkit", "A bundle of tools exposed to an agent only for senders that pass its gate.")
6
+
7
+ = card { render "form", toolkit: @toolkit }
@@ -0,0 +1,13 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @toolkit
3
+
4
+ = page_container do
5
+ = page_header(@toolkit.name, @toolkit.description) do
6
+ div
7
+ = ui_link("Edit", edit_toolkit_path(@toolkit), class: "px-3")
8
+ - unless @toolkit.system?
9
+ = destructive_link("Delete", toolkit_path(@toolkit), confirm: "Delete this toolkit?", method: :delete)
10
+
11
+ .space-y-4
12
+ = info_card("Toolkit", toolkit_overview(@toolkit))
13
+ = hint("This toolkit is a reusable set of tools. Who may use them is decided per agent — attach it on an agent's page to set its sender gate.")
data/config/routes.rb CHANGED
@@ -18,13 +18,24 @@ Protege::Engine.routes.draw do
18
18
  resources :responsibilities
19
19
  resources :responsibility_runs
20
20
 
21
+ resources :toolkits
22
+
23
+ # A grant (agent↔toolkit) is split across two controllers so nesting never goes two deep. The
24
+ # *nested* Agents::AgentToolkitsController lists an agent's grants and attaches/detaches them (in
25
+ # the agent's scope); the *top-level* AgentToolkitsController manages a created grant on its own
26
+ # page, where its sender-gate rules nest one level deep (like access_rules under an agent).
27
+ resources :agent_toolkits do
28
+ resources :agent_toolkit_rules
29
+ end
30
+
21
31
  resources :threads do
22
32
  resource :reply
23
33
  end
24
34
 
25
- resources :personas do
35
+ resources :agents do
26
36
  resource :archive
27
37
  resources :access_rules
38
+ resources :agent_toolkits, module: :agents
28
39
  end
29
40
 
30
41
  resources :email_domains do
@@ -3,7 +3,7 @@
3
3
  # Replace the persona +active+ boolean with an +archived_at+ timestamp — the idiomatic soft-delete shape,
4
4
  # which also records *when* a persona was archived. The model keeps +active?+/+scope :active+ as derived
5
5
  # readers over +archived_at+, so routing/scheduler/status code is unchanged.
6
- class ReplacePersonaActiveWithArchivedAt < ActiveRecord::Migration[8.1]
6
+ class ReplacePersonaActiveWithArchivedAt < ActiveRecord::Migration[7.2]
7
7
  # Add archived_at, mark already-deactivated personas as archived-now, then drop active.
8
8
  #
9
9
  # @return [void]
@@ -5,7 +5,7 @@
5
5
  # has switched *off* within that grant, so scoping narrows and never widens. Stored as a JSON array in a
6
6
  # text column (SQLite has no native JSON type), matching +Message+'s recipient-list convention, and
7
7
  # defaults to an empty array so an unconfigured persona keeps its full grant.
8
- class AddDisabledToolIdsToPersonas < ActiveRecord::Migration[8.1]
8
+ class AddDisabledToolIdsToPersonas < ActiveRecord::Migration[7.2]
9
9
  # Add the JSON-array text column, non-null with an empty-array default.
10
10
  #
11
11
  # @return [void]
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scopes inbound-mail uniqueness per persona, so one email addressed to several proteges can be handled
4
+ # by each of them independently. Previously +thread_id+ and +message_id+ were globally unique, which
5
+ # assumed exactly one persona per inbound message: the second persona's thread/message insert collided on
6
+ # the global index. Making both unique per +persona_id+ lets every addressed persona own its own thread
7
+ # and message for the same RFC Message-ID, while still keeping a single persona's retries idempotent.
8
+ class ScopeMessageAndThreadUniquenessPerPersona < ActiveRecord::Migration[7.2]
9
+ def change
10
+ remove_index :protege_email_threads, column: :thread_id, unique: true
11
+ add_index :protege_email_threads, %i[persona_id thread_id], unique: true
12
+
13
+ remove_index :protege_messages, column: :message_id, unique: true
14
+ add_index :protege_messages, %i[persona_id message_id], unique: true
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Creates the toolkit binding tables — the first "Binding" of the extension framework. A toolkit is a
4
+ # global, dashboard-managed, reusable bundle of tools (referenced by their manifest ids, stored as a JSON
5
+ # id list like +disabled_tool_ids+) — nothing more. Permission lives on the *attachment*: a
6
+ # +protege_persona_toolkits+ row gives a toolkit to one persona and carries that grant's sender gate
7
+ # (child +persona_toolkit_rules+ rows, kind + pattern, mirroring +AccessRule+) plus +allow_proactive+ (may
8
+ # these tools run on that persona's scheduled, senderless runs?). So the same toolkit attached to two
9
+ # personas can be gated differently, with no tool-list duplication.
10
+ class CreateProtegeToolkits < ActiveRecord::Migration[7.2]
11
+ def change
12
+ create_table :protege_toolkits do |t|
13
+ t.string :name, null: false, index: { unique: true }
14
+ t.text :description
15
+ t.text :member_tool_ids, null: false, default: '[]'
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ create_table :protege_persona_toolkits do |t|
21
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
22
+ t.references :toolkit, null: false, foreign_key: { to_table: :protege_toolkits }
23
+ t.boolean :allow_proactive, null: false, default: false
24
+
25
+ t.timestamps
26
+ end
27
+ add_index :protege_persona_toolkits, %i[persona_id toolkit_id], unique: true
28
+
29
+ create_table :protege_persona_toolkit_rules do |t|
30
+ t.references :persona_toolkit, null: false, foreign_key: { to_table: :protege_persona_toolkits }
31
+ t.string :kind, null: false
32
+ t.string :pattern, null: false
33
+
34
+ t.timestamps
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Remove +disabled_tool_ids+ from personas. Tool access no longer flows through a per-persona ceiling (a
4
+ # code-declared +tools+ grant minus this disable list); a persona's tools now come entirely from the
5
+ # toolkits attached to it, gated per attachment (see +Protege::Toolable+ / +PersonaToolkit+). The
6
+ # subtractive override this column backed no longer exists, so the column is dropped.
7
+ class RemoveDisabledToolIdsFromPersonas < ActiveRecord::Migration[7.2]
8
+ # Drop the column.
9
+ #
10
+ # @return [void]
11
+ def up
12
+ remove_column :protege_personas, :disabled_tool_ids
13
+ end
14
+
15
+ # Restore the JSON-array text column, non-null with an empty-array default.
16
+ #
17
+ # @return [void]
18
+ def down
19
+ add_column :protege_personas, :disabled_tool_ids, :text, null: false, default: '[]'
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add a stable +key+ to toolkits so the engine can identify its *system* toolkits — the auto-maintained
4
+ # "All Tools" (+all_tools+) and the auto-attached "Default Tools" (+default_tools+) — independently of
5
+ # their display name (which operators may rename). User-created toolkits leave +key+ null; a unique index
6
+ # (SQLite allows multiple NULLs) keeps at most one toolkit per system key.
7
+ #
8
+ # Installs seeded before system toolkits existed already have an "All Tools" toolkit (created by the old
9
+ # host seed). Adopt it as the keyed system toolkit here, so the sync updates that record in place rather
10
+ # than colliding on its unique name.
11
+ class AddKeyToProtegeToolkits < ActiveRecord::Migration[7.2]
12
+ def up
13
+ add_column :protege_toolkits, :key, :string
14
+ add_index :protege_toolkits, :key, unique: true
15
+
16
+ execute("UPDATE protege_toolkits SET key = 'all_tools' WHERE name = 'All Tools' AND key IS NULL")
17
+ end
18
+
19
+ def down
20
+ remove_column :protege_toolkits, :key
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rename the Persona concept to Agent across the schema — the industry has settled on "agent" for the
4
+ # addressable actor, and the code now follows suit (+Protege::Agent+, +AgentToolkit+, …). Pure renames,
5
+ # so existing installs transition in place with their data intact: the three persona tables become agent
6
+ # tables, and every +persona_id+ foreign key becomes +agent_id+. Rails renames the conventionally-named
7
+ # indexes along with each table/column, and SQLite keeps foreign-key references pointed at the renamed
8
+ # tables. Earlier migrations keep their historical persona names — on a fresh install they run first and
9
+ # this migration renames their output.
10
+ class RenamePersonasToAgents < ActiveRecord::Migration[7.2]
11
+ def change
12
+ rename_table :protege_personas, :protege_agents
13
+ rename_table :protege_persona_toolkits, :protege_agent_toolkits
14
+ rename_table :protege_persona_toolkit_rules, :protege_agent_toolkit_rules
15
+
16
+ rename_column :protege_email_threads, :persona_id, :agent_id
17
+ rename_column :protege_messages, :persona_id, :agent_id
18
+ rename_column :protege_access_rules, :persona_id, :agent_id
19
+ rename_column :protege_responsibilities, :persona_id, :agent_id
20
+ rename_column :protege_tool_uses, :persona_id, :agent_id
21
+ rename_column :protege_agent_toolkits, :persona_id, :agent_id
22
+ rename_column :protege_agent_toolkit_rules, :persona_toolkit_id, :agent_toolkit_id
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/named_base'
4
+ require_relative '../extension_naming'
5
+
6
+ module Protege
7
+ module Generators
8
+ # Scaffolds an agent — the identity Protege binds an inbox to — into the host app's agents
9
+ # directory (+config.agents_path+, default +app/agents+). An agent is an STI subclass of
10
+ # +Protege::Agent+ (an ActiveRecord model); subclassing registers the subtype, and each agent
11
+ # declares its display name and the resolver chains that assemble context for its reply and
12
+ # scheduled runs.
13
+ #
14
+ # The +Agent+ suffix is applied for you, so all of +support+, +Support+, and +SupportAgent+
15
+ # produce class +SupportAgent+ in +support_agent.rb+.
16
+ #
17
+ # @example
18
+ # bin/rails g protege:agent support
19
+ class AgentGenerator < Rails::Generators::NamedBase
20
+ include ExtensionNaming
21
+
22
+ source_root File.expand_path('templates', __dir__)
23
+
24
+ desc 'Scaffold a Protege::Agent subclass in the configured agents path (default app/agents).'
25
+
26
+ # Render the agent scaffold into +config.agents_path+.
27
+ #
28
+ # @return [void]
29
+ def create_agent
30
+ template 'agent.rb.tt',
31
+ File.join(Protege.configuration.agents_path, "#{extension_file_name('Agent')}.rb")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,31 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # <%= extension_class_name('Persona') %> — a persona: the identity Protege binds an inbox to.
3
+ # <%= extension_class_name('Agent') %> — an agent: the identity Protege binds an inbox to.
4
4
  #
5
- # Subclassing +Protege::Persona+ (an STI ActiveRecord model) registers this subtype. Create the row in
5
+ # Subclassing +Protege::Agent+ (an STI ActiveRecord model) registers this subtype. Create the row in
6
6
  # the dashboard (or a seed) to give it a +name+, +email_address+, and +instructions+ (its editable
7
7
  # system prompt); inbound mail to that address routes here, and outbound replies are sent as it.
8
8
  #
9
9
  # The two chains below assemble the context sent to the model. Engine built-in resolvers are named by
10
10
  # their flat +Protege::+ constant; host-app resolvers (see <%= Protege.configuration.resolvers_path %>/) by their own top-level constant.
11
- class <%= extension_class_name('Persona') %> < Protege::Persona
12
- # Human-friendly label shown for this persona in the dashboard, logs, and tooling.
13
- self.display_name = '<%= extension_class_name('Persona').titleize %>'
11
+ class <%= extension_class_name('Agent') %> < Protege::Agent
12
+ # Human-friendly label shown for this agent in the dashboard, logs, and tooling.
13
+ self.display_name = '<%= extension_class_name('Agent').titleize %>'
14
14
 
15
15
  # Context assembled before every reply to inbound mail. +Protege::LoadTextResolver+ supplies this
16
- # persona's dashboard-editable +instructions+ as the system prompt; +Protege::ThreadHistoryResolver+
16
+ # agent's dashboard-editable +instructions+ as the system prompt; +Protege::ThreadHistoryResolver+
17
17
  # replays the conversation so far. Insert your own resolvers between them (e.g. to inject records,
18
18
  # summaries, or retrieved facts).
19
19
  message_resolvers do |chain|
20
- chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
20
+ chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.agent.instructions }
21
21
  chain.use Protege::ThreadHistoryResolver
22
22
  end
23
23
 
24
24
  # Context assembled for a scheduled run (the Loop layer). There is no inbound thread, so the chain is
25
- # just identity: the persona's +instructions+ as the system prompt and the responsibility's own
25
+ # just identity: the agent's +instructions+ as the system prompt and the responsibility's own
26
26
  # prompt as the opening user message. Add task-specific resolvers here if a responsibility needs more.
27
27
  responsibility_resolvers do |chain|
28
- chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
28
+ chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.agent.instructions }
29
29
  chain.use(Protege::LoadTextResolver, role: :user) { |ctx| ctx.responsibility.instructions }
30
30
  end
31
31
  end
@@ -9,7 +9,7 @@ module Protege
9
9
  #
10
10
  # The suffix is applied whether or not it was supplied, so all three of +send_email+, +SendEmail+,
11
11
  # and +SendEmailTool+ resolve to class +SendEmailTool+ / file +send_email_tool.rb+. Pass an empty
12
- # suffix (as the persona generator does) to use the name verbatim.
12
+ # suffix (as the agent generator does) to use the name verbatim.
13
13
  module ExtensionNaming
14
14
  private
15
15
 
@@ -5,7 +5,7 @@
5
5
  # Subclassing +Protege::Hook+ wires these handlers onto the event bus at boot (auto-discovered, no
6
6
  # registration). Each +on+ block receives a typed, frozen +Protege::Event+ and runs isolated — a raised
7
7
  # error is swallowed and logged, never breaking the run. Read event fields with nil-safe named readers
8
- # (+event.persona+, +event.tool_call+, +event.result+, +event.error+, +event.correlation_id+).
8
+ # (+event.agent+, +event.tool_call+, +event.result+, +event.error+, +event.correlation_id+).
9
9
  #
10
10
  # Declare a handler with +on(EventClass) { |event| ... }+ (pass several classes to share one block).
11
11
  # Available events (all +Protege::+ constants), in fire order:
@@ -21,7 +21,7 @@ class <%= extension_class_name('Hook') %> < Protege::Hook
21
21
 
22
22
  # TODO: replace with the events you care about.
23
23
  on InferenceStartedEvent do |event|
24
- log "inference started for persona=#{event.persona&.name} (#{event.correlation_id})"
24
+ log "inference started for agent=#{event.agent&.name} (#{event.correlation_id})"
25
25
  end
26
26
 
27
27
  private
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails/generators/base'
4
- require_relative '../persona/persona_generator'
4
+ require_relative '../agent/agent_generator'
5
5
 
6
6
  module Protege
7
7
  module Generators
8
8
  # Bootstraps Protege into a host Rails app — the one command to go from +gem 'protege'+ to a
9
9
  # runnable dashboard. It installs the Rails frameworks Protege depends on (Active Storage for
10
10
  # attachments, Action Mailbox for inbound mail), writes a fully-documented initializer, mounts the
11
- # engine, wires the Loop's recurring tick, and scaffolds a starter persona. The steps it cannot do
11
+ # engine, wires the Loop's recurring tick, and scaffolds a starter agent. The steps it cannot do
12
12
  # safely for you — wrapping the mount in your own authentication, running migrations, setting
13
13
  # credentials — are printed as next steps.
14
14
  #
@@ -17,7 +17,7 @@ module Protege
17
17
  class InstallGenerator < Rails::Generators::Base
18
18
  source_root File.expand_path('templates', __dir__)
19
19
 
20
- desc 'Install Protege into the host app: initializer, engine mount, recurring tick, and a starter persona.'
20
+ desc 'Install Protege into the host app: initializer, engine mount, recurring tick, and a starter agent.'
21
21
 
22
22
  # The recurring job entry that drives the Loop scheduler — one tick a minute fires due
23
23
  # responsibilities. Written at column zero; {#indented_tick} nests it under an environment key.
@@ -76,12 +76,12 @@ module Protege
76
76
  end
77
77
  end
78
78
 
79
- # Scaffold a starter +Agent+ persona by delegating to the persona generator (single source of
80
- # truth for the persona template).
79
+ # Scaffold a starter +ExecutiveAgent+ by delegating to the agent generator (single source of
80
+ # truth for the agent template).
81
81
  #
82
82
  # @return [void]
83
- def create_starter_persona
84
- invoke Protege::Generators::PersonaGenerator, ['Agent']
83
+ def create_starter_agent
84
+ invoke Protege::Generators::AgentGenerator, ['Executive']
85
85
  end
86
86
 
87
87
  # Print the wiring the generator can't safely automate.
@@ -96,18 +96,23 @@ module Protege
96
96
  1. Run the migrations (Active Storage, Action Mailbox, and Protege's own):
97
97
  bin/rails db:migrate
98
98
 
99
- 2. WRAP THE ENGINE MOUNT in your authentication in config/routes.rb — the engine ships no
99
+ 2. Sync the system toolkits (creates "All Tools" and "Default Tools"; agents you create
100
+ afterwards auto-attach Default Tools, so they can reply out of the box). Run it on
101
+ every deploy, after db:migrate:
102
+ bin/rails protege:toolkits:sync
103
+
104
+ 3. WRAP THE ENGINE MOUNT in your authentication in config/routes.rb — the engine ships no
100
105
  auth of its own. An unwrapped mount exposes the dashboard to anyone.
101
106
 
102
- 3. Set your provider credentials (the defaults read OpenRouter from ENV):
107
+ 4. Set your provider credentials (the defaults read OpenRouter from ENV):
103
108
  OPENROUTER_API_KEY=sk-... (and OPENROUTER_MODEL to pick a model)
104
109
 
105
- 4. Let extensions register in development by loading them eagerly — add to
110
+ 5. Let extensions register in development by loading them eagerly — add to
106
111
  config/environments/development.rb:
107
112
  config.eager_load = true
108
113
  (tools/providers/hooks are discovered as loaded subclasses).
109
114
 
110
- 5. Create your first persona + email domain (dashboard, or a db/seeds.rb bootstrap) so
115
+ 6. Create your first agent + email domain (dashboard, or a db/seeds.rb bootstrap) so
111
116
  inbound mail has somewhere to route. Then boot and open /protege.
112
117
  STEPS
113
118
  end
@@ -16,8 +16,13 @@ Protege.configure do |config|
16
16
 
17
17
  # ── Inference ─────────────────────────────────────────────────────────────────
18
18
 
19
- # Maximum tool-calling rounds before the harness returns the last response. (default: 8)
20
- # config.max_tool_turns = 8
19
+ # Maximum tool-calling rounds before the harness returns the last response. (default: 100)
20
+ # config.max_tool_turns = 100
21
+
22
+ # X-Protege-Recursion hop count at which inbound mail is silently dropped — bounds agent-to-agent
23
+ # reply loops. A human replying anywhere resets the chain (mail clients don't echo the header).
24
+ # (default: 50)
25
+ # config.recursion_limit = 50
21
26
 
22
27
  # Symbolic id of the provider extension to run inference through; must match a registered provider's
23
28
  # +protege_id+ (here the built-in OpenRouter's :openrouter). (default: nil)
@@ -47,8 +52,8 @@ Protege.configure do |config|
47
52
 
48
53
  # ── Inbound access control ──────────────────────────────────────────────────
49
54
 
50
- # Global ceiling on which senders may reach ANY persona — the committed layer of the access guardrail.
51
- # Per-persona rules (managed in the dashboard) only narrow this further, never widen it. Patterns are
55
+ # Global ceiling on which senders may reach ANY agent — the committed layer of the access guardrail.
56
+ # Per-agent rules (managed in the dashboard) only narrow this further, never widen it. Patterns are
52
57
  # an exact address or a single "*" wildcard. Defaults to permit-all.
53
58
  # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@your-company.com'])
54
59
  # config.inbound_access = Protege::Gateway.build_access_policy(deny: ['*@spam.example'])
@@ -68,15 +73,21 @@ Protege.configure do |config|
68
73
 
69
74
  # ── Extension scaffold paths ──────────────────────────────────────────────────
70
75
 
71
- # Where the `bin/rails g protege:{tool,resolver,hook,persona,provider}` generators write. Defaults to
76
+ # Where the `bin/rails g protege:{tool,resolver,hook,agent,provider}` generators write. Defaults to
72
77
  # the conventional app/ directories; override if you group extensions elsewhere (must stay under an
73
78
  # autoloaded path).
74
79
  # config.tools_path = 'app/tools'
75
80
  # config.resolvers_path = 'app/resolvers'
76
81
  # config.hooks_path = 'app/hooks'
77
- # config.personas_path = 'app/personas'
82
+ # config.agents_path = 'app/agents'
78
83
  # config.providers_path = 'app/providers'
79
84
 
85
+ # ── Failure alerts ──────────────────────────────────────────────────────────
86
+
87
+ # Email the platform operator when an inference run fails (sent via Action Mailer, outside the
88
+ # agent's own mail flow). Off by default; no alert is sent until `to:` is set.
89
+ # config.failure_alerts = { to: ['ops@example.com'], from: 'alerts@your-domain.com' }
90
+
80
91
  # ── Logging ─────────────────────────────────────────────────────────────────
81
92
 
82
93
  # Logger for engine output; defaults to Rails.logger. Override to route to a dedicated target:
@@ -101,7 +101,7 @@ Pre-stage the TXT records anytime. Do **not** change the MX until you cut over (
101
101
  2. Deploy the app and **boot the accessory** (Kamal `deploy` does not manage accessories):
102
102
  `kamal accessory boot mail`.
103
103
  3. Point the **MX** at `agent.example.com` (priority 10). Inbound now arrives at your box.
104
- 4. Validate: `kamal accessory logs mail`; email the persona from Gmail → reply lands with
104
+ 4. Validate: `kamal accessory logs mail`; email the agent from Gmail → reply lands with
105
105
  **SPF/DKIM/DMARC = pass** ("Show original"). Score at [mail-tester.com](https://www.mail-tester.com)
106
106
  and iterate on reputation.
107
107
 
@@ -126,7 +126,7 @@ Our own outbound (the app container, on RFC1918) skips all three — only remote
126
126
  first-run window: deploy with `monitor`, watch `kamal accessory logs mail` for `dmarc=fail` on
127
127
  legitimate senders, then switch to `enforce` and redeploy (no image rebuild needed).
128
128
 
129
- To verify: from a Gmail account, email the persona and confirm a normal message is delivered with
129
+ To verify: from a Gmail account, email the agent and confirm a normal message is delivered with
130
130
  `dmarc=pass`; a spoofing attempt against a `p=reject` domain is refused at SMTP (visible in the logs).
131
131
 
132
132
  ## Rollback