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
@@ -5,12 +5,12 @@ require_relative '../extension_naming'
5
5
 
6
6
  module Protege
7
7
  module Generators
8
- # Scaffolds a Protege resolver — one link in a persona's context-building chain — into the host
8
+ # Scaffolds a Protege resolver — one link in an agent's context-building chain — into the host
9
9
  # app's resolvers directory (+config.resolvers_path+, default +app/resolvers+). A resolver
10
10
  # subclasses +Protege::Resolver+ and implements +resolve+, contributing zero or more
11
11
  # +Protege::ModelMessage+s to the prompt the harness sends the model.
12
12
  #
13
- # Unlike tools, resolvers are NOT auto-discovered: add the class to a persona's chain
13
+ # Unlike tools, resolvers are NOT auto-discovered: add the class to an agent's chain
14
14
  # (+chain.use MyResolver+) for it to run. The +Resolver+ suffix is applied for you.
15
15
  #
16
16
  # @example
@@ -2,10 +2,10 @@
2
2
 
3
3
  # <%= extension_class_name('Resolver') %> — contributes context to the prompt before the model runs.
4
4
  #
5
- # A resolver is one link in a persona's chain. Subclass +Protege::Resolver+, implement +#resolve+, and
5
+ # A resolver is one link in an agent's chain. Subclass +Protege::Resolver+, implement +#resolve+, and
6
6
  # add it to a chain to activate it (resolvers are NOT auto-discovered):
7
7
  #
8
- # # in app/personas/your_persona.rb
8
+ # # in app/agents/your_agent.rb
9
9
  # message_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # reply runs
10
10
  # responsibility_resolvers { |chain| chain.use <%= extension_class_name('Resolver') %> } # scheduled runs
11
11
  #
@@ -17,7 +17,7 @@ class <%= extension_class_name('Resolver') %> < Protege::Resolver
17
17
  # The +context+ tells you which kind of run this is (read it, never mutate it — it is frozen):
18
18
  # - reply run: +context.message+ is the inbound +Protege::Message+ (and +context.message.email_thread+)
19
19
  # - scheduled run: +context.responsibility+ is the +Protege::Responsibility+; +context.message+ is nil
20
- # Both always expose +context.persona+, +context.config+, and +context.logger+.
20
+ # Both always expose +context.agent+, +context.config+, and +context.logger+.
21
21
  #
22
22
  # Pick a +role+ for what you contribute: +:system+ for standing instructions/facts, +:user+ or
23
23
  # +:assistant+ for conversation turns, +:tool+ for a replayed tool result.
@@ -26,6 +26,6 @@ class <%= extension_class_name('Resolver') %> < Protege::Resolver
26
26
  # @return [Protege::ModelMessage, Array<Protege::ModelMessage>, nil] the contribution, or nil for none
27
27
  def resolve(context:)
28
28
  # TODO: build and return your context. Return nil when there is nothing to add.
29
- message(role: :system, content: "TODO: context for #{context.persona.name}")
29
+ message(role: :system, content: "TODO: context for #{context.agent.name}")
30
30
  end
31
31
  end
@@ -2,9 +2,11 @@
2
2
 
3
3
  # <%= extension_class_name('Tool') %> — a capability the agent can call mid-conversation.
4
4
  #
5
- # Subclassing +Protege::Tool+ registers this tool automatically (no wiring): the harness publishes it
6
- # in the model's tool catalog and routes matching tool calls to +#use+. The tool's id is derived from
7
- # this class name with the +Tool+ suffix stripped and snake_cased — here +:<%= extension_file_name('Tool').sub(/_tool\z/, '') %>+.
5
+ # Subclassing +Protege::Tool+ registers this tool automatically in the manifest; an agent can use it
6
+ # once it belongs to a toolkit attached to that agent (add it in the dashboard, or run
7
+ # `bin/rails protege:toolkits:sync` so the All Tools toolkit picks it up). When offered, the harness
8
+ # routes matching tool calls to +#use+. The tool's id is derived from this class name with the +Tool+
9
+ # suffix stripped and snake_cased — here +:<%= extension_file_name('Tool').sub(/_tool\z/, '') %>+.
8
10
  #
9
11
  # Fill in the three things the model relies on:
10
12
  # 1. +description+ — WHEN to reach for this tool and WHAT it does, written for the model to read.
