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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# An agent's tools — sourced entirely from the toolkits attached to it. There is no code-declared grant
|
|
5
|
+
# and no per-agent disable list: attaching a toolkit equips the agent with its member tools, and the
|
|
6
|
+
# attachment (+AgentToolkit+) decides who may reach them. Resolution is *fail-closed* — an agent with
|
|
7
|
+
# no attached toolkit has no tools at all, so even replying (send_email is itself a tool) requires a
|
|
8
|
+
# toolkit that supplies it.
|
|
9
|
+
#
|
|
10
|
+
# Two questions, two methods. {#available_tools} is the gate-agnostic union across every attached toolkit
|
|
11
|
+
# — the at-a-glance catalogue for the dashboard. {#available_tools_for} narrows that to a single run: a
|
|
12
|
+
# tool is offered only when some attached toolkit that contains it admits the run — on a reply the inbound
|
|
13
|
+
# sender must pass that grant's gate; on a scheduled (senderless) run the grant must opt into proactive
|
|
14
|
+
# use (fail-closed by default). Both reconcile against the live registry (via the toolkit's
|
|
15
|
+
# +member_tools+), so a stale member id — naming a tool since deleted from the code — is simply skipped.
|
|
16
|
+
module Toolable
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
has_many :agent_toolkits, class_name: 'Protege::AgentToolkit', dependent: :destroy
|
|
21
|
+
has_many :toolkits, through: :agent_toolkits
|
|
22
|
+
|
|
23
|
+
after_create :attach_default_toolkit
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The tool classes this agent may use for a specific run — the members of every attached toolkit whose
|
|
27
|
+
# grant admits the run, deduped. This is the single source of truth called by both the advertised
|
|
28
|
+
# catalogue and the dispatch backstop, so what the model is offered and what it is allowed to call can
|
|
29
|
+
# never diverge.
|
|
30
|
+
#
|
|
31
|
+
# @param context [Protege::Orchestrator::Context] the run context (its +message+/sender drives gating)
|
|
32
|
+
# @return [Array<Class>] the tool classes offered for this run
|
|
33
|
+
def available_tools_for(context:)
|
|
34
|
+
tool_classes(admitted_grants(context:))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The ids of {#available_tools_for} — the convenience the dispatch backstop checks.
|
|
38
|
+
#
|
|
39
|
+
# @param context [Protege::Orchestrator::Context] the run context
|
|
40
|
+
# @return [Array<Symbol>] the offered tool ids
|
|
41
|
+
def available_tool_ids_for(context:)
|
|
42
|
+
available_tools_for(context:).map(&:id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every tool this agent is equipped with across all attached toolkits, gate-agnostic — the at-a-glance
|
|
46
|
+
# catalogue for the dashboard. Who may actually reach each tool is decided per run by
|
|
47
|
+
# {#available_tools_for}; this answers only "what could this agent ever use".
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<Class>] the union of the attached toolkits' tool classes
|
|
50
|
+
def available_tools
|
|
51
|
+
tool_classes(attachments)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The ids of {#available_tools} — a convenience for UI and tests.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Symbol>] the equipped tool ids
|
|
57
|
+
def available_tool_ids
|
|
58
|
+
available_tools.map(&:id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# The attachments that admit this run — each grant whose gate the run passes.
|
|
64
|
+
#
|
|
65
|
+
# @param context [Protege::Orchestrator::Context] the run context
|
|
66
|
+
# @return [Array<Protege::AgentToolkit>] the admitting grants
|
|
67
|
+
def admitted_grants(context:)
|
|
68
|
+
attachments.select { |grant| grant_admits?(grant, context) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether one grant admits this run: the inbound sender must pass the grant's gate, or — with no sender
|
|
72
|
+
# (a scheduled run) — the grant must opt into proactive use.
|
|
73
|
+
#
|
|
74
|
+
# @param grant [Protege::AgentToolkit] the attachment
|
|
75
|
+
# @param context [Protege::Orchestrator::Context] the run context
|
|
76
|
+
# @return [Boolean] true when the grant admits the run
|
|
77
|
+
def grant_admits?(grant, context)
|
|
78
|
+
sender = context.message&.from_address
|
|
79
|
+
sender ? grant.permits?(sender:) : grant.allow_proactive?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The reconciled, deduped tool classes across a set of grants. Each toolkit's +member_tools+ already
|
|
83
|
+
# drops ids with no registered tool, so a stale member can never conjure or break a tool.
|
|
84
|
+
#
|
|
85
|
+
# @param grants [Enumerable<Protege::AgentToolkit>] the grants to draw tools from
|
|
86
|
+
# @return [Array<Class>] the deduped tool classes
|
|
87
|
+
def tool_classes(grants)
|
|
88
|
+
grants.flat_map(&:member_tools).uniq(&:id).map(&:klass)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The agent's toolkit grants with their toolkits eager-loaded — the single source read by resolution.
|
|
92
|
+
#
|
|
93
|
+
# @return [ActiveRecord::Relation<Protege::AgentToolkit>] the attachments
|
|
94
|
+
def attachments
|
|
95
|
+
agent_toolkits.includes(:toolkit)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Give a freshly created agent its baseline tools by attaching the Default Tools system toolkit (with
|
|
99
|
+
# an open gate). A no-op before that toolkit has been synced into existence, so it never blocks
|
|
100
|
+
# creation — the agent simply starts with no tools until a toolkit is attached.
|
|
101
|
+
#
|
|
102
|
+
# @return [void]
|
|
103
|
+
def attach_default_toolkit
|
|
104
|
+
default = Toolkit.default_tools
|
|
105
|
+
agent_toolkits.create!(toolkit: default) if default
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Protege
|
|
4
|
-
# One runtime allow/deny rule for a single
|
|
4
|
+
# One runtime allow/deny rule for a single agent — the dashboard-editable layer of the inbound
|
|
5
5
|
# access-control guardrail. Each record carries a +kind+ (+allow+/+deny+) and an address +pattern+
|
|
6
|
-
# (an exact address or a +*+ wildcard such as +*@company.co+); together
|
|
6
|
+
# (an exact address or a +*+ wildcard such as +*@company.co+); together an agent's rules fold into
|
|
7
7
|
# a +Gateway::AccessPolicy+ via {.policy_for}.
|
|
8
8
|
#
|
|
9
9
|
# This is the *narrowing* layer: +AccessControl+ intersects it with the committed global policy
|
|
10
|
-
# (+Protege.configuration.inbound_access+), so
|
|
11
|
-
# reach that agent — never widen past the org-wide ceiling.
|
|
10
|
+
# (+Protege.configuration.inbound_access+), so an agent's rules can only further restrict who may
|
|
11
|
+
# reach that agent — never widen past the org-wide ceiling. An agent with no rules imposes no
|
|
12
12
|
# constraint of its own.
|
|
13
13
|
class AccessRule < ApplicationRecord
|
|
14
14
|
# ── Associations ────────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
belongs_to :
|
|
16
|
+
belongs_to :agent, class_name: 'Protege::Agent'
|
|
17
17
|
|
|
18
18
|
# ── Attributes ──────────────────────────────────────────────────────────
|
|
19
19
|
|
|
@@ -28,17 +28,17 @@ module Protege
|
|
|
28
28
|
# ── Class methods ───────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
30
|
class << self
|
|
31
|
-
# Fold one
|
|
31
|
+
# Fold one agent's rules into a single +Gateway::AccessPolicy+.
|
|
32
32
|
#
|
|
33
|
-
# Groups the
|
|
34
|
-
# its own default (an allow-list implies default-deny).
|
|
33
|
+
# Groups the agent's +allow+/+deny+ patterns into the policy's lists; the policy then derives
|
|
34
|
+
# its own default (an allow-list implies default-deny). An agent with no rules yields a bare
|
|
35
35
|
# +Gateway::AccessPolicy+, which permits everyone and so drops out of the +AccessControl+
|
|
36
36
|
# intersection.
|
|
37
37
|
#
|
|
38
|
-
# @param
|
|
39
|
-
# @return [Protege::Gateway::AccessPolicy] the
|
|
40
|
-
def policy_for(
|
|
41
|
-
grouped = where(
|
|
38
|
+
# @param agent [Protege::Agent] the agent whose rules to fold
|
|
39
|
+
# @return [Protege::Gateway::AccessPolicy] the agent's runtime access policy
|
|
40
|
+
def policy_for(agent)
|
|
41
|
+
grouped = where(agent:).group_by(&:kind)
|
|
42
42
|
|
|
43
43
|
Gateway.build_access_policy(
|
|
44
44
|
allow: Array(grouped['allow']).map(&:pattern),
|
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Protege
|
|
4
|
-
# Active Record base class for every Protege
|
|
4
|
+
# Active Record base class for every Protege agent — the agent identity behind an email address.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# in the host application and persisted in +
|
|
8
|
-
#
|
|
6
|
+
# Agents are single-table-inheritance (STI) records: each concrete agent is a subclass defined
|
|
7
|
+
# in the host application and persisted in +protege_agents+, discriminated by the +type+ column.
|
|
8
|
+
# An agent owns the conversations addressed to it (+EmailThread+, +Message+) and declares the
|
|
9
9
|
# resolver chain that the Orchestrator runs to build the inference context for each turn.
|
|
10
10
|
#
|
|
11
11
|
# The +email_address+ is operator-chosen and must line up with the host's MX records. Inbound
|
|
12
|
-
# routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the
|
|
12
|
+
# routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the agent
|
|
13
13
|
# whose canonical address is +alice@example.com+ (see {.lookup}).
|
|
14
14
|
#
|
|
15
|
-
# @example Define
|
|
16
|
-
# class
|
|
17
|
-
# self.display_name = '
|
|
15
|
+
# @example Define an agent in the host application
|
|
16
|
+
# class ExecutiveAgent < Protege::Agent
|
|
17
|
+
# self.display_name = 'Executive Agent'
|
|
18
18
|
#
|
|
19
19
|
# resolvers do |chain|
|
|
20
20
|
# chain.use Protege::ThreadHistoryResolver
|
|
21
21
|
# end
|
|
22
22
|
# end
|
|
23
|
-
class
|
|
24
|
-
self.table_name = '
|
|
23
|
+
class Agent < ApplicationRecord
|
|
24
|
+
self.table_name = 'protege_agents'
|
|
25
25
|
|
|
26
|
-
# Keep the
|
|
27
|
-
include
|
|
26
|
+
# Keep the Agents sidebar live (append/replace/remove).
|
|
27
|
+
include BroadcastableAgent
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
include
|
|
29
|
+
# The agent's tools, sourced entirely from the toolkits attached to it and gated per attachment —
|
|
30
|
+
# there is no code-declared grant (see +Toolable+, +Toolkit+, +AgentToolkit+).
|
|
31
|
+
include Toolable
|
|
32
32
|
|
|
33
33
|
# ── Associations ──────────────────────────────────────────────────────────
|
|
34
34
|
|
|
35
35
|
with_options dependent: :destroy do
|
|
36
|
-
# The
|
|
37
|
-
# access-control guardrail (see +AccessRule+ and +AccessControl+). Destroyed with the
|
|
36
|
+
# The agent's runtime inbound access rules — the dashboard-editable, narrowing layer of the
|
|
37
|
+
# access-control guardrail (see +AccessRule+ and +AccessControl+). Destroyed with the agent.
|
|
38
38
|
has_many :access_rules, class_name: 'Protege::AccessRule'
|
|
39
39
|
|
|
40
|
-
# Every email the
|
|
41
|
-
# +
|
|
42
|
-
# tool. Destroyed with the
|
|
40
|
+
# Every email the agent has sent or received. Each +Message+ carries a denormalised
|
|
41
|
+
# +agent_id+, so this is the scoping seam for per-agent reads such as the +SearchEmailsTool+
|
|
42
|
+
# tool. Destroyed with the agent.
|
|
43
43
|
has_many :messages, class_name: 'Protege::Message'
|
|
44
44
|
|
|
45
|
-
# The
|
|
46
|
-
# CRUD is nested under the
|
|
47
|
-
# with the
|
|
45
|
+
# The agent's standing, cron-scheduled duties — the Loop layer (see +Responsibility+). Dashboard
|
|
46
|
+
# CRUD is nested under the agent; runs the duty's +responsibility_resolvers+ chain. Destroyed
|
|
47
|
+
# with the agent.
|
|
48
48
|
has_many :responsibilities, class_name: 'Protege::Responsibility'
|
|
49
49
|
|
|
50
|
-
# Every conversation addressed to the
|
|
51
|
-
# so it must be destroyed with the
|
|
50
|
+
# Every conversation addressed to the agent. Carries a +agent_id+ FK with no DB-level cascade,
|
|
51
|
+
# so it must be destroyed with the agent (its messages cascade in turn) — otherwise a permanent
|
|
52
52
|
# delete would orphan threads / raise a foreign-key error.
|
|
53
53
|
has_many :email_threads, class_name: 'Protege::EmailThread'
|
|
54
54
|
end
|
|
@@ -69,10 +69,10 @@ module Protege
|
|
|
69
69
|
|
|
70
70
|
default_scope { order(:name) }
|
|
71
71
|
|
|
72
|
-
# Only
|
|
72
|
+
# Only agents currently enabled to receive and answer mail (i.e. not archived).
|
|
73
73
|
scope :active, -> { where(archived_at: nil) }
|
|
74
74
|
|
|
75
|
-
#
|
|
75
|
+
# Agents that have been archived (soft-removed): kept for history, out of routing.
|
|
76
76
|
scope :archived, -> { where.not(archived_at: nil) }
|
|
77
77
|
|
|
78
78
|
# ── Display name ────────────────────────────────────────────────────────
|
|
@@ -84,17 +84,17 @@ module Protege
|
|
|
84
84
|
# ── Class methods ───────────────────────────────────────────────────────
|
|
85
85
|
|
|
86
86
|
class << self
|
|
87
|
-
# Return the human-readable label for this
|
|
87
|
+
# Return the human-readable label for this agent subclass.
|
|
88
88
|
#
|
|
89
89
|
# Falls back to the demodulized class name when no explicit label has been set, so an
|
|
90
|
-
# unconfigured +
|
|
90
|
+
# unconfigured +SupportAgent+ subclass reads as +"SupportAgent"+.
|
|
91
91
|
#
|
|
92
92
|
# @return [String, nil] the configured label, or the demodulized class name
|
|
93
93
|
def display_name
|
|
94
94
|
_display_name || name&.demodulize
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# Set the human-readable label for this
|
|
97
|
+
# Set the human-readable label for this agent subclass.
|
|
98
98
|
#
|
|
99
99
|
# @param val [String] the label to display in the UI
|
|
100
100
|
# @return [String] the assigned label
|
|
@@ -130,7 +130,7 @@ module Protege
|
|
|
130
130
|
# Return the resolver chain that assembles context for scheduled responsibility runs — the
|
|
131
131
|
# proactive path (the Loop layer) — optionally mutating it in a block.
|
|
132
132
|
#
|
|
133
|
-
# Held separately from the reply chain so
|
|
133
|
+
# Held separately from the reply chain so an agent can present different context when acting on
|
|
134
134
|
# its own initiative (e.g. a task prompt, no inbound thread) than when answering mail. Like the
|
|
135
135
|
# reply chain it is lazily built and owned per subclass, never shared with the parent.
|
|
136
136
|
#
|
|
@@ -153,15 +153,15 @@ module Protege
|
|
|
153
153
|
self
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
# Find the active
|
|
156
|
+
# Find the active agent that owns the given address, honouring subaddressing.
|
|
157
157
|
#
|
|
158
158
|
# Parses the address through +Gateway::Mail::Address+ and matches its +routing_key+ (the
|
|
159
159
|
# tag-stripped, lowercased +local@domain+) against the canonical +email_address+. So
|
|
160
|
-
# +Alice+thread9@Example.com+ resolves to the
|
|
160
|
+
# +Alice+thread9@Example.com+ resolves to the agent at +alice@example.com+. Returns nil for
|
|
161
161
|
# blank or malformed input.
|
|
162
162
|
#
|
|
163
163
|
# @param address [String, nil] the recipient address from an inbound email
|
|
164
|
-
# @return [Protege::
|
|
164
|
+
# @return [Protege::Agent, nil] the matching active agent, or nil when none routes
|
|
165
165
|
def lookup(address)
|
|
166
166
|
active.find_by(email_address: Gateway.routing_key(address))
|
|
167
167
|
rescue ArgumentError
|
|
@@ -171,52 +171,52 @@ module Protege
|
|
|
171
171
|
|
|
172
172
|
# ── Instance methods ────────────────────────────────────────────────────
|
|
173
173
|
|
|
174
|
-
# Return this
|
|
174
|
+
# Return this agent's resolver chains by delegating to the class-level chains — the reply chain
|
|
175
175
|
# (+message_resolvers+) and the scheduled-run chain (+responsibility_resolvers+).
|
|
176
176
|
#
|
|
177
|
-
# @return [Orchestrator::ResolverChain] the chain declared on this
|
|
177
|
+
# @return [Orchestrator::ResolverChain] the chain declared on this agent's subclass
|
|
178
178
|
delegate :message_resolvers, :responsibility_resolvers, to: :class
|
|
179
179
|
|
|
180
180
|
# ── Archive state ─────────────────────────────────────────────────────────
|
|
181
181
|
# +active+/+archived+ are derived from +archived_at+ (no boolean column): +active?+ is what inbound
|
|
182
|
-
# routing (+.active+ scope, {.lookup}) and the status dot read, so archiving
|
|
182
|
+
# routing (+.active+ scope, {.lookup}) and the status dot read, so archiving an agent removes it from
|
|
183
183
|
# service while keeping all its history.
|
|
184
184
|
|
|
185
|
-
# Whether the
|
|
185
|
+
# Whether the agent is active (not archived).
|
|
186
186
|
#
|
|
187
187
|
# @return [Boolean]
|
|
188
188
|
def active?
|
|
189
189
|
archived_at.nil?
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
# Whether the
|
|
192
|
+
# Whether the agent has been archived (soft-removed): out of routing + scheduled runs, data kept.
|
|
193
193
|
#
|
|
194
194
|
# @return [Boolean]
|
|
195
195
|
def archived?
|
|
196
196
|
archived_at.present?
|
|
197
197
|
end
|
|
198
198
|
|
|
199
|
-
# Archive the
|
|
199
|
+
# Archive the agent — stops inbound routing and scheduled responsibility runs; data is preserved.
|
|
200
200
|
#
|
|
201
201
|
# @return [void]
|
|
202
202
|
def archive!
|
|
203
203
|
update!(archived_at: Time.current)
|
|
204
204
|
end
|
|
205
205
|
|
|
206
|
-
# Return an archived
|
|
206
|
+
# Return an archived agent to active service.
|
|
207
207
|
#
|
|
208
208
|
# @return [void]
|
|
209
209
|
def unarchive!
|
|
210
210
|
update!(archived_at: nil)
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
-
# Find one of this
|
|
213
|
+
# Find one of this agent's own message attachments by its Active Storage attachment id.
|
|
214
214
|
#
|
|
215
|
-
# Scopes the lookup to attachments whose record is one of *this*
|
|
216
|
-
# now resolve by *blob* id (see +Protege::StoredFile.find+), so this
|
|
215
|
+
# Scopes the lookup to attachments whose record is one of *this* agent's messages. The file tools
|
|
216
|
+
# now resolve by *blob* id (see +Protege::StoredFile.find+), so this agent-scoped lookup is
|
|
217
217
|
# currently unused by them — it is retained to become the scoping filter for the deferred
|
|
218
218
|
# authorization pass (see the blob-currency spec §9). Returns +nil+ when the id is unknown or
|
|
219
|
-
# belongs to a different
|
|
219
|
+
# belongs to a different agent.
|
|
220
220
|
#
|
|
221
221
|
# @param id [Integer, String] the +ActiveStorage::Attachment+ id
|
|
222
222
|
# @return [ActiveStorage::Attachment, nil] the scoped attachment, or nil
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# The grant binding one +Toolkit+ to one +Agent+ — and where the toolkit's permission lives. Giving a
|
|
5
|
+
# toolkit to an agent is not just "these tools are available"; it is "these tools are available to the
|
|
6
|
+
# senders I allow here". So the attachment carries the sender gate (its +agent_toolkit_rules+ folded
|
|
7
|
+
# into a +Gateway::AccessPolicy+, mirroring +AccessRule+) and +allow_proactive+ (whether the tools may
|
|
8
|
+
# run on this agent's scheduled, senderless runs). Two agents can attach the same toolkit and gate it
|
|
9
|
+
# differently.
|
|
10
|
+
class AgentToolkit < ApplicationRecord
|
|
11
|
+
# ── Associations ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
belongs_to :agent, class_name: 'Protege::Agent'
|
|
14
|
+
belongs_to :toolkit, class_name: 'Protege::Toolkit'
|
|
15
|
+
|
|
16
|
+
has_many :agent_toolkit_rules, class_name: 'Protege::AgentToolkitRule', dependent: :destroy
|
|
17
|
+
|
|
18
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
# A toolkit is attached to an agent at most once.
|
|
21
|
+
validates :toolkit_id, uniqueness: { scope: :agent_id }
|
|
22
|
+
|
|
23
|
+
# ── Delegation ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# The tools this grant exposes come from the toolkit.
|
|
26
|
+
delegate :covers?, :member_tools, :name, to: :toolkit
|
|
27
|
+
|
|
28
|
+
# ── Instance methods ──────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
# The sender gate for this grant as a policy, folded from its rules through the Gateway factory (never
|
|
31
|
+
# constructed by hand), mirroring +AccessRule.policy_for+. A grant with no rules permits everyone.
|
|
32
|
+
#
|
|
33
|
+
# @return [Protege::Gateway::AccessPolicy] the grant's gate
|
|
34
|
+
def access_policy
|
|
35
|
+
grouped = agent_toolkit_rules.group_by(&:kind)
|
|
36
|
+
Gateway.build_access_policy(
|
|
37
|
+
allow: Array(grouped['allow']).map(&:pattern),
|
|
38
|
+
deny: Array(grouped['deny']).map(&:pattern)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether a sender may unlock this grant's tools, per its gate.
|
|
43
|
+
#
|
|
44
|
+
# @param sender [String] the inbound sender's address
|
|
45
|
+
# @return [Boolean] true when the gate permits the sender
|
|
46
|
+
def permits?(sender:)
|
|
47
|
+
access_policy.permits?(address: sender)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One allow/deny address rule for a toolkit grant's sender gate — the dashboard-editable pattern that
|
|
5
|
+
# decides which correspondents can unlock this agent's copy of a toolkit's tools. Belongs to the
|
|
6
|
+
# attachment (+AgentToolkit+), not the toolkit, so each grant is gated independently. Mirrors
|
|
7
|
+
# +AccessRule+: each row carries a +kind+ (+allow+/+deny+) and an address +pattern+ (an exact address or
|
|
8
|
+
# a +*+ wildcard such as +*@company.co+); the grant folds its rules into a +Gateway::AccessPolicy+ via
|
|
9
|
+
# {AgentToolkit#access_policy}.
|
|
10
|
+
class AgentToolkitRule < ApplicationRecord
|
|
11
|
+
# ── Associations ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
belongs_to :agent_toolkit, class_name: 'Protege::AgentToolkit'
|
|
14
|
+
|
|
15
|
+
# ── Attributes ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
# Whether this rule admits (+allow+) or blocks (+deny+) matching senders.
|
|
18
|
+
enum :kind, { allow: 'allow', deny: 'deny' }
|
|
19
|
+
|
|
20
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
# A rule with no pattern matches nothing and is meaningless; the address pattern is required.
|
|
23
|
+
validates :pattern, presence: true
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Protege
|
|
4
4
|
# Abstract base class for every Active Record model in the Protege engine.
|
|
5
5
|
#
|
|
6
|
-
# Every persisted model — +
|
|
6
|
+
# Every persisted model — +Agent+, +Message+, +EmailThread+, +EmailDomain+ — inherits
|
|
7
7
|
# from this class rather than +ActiveRecord::Base+ directly, mirroring the convention a host
|
|
8
8
|
# Rails application uses for its own +ApplicationRecord+. Keeping a dedicated base class gives
|
|
9
9
|
# the engine a single seam for shared concerns (connection handling, common scopes, callbacks)
|
|
@@ -5,7 +5,7 @@ module Protege
|
|
|
5
5
|
#
|
|
6
6
|
# A thread is identified by its canonical +thread_id+: the RFC 2822 Message-ID of the conversation
|
|
7
7
|
# root. For a reply that is the first entry of the References chain (falling back to In-Reply-To);
|
|
8
|
-
# for a brand-new conversation it is the message's own Message-ID. Threads belong to one +
|
|
8
|
+
# for a brand-new conversation it is the message's own Message-ID. Threads belong to one +Agent+
|
|
9
9
|
# and carry denormalised counters (+message_count+, +last_message_at+) that +Message+ keeps fresh
|
|
10
10
|
# so the inbox can sort and display without aggregating on every render.
|
|
11
11
|
class EmailThread < ApplicationRecord
|
|
@@ -15,7 +15,7 @@ module Protege
|
|
|
15
15
|
|
|
16
16
|
# ── Associations ────────────────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
-
belongs_to :
|
|
18
|
+
belongs_to :agent, class_name: 'Protege::Agent'
|
|
19
19
|
|
|
20
20
|
# Destroying a thread removes its messages; inverse lets Message keep counters in sync. Each message
|
|
21
21
|
# in turn destroys its own recorded tool uses (see +Message+), so a thread's tool history is torn
|
|
@@ -24,15 +24,16 @@ module Protege
|
|
|
24
24
|
|
|
25
25
|
# ── Scopes ──────────────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
|
-
# Eager-load
|
|
28
|
-
scope :for_inbox, -> { includes(:
|
|
27
|
+
# Eager-load agent and messages, most-recently-active first — the inbox listing query.
|
|
28
|
+
scope :for_inbox, -> { includes(:agent, :messages).order(last_message_at: :desc) }
|
|
29
29
|
|
|
30
30
|
# ── Validations ─────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
with_options presence: true do
|
|
33
|
-
# The canonical Message-ID
|
|
34
|
-
|
|
35
|
-
validates :
|
|
33
|
+
# The canonical Message-ID is unique per agent: each agent maps a conversation to its own thread,
|
|
34
|
+
# so one email to several proteges yields one thread each rather than colliding on a shared id.
|
|
35
|
+
validates :thread_id, uniqueness: { scope: :agent_id }
|
|
36
|
+
validates :agent
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
# ── Instance methods ─────────────────────────────────────────────────────
|
|
@@ -61,17 +62,19 @@ module Protege
|
|
|
61
62
|
Gateway.canonical_message_id(mail.in_reply_to.presence || mail.message_id)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
# Find or create the thread for the given mail and
|
|
65
|
+
# Find or create the thread for the given mail and agent.
|
|
65
66
|
#
|
|
66
|
-
# On creation, seeds the
|
|
67
|
+
# On creation, seeds the agent, subject, and last-activity timestamp from the mail.
|
|
68
|
+
#
|
|
69
|
+
# Scoped to the agent: the same conversation reaching two agents resolves to a separate thread
|
|
70
|
+
# for each, since +thread_id+ is unique only within an agent.
|
|
67
71
|
#
|
|
68
72
|
# @param mail [Mail::Message] the email being threaded
|
|
69
|
-
# @param
|
|
73
|
+
# @param agent [Protege::Agent] the agent that owns the conversation
|
|
70
74
|
# @return [Protege::EmailThread] the existing or newly created thread
|
|
71
75
|
# @raise [ActiveRecord::RecordInvalid] when a new thread fails validation
|
|
72
|
-
def find_or_create_for(mail:,
|
|
73
|
-
find_or_create_by!(thread_id: thread_id_for(mail)) do |t|
|
|
74
|
-
t.persona = persona
|
|
76
|
+
def find_or_create_for(mail:, agent:)
|
|
77
|
+
find_or_create_by!(thread_id: thread_id_for(mail), agent:) do |t|
|
|
75
78
|
t.subject = mail.subject.to_s
|
|
76
79
|
t.last_message_at = Time.current
|
|
77
80
|
end
|