protege 0.1.0.alpha.2 → 0.1.0.alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +21 -0
  3. data/Rakefile +1 -3
  4. data/app/assets/builds/protege.css +76 -83
  5. data/app/controllers/concerns/protege/agent_scoped.rb +22 -0
  6. data/app/controllers/concerns/protege/agent_toolkit_scoped.rb +22 -0
  7. data/app/controllers/protege/access_rules_controller.rb +13 -13
  8. data/app/controllers/protege/agent_toolkit_rules_controller.rb +62 -0
  9. data/app/controllers/protege/agent_toolkits_controller.rb +44 -0
  10. data/app/controllers/protege/agents/agent_toolkits_controller.rb +65 -0
  11. data/app/controllers/protege/agents_controller.rb +118 -0
  12. data/app/controllers/protege/archives_controller.rb +18 -18
  13. data/app/controllers/protege/home_controller.rb +1 -1
  14. data/app/controllers/protege/messages_controller.rb +1 -1
  15. data/app/controllers/protege/replies_controller.rb +3 -3
  16. data/app/controllers/protege/responsibilities_controller.rb +7 -7
  17. data/app/controllers/protege/threads_controller.rb +4 -4
  18. data/app/controllers/protege/toolkits_controller.rb +84 -0
  19. data/app/helpers/protege/agents_helper.rb +75 -0
  20. data/app/helpers/protege/application_helper.rb +3 -2
  21. data/app/helpers/protege/components/badges_helper.rb +11 -2
  22. data/app/helpers/protege/components/forms_helper.rb +47 -12
  23. data/app/helpers/protege/components/typography_helper.rb +1 -1
  24. data/app/helpers/protege/messages_helper.rb +2 -2
  25. data/app/helpers/protege/responsibilities_helper.rb +2 -2
  26. data/app/helpers/protege/threads_helper.rb +11 -11
  27. data/app/helpers/protege/toolkits_helper.rb +66 -0
  28. data/app/hooks/protege/event_logger_hook.rb +8 -8
  29. data/app/jobs/protege/console_inference_job.rb +8 -8
  30. data/app/jobs/protege/inference_job.rb +11 -11
  31. data/app/jobs/protege/responsibility_job.rb +10 -10
  32. data/app/mailboxes/protege/agent_mailbox.rb +54 -29
  33. data/app/mailers/protege/alert_mailer.rb +41 -0
  34. data/app/mailers/protege/application_mailer.rb +9 -8
  35. data/app/models/concerns/protege/broadcastable_agent.rb +50 -0
  36. data/app/models/concerns/protege/broadcastable_toolkit.rb +49 -0
  37. data/app/models/concerns/protege/toolable.rb +108 -0
  38. data/app/models/protege/access_rule.rb +12 -12
  39. data/app/models/protege/{persona.rb → agent.rb} +45 -45
  40. data/app/models/protege/agent_toolkit.rb +50 -0
  41. data/app/models/protege/agent_toolkit_rule.rb +25 -0
  42. data/app/models/protege/application_record.rb +1 -1
  43. data/app/models/protege/email_thread.rb +16 -13
  44. data/app/models/protege/message.rb +36 -20
  45. data/app/models/protege/responsibility.rb +12 -12
  46. data/app/models/protege/tool_use.rb +6 -6
  47. data/app/models/protege/toolkit.rb +115 -0
  48. data/app/models/protege/trace.rb +1 -1
  49. data/app/providers/protege/open_router_provider.rb +17 -0
  50. data/app/resolvers/README.md +4 -4
  51. data/app/resolvers/protege/load_file_resolver.rb +5 -5
  52. data/app/resolvers/protege/load_text_resolver.rb +3 -3
  53. data/app/resolvers/protege/thread_history_resolver.rb +1 -1
  54. data/app/services/protege/message_search.rb +2 -2
  55. data/app/services/protege/system_toolkits.rb +67 -0
  56. data/app/tools/protege/create_file_tool.rb +2 -0
  57. data/app/tools/protege/read_attachment_tool.rb +3 -1
  58. data/app/tools/protege/search_emails_tool.rb +15 -13
  59. data/app/tools/protege/send_email_tool.rb +60 -18
  60. data/app/tools/protege/web_fetch_tool.rb +2 -0
  61. data/app/tools/protege/web_search_tool.rb +3 -1
  62. data/app/views/layouts/mailer.text.erb +1 -0
  63. data/app/views/protege/access_rules/create.turbo_stream.slim +3 -3
  64. data/app/views/protege/agent_toolkit_rules/create.turbo_stream.slim +6 -0
  65. data/app/views/protege/agent_toolkits/_gate_rule.html.slim +7 -0
  66. data/app/views/protege/agent_toolkits/_gate_rule_form.html.slim +8 -0
  67. data/app/views/protege/agent_toolkits/_gate_rules.html.slim +19 -0
  68. data/app/views/protege/agent_toolkits/show.html.slim +17 -0
  69. data/app/views/protege/{personas → agents}/_access_rule.html.slim +1 -1
  70. data/app/views/protege/{personas → agents}/_access_rule_form.html.slim +1 -1
  71. data/app/views/protege/{personas → agents}/_access_rules.html.slim +5 -5
  72. data/app/views/protege/agents/_actions.html.slim +15 -0
  73. data/app/views/protege/agents/_agent_sidebar_item.html.slim +5 -0
  74. data/app/views/protege/agents/_agent_toolkit.html.slim +7 -0
  75. data/app/views/protege/agents/_agent_toolkit_form.html.slim +7 -0
  76. data/app/views/protege/agents/_form.html.slim +25 -0
  77. data/app/views/protege/agents/_sidebar.html.slim +11 -0
  78. data/app/views/protege/agents/_toolkits.html.slim +19 -0
  79. data/app/views/protege/agents/agent_toolkits/create.turbo_stream.slim +6 -0
  80. data/app/views/protege/agents/edit.html.slim +7 -0
  81. data/app/views/protege/agents/index.html.slim +6 -0
  82. data/app/views/protege/agents/new.html.slim +7 -0
  83. data/app/views/protege/agents/show.html.slim +29 -0
  84. data/app/views/protege/alert_mailer/inference_failed.text.erb +4 -0
  85. data/app/views/protege/email_domains/show.html.slim +1 -1
  86. data/app/views/protege/home/show.html.slim +13 -13
  87. data/app/views/protege/responsibilities/_form.html.slim +2 -2
  88. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +1 -1
  89. data/app/views/protege/responsibilities/new.html.slim +1 -1
  90. data/app/views/protege/responsibilities/show.html.slim +2 -2
  91. data/app/views/protege/responsibility_runs/show.html.slim +1 -1
  92. data/app/views/protege/shared/_header.html.slim +2 -1
  93. data/app/views/protege/shared/_hotkeys.html.slim +15 -11
  94. data/app/views/protege/threads/new.html.slim +2 -2
  95. data/app/views/protege/toolkits/_form.html.slim +13 -0
  96. data/app/views/protege/toolkits/_sidebar.html.slim +11 -0
  97. data/app/views/protege/toolkits/_toolkit_sidebar_item.html.slim +5 -0
  98. data/app/views/protege/toolkits/edit.html.slim +7 -0
  99. data/app/views/protege/toolkits/index.html.slim +6 -0
  100. data/app/views/protege/toolkits/new.html.slim +7 -0
  101. data/app/views/protege/toolkits/show.html.slim +13 -0
  102. data/config/routes.rb +12 -1
  103. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +1 -1
  104. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +1 -1
  105. data/db/migrate/20260728140000_scope_message_and_thread_uniqueness_per_persona.rb +16 -0
  106. data/db/migrate/20260812000001_create_protege_toolkits.rb +37 -0
  107. data/db/migrate/20260815000001_remove_disabled_tool_ids_from_personas.rb +21 -0
  108. data/db/migrate/20260815000002_add_key_to_protege_toolkits.rb +22 -0
  109. data/db/migrate/20260815100000_rename_personas_to_agents.rb +24 -0
  110. data/lib/generators/protege/agent/agent_generator.rb +35 -0
  111. data/lib/generators/protege/{persona/templates/persona.rb.tt → agent/templates/agent.rb.tt} +9 -9
  112. data/lib/generators/protege/extension_naming.rb +1 -1
  113. data/lib/generators/protege/hook/templates/hook.rb.tt +2 -2
  114. data/lib/generators/protege/install/install_generator.rb +16 -11
  115. data/lib/generators/protege/install/templates/initializer.rb.tt +17 -6
  116. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +2 -2
  117. data/lib/generators/protege/resolver/resolver_generator.rb +2 -2
  118. data/lib/generators/protege/resolver/templates/resolver.rb.tt +4 -4
  119. data/lib/generators/protege/tool/templates/tool.rb.tt +7 -5
  120. data/lib/protege/configuration.rb +43 -8
  121. data/lib/protege/engine.rb +3 -1
  122. data/lib/protege/errors/tool_not_available_error.rb +7 -6
  123. data/lib/protege/events/event.rb +2 -2
  124. data/lib/protege/events/inference_chunk_event.rb +1 -1
  125. data/lib/protege/events/inference_completed_event.rb +1 -1
  126. data/lib/protege/events/inference_failed_event.rb +1 -1
  127. data/lib/protege/events/inference_generated_event.rb +1 -1
  128. data/lib/protege/events/inference_max_turns_reached_event.rb +1 -1
  129. data/lib/protege/events/inference_started_event.rb +1 -1
  130. data/lib/protege/events/loop_run_completed_event.rb +1 -1
  131. data/lib/protege/events/loop_run_enqueued_event.rb +1 -1
  132. data/lib/protege/events/loop_run_failed_event.rb +1 -1
  133. data/lib/protege/events/loop_run_started_event.rb +1 -1
  134. data/lib/protege/events/tool_call_completed_event.rb +1 -1
  135. data/lib/protege/events/tool_call_failed_event.rb +1 -1
  136. data/lib/protege/events/tool_call_started_event.rb +1 -1
  137. data/lib/protege/events/tool_calls_received_event.rb +1 -1
  138. data/lib/protege/extensions/hook.rb +1 -0
  139. data/lib/protege/extensions/hook_mixin.rb +1 -1
  140. data/lib/protege/extensions/manifest.rb +67 -0
  141. data/lib/protege/extensions/manifested.rb +81 -0
  142. data/lib/protege/extensions/provider.rb +1 -0
  143. data/lib/protege/extensions/provider_mixin.rb +9 -17
  144. data/lib/protege/extensions/resolver.rb +2 -1
  145. data/lib/protege/extensions/resolver_mixin.rb +2 -2
  146. data/lib/protege/extensions/tool.rb +1 -0
  147. data/lib/protege/extensions/tool_mixin.rb +16 -40
  148. data/lib/protege/gateway/access_control.rb +19 -19
  149. data/lib/protege/gateway/access_policy.rb +2 -2
  150. data/lib/protege/gateway/mail/outbound.rb +22 -5
  151. data/lib/protege/gateway.rb +48 -37
  152. data/lib/protege/loop/scheduler.rb +1 -1
  153. data/lib/protege/orchestrator/context.rb +8 -8
  154. data/lib/protege/orchestrator/harness.rb +26 -24
  155. data/lib/protege/orchestrator/reply_context.rb +3 -3
  156. data/lib/protege/orchestrator/reply_harness.rb +8 -8
  157. data/lib/protege/orchestrator/resolver_chain.rb +3 -3
  158. data/lib/protege/orchestrator/responsibility_context.rb +4 -4
  159. data/lib/protege/orchestrator/responsibility_harness.rb +8 -8
  160. data/lib/protege/orchestrator.rb +7 -7
  161. data/lib/protege/subscribers/alerter.rb +117 -0
  162. data/lib/protege/subscribers/tracing.rb +1 -1
  163. data/lib/protege/version.rb +1 -1
  164. data/lib/protege.rb +2 -2
  165. data/lib/tasks/protege_tasks.rake +12 -0
  166. metadata +66 -23
  167. data/app/controllers/concerns/protege/persona_scoped.rb +0 -22
  168. data/app/controllers/protege/personas_controller.rb +0 -118
  169. data/app/helpers/protege/personas_helper.rb +0 -90
  170. data/app/models/concerns/protege/broadcastable_persona.rb +0 -50
  171. data/app/models/concerns/protege/tool_scoped.rb +0 -88
  172. data/app/views/protege/personas/_actions.html.slim +0 -15
  173. data/app/views/protege/personas/_form.html.slim +0 -29
  174. data/app/views/protege/personas/_persona_sidebar_item.html.slim +0 -5
  175. data/app/views/protege/personas/_sidebar.html.slim +0 -11
  176. data/app/views/protege/personas/edit.html.slim +0 -7
  177. data/app/views/protege/personas/index.html.slim +0 -6
  178. data/app/views/protege/personas/new.html.slim +0 -7
  179. data/app/views/protege/personas/show.html.slim +0 -37
  180. data/lib/generators/protege/persona/persona_generator.rb +0 -35
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages agent records — listing, creating, viewing, editing, and permanent deletion.
5
+ #
6
+ # An agent is the identity behind an inbox; the controller surfaces the full CRUD the dashboard
7
+ # needs. +destroy+ is the hard, irreversible removal (cascading all the agent's data); the
8
+ # reversible, history-preserving alternative is archiving — see +ArchivesController+.
9
+ class AgentsController < ApplicationController
10
+ before_action :set_agent, only: %i[show edit update destroy]
11
+
12
+ # List every agent. Serves +GET /agents+.
13
+ #
14
+ # @return [void]
15
+ def index
16
+ @agents = Agent.all
17
+ end
18
+
19
+ # Show a single agent. Serves +GET /agents/:id+.
20
+ #
21
+ # @return [void]
22
+ def show; end
23
+
24
+ # Render the form for creating an agent. Serves +GET /agents/new+.
25
+ #
26
+ # @return [void]
27
+ def new
28
+ @agent = Agent.new
29
+ end
30
+
31
+ # Render the form for editing an agent. Serves +GET /agents/:id/edit+.
32
+ #
33
+ # @return [void]
34
+ def edit; end
35
+
36
+ # Create an agent from the form's split email fields. Serves +POST /agents+.
37
+ #
38
+ # On failure, re-render the form with validation errors.
39
+ #
40
+ # @return [void]
41
+ def create
42
+ @agent = Agent.new(create_params)
43
+
44
+ if @agent.save
45
+ redirect_to agent_path(@agent), notice: 'Agent created.'
46
+ else
47
+ render :new, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ # Update an existing agent. Serves +PATCH /agents/:id+.
52
+ #
53
+ # On failure, re-render the edit form with validation errors.
54
+ #
55
+ # @return [void]
56
+ def update
57
+ if @agent.update(update_params)
58
+ redirect_to agent_path(@agent), notice: 'Agent updated.'
59
+ else
60
+ render :edit, status: :unprocessable_entity
61
+ end
62
+ end
63
+
64
+ # Permanently delete an agent and everything it owns. Serves +DELETE /agents/:id+.
65
+ #
66
+ # This is the hard, irreversible removal (the reversible path is Archive — see +ArchivesController+):
67
+ # the model cascades the agent's threads, messages, responsibilities, access rules, and tool-use
68
+ # history. +BroadcastableAgent+ removes its sidebar row.
69
+ #
70
+ # @return [void]
71
+ def destroy
72
+ @agent.destroy!
73
+
74
+ redirect_to agents_path, notice: 'Agent deleted.'
75
+ end
76
+
77
+ private
78
+
79
+ # Load the +Agent+ named by +params[:id]+ into +@agent+.
80
+ #
81
+ # @return [Protege::Agent] the loaded agent
82
+ def set_agent
83
+ @agent = Agent.find(params[:id])
84
+ end
85
+
86
+ # Permit creation attributes and fold the split email fields into a single address.
87
+ #
88
+ # @return [ActionController::Parameters] the permitted attributes plus +:email_address+
89
+ def create_params
90
+ params.require(:agent)
91
+ .permit(:type, :name, :instructions)
92
+ .merge(email_address: compose_email_address)
93
+ end
94
+
95
+ # Permit the editable attributes and recompose the address from the split email fields. Changing the
96
+ # address is allowed — only mail to the *old* address stops routing here; all history keys off
97
+ # +agent_id+, not the address. Tools are not edited here: they come from the toolkits attached to the
98
+ # agent (managed on its show page), not from a per-agent field.
99
+ #
100
+ # @return [ActionController::Parameters] the permitted attributes plus +:email_address+
101
+ def update_params
102
+ params.require(:agent)
103
+ .permit(:name, :instructions)
104
+ .merge(email_address: compose_email_address)
105
+ end
106
+
107
+ # Combine the split prefix + domain fields from the form into a single
108
+ # email address.
109
+ #
110
+ # @return [String] the +prefix@domain+ address with surrounding whitespace stripped
111
+ def compose_email_address
112
+ params[:agent].values_at(:email_prefix, :email_domain)
113
+ .map(&:to_s)
114
+ .map(&:strip)
115
+ .join('@')
116
+ end
117
+ end
118
+ end
@@ -1,51 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # Archives and unarchives a persona — the reversible, data-preserving alternative to permanent deletion.
4
+ # Archives and unarchives an agent — the reversible, data-preserving alternative to permanent deletion.
5
5
  #