@@ -40,11 +42,11 @@ class <%= extension_class_name('Tool') %> < Protege::Tool
40
42
  # the model as a failure, so raise freely for the genuinely exceptional.
41
43
  #
42
44
  # @param context [Protege::Orchestrator::Context] the run context — +context.message+ on a reply run
43
- # (and its +email_thread+), +context.responsibility+ on a scheduled run, plus +persona+/+logger+
45
+ # (and its +email_thread+), +context.responsibility+ on a scheduled run, plus +agent+/+logger+
44
46
  # @param example [String] the example argument declared in {input_schema} (rename to your own)
45
47
  # @return [Protege::Result] the outcome handed back to the model
46
48
  def use(context:, example:)
47
- # TODO: implement. `context` gives you the persona and the current message/thread or responsibility.
49
+ # TODO: implement. `context` gives you the agent and the current message/thread or responsibility.
48
50
  success(echo: example)
49
51
  end
50
52
  end
@@ -30,6 +30,13 @@ module Protege
30
30
  # Maximum tool-calling rounds before returning the last response.
31
31
  attr_accessor :max_tool_turns
32
32
 
33
+ # The +X-Protege-Recursion+ hop count at which inbound mail is silently dropped — the bound on
34
+ # agent-to-agent reply loops (see +Gateway::RECURSION_HEADER+). Agent-sent mail stamps the header
35
+ # (+1+ fresh, inbound plus one on a reply); a human replying anywhere resets the chain, since mail
36
+ # clients never echo the header back. Defaults to 50 — roomy enough for long agent-to-agent
37
+ # hand-offs while still bounding a true infinite loop.
38
+ attr_accessor :recursion_limit
39
+
33
40
  # Email address representing the dashboard user in local conversations. Messages from the
34
41
  # dashboard use this as their +from_address+, and outbound replies to this address skip SMTP
35
42
  # delivery.
@@ -50,11 +57,11 @@ module Protege
50
57
  attr_writer :logger
51
58
 
52
59
  # Destination directories the extension scaffold generators (+protege:tool+, +:resolver+, +:hook+,
53
- # +:persona+, +:provider+) write into, each relative to the app root. Defaults to the conventional
54
- # +app/tools+, +app/resolvers+, +app/hooks+, +app/personas+, +app/providers+. Override in the
60
+ # +:agent+, +:provider+) write into, each relative to the app root. Defaults to the conventional
61
+ # +app/tools+, +app/resolvers+, +app/hooks+, +app/agents+, +app/providers+. Override in the
55
62
  # initializer if a host groups its extensions elsewhere (e.g. +config.tools_path = 'app/agents/tools'+);
56
63
  # the target must stay under an autoloaded path so Rails still loads the extension.
57
- attr_accessor :tools_path, :resolvers_path, :hooks_path, :personas_path, :providers_path
64
+ attr_accessor :tools_path, :resolvers_path, :hooks_path, :agents_path, :providers_path
58
65
 
59
66
  # Per-provider options, keyed by provider id — assign a plain Hash (see {#providers}).
60
67
  attr_writer :providers
@@ -69,6 +76,11 @@ module Protege
69
76
  # {#tracing}). Off by default; nothing is captured until a host opts in.
70
77
  attr_writer :tracing
71
78
 
79
+ # Operational failure-alert options — assign a plain Hash, e.g.
80
+ # +config.failure_alerts = { to: ['ops@you.com'], from: 'alerts@your-domain.com' }+ (see
81
+ # {#failure_alerts}). Off by default; no alert mail is sent until a host sets +:to+.
82
+ attr_writer :failure_alerts
83
+
72
84
  # Initialize configuration with engine defaults.
73
85
  #
74
86
  # @return [void]
@@ -76,18 +88,20 @@ module Protege
76
88
  @nav_title = '🥚 Protege'
77
89
  @console_address = 'console@protege.local'
78
90
  @http_user_agent = DEFAULT_HTTP_USER_AGENT
79
- @max_tool_turns = 8
91
+ @max_tool_turns = 100
92
+ @recursion_limit = 50
80
93
  @provider_id = nil
81
94
  @logger = nil
82
95
  @tools_path = 'app/tools'
83
96
  @resolvers_path = 'app/resolvers'
84
97
  @hooks_path = 'app/hooks'
