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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +21 -0
- data/Rakefile +1 -3
- data/app/assets/builds/protege.css +76 -83
- data/app/controllers/concerns/protege/agent_scoped.rb +22 -0
- data/app/controllers/concerns/protege/agent_toolkit_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +13 -13
- data/app/controllers/protege/agent_toolkit_rules_controller.rb +62 -0
- data/app/controllers/protege/agent_toolkits_controller.rb +44 -0
- data/app/controllers/protege/agents/agent_toolkits_controller.rb +65 -0
- data/app/controllers/protege/agents_controller.rb +118 -0
- data/app/controllers/protege/archives_controller.rb +18 -18
- data/app/controllers/protege/home_controller.rb +1 -1
- data/app/controllers/protege/messages_controller.rb +1 -1
- data/app/controllers/protege/replies_controller.rb +3 -3
- data/app/controllers/protege/responsibilities_controller.rb +7 -7
- data/app/controllers/protege/threads_controller.rb +4 -4
- data/app/controllers/protege/toolkits_controller.rb +84 -0
- data/app/helpers/protege/agents_helper.rb +75 -0
- data/app/helpers/protege/application_helper.rb +3 -2
- data/app/helpers/protege/components/badges_helper.rb +11 -2
- data/app/helpers/protege/components/forms_helper.rb +47 -12
- data/app/helpers/protege/components/typography_helper.rb +1 -1
- data/app/helpers/protege/messages_helper.rb +2 -2
- data/app/helpers/protege/responsibilities_helper.rb +2 -2
- data/app/helpers/protege/threads_helper.rb +11 -11
- data/app/helpers/protege/toolkits_helper.rb +66 -0
- data/app/hooks/protege/event_logger_hook.rb +8 -8
- data/app/jobs/protege/console_inference_job.rb +8 -8
- data/app/jobs/protege/inference_job.rb +11 -11
- data/app/jobs/protege/responsibility_job.rb +10 -10
- data/app/mailboxes/protege/agent_mailbox.rb +54 -29
- data/app/mailers/protege/alert_mailer.rb +41 -0
- data/app/mailers/protege/application_mailer.rb +9 -8
- data/app/models/concerns/protege/broadcastable_agent.rb +50 -0
- data/app/models/concerns/protege/broadcastable_toolkit.rb +49 -0
- data/app/models/concerns/protege/toolable.rb +108 -0
- data/app/models/protege/access_rule.rb +12 -12
- data/app/models/protege/{persona.rb → agent.rb} +45 -45
- data/app/models/protege/agent_toolkit.rb +50 -0
- data/app/models/protege/agent_toolkit_rule.rb +25 -0
- data/app/models/protege/application_record.rb +1 -1
- data/app/models/protege/email_thread.rb +16 -13
- data/app/models/protege/message.rb +36 -20
- data/app/models/protege/responsibility.rb +12 -12
- data/app/models/protege/tool_use.rb +6 -6
- data/app/models/protege/toolkit.rb +115 -0
- data/app/models/protege/trace.rb +1 -1
- data/app/providers/protege/open_router_provider.rb +17 -0
- data/app/resolvers/README.md +4 -4
- data/app/resolvers/protege/load_file_resolver.rb +5 -5
- data/app/resolvers/protege/load_text_resolver.rb +3 -3
- data/app/resolvers/protege/thread_history_resolver.rb +1 -1
- data/app/services/protege/message_search.rb +2 -2
- data/app/services/protege/system_toolkits.rb +67 -0
- data/app/tools/protege/create_file_tool.rb +2 -0
- data/app/tools/protege/read_attachment_tool.rb +3 -1
- data/app/tools/protege/search_emails_tool.rb +15 -13
- data/app/tools/protege/send_email_tool.rb +60 -18
- data/app/tools/protege/web_fetch_tool.rb +2 -0
- data/app/tools/protege/web_search_tool.rb +3 -1
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +3 -3
- data/app/views/protege/agent_toolkit_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/agent_toolkits/_gate_rule.html.slim +7 -0
- data/app/views/protege/agent_toolkits/_gate_rule_form.html.slim +8 -0
- data/app/views/protege/agent_toolkits/_gate_rules.html.slim +19 -0
- data/app/views/protege/agent_toolkits/show.html.slim +17 -0
- data/app/views/protege/{personas → agents}/_access_rule.html.slim +1 -1
- data/app/views/protege/{personas → agents}/_access_rule_form.html.slim +1 -1
- data/app/views/protege/{personas → agents}/_access_rules.html.slim +5 -5
- data/app/views/protege/agents/_actions.html.slim +15 -0
- data/app/views/protege/agents/_agent_sidebar_item.html.slim +5 -0
- data/app/views/protege/agents/_agent_toolkit.html.slim +7 -0
- data/app/views/protege/agents/_agent_toolkit_form.html.slim +7 -0
- data/app/views/protege/agents/_form.html.slim +25 -0
- data/app/views/protege/agents/_sidebar.html.slim +11 -0
- data/app/views/protege/agents/_toolkits.html.slim +19 -0
- data/app/views/protege/agents/agent_toolkits/create.turbo_stream.slim +6 -0
- data/app/views/protege/agents/edit.html.slim +7 -0
- data/app/views/protege/agents/index.html.slim +6 -0
- data/app/views/protege/agents/new.html.slim +7 -0
- data/app/views/protege/agents/show.html.slim +29 -0
- data/app/views/protege/alert_mailer/inference_failed.text.erb +4 -0
- data/app/views/protege/email_domains/show.html.slim +1 -1
- data/app/views/protege/home/show.html.slim +13 -13
- data/app/views/protege/responsibilities/_form.html.slim +2 -2
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +1 -1
- data/app/views/protege/responsibilities/new.html.slim +1 -1
- data/app/views/protege/responsibilities/show.html.slim +2 -2
- data/app/views/protege/responsibility_runs/show.html.slim +1 -1
- data/app/views/protege/shared/_header.html.slim +2 -1
- data/app/views/protege/shared/_hotkeys.html.slim +15 -11
- data/app/views/protege/threads/new.html.slim +2 -2
- data/app/views/protege/toolkits/_form.html.slim +13 -0
- data/app/views/protege/toolkits/_sidebar.html.slim +11 -0
- data/app/views/protege/toolkits/_toolkit_sidebar_item.html.slim +5 -0
- data/app/views/protege/toolkits/edit.html.slim +7 -0
- data/app/views/protege/toolkits/index.html.slim +6 -0
- data/app/views/protege/toolkits/new.html.slim +7 -0
- data/app/views/protege/toolkits/show.html.slim +13 -0
- data/config/routes.rb +12 -1
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +1 -1
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +1 -1
- data/db/migrate/20260728140000_scope_message_and_thread_uniqueness_per_persona.rb +16 -0
- data/db/migrate/20260812000001_create_protege_toolkits.rb +37 -0
- data/db/migrate/20260815000001_remove_disabled_tool_ids_from_personas.rb +21 -0
- data/db/migrate/20260815000002_add_key_to_protege_toolkits.rb +22 -0
- data/db/migrate/20260815100000_rename_personas_to_agents.rb +24 -0
- data/lib/generators/protege/agent/agent_generator.rb +35 -0
- data/lib/generators/protege/{persona/templates/persona.rb.tt → agent/templates/agent.rb.tt} +9 -9
- data/lib/generators/protege/extension_naming.rb +1 -1
- data/lib/generators/protege/hook/templates/hook.rb.tt +2 -2
- data/lib/generators/protege/install/install_generator.rb +16 -11
- data/lib/generators/protege/install/templates/initializer.rb.tt +17 -6
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +2 -2
- data/lib/generators/protege/resolver/resolver_generator.rb +2 -2
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +4 -4
- data/lib/generators/protege/tool/templates/tool.rb.tt +7 -5
- data/lib/protege/configuration.rb +43 -8
- data/lib/protege/engine.rb +3 -1
- data/lib/protege/errors/tool_not_available_error.rb +7 -6
- data/lib/protege/events/event.rb +2 -2
- data/lib/protege/events/inference_chunk_event.rb +1 -1
- data/lib/protege/events/inference_completed_event.rb +1 -1
- data/lib/protege/events/inference_failed_event.rb +1 -1
- data/lib/protege/events/inference_generated_event.rb +1 -1
- data/lib/protege/events/inference_max_turns_reached_event.rb +1 -1
- data/lib/protege/events/inference_started_event.rb +1 -1
- data/lib/protege/events/loop_run_completed_event.rb +1 -1
- data/lib/protege/events/loop_run_enqueued_event.rb +1 -1
- data/lib/protege/events/loop_run_failed_event.rb +1 -1
- data/lib/protege/events/loop_run_started_event.rb +1 -1
- data/lib/protege/events/tool_call_completed_event.rb +1 -1
- data/lib/protege/events/tool_call_failed_event.rb +1 -1
- data/lib/protege/events/tool_call_started_event.rb +1 -1
- data/lib/protege/events/tool_calls_received_event.rb +1 -1
- data/lib/protege/extensions/hook.rb +1 -0
- data/lib/protege/extensions/hook_mixin.rb +1 -1
- data/lib/protege/extensions/manifest.rb +67 -0
- data/lib/protege/extensions/manifested.rb +81 -0
- data/lib/protege/extensions/provider.rb +1 -0
- data/lib/protege/extensions/provider_mixin.rb +9 -17
- data/lib/protege/extensions/resolver.rb +2 -1
- data/lib/protege/extensions/resolver_mixin.rb +2 -2
- data/lib/protege/extensions/tool.rb +1 -0
- data/lib/protege/extensions/tool_mixin.rb +16 -40
- data/lib/protege/gateway/access_control.rb +19 -19
- data/lib/protege/gateway/access_policy.rb +2 -2
- data/lib/protege/gateway/mail/outbound.rb +22 -5
- data/lib/protege/gateway.rb +48 -37
- data/lib/protege/loop/scheduler.rb +1 -1
- data/lib/protege/orchestrator/context.rb +8 -8
- data/lib/protege/orchestrator/harness.rb +26 -24
- data/lib/protege/orchestrator/reply_context.rb +3 -3
- data/lib/protege/orchestrator/reply_harness.rb +8 -8
- data/lib/protege/orchestrator/resolver_chain.rb +3 -3
- data/lib/protege/orchestrator/responsibility_context.rb +4 -4
- data/lib/protege/orchestrator/responsibility_harness.rb +8 -8
- data/lib/protege/orchestrator.rb +7 -7
- data/lib/protege/subscribers/alerter.rb +117 -0
- data/lib/protege/subscribers/tracing.rb +1 -1
- data/lib/protege/version.rb +1 -1
- data/lib/protege.rb +2 -2
- data/lib/tasks/protege_tasks.rake +12 -0
- metadata +66 -23
- data/app/controllers/concerns/protege/persona_scoped.rb +0 -22
- data/app/controllers/protege/personas_controller.rb +0 -118
- data/app/helpers/protege/personas_helper.rb +0 -90
- data/app/models/concerns/protege/broadcastable_persona.rb +0 -50
- data/app/models/concerns/protege/tool_scoped.rb +0 -88
- data/app/views/protege/personas/_actions.html.slim +0 -15
- data/app/views/protege/personas/_form.html.slim +0 -29
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +0 -5
- data/app/views/protege/personas/_sidebar.html.slim +0 -11
- data/app/views/protege/personas/edit.html.slim +0 -7
- data/app/views/protege/personas/index.html.slim +0 -6
- data/app/views/protege/personas/new.html.slim +0 -7
- data/app/views/protege/personas/show.html.slim +0 -37
- data/lib/generators/protege/persona/persona_generator.rb +0 -35
|
@@ -6,10 +6,12 @@ module Protege
|
|
|
6
6
|
# their description and JSON Schema via the class-level DSL, and implement +#use(context:, **input)+.
|
|
7
7
|
#
|
|
8
8
|
# Registration is automatic: every +Tool+ subclass is discoverable via +Tool.registered+
|
|
9
|
-
# (resolved from +Tool.descendants+, so it survives Zeitwerk reloads)
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# (resolved from +Tool.descendants+, so it survives Zeitwerk reloads). Availability is not — the
|
|
10
|
+
# +Harness+ builds the LLM-visible catalog from the agent's attached toolkits
|
|
11
|
+
# (+Agent#available_tools_for+, fail-closed), so a registered tool reaches the model only through a
|
|
12
|
+
# toolkit that contains it. Tools have no +protege_id+ DSL — the id is derived from the class
|
|
13
|
+
# basename with the +Tool+ suffix stripped, then snake_cased (+SendEmailTool+ → +:send_email+,
|
|
14
|
+
# +HTTPFetchTool+ → +:http_fetch+).
|
|
13
15
|
#
|
|
14
16
|
# Tools must return a +Protege::Result+ from +#use+ — construct one via +Result.success(**data)+ /
|
|
15
17
|
# +Result.failure(reason:, **data)+ (or the +#success+/+#failure+ helpers). The +Harness+ serializes
|
|
@@ -81,17 +83,17 @@ module Protege
|
|
|
81
83
|
# +Protege::InvalidToolResultError+, rather than blowing up later when the +Harness+
|
|
82
84
|
# calls +#success?+ on the bad value.
|
|
83
85
|
#
|
|
84
|
-
# Enforcement of tool scoping lives here: a call to any tool outside the
|
|
85
|
-
# (
|
|
86
|
-
# runs. Advertising a narrowed catalogue in the request
|
|
87
|
-
# stays visible to the model in earlier turns and
|
|
88
|
-
# backstop.
|
|
86
|
+
# Enforcement of tool scoping lives here: a call to any tool outside the agent's effective scope
|
|
87
|
+
# for this run (the tools its attached toolkits expose to the run's sender, read from
|
|
88
|
+
# +context.agent+) is refused before +#use+ runs. Advertising a narrowed catalogue in the request
|
|
89
|
+
# isn't enough — a tool that left scope mid-thread stays visible to the model in earlier turns and
|
|
90
|
+
# could still be called — so dispatch is the backstop.
|
|
89
91
|
#
|
|
90
92
|
# @param tool_call [Object] the tool call carrying name and input
|
|
91
93
|
# @param context [Protege::Orchestrator::Context] the per-delivery tool context
|
|
92
94
|
# @return [Protege::ToolResult] the call paired with its result
|
|
93
95
|
def invoke(tool_call, context:)
|
|
94
|
-
if context.
|
|
96
|
+
if context.agent.available_tool_ids_for(context:).exclude?(tool_call.name.to_sym)
|
|
95
97
|
return failed_tool_result(tool_call:,
|
|
96
98
|
error: Protege::ToolNotAvailableError.new(name: tool_call.name))
|
|
97
99
|
end
|
|
@@ -170,21 +172,11 @@ module Protege
|
|
|
170
172
|
Result.failure(reason:, **data)
|
|
171
173
|
end
|
|
172
174
|
|
|
173
|
-
# Class-level DSL mixed into every tool class via +Tool.included+.
|
|
175
|
+
# Class-level DSL mixed into every tool class via +Tool.included+. The tool's +id+, +description+, and
|
|
176
|
+
# +display_name+ come from the shared +Extensions::Manifested+ concern (a tool's +id+ is its class
|
|
177
|
+
# basename with the +Tool+ suffix stripped, e.g. +SendEmailTool → :send_email+); only +input_schema+,
|
|
178
|
+
# which describes call-time input rather than manifest metadata, lives here.
|
|
174
179
|
module ClassMethods
|
|
175
|
-
# Declare or read the natural-language description shown to the LLM in the tool catalog.
|
|
176
|
-
#
|
|
177
|
-
# With an argument, sets the description; with no argument, returns the previously declared
|
|
178
|
-
# value.
|
|
179
|
-
#
|
|
180
|
-
# @param text [String, nil] the description to set, or nil to read
|
|
181
|
-
# @return [String, nil] the current description
|
|
182
|
-
def description(text = nil)
|
|
183
|
-
return @description if text.nil?
|
|
184
|
-
|
|
185
|
-
@description = text
|
|
186
|
-
end
|
|
187
|
-
|
|
188
180
|
# Declare or read the JSON Schema describing this tool's input parameters.
|
|
189
181
|
#
|
|
190
182
|
# With an argument, sets the schema; with no argument, returns the previously declared value.
|
|
@@ -196,22 +188,6 @@ module Protege
|
|
|
196
188
|
|
|
197
189
|
@input_schema = schema
|
|
198
190
|
end
|
|
199
|
-
|
|
200
|
-
# Return the tool's symbolic identifier, derived from the class basename with the +Tool+
|
|
201
|
-
# suffix stripped and the rest snake_cased. Every concrete tool is named +<Purpose>Tool+ by
|
|
202
|
-
# convention (so tool classes never collide with same-named models), yet the id the agent sees
|
|
203
|
-
# stays clean. Memoized per class.
|
|
204
|
-
#
|
|
205
|
-
# @example
|
|
206
|
-
# SendEmailTool # => :send_email
|
|
207
|
-
# HTTPFetchTool # => :http_fetch
|
|
208
|
-
# EchoTool # => :echo
|
|
209
|
-
# Read2Tool # => :read2
|
|
210
|
-
#
|
|
211
|
-
# @return [Symbol] the tool id
|
|
212
|
-
def id
|
|
213
|
-
@id ||= name.demodulize.delete_suffix('Tool').underscore.to_sym
|
|
214
|
-
end
|
|
215
191
|
end
|
|
216
192
|
end
|
|
217
193
|
end
|
|
@@ -2,45 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
module Protege
|
|
4
4
|
module Gateway
|
|
5
|
-
# The inbound access guardrail: composes every access-policy *layer* that applies to
|
|
6
|
-
# answers the one question the Gateway asks — may this sender reach this
|
|
5
|
+
# The inbound access guardrail: composes every access-policy *layer* that applies to an agent and
|
|
6
|
+
# answers the one question the Gateway asks — may this sender reach this agent? A gateway concern
|
|
7
7
|
# (inbound mail authorization), it lives beside the other Gateway protocol pieces and is called from
|
|
8
8
|
# the Gateway's inbound edge, +AgentMailbox+.
|
|
9
9
|
#
|
|
10
10
|
# Composition is by **intersection**: a sender is permitted only when *every* layer permits it
|
|
11
11
|
# (logical AND). Because intersection is commutative there is no precedence to reason about — the
|
|
12
12
|
# order of layers never changes the outcome — and the security property falls out for free: a layer
|
|
13
|
-
# can only *narrow* the set of admitted senders, never widen it. So a per-
|
|
13
|
+
# can only *narrow* the set of admitted senders, never widen it. So a per-agent rule can tighten
|
|
14
14
|
# who reaches that agent but can never punch through the committed org-wide ceiling.
|
|
15
15
|
#
|
|
16
16
|
# Two layers exist today — the committed global policy (+Protege.configuration.inbound_access+) and
|
|
17
|
-
# the
|
|
18
|
-
# the engine calls; adding a layer later (e.g. a per-
|
|
17
|
+
# the agent's runtime rules (+AccessRule.policy_for+). This composer is the single seam the rest of
|
|
18
|
+
# the engine calls; adding a layer later (e.g. a per-agent-class committed policy) is a one-line
|
|
19
19
|
# change to {#policies}, never a change at the call sites.
|
|
20
20
|
#
|
|
21
21
|
# @example Gate an inbound sender
|
|
22
|
-
# Gateway::AccessControl.for(
|
|
22
|
+
# Gateway::AccessControl.for(agent:).permits?(address: mail.from.first)
|
|
23
23
|
class AccessControl
|
|
24
|
-
# @return [Protege::
|
|
25
|
-
attr_reader :
|
|
24
|
+
# @return [Protege::Agent] the agent the inbound mail is addressed to
|
|
25
|
+
attr_reader :agent
|
|
26
26
|
|
|
27
27
|
class << self
|
|
28
|
-
# Build the access control for
|
|
28
|
+
# Build the access control for an agent. Factory mirror of +.new+ reading as a lookup.
|
|
29
29
|
#
|
|
30
|
-
# @param
|
|
30
|
+
# @param agent [Protege::Agent] the routed agent
|
|
31
31
|
# @return [Protege::Gateway::AccessControl] the composed guardrail
|
|
32
|
-
def for(
|
|
33
|
-
new(
|
|
32
|
+
def for(agent:)
|
|
33
|
+
new(agent:)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# @param
|
|
37
|
+
# @param agent [Protege::Agent] the routed agent
|
|
38
38
|
# @return [void]
|
|
39
|
-
def initialize(
|
|
40
|
-
@
|
|
39
|
+
def initialize(agent:)
|
|
40
|
+
@agent = agent
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Decide whether a sender may reach this
|
|
43
|
+
# Decide whether a sender may reach this agent, intersecting all applicable layers.
|
|
44
44
|
#
|
|
45
45
|
# @param address [String] the raw sender address
|
|
46
46
|
# @return [Boolean] true only when every layer permits the sender
|
|
@@ -50,15 +50,15 @@ module Protege
|
|
|
50
50
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
|
-
# The ordered (order-immaterial) list of policy layers that apply to this
|
|
53
|
+
# The ordered (order-immaterial) list of policy layers that apply to this agent.
|
|
54
54
|
#
|
|
55
55
|
# Add a layer here — and only here — to extend the guardrail.
|
|
56
56
|
#
|
|
57
|
-
# @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the
|
|
57
|
+
# @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the agent's runtime policy
|
|
58
58
|
def policies
|
|
59
59
|
[
|
|
60
60
|
Protege.configuration.inbound_access,
|
|
61
|
-
AccessRule.policy_for(
|
|
61
|
+
AccessRule.policy_for(agent)
|
|
62
62
|
]
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -6,7 +6,7 @@ module Protege
|
|
|
6
6
|
# sender address is permitted, given an allow-list and a deny-list of address patterns. A Gateway
|
|
7
7
|
# value type (inbound authorization) — built through the +Gateway.build_access_policy+ factory,
|
|
8
8
|
# composed by +Gateway::AccessControl+, and assigned as the global ceiling via +config.inbound_access+
|
|
9
|
-
# or per-
|
|
9
|
+
# or per-agent by +AccessRule.policy_for+:
|
|
10
10
|
#
|
|
11
11
|
# config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
|
|
12
12
|
#
|
|
@@ -19,7 +19,7 @@ module Protege
|
|
|
19
19
|
# Patterns are matched against the sender's tag-stripped, lowercased +local@domain+ routing key (so
|
|
20
20
|
# +ceo+finance@Company.CO+ matches a +ceo@company.co+ rule), and support a single +*+ wildcard
|
|
21
21
|
# (e.g. +*@company.co+). This object knows nothing about layering; composing several policies into
|
|
22
|
-
# the org→
|
|
22
|
+
# the org→agent guardrail is +AccessControl+'s job.
|
|
23
23
|
#
|
|
24
24
|
# @example The identity policy — a bare +build_access_policy+ permits everyone (empty lists derive a
|
|
25
25
|
# default-allow), so it drops out of the +AccessControl+ intersection as a no-op.
|
|
@@ -15,7 +15,7 @@ module Protege
|
|
|
15
15
|
#
|
|
16
16
|
# @example A threaded reply
|
|
17
17
|
# Outbound.build(
|
|
18
|
-
# from:
|
|
18
|
+
# from: agent.email_address,
|
|
19
19
|
# to: inbound.from_address,
|
|
20
20
|
# subject: 'Re: Hello',
|
|
21
21
|
# body: 'Thanks!',
|
|
@@ -37,6 +37,9 @@ module Protege
|
|
|
37
37
|
# Strings are split on whitespace, Arrays/References normalized per id.
|
|
38
38
|
# @param attachments [Array<Mail::Attachment>] files to attach; when any are present the message
|
|
39
39
|
# is assembled as multipart with the body as a text part.
|
|
40
|
+
# @param hops [Integer, nil] the +X-Protege-Recursion+ hop count to stamp — agent-sent mail
|
|
41
|
+
# passes it (see +Gateway::RECURSION_HEADER+); +nil+ (operator/console mail) leaves the
|
|
42
|
+
# header off entirely.
|
|
40
43
|
# @return [::Mail::Message] the assembled message.
|
|
41
44
|
def build(
|
|
42
45
|
from:,
|
|
@@ -47,7 +50,8 @@ module Protege
|
|
|
47
50
|
bcc: [],
|
|
48
51
|
in_reply_to: nil,
|
|
49
52
|
references: nil,
|
|
50
|
-
attachments: []
|
|
53
|
+
attachments: [],
|
|
54
|
+
hops: nil
|
|
51
55
|
)
|
|
52
56
|
new(
|
|
53
57
|
from:,
|
|
@@ -58,7 +62,8 @@ module Protege
|
|
|
58
62
|
bcc:,
|
|
59
63
|
in_reply_to:,
|
|
60
64
|
references:,
|
|
61
|
-
attachments
|
|
65
|
+
attachments:,
|
|
66
|
+
hops:
|
|
62
67
|
).message
|
|
63
68
|
end
|
|
64
69
|
end
|
|
@@ -75,7 +80,8 @@ module Protege
|
|
|
75
80
|
bcc: [],
|
|
76
81
|
in_reply_to: nil,
|
|
77
82
|
references: nil,
|
|
78
|
-
attachments: []
|
|
83
|
+
attachments: [],
|
|
84
|
+
hops: nil
|
|
79
85
|
)
|
|
80
86
|
@from = from
|
|
81
87
|
@to = to
|
|
@@ -86,6 +92,7 @@ module Protege
|
|
|
86
92
|
@in_reply_to = in_reply_to
|
|
87
93
|
@references = references
|
|
88
94
|
@attachments = attachments
|
|
95
|
+
@hops = hops
|
|
89
96
|
end
|
|
90
97
|
|
|
91
98
|
# Assemble the +::Mail::Message+ from the stored inputs.
|
|
@@ -103,12 +110,22 @@ module Protege
|
|
|
103
110
|
m.in_reply_to = parent_id if parent_id
|
|
104
111
|
m.references = reference_values if reference_values.any?
|
|
105
112
|
|
|
113
|
+
stamp_hops(m)
|
|
106
114
|
assign_content(m)
|
|
107
115
|
end
|
|
108
116
|
end
|
|
109
117
|
|
|
110
118
|
private
|
|
111
119
|
|
|
120
|
+
# Stamp the +X-Protege-Recursion+ hop count on agent-sent mail; a nil +hops+ (operator/console
|
|
121
|
+
# mail) leaves the header off so human-authored messages never look like an agent chain.
|
|
122
|
+
#
|
|
123
|
+
# @param mail [::Mail::Message] the message under construction
|
|
124
|
+
# @return [void]
|
|
125
|
+
def stamp_hops(mail)
|
|
126
|
+
mail.header[Gateway::RECURSION_HEADER] = @hops.to_s if @hops
|
|
127
|
+
end
|
|
128
|
+
|
|
112
129
|
# Set the body on +mail+: a single +text/plain+ part when there are no attachments, otherwise a
|
|
113
130
|
# multipart message whose first part is the plain-text body and whose remaining parts are the
|
|
114
131
|
# attachments. Forcing a top-level +text/plain+ content type would break the multipart case, so
|
|
@@ -138,7 +155,7 @@ module Protege
|
|
|
138
155
|
|
|
139
156
|
# The domain for the outbound Message-ID — the +From+ address's domain, so the id carries the
|
|
140
157
|
# real sending domain (recipients like Gmail distrust a bogus domain and won't thread). A
|
|
141
|
-
#
|
|
158
|
+
# agent (or the console) always sends from a full address, so a From with no domain is a
|
|
142
159
|
# caller bug rather than something to paper over.
|
|
143
160
|
#
|
|
144
161
|
# @return [String] the sending domain.
|
data/lib/protege/gateway.rb
CHANGED
|
@@ -17,6 +17,13 @@ module Protege
|
|
|
17
17
|
autoload :AccessControl, 'protege/gateway/access_control'
|
|
18
18
|
autoload :AttachmentPolicy, 'protege/gateway/attachment_policy'
|
|
19
19
|
|
|
20
|
+
# The hop-count header agent-sent mail carries so agent-to-agent reply loops stay bounded. Every
|
|
21
|
+
# email an agent sends is stamped: +1+ on fresh mail, the inbound value plus one on a reply. Human
|
|
22
|
+
# mail clients never echo a custom header back, so a conversation with a person always resets — only
|
|
23
|
+
# an unbroken chain of Protege-to-Protege replies accumulates hops, and the inbound edge
|
|
24
|
+
# (+AgentMailbox+) silently drops mail whose count reaches its limit.
|
|
25
|
+
RECURSION_HEADER = 'X-Protege-Recursion'
|
|
26
|
+
|
|
20
27
|
extend self
|
|
21
28
|
|
|
22
29
|
# Accept a message typed into the dashboard console.
|
|
@@ -27,18 +34,18 @@ module Protege
|
|
|
27
34
|
# correlation id so events across the run can be traced.
|
|
28
35
|
#
|
|
29
36
|
# @param mail [::Mail::Message] the console-authored message.
|
|
30
|
-
# @param
|
|
37
|
+
# @param agent [Agent] the agent receiving the message.
|
|
31
38
|
# @param attachments [Array<ActiveStorage::Blob, ActiveStorage::Attachment, #original_filename, Hash>]
|
|
32
|
-
# files for the
|
|
39
|
+
# files for the agent to read. Each entry is normalized to a blob (see {#blob_for}) and attached by
|
|
33
40
|
# reference (mirroring +deliver+) — an already-stored blob/attachment reuses its blob, an uploaded
|
|
34
41
|
# file or a +{ filename:, content_type:, bytes: }+ Hash is uploaded once. Attached after +save!+ so
|
|
35
|
-
# they are committed before the (delayed) job reads the message; the
|
|
42
|
+
# they are committed before the (delayed) job reads the message; the agent sees them via
|
|
36
43
|
# +Message#content_parts+ / the +read_attachment+ tool.
|
|
37
44
|
# @return [Message] the saved inbound message record.
|
|
38
|
-
def accept_console_message(mail:,
|
|
45
|
+
def accept_console_message(mail:, agent:, attachments: [])
|
|
39
46
|
correlation_id = Mail::MessageId.ensure(mail.message_id).value
|
|
40
|
-
thread = EmailThread.find_or_create_for(mail:,
|
|
41
|
-
message = Message.build_from_mail(mail:, direction: :inbound, thread:,
|
|
47
|
+
thread = EmailThread.find_or_create_for(mail:, agent:)
|
|
48
|
+
message = Message.build_from_mail(mail:, direction: :inbound, thread:, agent:)
|
|
42
49
|
message.processing_status = :pending
|
|
43
50
|
message.save!
|
|
44
51
|
|
|
@@ -52,13 +59,13 @@ module Protege
|
|
|
52
59
|
ConsoleInferenceJob
|
|
53
60
|
.set(wait: 1.second)
|
|
54
61
|
.perform_later(message_id: message.id,
|
|
55
|
-
|
|
62
|
+
agent_id: agent.id,
|
|
56
63
|
correlation_id:)
|
|
57
64
|
|
|
58
65
|
message
|
|
59
66
|
end
|
|
60
67
|
|
|
61
|
-
# Send a message to
|
|
68
|
+
# Send a message to an agent from the host application — the reverse of tools/resolvers.
|
|
62
69
|
#
|
|
63
70
|
# Where tools and resolvers let the agent reach *into* the app, this lets ordinary application code
|
|
64
71
|
# (a service object, controller, or state machine) hand the agent something to act on:
|
|
@@ -68,12 +75,12 @@ module Protege
|
|
|
68
75
|
#
|
|
69
76
|
# It is a thin convenience over +build_outbound+ + +accept_console_message+ — the exact sequence the
|
|
70
77
|
# dashboard console uses — so the brief flows through the same "inbound message starts a thread" path
|
|
71
|
-
# (+ReplyHarness+ and the
|
|
72
|
-
# resolved through +
|
|
73
|
-
# literal address or +
|
|
78
|
+
# (+ReplyHarness+ and the agent's +message_resolvers+). +to+ is an address, not a record: it is
|
|
79
|
+
# resolved through +Agent.lookup+ (honouring case and +tag+ subaddressing), so a caller can pass a
|
|
80
|
+
# literal address or +agent.email_address+ without loading the record first.
|
|
74
81
|
#
|
|
75
82
|
# Fire-and-forget: it returns the persisted inbound +Message+ for reference/tracing, not an LLM
|
|
76
|
-
# response — the agent's actions (any mail it chooses to send) are the outcome. The
|
|
83
|
+
# response — the agent's actions (any mail it chooses to send) are the outcome. The agent reads it,
|
|
77
84
|
# reasons, and uses its (scoped) tools; there is nothing it must reply to.
|
|
78
85
|
#
|
|
79
86
|
# Attachments meet the host where its file already is — a stored blob/attachment (e.g.
|
|
@@ -82,42 +89,42 @@ module Protege
|
|
|
82
89
|
# generated bytes, uploaded once). So a caller never constructs a +Gateway::Mail::Attachment+ by hand.
|
|
83
90
|
# See {#blob_for} for the full set of accepted shapes.
|
|
84
91
|
#
|
|
85
|
-
# @param to [String] the recipient
|
|
92
|
+
# @param to [String] the recipient agent's address (or +agent.email_address+).
|
|
86
93
|
# @param subject [String, nil] the message subject (the thread's title).
|
|
87
|
-
# @param body [String] the brief the
|
|
88
|
-
# @param attachments [Array] files for the
|
|
94
|
+
# @param body [String] the brief the agent acts on.
|
|
95
|
+
# @param attachments [Array] files for the agent to read; see {#accept_console_message} / {#blob_for}.
|
|
89
96
|
# @return [Message] the saved inbound message record.
|
|
90
|
-
# @raise [ArgumentError] when no active
|
|
97
|
+
# @raise [ArgumentError] when no active agent owns +to+, or an attachment is an unsupported shape.
|
|
91
98
|
def message(to:, body:, subject: nil, attachments: [])
|
|
92
|
-
|
|
93
|
-
raise ArgumentError, "no active
|
|
99
|
+
agent = Agent.lookup(to)
|
|
100
|
+
raise ArgumentError, "no active agent for address: #{to.inspect}" unless agent
|
|
94
101
|
|
|
95
102
|
mail = build_outbound(from: Protege.configuration.console_address,
|
|
96
|
-
to:
|
|
103
|
+
to: agent.email_address,
|
|
97
104
|
subject:,
|
|
98
105
|
body:)
|
|
99
106
|
|
|
100
|
-
accept_console_message(mail:,
|
|
107
|
+
accept_console_message(mail:, agent:, attachments:)
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Accept an inbound email arriving via SMTP / Action Mailbox.
|
|
104
111
|
#
|
|
105
112
|
# Unlike the console path, ingestion (persisting the +Message+, building the thread) happens
|
|
106
|
-
# inside +InferenceJob+; this method only enqueues that job with the
|
|
113
|
+
# inside +InferenceJob+; this method only enqueues that job with the agent and the inbound
|
|
107
114
|
# email reference, carrying the mail's message id as the correlation id.
|
|
108
115
|
#
|
|
109
116
|
# @param inbound_email [ActionMailbox::InboundEmail] the ingress email record.
|
|
110
|
-
# @param
|
|
117
|
+
# @param agent [Agent] the agent the message was routed to.
|
|
111
118
|
# @return [void]
|
|
112
|
-
def accept_smtp_message(inbound_email:,
|
|
119
|
+
def accept_smtp_message(inbound_email:, agent:)
|
|
113
120
|
InferenceJob.perform_later(
|
|
114
|
-
|
|
121
|
+
agent_id: agent.id,
|
|
115
122
|
inbound_email_id: inbound_email.id,
|
|
116
123
|
correlation_id: Mail::MessageId.ensure(inbound_email.mail.message_id).value
|
|
117
124
|
)
|
|
118
125
|
end
|
|
119
126
|
|
|
120
|
-
# Deliver an outbound message on behalf of
|
|
127
|
+
# Deliver an outbound message on behalf of an agent and record it.
|
|
121
128
|
#
|
|
122
129
|
# Sends over SMTP via +mail.deliver!+ unless the recipient is the local console address, in
|
|
123
130
|
# which case the conversation stays entirely in-app. Either way the message is persisted as an
|
|
@@ -129,18 +136,18 @@ module Protege
|
|
|
129
136
|
# uploaded once and thereafter only referenced.
|
|
130
137
|
#
|
|
131
138
|
# @param mail [::Mail::Message] the outbound mail to deliver.
|
|
132
|
-
# @param
|
|
139
|
+
# @param agent [Agent] the sending agent.
|
|
133
140
|
# @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound record by reference.
|
|
134
141
|
# @return [Message] the saved outbound message record.
|
|
135
|
-
def deliver(mail:,
|
|
142
|
+
def deliver(mail:, agent:, attachments: [])
|
|
136
143
|
deliver_over_transport(mail) unless local_delivery?(mail)
|
|
137
144
|
|
|
138
|
-
thread = EmailThread.find_or_create_for(mail:,
|
|
145
|
+
thread = EmailThread.find_or_create_for(mail:, agent:)
|
|
139
146
|
|
|
140
147
|
Message.build_from_mail(
|
|
141
148
|
mail:,
|
|
142
149
|
thread:,
|
|
143
|
-
|
|
150
|
+
agent:,
|
|
144
151
|
direction: :outbound,
|
|
145
152
|
attach_from_mail: false
|
|
146
153
|
).tap do |record|
|
|
@@ -162,7 +169,7 @@ module Protege
|
|
|
162
169
|
|
|
163
170
|
# Build an inbound access policy — one layer of the sender-authorization guardrail. The public way
|
|
164
171
|
# to construct an +AccessPolicy+, whether the global ceiling (+config.inbound_access+) or a
|
|
165
|
-
# per-
|
|
172
|
+
# per-agent layer (+AccessRule.policy_for+). +build_+ marks it as an instance factory, not a
|
|
166
173
|
# persisted record. Keyword arguments are forwarded (+allow:+, +deny:+, +default_decision:+).
|
|
167
174
|
#
|
|
168
175
|
# @return [Protege::Gateway::AccessPolicy] the composed policy layer
|
|
@@ -215,24 +222,28 @@ module Protege
|
|
|
215
222
|
Mail::Address.parse(address).routing_key
|
|
216
223
|
end
|
|
217
224
|
|
|
218
|
-
# Report whether a sender may reach
|
|
225
|
+
# Report whether a sender may reach an agent, per the agent's inbound access guardrail.
|
|
219
226
|
#
|
|
220
|
-
# @param
|
|
227
|
+
# @param agent [Agent] the routed agent
|
|
221
228
|
# @param address [String, nil] the sender's address
|
|
222
229
|
# @return [Boolean] true when every access layer permits the sender
|
|
223
|
-
def permits?(
|
|
224
|
-
AccessControl.for(
|
|
230
|
+
def permits?(agent:, address:)
|
|
231
|
+
AccessControl.for(agent:).permits?(address:)
|
|
225
232
|
end
|
|
226
233
|
|
|
227
234
|
# Report whether an inbound email is addressed to a domain Protege receives for — the Action
|
|
228
235
|
# Mailbox routing predicate. Domain-scoped on purpose: the engine claims only mail for its own
|
|
229
236
|
# domains and leaves everything else to the host's own mailbox routing, so mounting Protege never
|
|
230
|
-
# hijacks a host's other inbound mail. Per-
|
|
237
|
+
# hijacks a host's other inbound mail. Per-agent/subaddress validation stays in +AgentMailbox+.
|
|
238
|
+
#
|
|
239
|
+
# Considers both To and Cc: an agent reached only as a Cc recipient (e.g. on a reply-all) is a
|
|
240
|
+
# legitimate participant and must be claimed, matching how the rest of the system treats Cc.
|
|
231
241
|
#
|
|
232
242
|
# @param inbound_email [ActionMailbox::InboundEmail] the received email
|
|
233
|
-
# @return [Boolean] true when any recipient's domain is one of ours
|
|
243
|
+
# @return [Boolean] true when any To/Cc recipient's domain is one of ours
|
|
234
244
|
def claims?(inbound_email:)
|
|
235
|
-
|
|
245
|
+
mail = inbound_email.mail
|
|
246
|
+
(Array(mail.to) + Array(mail.cc)).any? { |address| EmailDomain.receives?(address) }
|
|
236
247
|
end
|
|
237
248
|
|
|
238
249
|
# ── MTA provisioning (push) ────────────────────────────────────────────────
|
|
@@ -23,7 +23,7 @@ module Protege
|
|
|
23
23
|
#
|
|
24
24
|
# @return [void]
|
|
25
25
|
def dispatch
|
|
26
|
-
Protege::Responsibility.active.
|
|
26
|
+
Protege::Responsibility.active.with_active_agent.find_each do |responsibility|
|
|
27
27
|
responsibility.dispatch! if responsibility.due?(now)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Protege
|
|
4
4
|
module Orchestrator
|
|
5
5
|
# Base context shared by both kinds of run. Carries the per-run runtime data every tool and
|
|
6
|
-
# resolver needs —
|
|
6
|
+
# resolver needs — agent, a frozen config snapshot, logger, correlation id — plus the
|
|
7
7
|
# +#deliver+ affordance for sending mail through the Gateway, without exposing mutable framework
|
|
8
8
|
# state. Frozen after construction so one instance is safely shared across every tool/resolver
|
|
9
9
|
# invocation in a run.
|
|
@@ -13,19 +13,19 @@ module Protege
|
|
|
13
13
|
# +#message+ (reactive, an inbound email) and +ResponsibilityContext+ sets +#responsibility+
|
|
14
14
|
# (proactive, a scheduled Loop run). Tools and resolvers branch on whichever is present.
|
|
15
15
|
class Context
|
|
16
|
-
# @return [Protege::
|
|
16
|
+
# @return [Protege::Agent] the agent handling this run
|
|
17
17
|
# @return [Protege::Configuration] the frozen process configuration snapshot
|
|
18
18
|
# @return [Logger] the engine logger
|
|
19
19
|
# @return [String, nil] the correlation id threaded through this run's events
|
|
20
|
-
attr_reader :
|
|
20
|
+
attr_reader :agent, :config, :logger, :correlation_id
|
|
21
21
|
|
|
22
22
|
# Capture the shared runtime data and freeze. Subclasses set their own subject ivar *before*
|
|
23
23
|
# calling +super+, since this constructor freezes the instance.
|
|
24
24
|
#
|
|
25
|
-
# @param
|
|
25
|
+
# @param agent [Protege::Agent] the agent handling this run
|
|
26
26
|
# @return [void]
|
|
27
|
-
def initialize(
|
|
28
|
-
@
|
|
27
|
+
def initialize(agent:)
|
|
28
|
+
@agent = agent
|
|
29
29
|
@logger = Protege.configuration.logger
|
|
30
30
|
# Snapshot a frozen *copy* so the context is immutable without freezing the process-wide
|
|
31
31
|
# configuration (which boot-time code and tests still mutate).
|
|
@@ -51,14 +51,14 @@ module Protege
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Deliver an outbound mail via the Gateway, handling transport, thread resolution, and message
|
|
54
|
-
# persistence under the current
|
|
54
|
+
# persistence under the current agent. Any +attachments+ (blobs already in storage) are attached
|
|
55
55
|
# to the persisted outbound message by reference, so a file is never re-uploaded on send.
|
|
56
56
|
#
|
|
57
57
|
# @param mail [Object] the outbound mail to deliver
|
|
58
58
|
# @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound message
|
|
59
59
|
# @return [Object] the Gateway delivery outcome
|
|
60
60
|
def deliver(mail:, attachments: [])
|
|
61
|
-
Gateway.deliver(mail:,
|
|
61
|
+
Gateway.deliver(mail:, agent:, attachments:)
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
end
|