6
- # Archiving is modeled as an +archive+ resource on the persona: +create+ archives it (stamps
7
- # +archived_at+), +destroy+ unarchives it (clears it). An archived persona stops receiving mail and
6
+ # Archiving is modeled as an +archive+ resource on the agent: +create+ archives it (stamps
7
+ # +archived_at+), +destroy+ unarchives it (clears it). An archived agent stops receiving mail and
8
8
  # running scheduled responsibilities, but all its history is kept and it can be restored at any time.
9
9
  #
10
- # Both actions re-render the persona's action bar in place over Turbo (the sidebar row updates
11
- # separately via +BroadcastablePersona+), falling back to a full redirect for non-Turbo requests.
10
+ # Both actions re-render the agent's action bar in place over Turbo (the sidebar row updates
11
+ # separately via +BroadcastableAgent+), falling back to a full redirect for non-Turbo requests.
12
12
  class ArchivesController < ApplicationController
13
- include PersonaScoped
13
+ include AgentScoped
14
14
 
15
- # Archive the persona. Serves +POST /personas/:persona_id/archive+.
15
+ # Archive the agent. Serves +POST /agents/:agent_id/archive+.
16
16
  #
17
17
  # @return [void]
18
18
  def create
19
- @persona.archive!
19
+ @agent.archive!
20
20
 