85
- @personas_path = 'app/personas'
98
+ @agents_path = 'app/agents'
86
99
  @providers_path = 'app/providers'
87
100
  @providers = nil
88
101
  @attachment_policy = nil
89
102
  @inbound_access = nil
90
103
  @tracing = nil
104
+ @failure_alerts = nil
91
105
  end
92
106
 
93
107
  # Return the configured logger, lazily resolving a default.
@@ -139,14 +153,14 @@ module Protege
139
153
  end
140
154
 
141
155
  # The global inbound access policy — the committed, org-wide ceiling on which senders may reach
142
- # *any* persona. This is the static layer of the access-control guardrail; the runtime,
143
- # per-persona layer lives in +Protege::AccessRule+ records, and +AccessControl+ intersects the two
156
+ # *any* agent. This is the static layer of the access-control guardrail; the runtime,
157
+ # per-agent layer lives in +Protege::AccessRule+ records, and +AccessControl+ intersects the two
144
158
  # (each layer can only narrow, never widen).
145
159
  #
146
160
  # Defaults to a bare permit-everyone policy, built lazily on first read so an unconfigured engine
147
161
  # imposes no constraint.
148
162
  #
149
- # @example Restrict every persona to the company domain
163
+ # @example Restrict every agent to the company domain
150
164
  # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
151
165
  #
152
166
  # @return [Protege::Gateway::AccessPolicy] the configured policy, or a permit-everyone default
@@ -167,5 +181,26 @@ module Protege
167
181
  def tracing
168
182
  @tracing ||= { enabled: false }
169
183
  end
184
+
185
+ # Operational failure-alert options, a plain Hash — the opt-in seam for emailing a platform admin
186
+ # when inference fails. Assigned wholesale in the initializer (like {#tracing}); assignment replaces
187
+ # the whole Hash. Recognised keys:
188
+ #
189
+ # * +:to+ — an Array of recipient addresses. Alerting is enabled only when this is present and
190
+ # non-empty, so an unconfigured engine (the default +{}+) sends nothing.
191
+ # * +:from+ — the sender address. For production deliverability this must be an address at a
192
+ # registered +EmailDomain+ so the self-hosted MTA DKIM-signs it and it passes that domain's DMARC
193
+ # policy; there is no safe way to deduce it, so it is set explicitly. Omitted → the
194
+ # +ApplicationMailer+ default sender (adequate only under the +:test+ delivery method).
195
+ #
196
+ # When +:to+ is set, +Protege::Subscribers::Alerter+ emails those recipients on inference failure.
197
+ #
198
+ # @example Turn failure alerts on
199
+ # config.failure_alerts = { to: ['ops@you.com'], from: 'alerts@agent.you.com' }
200
+ #
201
+ # @return [Hash] the failure-alert options, defaulting to +{}+ (disabled)
202
+ def failure_alerts
203
+ @failure_alerts ||= {}
204
+ end
170
205
  end
171
206
  end
@@ -83,11 +83,13 @@ module Protege
83
83
  # configuration, and installs the engine's event subscribers (all under +Protege::Subscribers+): the
84
84
  # introspection broadcaster (console activity feed), the hook dispatcher (fans events to host hooks —
85
85
  # its dispatch reads +HookDispatcher+'s registry live, so reloaded hooks are picked up without
86
- # re-subscribing), and tracing. Each exposes the +install!+ contract and subscribes once here.
86
+ # re-subscribing), tracing, and the alerter (emails the platform admin on inference failure when
87
+ # +config.failure_alerts+ opts in). Each exposes the +install!+ contract and subscribes once here.
87
88
  config.after_initialize do
88
89
  Protege::IntrospectionBroadcaster.install!
89
90
  Protege::Subscribers::HookDispatcher.install!
90
91
  Protege::Subscribers::Tracing.install!
92
+ Protege::Subscribers::Alerter.install!
91
93
  end
92
94
 
