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,29 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @agent
3
+
4
+ = page_container do
5
+ = page_header(@agent.name, @agent.class.display_name) do
6
+ = render("actions", agent: @agent)
7
+
8
+ .space-y-4
9
+ = info_card("Email address", { "Address" => @agent.email_address })
10
+
11
+ = card do
12
+ = heading_2("Instructions")
13
+ - if @agent.instructions.present?
14
+ .mt-3
15
+ = body_text(@agent.instructions)
16
+ - else
17
+ .mt-3
18
+ = hint("No instructions set yet — add them via Edit. They're used as the system prompt only when a resolver (e.g. LoadText) includes them in the agent's context.")
19
+
20
+ = render("access_rules", agent: @agent)
21
+
22
+ = render("toolkits", agent: @agent)
23
+
24
+ = render("protege/shared/confirm_dialog",
25
+ id: "delete-agent",
26
+ title: "Delete #{@agent.name}?",
27
+ message: "This permanently removes the agent and ALL of its data — every thread, message, attachment, responsibility, access rule, and tool-use record. This cannot be undone. To keep the history but stop the agent instead, archive it.",
28
+ action_url: agent_path(@agent),
29
+ action_text: "Delete agent")
@@ -0,0 +1,4 @@
1
+ An inference run failed and could not be recovered.
2
+
3
+ Error: <%= @error_class %>: <%= @error_message %>
4
+ Correlation ID: <%= @correlation_id || '(none)' %>
@@ -31,6 +31,6 @@
31
31
  = render("protege/shared/confirm_dialog",
32
32
  id: "remove-domain",
33
33
  title: "Remove #{@email_domain.domain}?",
34
- message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by a persona, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
34
+ message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by an agent, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
35
35
  action_url: email_domain_path(@email_domain),
36
36
  action_text: "Remove domain")
@@ -2,9 +2,9 @@
2
2
  = page_header('About Protege')
3
3
 
4
4
  div class="space-y-4 text-sm leading-relaxed mb-10"
5
- p Protege is an email-native AI agent toolkit. Your personas receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
5
+ p Protege is an email-native AI agent toolkit. Your agents receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
6
6
 
7
- p To get started, configure at least one email domain with valid DNS records, create a persona with a routable email address, and set up your provider credentials. Once a persona is active and your MTA is running, it will process inbound email automatically.
7
+ p To get started, configure at least one email domain with valid DNS records, create an agent with a routable email address, and set up your provider credentials. Once an agent is active and your MTA is running, it will process inbound email automatically.
8
8
 
9
9
  p Protege is a toolkit for developers, not an out-of-the-box solution. Every part of the pipeline — providers, resolvers, tools, hooks — is an extension surface you implement. The #{ui_link("documentation", "https://docs.protege.bot", target: "_blank")} covers each surface in detail with examples and guides.
10
10
 
@@ -13,14 +13,14 @@
13
13
  = heading_2("Extension surfaces")
14
14
 
15
15
  = grid_container(cols: 2) do
16
- = grid_card("Personas", "The agent identity that receives and responds to email. Each persona has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/personas")
17
-
18
- = grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/providers")
19
-
20
- = grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/resolvers")
21
-
22
- = grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/tools")
23
-
24
- = grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/hooks")
25
-
26
- = grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/gateway")
16
+ = grid_card("Agents", "The addressable identity that receives and responds to email. Each agent has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/reference/agents")
17
+
18
+ = grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/extensions/providers")
19
+
20
+ = grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/extensions/resolvers")
21
+
22
+ = grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/extensions/tools")
23
+
24
+ = grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/extensions/hooks-and-events")
25
+
26
+ = grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/architecture/gateway")
@@ -4,13 +4,13 @@
4
4
  = form_with url:, method:, scope: :responsibility, class: "space-y-5" do |f|
5
5
  = form_errors(responsibility)
6
6
 
7
- = form_select(f, :persona_id, all_personas.map { |p| [p.name, p.id] }, label: "Persona", prompt: "Choose a persona")
7
+ = form_select(f, :agent_id, all_agents.map { |p| [p.name, p.id] }, label: "Agent", prompt: "Choose an agent")
8
8
 
9
9
  = form_field(f, :name, placeholder: "e.g. Morning digest")
10
10
 
11
11
  = form_field(f, :schedule, label: "Schedule", placeholder: "0 9 * * 1-5", hint: "A 5-field cron expression (minute hour day-of-month month day-of-week) in the app timezone — e.g. \"0 9 * * 1-5\" is 9am on weekdays, \"*/15 * * * *\" every 15 minutes.")
12
12
 
13
- = form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The persona's responsibility_resolvers chain supplies the system context around it.")
13
+ = form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The agent's responsibility_resolvers chain supplies the system context around it.")
14
14
 
15
15
  - unless responsibility.new_record?
16
16
  = form_checkbox(f, :active, label: "Active")
@@ -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 &nbsp;
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