21
- respond_with_persona(notice: 'Persona archived — it no longer receives mail or runs scheduled tasks.')
21
+ respond_with_agent(notice: 'Agent archived — it no longer receives mail or runs scheduled tasks.')
22
22
  end
23
23
 
24
- # Unarchive the persona. Serves +DELETE /personas/:persona_id/archive+.
24
+ # Unarchive the agent. Serves +DELETE /agents/:agent_id/archive+.
25
25
  #
26
26
  # @return [void]
27
27
  def destroy
28
- @persona.unarchive!
28
+ @agent.unarchive!
29
29
 
30
- respond_with_persona(notice: 'Persona restored.')
30
+ respond_with_agent(notice: 'Agent restored.')
31
31
  end
32
32
 
33
33
  private
34
34
 
35
- # Replace the persona's action bar over Turbo, or redirect back to the persona for a full request.
35
+ # Replace the agent's action bar over Turbo, or redirect back to the agent for a full request.
36
36
  #
37
37
  # @param notice [String] the flash message for the redirect fallback
38
38
  # @return [void]
39
- def respond_with_persona(notice:)
39
+ def respond_with_agent(notice:)
40
40
  respond_to do |format|
41
41
  format.turbo_stream do
42
42
  render turbo_stream: turbo_stream.replace(
43
- helpers.dom_id(@persona, :actions),
44
- partial: 'protege/personas/actions',
45
- locals: { persona: @persona }
43
+ helpers.dom_id(@agent, :actions),
44
+ partial: 'protege/agents/actions',
45
+ locals: { agent: @agent }
46
46
  )