93
95
  # Return the absolute path to a file in the engine's lib/ directory. Used by the engine's rake tasks
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Protege
4
- # The model called a registered tool that is not in the current persona's scope — its code grant
5
- # minus +disabled_tool_ids+ (see +Protege::ToolScoped+). Distinct from +ToolNotFoundError+ (the tool
6
- # does not exist at all): here the tool exists but is withheld from this persona, so dispatch refuses
7
- # it before +#use+ runs and the model gets a serialized failure to self-correct from. Guards the case
8
- # where a tool disabled mid-thread is still visible to the model in earlier turns.
4
+ # The model called a registered tool that is not in the current agent's scope for this run — the tools
5
+ # its attached toolkits expose to the run's sender (see +Protege::Toolable+ / +AgentToolkit+). Distinct
6
+ # from +ToolNotFoundError+ (the tool does not exist at all): here the tool exists but is withheld from
7
+ # this agent, so dispatch refuses it before +#use+ runs and the model gets a serialized failure to
8
+ # self-correct from. Guards the case where a tool that left scope mid-thread is still visible to the
9
+ # model in earlier turns.
9
10
  class ToolNotAvailableError < Error
10
- def initialize(name:) = super("tool :#{name} is not available to this persona")
11
+ def initialize(name:) = super("tool :#{name} is not available to this agent")
11
12
  end
12
13
  end
@@ -10,12 +10,12 @@ module Protege
10
10
  # same class-attribute style as a provider's +protege_id+) and writes a plain reader per payload key.
11
11
  # Publishing and subscribing go through the subclass:
12
12
  #
13
- # Protege::InferenceCompletedEvent.emit(persona:, message:, result:) # engine internals
13
+ # Protege::InferenceCompletedEvent.emit(agent:, message:, result:) # engine internals
14
14
  # Protege::InferenceCompletedEvent.subscribe { |event| event.result } # host / broadcaster
15
15
  #
16
16
  # +correlation_id+ is injected automatically from +Protege::Current+ into every emitted event — set it
17
17
  # once in +AgentMailbox+ and it rides through the whole processing run. A subscriber's block receives a
18
- # frozen instance of the specific event class, so +event.persona+ / +event.result+ read the payload
18
+ # frozen instance of the specific event class, so +event.agent+ / +event.result+ read the payload
19
19
  # with named, nil-safe accessors (and +event[:key]+ reaches anything without a dedicated reader).
20
20
  class Event
21
21
  # @return [Hash] the underlying payload hash
@@ -5,7 +5,7 @@ module Protege
5
5
  class InferenceChunkEvent < Event
6
6
  channel 'protege.inference.chunk'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def thread = self[:thread]
10
10
  def chunk = self[:chunk]
11
11
 
@@ -5,7 +5,7 @@ module Protege
5
5
  class InferenceCompletedEvent < Event
6
6
  channel 'protege.inference.completed'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def message = self[:message]
10
10
  def result = self[:result]
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class InferenceFailedEvent < Event
6
6
  channel 'protege.inference.failed'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def message = self[:message]
10
10
  def error = self[:error]
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  # final tool-free turn. Unlike +InferenceCompletedEvent+ (one per run, carrying only the final
6
6
  # result), this carries the wire +request+ as sent, the +response+ it produced, and the inference
7
7
  # settings (+model+ + +settings+) that shaped it — everything a trace needs to be a training example,
8
- # and nothing else (no persona/provider identity). This is the seam the tracing subscriber listens on;
8
+ # and nothing else (no agent/provider identity). This is the seam the tracing subscriber listens on;
9
9
  # +correlation_id+ (from +Protege::Current+) and +turn+ group the turns of one run for dedup.
10
10
  class InferenceGeneratedEvent < Event
11
11
  channel 'protege.inference.generated'
@@ -5,7 +5,7 @@ module Protege
5
5
  class InferenceMaxTurnsReachedEvent < Event
6
6
  channel 'protege.inference.max_turns_reached'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def turn = self[:turn]
10
10
  def tool_calls = self[:tool_calls]
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class InferenceStartedEvent < Event
6
6
  channel 'protege.inference.started'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def message = self[:message]
10
10
  end
11
11
  end
@@ -6,6 +6,6 @@ module Protege
6
6
  channel 'protege.loop.run.completed'
7
7
 
8
8
  def name = self[:name]
9
- def persona_name = self[:persona_name]
9
+ def agent_name = self[:agent_name]
10
10
  end
11
11
  end
@@ -7,6 +7,6 @@ module Protege
7
7
  channel 'protege.loop.run.enqueued'
8
8
 
9
9
  def name = self[:name]
10
- def persona_name = self[:persona_name]
10
+ def agent_name = self[:agent_name]
11
11
  end
12
12
  end
@@ -6,7 +6,7 @@ module Protege
6
6
  channel 'protege.loop.run.failed'
7
7
 
8
8
  def name = self[:name]
9
- def persona_name = self[:persona_name]
9
+ def agent_name = self[:agent_name]
10
10
  def error = self[:error]
11
11
  end
12
12
  end
@@ -6,6 +6,6 @@ module Protege
6
6
  channel 'protege.loop.run.started'
7
7
 
8
8
  def name = self[:name]
9
- def persona_name = self[:persona_name]
9
+ def agent_name = self[:agent_name]
10
10
  end
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class ToolCallCompletedEvent < Event
6
6
  channel 'protege.inference.tool_call.completed'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def tool_call = self[:tool_call]
10
10
  def result = self[:result]
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class ToolCallFailedEvent < Event
6
6
  channel 'protege.inference.tool_call.failed'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def tool_call = self[:tool_call]
10
10
  def error = self[:error]
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class ToolCallStartedEvent < Event
6
6
  channel 'protege.inference.tool_call.started'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def tool_call = self[:tool_call]
10
10
  end
11
11
  end
@@ -5,7 +5,7 @@ module Protege
5
5
  class ToolCallsReceivedEvent < Event
6
6
  channel 'protege.inference.tool_calls.received'
7
7
 
8
- def persona = self[:persona]
8
+ def agent = self[:agent]
9
9
  def tool_calls = self[:tool_calls]
10
10
  end
11
11
  end
@@ -9,5 +9,6 @@ module Protege
9
9
  # installed once at boot.
10
10
  class Hook
11
11
  include HookMixin
12
+ include Protege::Manifested
12
13
  end
13
14
  end
@@ -17,7 +17,7 @@ module Protege
17
17
  # @example
18
18
  # class EventLoggerHook < Protege::Hook
19
19
  # on Protege::InferenceStartedEvent do |event|
20
- # log "START #{event.persona&.name}"
20
+ # log "START #{event.agent&.name}"
21
21
  # end
22
22
  #