47
47
  end
48
- format.html { redirect_to persona_path(@persona), notice: }
48
+ format.html { redirect_to agent_path(@agent), notice: }
49
49
  end
50
50
  end
51
51
  end
@@ -4,7 +4,7 @@ module Protege
4
4
  # Serves the dashboard landing page mounted at the engine root.
5
5
  #
6
6
  # The page is static — it renders the +show+ view as the entry point into the
7
- # Protege dashboard, from which operators navigate to personas, threads, and
7
+ # Protege dashboard, from which operators navigate to agents, threads, and
8
8
  # email domains.
9
9
  class HomeController < ApplicationController
10
10
  # Render the dashboard landing page. Serves +GET /+.
@@ -4,7 +4,7 @@ module Protege
4
4
  # Messages — the console email search and per-message delete, both keyed off the globally-unique
5
5
  # message, so neither is thread-scoped.
6
6
  #
7
- # +index+ (+GET /messages+) is the cross-persona search view: query params filter the messages via
7
+ # +index+ (+GET /messages+) is the cross-agent search view: query params filter the messages via
8
8
  # +MessageSearch+ and the matches render in the sidebar, each linking to its thread. +destroy+
9
9
  # (+DELETE /messages/:id+) removes a single message by id.
10
10
  class MessagesController < ApplicationController