23
23
  # on Protege::ToolCallStartedEvent, Protege::ToolCallCompletedEvent do |event|
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The read-only facade over the extension registries — one place the dashboard asks "what extensions of
5
+ # this type exist, and what is their metadata?". Generalizes the tools-only +ToolMixin.registered+ into a
6
+ # uniform lookup across all four types, each row a plain {Entry} carrying the {Manifested} metadata plus
7
+ # the class itself.
8
+ module Manifest
9
+ # One registered extension, flattened to its manifest metadata for the UI and for bindings. +summary+
10
+ # is the short human-facing blurb; +description+ is the verbose, LLM-facing text (a tool's schema text).
11
+ Entry = Data.define(:type, :id, :display_name, :summary, :description, :klass)
12
+
13
+ # The extension base class for each manifest type (referenced lazily so the facade loads before them).
14
+ BASES = {
15
+ tool: 'Protege::Tool',
16
+ provider: 'Protege::Provider',
17
+ hook: 'Protege::Hook',
18
+ resolver: 'Protege::Resolver'
19
+ }.freeze
20
+
21
+ class << self
22
+ # Every registered extension of a type, as {Entry}s sorted by display name.
23
+ #
24
+ # @param type [Symbol] one of +:tool+, +:provider+, +:hook+, +:resolver+
25
+ # @return [Array<Entry>] the registered extensions' manifest entries
26
+ def registered(type)
27
+ base(type).registered.map { |klass| entry_for(type:, klass:) }.sort_by(&:display_name)
28
+ end
29
+
30
+ # The manifest entry for a single extension id, or nil when none is registered under it.
31
+ #
32
+ # @param type [Symbol] the manifest type
33
+ # @param id [Symbol] the extension id to look up
34
+ # @return [Entry, nil] the matching entry, or nil
35
+ def entry(type:, id:)
36
+ klass = base(type).registered.find { |candidate| candidate.id == id }
37
+ entry_for(type:, klass:) if klass
38
+ end
39
+
40
+ private
41
+
42
+ # Resolve a manifest type to its extension base class.
43
+ #
44
+ # @param type [Symbol] the manifest type
45
+ # @return [Class] the extension base class
46
+ def base(type)
47
+ BASES.fetch(type).constantize
48
+ end
49
+
50
+ # Build an {Entry} from a registered extension class.
51
+ #
52
+ # @param type [Symbol] the manifest type
53
+ # @param klass [Class] the extension class
54
+ # @return [Entry] the manifest entry
55
+ def entry_for(type:, klass:)
56
+ Entry.new(
57
+ type:,
58
+ id: klass.id,
59
+ display_name: klass.display_name,
60
+ summary: klass.summary,
61
+ description: klass.description,
62
+ klass:
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Uniform, UI-facing metadata for every registered extension — the "Manifest" layer of the extension
5
+ # framework. Mixed once into each extension base class (+Protege::Tool+/+Provider+/+Hook+/+Resolver+) so
6
+ # a dashboard can list any of them by the same handful of attributes, regardless of how each type
7
+ # registers or activates. Defining the surface here, in one concern, is what lets bindings (e.g.
8
+ # Toolkits) reference extensions uniformly instead of hand-wiring a registry per type.
9
+ #
10
+ # Provides, at the class level: a stable +id+ (name-derived, or explicit via {ClassMethods#manifest_id}),
11
+ # a human +display_name+ (defaulting to the titleized id), an optional +description+, and +registered+
12
+ # (the loaded, named concrete subclasses of the base it is called on). Enumeration is descendant-based,
13
+ # like the tools registry, so it survives Zeitwerk reloads and skips anonymous classes.
14
+ module Manifested
15
+ extend ActiveSupport::Concern
16
+
17
+ # Class-level metadata surface added to every extension base.
18
+ module ClassMethods
19
+ # The extension's stable symbolic id — the manifest key a binding references and the dashboard
20
+ # shows. Defaults to the class basename with a trailing +Tool+/+Provider+/+Hook+/+Resolver+ suffix
21
+ # stripped and the rest snake_cased; override with {#manifest_id} when the id must be explicit (a
22
+ # provider, whose id is matched against +config.provider_id+). Memoized per class.
23
+ #
24
+ # @return [Symbol] the extension id
25
+ def id
26
+ @id ||= name.demodulize.sub(/(?:Tool|Provider|Hook|Resolver)\z/, '').underscore.to_sym
27
+ end
28
+
29
+ # Declare an explicit id, overriding the name-derived default.
30
+ #
31
+ # @param value [Symbol, String] the explicit id
32
+ # @return [Symbol] the stored id
33
+ def manifest_id(value)
34
+ @id = value.to_sym
35
+ end
36
+
37
+ # A human-readable label for the dashboard: the declared name, or the titleized id by default. Pass
38
+ # a value to set it.
39
+ #
40
+ # @param value [String, nil] the display name to set, or nil to read
41
+ # @return [String] the display name
42
+ def display_name(value = nil)
43
+ return @display_name || id.to_s.titleize if value.nil?
44
+
45
+ @display_name = value
46
+ end
47
+
48
+ # A natural-language description of the extension: the declared value, or nil when unset. Pass a
49
+ # value to set it. For a tool this is the *LLM-facing* text published in the model's tool schema —
50
+ # verbose and model-directed. For a short, human-facing dashboard blurb, use {#summary} instead.
51
+ #
52
+ # @param value [String, nil] the description to set, or nil to read
53
+ # @return [String, nil] the description
54
+ def description(value = nil)
55
+ return @description if value.nil?
56
+
57
+ @description = value
58
+ end
59
+
60
+ # A short, human-facing one-liner for the dashboard — what an operator reads when picking this
61
+ # extension. Distinct from {#description}, which for tools is the verbose text sent to the model.
62
+ # The declared value, or nil when unset. Pass a value to set it.
63
+ #
64
+ # @param value [String, nil] the summary to set, or nil to read
65
+ # @return [String, nil] the summary
66
+ def summary(value = nil)
67
+ return @summary if value.nil?
68
+
69
+ @summary = value
70
+ end
71
+
72
+ # The loaded, named concrete subclasses of this extension base — its registry. Anonymous classes
73
+ # (nil name) are skipped; descendant-based so it survives Zeitwerk reloads without bookkeeping.
74
+ #
75
+ # @return [Array<Class>] the registered extension classes
76
+ def registered
77
+ descendants.reject { _1.name.nil? }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -8,5 +8,6 @@ module Protege
8
8
  # exchanges are the +Inference::Provider::*+ types.
9
9
  class Provider
10
10
  include ProviderMixin
11
+ include Protege::Manifested
11
12
  end
12
13
  end
@@ -80,7 +80,7 @@ module Protege
80
80
 
81
81
  # The model id this provider targets, read from its own +config.providers+ slice (+:model+).
82
82
  # Public so the harness can record which model produced a turn (for tracing); resolved per call, so
83
- # a future persona-level override is reflected here. Providers may treat a missing model as a hard
83
+ # a future agent-level override is reflected here. Providers may treat a missing model as a hard
84
84
  # error at generation time — that enforcement is theirs, not this soft reader's.
85
85
  #
86
86
  # @return [String, nil] the configured model id, or nil when unset
@@ -97,27 +97,19 @@ module Protege
97
97
  {}
98
98
  end
99
99
 
100
- # Class-level DSL provided by the mixin.
100
+ # Class-level DSL provided by the mixin. The provider's +id+ (and the rest of its manifest metadata)
101
+ # comes from the shared +Extensions::Manifested+ concern; +protege_id+ is the provider-facing name for
102
+ # its explicit-id override, kept because a provider's id must match +config.provider_id+ exactly rather
103
+ # than be derived from the class name.
101
104
  module ClassMethods
102
- # Declare the symbolic identifier this provider responds to.
103
- #
104
- # Call inside the class body; the configured +provider_id+ references the provider by this symbol.
105
+ # Declare the symbolic identifier this provider responds to — an explicit override of the
106
+ # name-derived manifest id. Call inside the class body; the configured +provider_id+ references the
107
+ # provider by this symbol.
105
108
  #
106
109
  # @param symbol [Symbol] the provider's unique identifier (e.g. +:openrouter+).
107
110
  # @return [Symbol] the stored identifier.
108
111
  def protege_id(symbol)
109
- @protege_id = symbol
110
- end
111
-
112
- # Return the declared symbolic id.
113
- #
114
- # @return [Symbol] the id set via +protege_id+.
115
- # @raise [Protege::ContractViolationError] when the class never called +protege_id+.
116
- def id
117
- @protege_id || raise(
118
- Protege::ContractViolationError,
119
- "#{self} must declare its id with `protege_id :<symbol>` inside the class body"
120
- )
112
+ manifest_id(symbol)
121
113
  end
122
114
  end
123
115
  end
@@ -5,8 +5,9 @@ module Protege
5
5
  # prompts, history, retrieved knowledge). Host apps subclass +Protege::Resolver+ and implement
6
6
  # +#resolve(context:)+, returning an +Array<Protege::ModelMessage>+ or nil. The contract lives
7
7
  # in +ResolverMixin+, which this includes. Unlike tools, resolvers are not auto-registered — they are
8
- # added explicitly to a persona's +ResolverChain+.
8
+ # added explicitly to an agent's +ResolverChain+.
9
9
  class Resolver
10
10
  include ResolverMixin
11
+ include Protege::Manifested
11
12
  end
12
13
  end
@@ -6,7 +6,7 @@ module Protege
6
6
  # or +nil+ to contribute nothing. Resolvers are the Orchestrator's mechanism for assembling
7
7
  # inference context — system prompts, conversation history, retrieved knowledge.
8
8
  #
9
- # Unlike +Tool+, resolvers are NOT auto-registered. They are explicitly added to a persona's
9
+ # Unlike +Tool+, resolvers are NOT auto-registered. They are explicitly added to an agent's
10
10
  # +ResolverChain+ at declaration time. Host resolvers subclass +Protege::Resolver+, which
11
11
  # includes this module.
12
12
  #
@@ -20,7 +20,7 @@ module Protege
20
20
  # @example
21
21
  # class MyResolver < Protege::Resolver
22
22
  # def resolve(context:)
23
- # message(role: :system, content: "You are #{context.persona.name}.")
23
+ # message(role: :system, content: "You are #{context.agent.name}.")
24
24
  # end
25
25
  # end
26
26
  module ResolverMixin
@@ -8,5 +8,6 @@ module Protege
8
8
  # is auto-discovered via +Tool.descendants+ (see +ToolMixin.registered+).
9
9
  class Tool
10
10
  include ToolMixin
11
+ include Protege::Manifested
11
12
  end
12
13
  end