@@ -11,7 +11,7 @@ module Protege
11
11
  # Serves +POST /threads/:thread_id/reply+.
12
12
  #
13
13
  # The console acts as the sender; the reply is addressed to the thread's
14
- # persona so the LOGI loop processes it as if it arrived over SMTP.
14
+ # agent so the LOGI loop processes it as if it arrived over SMTP.
15
15
  #
16
16
  # The new message inserts itself into the thread over Turbo (its +after_create_commit+ broadcast,
17
17
  # see +BroadcastableMessage+), so a Turbo request needs no body — it just returns +201 Created+, which
@@ -22,7 +22,7 @@ module Protege
22
22
  def create
23
23
  Gateway.accept_console_message(
24
24
  mail: build_reply_mail,
25
- persona: @thread.persona,
25
+ agent: @thread.agent,
26
26
  attachments: reply_params[:files] || []
27
27
  )
28
28
 
@@ -53,7 +53,7 @@ module Protege
53
53
 
54
54
  Gateway.build_outbound(
55
55
  from: Protege.configuration.console_address,
56
- to: @thread.persona.email_address,
56
+ to: @thread.agent.email_address,
57
57
  subject: @thread.subject,
58
58
  body: reply_params[:body],
59
59
  in_reply_to: messages.last&.message_id,
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Protege
4
4
  # Manages scheduled responsibilities — the Loop layer's dashboard CRUD. A top-level section (like
5
- # Personas or Traces): responsibilities are listed across all personas, and each one names its owning
6
- # persona in the form rather than carrying it in the URL.
5
+ # Agents or Traces): responsibilities are listed across all agents, and each one names its owning
6
+ # agent in the form rather than carrying it in the URL.
7
7
  class ResponsibilitiesController < ApplicationController
8
8
  before_action :set_responsibility, only: %i[show edit update destroy]
9
9
 
@@ -11,7 +11,7 @@ module Protege
11
11
  #
12
12
  # @return [void]
13
13
  def index
14
- @responsibilities = Responsibility.includes(:persona)
14
+ @responsibilities = Responsibility.includes(:agent)
15
15
  end
16
16
 
17
17
  # Show a single responsibility and its recent runs.
@@ -31,7 +31,7 @@ module Protege
31
31
  # @return [void]
32
32
  def edit; end
33
33
 
34
- # Create a responsibility (its persona is chosen in the form). On failure, re-render with errors.
34
+ # Create a responsibility (its agent is chosen in the form). On failure, re-render with errors.
35
35
  #
36
36
  # @return [void]
37
37
  def create
@@ -65,18 +65,18 @@ module Protege
65
65
 
66
66
  private
67
67
 
68
- # Load the responsibility named by the route into +@responsibility+ (its persona via the association).
68
+ # Load the responsibility named by the route into +@responsibility+ (its agent via the association).
69
69
  #
70
70
  # @return [Protege::Responsibility] the loaded responsibility
71
71
  def set_responsibility
72
72
  @responsibility = Responsibility.find(params[:id])
73
73
  end
74
74
 
75
- # Permit the editable responsibility attributes, including the +:persona_id+ the form now supplies.
75
+ # Permit the editable responsibility attributes, including the +:agent_id+ the form now supplies.
76
76
  #
77
77
  # @return [ActionController::Parameters] the permitted attributes
78
78
  def responsibility_params
79
- params.require(:responsibility).permit(:persona_id, :name, :instructions, :schedule, :active)
79
+ params.require(:responsibility).permit(:agent_id, :name, :instructions, :schedule, :active)
80
80
  end
81
81
  end
82
82
  end
@@ -35,10 +35,10 @@ module Protege
35
35
  #
36
36
  # @return [void]
37
37
  def create
38
- persona = Persona.find(compose_params[:persona_id])
38
+ agent = Agent.find(compose_params[:agent_id])
39
39
 
40
40
  message = Gateway.message(
41
- to: persona.email_address,
41
+ to: agent.email_address,
42
42
  subject: compose_params[:subject],
43
43
  body: compose_params[:body],
44
44
  attachments: compose_params[:files] || []
@@ -67,9 +67,9 @@ module Protege
67
67
 
68
68
  # Permit the compose-form attributes for a new conversation.
69
69
  #
70
- # @return [ActionController::Parameters] the permitted +:persona_id+, +:subject+, +:body+, +:files+
70
+ # @return [ActionController::Parameters] the permitted +:agent_id+, +:subject+, +:body+, +:files+
71
71
  def compose_params
72
- params.require(:thread).permit(:persona_id, :subject, :body, files: [])
72
+ params.require(:thread).permit(:agent_id, :subject, :body, files: [])
73
73
  end
74
74
  end
75
75
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages toolkits — the dashboard CRUD for the tool-bundle binding. A top-level section (like
5
+ # Responsibilities): toolkits are global, reusable tool bundles, attached to agents from the agent
6
+ # page. A toolkit is *just* its tools — permission (the sender gate + proactive flag) lives on each grant
7
+ # (+AgentToolkit+), so this controller only edits the name/description/tool set.
8
+ class ToolkitsController < ApplicationController
9
+ before_action :set_toolkit, only: %i[show edit update destroy]
10
+
11
+ # List every toolkit.
12
+ #
13
+ # @return [void]
14
+ def index
15
+ @toolkits = Toolkit.order(:name)
16
+ end
17
+
18
+ # Show a single toolkit and its gate rules.
19
+ #
20
+ # @return [void]
21
+ def show; end
22
+
23
+ # Render the form for creating a toolkit.
24
+ #
25
+ # @return [void]
26
+ def new
27
+ @toolkit = Toolkit.new
28
+ end
29
+
30
+ # Render the form for editing a toolkit.
31
+ #
32
+ # @return [void]
33
+ def edit; end
34
+
35
+ # Create a toolkit. On failure, re-render the form with validation errors.
36
+ #
37
+ # @return [void]
38
+ def create
39
+ @toolkit = Toolkit.new(toolkit_params)
40
+
41
+ if @toolkit.save
42
+ redirect_to toolkit_path(@toolkit), notice: 'Toolkit created.'
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ # Update a toolkit. On failure, re-render the edit form with validation errors.
49
+ #
50
+ # @return [void]
51
+ def update
52
+ if @toolkit.update(toolkit_params)
53
+ redirect_to toolkit_path(@toolkit), notice: 'Toolkit updated.'
54
+ else
55
+ render :edit, status: :unprocessable_entity
56
+ end
57
+ end
58
+
59
+ # Remove a toolkit (detaching it from every agent via the join's dependent destroy).
60
+ #
61
+ # @return [void]
62
+ def destroy
63
+ @toolkit.destroy
64
+ redirect_to toolkits_path, notice: 'Toolkit deleted.'
65
+ end
66
+
67
+ private
68
+
69
+ # Load the toolkit named by the route.
70
+ #
71
+ # @return [Protege::Toolkit] the loaded toolkit
72
+ def set_toolkit
73
+ @toolkit = Toolkit.find(params[:id])
74
+ end
75
+
76
+ # Permit the editable attributes. The member tools arrive as an array from the checklist; the gate
77
+ # rules are edited separately as child rows, so they are not permitted here.
78
+ #
79
+ # @return [ActionController::Parameters] the permitted attributes
80
+ def toolkit_params
81
+ params.require(:toolkit).permit(:name, :description, member_tool_ids: [])
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for agent pages — selectors, labels, status, and resolver display.
5
+ module AgentsHelper
6
+ # List the loaded +Agent+ subclasses for the new-agent type selector.
7
+ #
8
+ # @return [Array<Class>] the +Agent+ descendant classes
9
+ def available_agent_types
10
+ Agent.descendants
11
+ end
12
+
13
+ # List active email domains for the agent form's domain selector.
14
+ #
15
+ # @return [ActiveRecord::Relation<Protege::EmailDomain>] domains eligible for selection
16
+ def available_email_domains
17
+ EmailDomain.all
18
+ end
19
+
20
+ # Fetch every +Agent+ ordered by name for sidebar listing.
21
+ #
22
+ # @return [ActiveRecord::Relation<Protege::Agent>] agents ordered alphabetically
23
+ def all_agents
24
+ Agent.order(:name)
25
+ end
26
+
27
+ # Format an agent's name together with its type for display.
28
+ #
29
+ # agent_label(agent) # => "Bob <Executive Agent>"
30
+ #
31
+ # @param agent [Protege::Agent] the agent to label
32
+ # @return [String] the +"name <Type>"+ label
33
+ def agent_label(agent)
34
+ "#{agent.name} <#{agent.class.display_name}>"
35
+ end
36
+
37
+ # Render a status dot reflecting whether an agent is available.
38
+ #
39
+ # @param agent [Protege::Agent] the agent whose availability to show
40
+ # @return [ActiveSupport::SafeBuffer] a green dot when active, red otherwise
41
+ def agent_status(agent)
42
+ if agent.active?
43
+ status_dot('Available', :green)
44
+ else
45
+ status_dot('Unavailable', :red)
46
+ end
47
+ end
48
+
49
+ # Format an agent's resolver chain as a hash suitable for +info_card+ (rendered full-width, since
50
+ # the resolver class names are long). Each entry maps the resolver's short class name to a summary
51
+ # of its positional args, keyword args, and whether it was given a block.
52
+ #
53
+ # @param agent [Protege::Agent] the agent whose reply (+message_resolvers+) chain to format
54
+ # @return [Hash{String => String}] resolver-name => configuration summary
55
+ def resolver_pairs(agent)
56
+ pairs = agent.message_resolvers.entries.each_with_object({}) do |(klass, args, kwargs, block), accumulator|
57
+ accumulator[klass.name.demodulize] = resolver_config(args, kwargs, block)
58
+ end
59
+
60
+ pairs.any? ? pairs : { 'Resolvers' => 'None configured' }
61
+ end
62
+
63
+ private
64
+
65
+ # Summarize a resolver entry's positional args, keyword args, and whether it was given a block.
66
+ #
67
+ # @return [String] a comma-joined summary, or "" when the resolver takes no configuration
68
+ def resolver_config(args, kwargs, block)
69
+ parts = args.map(&:inspect)
70
+ parts += kwargs.map { |key, value| "#{key}: #{value.inspect}" }
71
+ parts << '{ … }' if block
72
+ parts.join(', ')
73
+ end
74
+ end
75
+ end
@@ -8,16 +8,17 @@ module Protege
8
8
  # +Protege::Components::+ — pulled in as a whole via its own aggregator, +Components::ComponentsHelper+,
9
9
  # which includes the rest of the group (typography, tables, badges, buttons, cards, dialogs, menus,
10
10
  # forms, layout, sidebar, pagination, the introspection panel) — and the model-named helpers at the
11
- # top level (threads, personas, email
11
+ # top level (threads, agents, email
12
12
  # domains, messages, responsibilities, traces). Any cross-helper call resolves through the view, which
13
13
  # has them all via this module.
14
14
  module ApplicationHelper
15
15
  include Components::ComponentsHelper
16
16
  include EmailDomainsHelper
17
17
  include MessagesHelper
18
- include PersonasHelper
18
+ include AgentsHelper
19
19
  include ResponsibilitiesHelper
20
20
  include ThreadsHelper
21
+ include ToolkitsHelper
21
22
  include TraceHelper
22
23
 
23
24
  # List of JS files to be added to the console layout
@@ -10,13 +10,22 @@ module Protege
10
10
  #
11
11
  # @param text [String] the badge label
12
12
  # @param color [Symbol] the semantic color (see +badge_colors+)
13
+ # @param size [Symbol] +:md+ (default) or +:sm+ for a tinier pill (see +badge_size+)
13
14
  # @return [ActiveSupport::SafeBuffer] the badge markup
14
- def badge(text, color = :gray)
15
+ def badge(text, color = :gray, size: :md)
15
16
  _, fg = badge_colors(color)
16
- tag.span(text, class: "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium #{fg}",
17
+ tag.span(text, class: "inline-flex items-center rounded font-medium #{badge_size(size)} #{fg}",
17
18
  style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
18
19
  end
19
20
 
21
+ # Map a badge size to its padding + text-size classes.
22
+ #
23
+ # @param size [Symbol] +:sm+ or +:md+
24
+ # @return [String] the sizing classes
25
+ def badge_size(size)
26
+ size == :sm ? 'px-1 py-px text-[10px]' : 'px-1.5 py-0.5 text-xs'
27
+ end
28
+
20
29
  # Map a semantic badge color to its +[background, foreground]+ classes.
21
30
  #
22
31
  # @param color [Symbol] the semantic color
@@ -75,13 +75,13 @@ module Protege
75
75
  end
76
76
  end
77
77
 
78
- # Render the split prefix/domain email-address control for a persona.
78
+ # Render the split prefix/domain email-address control for an agent.
79
79
  #
80
80
  # When domains exist, render a prefix text box, an +@+ separator, and a domain
81
81
  # select preselected to the record's current domain; otherwise prompt the
82
82
  # operator to add a domain first.
83
83
  #
84
- # @param record [Protege::Persona] the persona whose email address to edit
84
+ # @param record [Protege::Agent] the agent whose email address to edit
85
85
  # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
86
86
  # @return [ActiveSupport::SafeBuffer] the email-address control markup
87
87
  def form_email_address(record, domains)
@@ -138,16 +138,16 @@ module Protege
138
138
  # @param hint [String, nil] optional helper text shown beneath the label
139
139
  # @param value [Symbol, Proc] maps an item to its submitted value (default: +:to_s+)
140
140
  # @param text [Symbol, Proc] maps an item to its checkbox label (default: +:to_s+)
141
+ # @param description [Symbol, nil] reader for an item's secondary description; when given (and the
142
+ # item answers it), each row stacks the label over a muted description line for scannability
141
143
  # @return [ActiveSupport::SafeBuffer] the checklist markup
142
- def form_checklist(form, field, collection, label: nil, hint: nil, value: :to_s, text: :to_s)
144
+ def form_checklist(form, field, collection, label: nil, hint: nil, value: :to_s, text: :to_s, description: nil)
143
145
  tag.div do
144
146
  (label ? tag.label(label, class: FORM_LABEL) : ''.html_safe) +
145
147
  form_hint(hint) +
146
- tag.div(class: 'space-y-2 pt-1') do
148
+ tag.div(class: 'space-y-2 mt-3') do
147
149
  form.collection_check_boxes(field, collection, value, text) do |b|
148
- b.label(class: 'flex items-center gap-3 text-sm') do
149
- b.check_box(class: FORM_CHECKBOX, style: 'border: 1px solid var(--border)') + b.text
150
- end
150
+ checklist_option(b, description)
151
151
  end
152
152
  end
153
153
  end
@@ -155,6 +155,41 @@ module Protege
155
155
 
156
156
  private
157
157
 
158
+ # Render a single checklist row: the checkbox and its label. With a +description+ reader, the label
159
+ # stacks the option name over a muted description line and top-aligns the box so multi-line rows read
160
+ # cleanly; without one, it falls back to a compact centered single line.
161
+ #
162
+ # @param builder [ActionView::Helpers::Tags::CollectionHelpers::Builder] the per-item builder
163
+ # @param description [Symbol, nil] reader for the option's description text
164
+ # @return [ActiveSupport::SafeBuffer] the labelled checkbox row
165
+ def checklist_option(builder, description)
166
+ desc = description && builder.object.public_send(description).presence
167
+ # With a description the row is multi-line, so top-align it and nudge the box down to sit on the
168
+ # first text line; without one, a compact centered single line.
169
+ row_align = desc ? 'items-start' : 'items-center'
170
+ box_class = desc ? "#{FORM_CHECKBOX} mt-0.5" : FORM_CHECKBOX
171
+
172
+ builder.label(class: "flex #{row_align} gap-3 text-sm") do
173
+ builder.check_box(class: box_class, style: 'border: 1px solid var(--border)') +
174
+ checklist_option_body(builder, desc)
175
+ end
176
+ end
177
+
178
+ # Build the text beside a checklist checkbox: a plain label, or — when a description is present — the
179
+ # option name stacked over its muted description.
180
+ #
181
+ # @param builder [ActionView::Helpers::Tags::CollectionHelpers::Builder] the per-item builder
182
+ # @param desc [String, nil] the resolved description text, or nil for a plain label
183
+ # @return [ActiveSupport::SafeBuffer] the label body
184
+ def checklist_option_body(builder, desc)
185
+ return builder.text unless desc
186
+
187
+ tag.span(class: 'flex flex-col gap-0.5') do
188
+ tag.span(builder.text, class: 'font-medium') +
189
+ tag.span(desc, class: 'text-xs leading-snug', style: 'color: var(--text-faint)')
190
+ end
191
+ end
192
+
158
193
  # Render the muted hint paragraph shown beneath a field, or nothing.
159
194
  #
160
195
  # @param hint [String, nil] the hint text
@@ -167,7 +202,7 @@ module Protege
167
202
 
168
203
  # Render the prefix input, +@+ separator, and domain select for an address.
169
204
  #
170
- # @param record [Protege::Persona] the persona whose address to edit
205
+ # @param record [Protege::Agent] the agent whose address to edit
171
206
  # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
172
207
  # @return [ActiveSupport::SafeBuffer] the prefix/separator/select markup
173
208
  def email_address_fields(record, domains)
@@ -180,13 +215,13 @@ module Protege
180
215
 
181
216
  # Render the local-part text input, seeded with the record's current prefix.
182
217
  #
183
- # @param record [Protege::Persona] the persona whose prefix to seed
218
+ # @param record [Protege::Agent] the agent whose prefix to seed
184
219
  # @param input_base [String] the shared input classes without the +w-full+ utility
185
220
  # @return [ActiveSupport::SafeBuffer] the prefix input markup
186
221
  def email_address_prefix(record, input_base)
187
222
  tag.input(
188
223
  type: 'text',
189
- name: 'persona[email_prefix]',
224
+ name: 'agent[email_prefix]',
190
225
  value: record.email_address&.split('@')&.first,
191
226
  placeholder: 'alice',
192
227
  class: "block w-40 #{input_base}",
@@ -196,7 +231,7 @@ module Protege
196
231
 
197
232
  # Render the domain select, preselecting the record's current domain.
198
233
  #
199
- # @param record [Protege::Persona] the persona whose domain to preselect
234
+ # @param record [Protege::Agent] the agent whose domain to preselect
200
235
  # @param domains [Enumerable<Protege::EmailDomain>] the selectable domains
201
236
  # @param input_base [String] the shared input classes without the +w-full+ utility
202
237
  # @return [ActiveSupport::SafeBuffer] the domain select markup
@@ -206,7 +241,7 @@ module Protege
206
241
  selected: record.email_address&.end_with?("@#{domain.domain}"))
207
242
  end)
208
243
 
209
- tag.select(options, name: 'persona[email_domain]', class: "block flex-1 #{input_base}",
244
+ tag.select(options, name: 'agent[email_domain]', class: "block flex-1 #{input_base}",
210
245
  style: form_input_style)
211
246
  end
212
247
 
@@ -39,7 +39,7 @@ module Protege
39
39
  end
40
40
 
41
41
  # Render a block of verbatim body text — whitespace preserved, monospace — for content shown
42
- # as-is, such as an email body or a persona's instructions. Escapes its input.
42
+ # as-is, such as an email body or an agent's instructions. Escapes its input.
43
43
  #
44
44
  # @param text [String] the text to render
45
45
  # @return [ActiveSupport::SafeBuffer] the preformatted text block
@@ -21,14 +21,14 @@ module Protege
21
21
  message.sent_at&.strftime('%-m/%-d/%y')
22
22
  end
23
23
 
24
- # Render the sender line — persona name for outbound mail, local part of the
24
+ # Render the sender line — agent name for outbound mail, local part of the
25
25
  # address for inbound mail — followed by the full address.
26
26
  #
27
27
  # @param message [Protege::Message] the message to render the sender for
28
28
  # @return [ActiveSupport::SafeBuffer] the bold +"name <address>"+ span
29
29
  def message_sender(message)
30
30
  name = if message.outbound?
31
- message.persona.name
31
+ message.agent.name
32
32
  else
33
33
  message.from_address.to_s.split('@').first
34
34
  end