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,8 +6,8 @@ module Protege
|
|
|
6
6
|
# catalog (id +:search_emails+); when the model emits a +search_emails+ call, the harness routes the
|
|
7
7
|
# arguments to +#use+.
|
|
8
8
|
#
|
|
9
|
-
# The search is always scoped to the active
|
|
10
|
-
# agent can never read another
|
|
9
|
+
# The search is always scoped to the active agent's own messages (via +Agent#messages+) — an
|
|
10
|
+
# agent can never read another agent's mail. A single free-text query is matched, case-insensitively,
|
|
11
11
|
# against both the participants (from/to/cc) and the content (subject/body); results come back newest
|
|
12
12
|
# first. Each result carries the message's *full* body — a truncated body could cut the very text that
|
|
13
13
|
# matched, so it is returned whole. The caller may cap the result count with +limit+ (default
|
|
@@ -41,6 +41,8 @@ module Protege
|
|
|
41
41
|
rather than raising the limit when you get too many hits.
|
|
42
42
|
DESC
|
|
43
43
|
|
|
44
|
+
summary 'Search the agent\'s own email archive for past messages.'
|
|
45
|
+
|
|
44
46
|
input_schema(
|
|
45
47
|
type: 'object',
|
|
46
48
|
additionalProperties: false,
|
|
@@ -58,9 +60,9 @@ module Protege
|
|
|
58
60
|
}
|
|
59
61
|
)
|
|
60
62
|
|
|
61
|
-
# Search the active
|
|
63
|
+
# Search the active agent's archive and return the matching messages as concise summaries.
|
|
62
64
|
#
|
|
63
|
-
# @param context [Protege::Orchestrator::Context] exposes the active +
|
|
65
|
+
# @param context [Protege::Orchestrator::Context] exposes the active +agent+
|
|
64
66
|
# @param query [String] the free-text query supplied by the model
|
|
65
67
|
# @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
|
|
66
68
|
# @return [Protege::Result] success carrying +count+ and the shaped +results+, or a failure when
|
|
@@ -68,7 +70,7 @@ module Protege
|
|
|
68
70
|
def use(context:, query:, limit: nil)
|
|
69
71
|
return failure(reason: 'query cannot be empty') if query.to_s.strip.empty?
|
|
70
72
|
|
|
71
|
-
hits = matches(
|
|
73
|
+
hits = matches(agent: context.agent, query:, limit:)
|
|
72
74
|
|
|
73
75
|
success(count: hits.size, results: hits.map { result_hash(_1) })
|
|
74
76
|
end
|
|
@@ -77,21 +79,21 @@ module Protege
|
|
|
77
79
|
|
|
78
80
|
# Build and run the scoped, wildcard-escaped LIKE query, newest first.
|
|
79
81
|
#
|
|
80
|
-
# @param
|
|
82
|
+
# @param agent [Protege::Agent] the agent whose messages are searched
|
|
81
83
|
# @param query [String] the raw query supplied by the model
|
|
82
84
|
# @param limit [Integer, nil] the requested cap, clamped into 1..{MAX_LIMIT}
|
|
83
85
|
# @return [Array<Protege::Message>] the matching messages, newest first
|
|
84
|
-
def matches(
|
|
86
|
+
def matches(agent:, query:, limit:)
|
|
85
87
|
count = (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
|
|
86
88
|
escaped = ActiveRecord::Base.sanitize_sql_like(query.strip.downcase)
|
|
87
89
|
clause = SEARCHABLE_COLUMNS.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
agent.messages
|
|
92
|
+
.with_attached_attachments
|
|
93
|
+
.where(clause, term: "%#{escaped}%")
|
|
94
|
+
.order(sent_at: :desc)
|
|
95
|
+
.limit(count)
|
|
96
|
+
.to_a
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
# Shape a single message into a result summary. The body is returned in full (never truncated) so
|
|
@@ -5,7 +5,7 @@ module Protege
|
|
|
5
5
|
# +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:send_email+);
|
|
6
6
|
# when the model emits a +send_email+ call, the harness routes the arguments to +#use+.
|
|
7
7
|
#
|
|
8
|
-
# +From+ is always enforced to the
|
|
8
|
+
# +From+ is always enforced to the agent's +email_address+ — the agent cannot impersonate another
|
|
9
9
|
# sender. The +mode+ argument selects behavior:
|
|
10
10
|
#
|
|
11
11
|
# * +"reply"+ — continue the current conversation. +Subject+ and threading (+In-Reply-To+ +
|
|
@@ -19,7 +19,7 @@ module Protege
|
|
|
19
19
|
# Assembly is delegated to +Protege::Gateway.build_outbound+. The agent may attach files by
|
|
20
20
|
# referencing blob ids — the ids shown in the conversation history, or returned by +create_file+ —
|
|
21
21
|
# resolved through +Protege::StoredFile.find+ and checked against the configured
|
|
22
|
-
# +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped;
|
|
22
|
+
# +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; agent scoping is a
|
|
23
23
|
# deferred pass — see the blob-currency spec §9.)
|
|
24
24
|
class SendEmailTool < Protege::Tool
|
|
25
25
|
description <<~DESC.strip
|
|
@@ -38,6 +38,8 @@ module Protege
|
|
|
38
38
|
create_file).
|
|
39
39
|
DESC
|
|
40
40
|
|
|
41
|
+
summary 'Send an email — the agent\'s only way to reply to or reach someone.'
|
|
42
|
+
|
|
41
43
|
input_schema(
|
|
42
44
|
type: 'object',
|
|
43
45
|
additionalProperties: false,
|
|
@@ -78,7 +80,7 @@ module Protege
|
|
|
78
80
|
|
|
79
81
|
# Build the outbound mail for the requested mode and hand it to the gateway for delivery.
|
|
80
82
|
#
|
|
81
|
-
# @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +
|
|
83
|
+
# @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +agent+, and +deliver+
|
|
82
84
|
# @param mode [String] +"reply"+ or +"new"+
|
|
83
85
|
# @param body [String] plain-text body supplied by the model
|
|
84
86
|
# @param to [Array<String>] recipients (required for +"new"+; additions for +"reply"+)
|
|
@@ -109,7 +111,7 @@ module Protege
|
|
|
109
111
|
|
|
110
112
|
# Build the outbound mail for the requested mode.
|
|
111
113
|
#
|
|
112
|
-
# @param context [Protege::Orchestrator::Context] supplies the inbound message and
|
|
114
|
+
# @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
|
|
113
115
|
# @param mode [String] +"reply"+ or +"new"+
|
|
114
116
|
# @param to [Array<String>] recipients
|
|
115
117
|
# @param cc [Array<String>] carbon-copy recipients
|
|
@@ -121,17 +123,20 @@ module Protege
|
|
|
121
123
|
def build_mail(context:, mode:, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
122
124
|
case mode
|
|
123
125
|
when 'reply' then reply_mail(context, to:, cc:, bcc:, body:, attachments:)
|
|
124
|
-
when 'new' then new_thread_mail(context.
|
|
126
|
+
when 'new' then new_thread_mail(context.agent, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
125
127
|
else failure(reason: %(unknown mode #{mode.inspect}; use "reply" or "new"))
|
|
126
128
|
end
|
|
127
129
|
end
|
|
128
130
|
|
|
129
|
-
# Construct a threaded reply: subject and threading from the inbound message,
|
|
130
|
-
#
|
|
131
|
+
# Construct a threaded reply: subject and threading from the inbound message, and a reply-all
|
|
132
|
+
# recipient set that keeps the whole conversation intact. The original sender and other To recipients
|
|
133
|
+
# go in To; the original Cc recipients stay in Cc; the model may add more via to/cc/bcc. The agent's
|
|
134
|
+
# own address is dropped (it is the sender, not a recipient), and every list is de-duplicated by
|
|
135
|
+
# routing key so no one is Cc'd who is already in To.
|
|
131
136
|
#
|
|
132
|
-
# @param context [Protege::Orchestrator::Context] supplies the inbound message and
|
|
133
|
-
# @param to [Array<String>] recipients to add beyond the
|
|
134
|
-
# @param cc [Array<String>] carbon-copy recipients
|
|
137
|
+
# @param context [Protege::Orchestrator::Context] supplies the inbound message and agent
|
|
138
|
+
# @param to [Array<String>] recipients to add beyond the carried-forward participants
|
|
139
|
+
# @param cc [Array<String>] carbon-copy recipients to add
|
|
135
140
|
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
136
141
|
# @param body [String] the plain-text body
|
|
137
142
|
# @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
|
|
@@ -140,22 +145,27 @@ module Protege
|
|
|
140
145
|
inbound = context.message
|
|
141
146
|
return failure(reason: 'there is no message to reply to; send a "new" email instead') unless inbound
|
|
142
147
|
|
|
148
|
+
own = context.agent.email_address
|
|
149
|
+
reply_to = merge_recipients([inbound.from_address, *inbound.to_addresses, *addresses(to)], excluding: [own])
|
|
150
|
+
reply_cc = merge_recipients([*inbound.cc_addresses, *addresses(cc)], excluding: [own, *reply_to])
|
|
151
|
+
|
|
143
152
|
Protege::Gateway.build_outbound(
|
|
144
|
-
from:
|
|
145
|
-
to:
|
|
146
|
-
cc:
|
|
153
|
+
from: own,
|
|
154
|
+
to: reply_to,
|
|
155
|
+
cc: reply_cc,
|
|
147
156
|
bcc: addresses(bcc),
|
|
148
157
|
subject: ensure_re_prefix(inbound.subject),
|
|
149
158
|
body:,
|
|
150
159
|
in_reply_to: inbound.message_id,
|
|
151
160
|
references: build_references(inbound),
|
|
152
|
-
attachments
|
|
161
|
+
attachments:,
|
|
162
|
+
hops: inbound.recursion_hops + 1
|
|
153
163
|
)
|
|
154
164
|
end
|
|
155
165
|
|
|
156
166
|
# Construct a new-thread email: the model supplies the recipients and subject; no threading headers.
|
|
157
167
|
#
|
|
158
|
-
# @param
|
|
168
|
+
# @param agent [Protege::Agent] the sending agent (enforced From)
|
|
159
169
|
# @param to [Array<String>] recipients (at least one required)
|
|
160
170
|
# @param cc [Array<String>] carbon-copy recipients
|
|
161
171
|
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
@@ -163,19 +173,20 @@ module Protege
|
|
|
163
173
|
# @param body [String] the plain-text body
|
|
164
174
|
# @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
|
|
165
175
|
# @return [Mail::Message, Protege::Result] the assembled mail, or a failure when +to+/+subject+ is missing
|
|
166
|
-
def new_thread_mail(
|
|
176
|
+
def new_thread_mail(agent, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
167
177
|
recipients = addresses(to)
|
|
168
178
|
return failure(reason: 'a new email needs at least one "to" recipient') if recipients.empty?
|
|
169
179
|
return failure(reason: 'a new email needs a subject') if subject.to_s.strip.empty?
|
|
170
180
|
|
|
171
181
|
Protege::Gateway.build_outbound(
|
|
172
|
-
from:
|
|
182
|
+
from: agent.email_address,
|
|
173
183
|
to: recipients,
|
|
174
184
|
cc: addresses(cc),
|
|
175
185
|
bcc: addresses(bcc),
|
|
176
186
|
subject:,
|
|
177
187
|
body:,
|
|
178
|
-
attachments
|
|
188
|
+
attachments:,
|
|
189
|
+
hops: 1
|
|
179
190
|
)
|
|
180
191
|
end
|
|
181
192
|
|
|
@@ -187,6 +198,37 @@ module Protege
|
|
|
187
198
|
Array(value).map { |address| address.to_s.strip }.reject(&:empty?)
|
|
188
199
|
end
|
|
189
200
|
|
|
201
|
+
# Build a reply recipient list from several sources, preserving each address's original form but
|
|
202
|
+
# collapsing duplicates and dropping any excluded address — compared by routing key (tag-stripped,
|
|
203
|
+
# lowercased) so casing and plus-tags don't defeat the de-dupe. This is what carries the original
|
|
204
|
+
# thread's participants onto a reply without doubling anyone up. Unparseable entries are dropped.
|
|
205
|
+
#
|
|
206
|
+
# @param candidates [Array<String>] the addresses to include, in priority order
|
|
207
|
+
# @param excluding [Array<String>] addresses to omit (e.g. the agent itself, or the To list)
|
|
208
|
+
# @return [Array<String>] the de-duplicated recipients
|
|
209
|
+
def merge_recipients(candidates, excluding: [])
|
|
210
|
+
seen = excluding.filter_map { |address| routing_key(address) }
|
|
211
|
+
|
|
212
|
+
candidates.each_with_object([]) do |address, kept|
|
|
213
|
+
key = routing_key(address)
|
|
214
|
+
next if key.nil? || seen.include?(key)
|
|
215
|
+
|
|
216
|
+
seen << key
|
|
217
|
+
kept << address
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The routing key (tag-stripped, lowercased +local@domain+) for an address, or +nil+ when it can't be
|
|
222
|
+
# parsed — so de-duping treats an unparseable address as droppable rather than raising.
|
|
223
|
+
#
|
|
224
|
+
# @param address [String] the raw address
|
|
225
|
+
# @return [String, nil] the routing key, or nil
|
|
226
|
+
def routing_key(address)
|
|
227
|
+
Protege::Gateway.routing_key(address)
|
|
228
|
+
rescue ArgumentError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
190
232
|
# Resolve blob ids to the stored files they name, limit-checked.
|
|
191
233
|
#
|
|
192
234
|
# @param ids [Array<Integer>] the requested blob ids
|
|
@@ -10,7 +10,7 @@ module Protege
|
|
|
10
10
|
# It is a keyless, best-effort search: it scrapes DuckDuckGo's HTML endpoint and parses the results
|
|
11
11
|
# page into a list of +{title, url, snippet}+ the agent can reason over and then +web_fetch+. Because
|
|
12
12
|
# it scrapes an unofficial endpoint, it can change or be rate-limited — that trade-off buys a search
|
|
13
|
-
# that needs no API key and is safe to expose to every
|
|
13
|
+
# that needs no API key and is safe to expose to every agent. Richer or keyed search (Tavily,
|
|
14
14
|
# Perplexity, Brave, …) belongs in a host-app tool, keyed from the host's own config.
|
|
15
15
|
class WebSearchTool < Protege::Tool
|
|
16
16
|
# Result count when the caller does not specify a +limit+.
|
|
@@ -35,6 +35,8 @@ module Protege
|
|
|
35
35
|
limit when results are off.
|
|
36
36
|
DESC
|
|
37
37
|
|
|
38
|
+
summary 'Search the web and get back a list of matching pages.'
|
|
39
|
+
|
|
38
40
|
input_schema(
|
|
39
41
|
type: 'object',
|
|
40
42
|
additionalProperties: false,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= yield %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
- if @access_rule.persisted?
|
|
2
|
-
= turbo_stream.append "access_rules", partial: "protege/
|
|
2
|
+
= turbo_stream.append "access_rules", partial: "protege/agents/access_rule", locals: { access_rule: @access_rule }
|
|
3
3
|
= turbo_stream.remove "access_rules_empty"
|
|
4
|
-
= turbo_stream.replace "access_rule_form", partial: "protege/
|
|
4
|
+
= turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @agent.access_rules.build }
|
|
5
5
|
- else
|
|
6
|
-
= turbo_stream.replace "access_rule_form", partial: "protege/
|
|
6
|
+
= turbo_stream.replace "access_rule_form", partial: "protege/agents/access_rule_form", locals: { agent: @agent, access_rule: @access_rule }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
- if @agent_toolkit_rule.persisted?
|
|
2
|
+
= turbo_stream.append "agent_toolkit_rules", partial: "protege/agent_toolkits/gate_rule", locals: { rule: @agent_toolkit_rule }
|
|
3
|
+
= turbo_stream.remove "agent_toolkit_rules_empty"
|
|
4
|
+
= turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit.agent_toolkit_rules.build }
|
|
5
|
+
- else
|
|
6
|
+
= turbo_stream.replace "gate_rule_form", partial: "protege/agent_toolkits/gate_rule_form", locals: { agent_toolkit: @agent_toolkit, rule: @agent_toolkit_rule }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tr id=dom_id(rule) style="border-top: 1px solid var(--border)"
|
|
2
|
+
td class="py-3 pr-4 align-middle"
|
|
3
|
+
= badge(rule.kind, gate_rule_kind_color(rule))
|
|
4
|
+
td class="py-3 pr-4 align-middle"
|
|
5
|
+
code class="font-mono break-all" = rule.pattern
|
|
6
|
+
td class="py-3 align-middle"
|
|
7
|
+
= destructive_link("Remove", agent_toolkit_agent_toolkit_rule_path(rule.agent_toolkit, rule), confirm: "Remove this rule?")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#gate_rule_form
|
|
2
|
+
= form_with model: rule, url: agent_toolkit_agent_toolkit_rules_path(agent_toolkit), scope: :agent_toolkit_rule, class: "space-y-3" do |f|
|
|
3
|
+
= form_errors(rule)
|
|
4
|
+
.flex.items-end.gap-3
|
|
5
|
+
= form_select(f, :kind, Protege::AgentToolkitRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
|
|
6
|
+
.flex-1
|
|
7
|
+
= form_field(f, :pattern, label: "Pattern", placeholder: "*@company.co")
|
|
8
|
+
= button("Add rule", type: :submit, class: "h-9")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/ The grant's allow/deny sender gate — which correspondents may unlock this agent's copy of the
|
|
2
|
+
/ toolkit's tools. Lists existing rules with a remove control and an add form; create/destroy update the
|
|
3
|
+
/ table in place via Turbo Streams, falling back to a redirect for non-Turbo.
|
|
4
|
+
|
|
5
|
+
= card do
|
|
6
|
+
.space-y-4
|
|
7
|
+
= heading_2("Sender gate")
|
|
8
|
+
= hint("Allow or deny senders from unlocking these tools. With no rules, every sender is permitted.")
|
|
9
|
+
|
|
10
|
+
= render "gate_rule_form", agent_toolkit: agent_toolkit, rule: agent_toolkit.agent_toolkit_rules.build
|
|
11
|
+
|
|
12
|
+
table class="w-full text-sm"
|
|
13
|
+
tbody id="agent_toolkit_rules"
|
|
14
|
+
- if agent_toolkit.agent_toolkit_rules.any?
|
|
15
|
+
= render partial: "gate_rule", collection: agent_toolkit.agent_toolkit_rules.order(:kind, :pattern), as: :rule
|
|
16
|
+
- else
|
|
17
|
+
tr id="agent_toolkit_rules_empty"
|
|
18
|
+
td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
|
|
19
|
+
| No rules — every sender can unlock these tools.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
= page_container do
|
|
2
|
+
= page_header(@agent_toolkit.name, "How #{@agent_toolkit.agent.name} may use this toolkit") do
|
|
3
|
+
.flex.items-center.gap-2
|
|
4
|
+
= ui_link("Edit toolkit", edit_toolkit_path(@agent_toolkit.toolkit), class: "px-3")
|
|
5
|
+
= ui_link("Back to agent", agent_path(@agent_toolkit.agent), class: "px-3")
|
|
6
|
+
|
|
7
|
+
.space-y-4
|
|
8
|
+
= info_card("Tools", toolkit_overview(@agent_toolkit.toolkit))
|
|
9
|
+
|
|
10
|
+
= card do
|
|
11
|
+
= heading_2("Scheduled runs")
|
|
12
|
+
= hint("Whether these tools may run on this agent's scheduled duties, where there is no sender to check.")
|
|
13
|
+
= form_with model: @agent_toolkit, url: agent_toolkit_path(@agent_toolkit), method: :patch, scope: :agent_toolkit, class: "mt-3 space-y-3" do |f|
|
|
14
|
+
= form_checkbox(f, :allow_proactive, label: "Allow on scheduled runs")
|
|
15
|
+
= button("Save", type: :submit)
|
|
16
|
+
|
|
17
|
+
= render("gate_rules", agent_toolkit: @agent_toolkit)
|
|
@@ -4,4 +4,4 @@ tr id=dom_id(access_rule) style="border-top: 1px solid var(--border)"
|
|
|
4
4
|
td class="py-3 pr-4 align-middle"
|
|
5
5
|
code class="font-mono break-all" = access_rule.pattern
|
|
6
6
|
td class="py-3 align-middle"
|
|
7
|
-
= destructive_link("Remove",
|
|
7
|
+
= destructive_link("Remove", agent_access_rule_path(access_rule.agent, access_rule), confirm: "Remove this rule?")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#access_rule_form
|
|
2
|
-
= form_with model: access_rule, url:
|
|
2
|
+
= form_with model: access_rule, url: agent_access_rules_path(agent), scope: :access_rule, class: "space-y-3" do |f|
|
|
3
3
|
= form_errors(access_rule)
|
|
4
4
|
.flex.items-end.gap-3
|
|
5
5
|
= form_select(f, :kind, Protege::AccessRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
/ Inbound access rules for
|
|
1
|
+
/ Inbound access rules for an agent — the dashboard-editable, narrowing layer of the access
|
|
2
2
|
/ guardrail. Lists existing rules with a remove control and an add form. Create/destroy update the
|
|
3
|
-
/ table in place via Turbo Streams (the
|
|
3
|
+
/ table in place via Turbo Streams (the agent page survives), falling back to a redirect for non-Turbo.
|
|
4
4
|
|
|
5
5
|
= card do
|
|
6
6
|
.space-y-4
|
|
7
7
|
= heading_2("Inbound access rules")
|
|
8
8
|
= hint("Allow or deny senders for this agent. Rules only narrow the global policy — they can never widen it.")
|
|
9
9
|
|
|
10
|
-
= render "access_rule_form",
|
|
10
|
+
= render "access_rule_form", agent: agent, access_rule: agent.access_rules.build
|
|
11
11
|
|
|
12
12
|
table class="w-full text-sm"
|
|
13
13
|
tbody id="access_rules"
|
|
14
|
-
- if
|
|
15
|
-
= render partial: "access_rule", collection:
|
|
14
|
+
- if agent.access_rules.any?
|
|
15
|
+
= render partial: "access_rule", collection: agent.access_rules.order(:kind, :pattern), as: :access_rule
|
|
16
16
|
- else
|
|
17
17
|
tr id="access_rules_empty"
|
|
18
18
|
td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/ The agent show page's action bar — Edit, Archive/Unarchive, and permanent Delete — plus an "Archived"
|
|
2
|
+
/ badge when soft-removed. Wrapped in a dom_id so Archive/Unarchive can replace it in place over Turbo
|
|
3
|
+
/ (see ArchivesController). The Delete button opens the confirm dialog rendered by the show template.
|
|
4
|
+
div id=dom_id(agent, :actions) class="flex items-center gap-3"
|
|
5
|
+
- if agent.archived?
|
|
6
|
+
= badge("Archived")
|
|
7
|
+
|
|
8
|
+
= ui_link("Edit", edit_agent_path(agent))
|
|
9
|
+
|
|
10
|
+
- if agent.archived?
|
|
11
|
+
= action_button("Unarchive", agent_archive_path(agent), method: :delete)
|
|
12
|
+
- else
|
|
13
|
+
= action_button("Archive", agent_archive_path(agent), method: :post, variant: :secondary)
|
|
14
|
+
|
|
15
|
+
= button("Delete", type: "button", variant: :danger, onclick: show_dialog("delete-agent"))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tr id=dom_id(agent_toolkit) style="border-top: 1px solid var(--border)"
|
|
2
|
+
td class="w-full py-3 pr-6 align-middle font-medium"
|
|
3
|
+
= agent_toolkit.name
|
|
4
|
+
td class="py-3 align-middle"
|
|
5
|
+
.flex.items-center.justify-end.gap-4
|
|
6
|
+
= ui_link("Configure Access Settings", agent_toolkit_path(agent_toolkit), class: "whitespace-nowrap")
|
|
7
|
+
= destructive_link("Detach", agent_agent_toolkit_path(agent_toolkit.agent, agent_toolkit), confirm: "Detach this toolkit?", class: "whitespace-nowrap")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#agent_toolkit_form
|
|
2
|
+
= form_with model: agent_toolkit, url: agent_agent_toolkits_path(agent), scope: :agent_toolkit, class: "space-y-3" do |f|
|
|
3
|
+
= form_errors(agent_toolkit)
|
|
4
|
+
.flex.items-end.gap-3
|
|
5
|
+
.flex-1
|
|
6
|
+
= form_select(f, :toolkit_id, attachable_toolkits(agent).map { |t| [t.name, t.id] }, label: "Toolkit", prompt: "Choose a toolkit")
|
|
7
|
+
= button("Attach", type: :submit, class: "h-9")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
- url = agent.new_record? ? agents_path : agent_path(agent)
|
|
2
|
+
- method = agent.new_record? ? :post : :patch
|
|
3
|
+
|
|
4
|
+
= form_with url:, method:, scope: :agent, class: "space-y-5" do |f|
|
|
5
|
+
= form_errors(agent)
|
|
6
|
+
|
|
7
|
+
= form_field(f, :name, placeholder: "e.g. alice")
|
|
8
|
+
|
|
9
|
+
= form_email_address(agent, available_email_domains)
|
|
10
|
+
|
|
11
|
+
- unless agent.new_record?
|
|
12
|
+
= hint("Changing the address only affects new mail — messages to the old address will no longer reach this agent. Existing threads and history are unaffected.")
|
|
13
|
+
|
|
14
|
+
- if agent.new_record?
|
|
15
|
+
= form_select(f, :type, available_agent_types.map { |k| [k.display_name, k.name] }, label: "Type")
|
|
16
|
+
|
|
17
|
+
= form_text_area(f, :instructions, \
|
|
18
|
+
label: "Instructions", \
|
|
19
|
+
rows: 10, \
|
|
20
|
+
hint: "The agent's system prompt — who it is and how it behaves. Only sent to the model when a resolver (such as LoadText) includes it in the agent's context.")
|
|
21
|
+
|
|
22
|
+
.flex.items-center.gap-3.pt-2
|
|
23
|
+
= button(agent.new_record? ? "Create agent" : "Save changes", type: :submit)
|
|
24
|
+
|
|
25
|
+
= ui_link("Cancel", agent.new_record? ? agents_path : agent_path(agent))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
- pagy, agents = paginate(all_agents)
|
|
2
|
+
|
|
3
|
+
= sidebar_content do
|
|
4
|
+
= sidebar_header("Agents (#{pagy.count})", new_path: new_agent_path)
|
|
5
|
+
|
|
6
|
+
= sidebar_stream("protege:agents", pagy: pagy)
|
|
7
|
+
|
|
8
|
+
= sidebar_body(agents, empty: "No agents yet", id: "agents_list") do |agent|
|
|
9
|
+
= render "agent_sidebar_item", agent: agent, active: local_assigns[:current] == agent
|
|
10
|
+
|
|
11
|
+
= sidebar_pagination(pagy)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/ Toolkits attached to an agent — the sender-gated tool bundles it exposes. Lists attached toolkits with
|
|
2
|
+
/ a detach control and an attach form. Attach/detach update the table in place via Turbo Streams (the
|
|
3
|
+
/ agent page survives), falling back to a redirect for non-Turbo.
|
|
4
|
+
|
|
5
|
+
= card do
|
|
6
|
+
.space-y-4
|
|
7
|
+
= heading_2("Agent Toolkits")
|
|
8
|
+
= hint("Attach a toolkit to give this agent its tools, then use Configure Access Settings to set who may use them. Toolkits themselves are managed under Toolkits.")
|
|
9
|
+
|
|
10
|
+
= render "agent_toolkit_form", agent: agent, agent_toolkit: agent.agent_toolkits.build
|
|
11
|
+
|
|
12
|
+
table class="w-full text-sm"
|
|
13
|
+
tbody id="agent_toolkits"
|
|
14
|
+
- if agent.agent_toolkits.any?
|
|
15
|
+
= render partial: "agent_toolkit", collection: agent.agent_toolkits.includes(:toolkit), as: :agent_toolkit
|
|
16
|
+
- else
|
|
17
|
+
tr id="agent_toolkits_empty"
|
|
18
|
+
td colspan="2" class="py-6 text-center text-sm" style="color: var(--text-faint)"
|
|
19
|
+
| No toolkits attached — this agent has no tools yet.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
- if @agent_toolkit.persisted?
|
|
2
|
+
= turbo_stream.append "agent_toolkits", partial: "protege/agents/agent_toolkit", locals: { agent_toolkit: @agent_toolkit }
|
|
3
|
+
= turbo_stream.remove "agent_toolkits_empty"
|
|
4
|
+
= turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent.agent_toolkits.build }
|
|
5
|
+
- else
|
|
6
|
+
= turbo_stream.replace "agent_toolkit_form", partial: "protege/agents/agent_toolkit_form", locals: { agent: @agent, agent_toolkit: @agent_toolkit }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
- content_for :sidebar do
|
|
2
|
+
= render "sidebar", current: @agent
|
|
3
|
+
|
|
4
|
+
= page_container do
|
|
5
|
+
= page_header(@agent.name, @agent.class.display_name) do
|
|
6
|
+
= render("actions", agent: @agent)
|
|
7
|
+
|
|
8
|
+
.space-y-4
|
|
9
|
+
= info_card("Email address", { "Address" => @agent.email_address })
|
|
10
|
+
|
|
11
|
+
= card do
|
|
12
|
+
= heading_2("Instructions")
|
|
13
|
+
- if @agent.instructions.present?
|
|
14
|
+
.mt-3
|
|
15
|
+
= body_text(@agent.instructions)
|
|
16
|
+
- else
|
|
17
|
+
.mt-3
|
|
18
|
+
= hint("No instructions set yet — add them via Edit. They're used as the system prompt only when a resolver (e.g. LoadText) includes them in the agent's context.")
|
|
19
|
+
|
|
20
|
+
= render("access_rules", agent: @agent)
|
|
21
|
+
|
|
22
|
+
= render("toolkits", agent: @agent)
|
|
23
|
+
|
|
24
|
+
= render("protege/shared/confirm_dialog",
|
|
25
|
+
id: "delete-agent",
|
|
26
|
+
title: "Delete #{@agent.name}?",
|
|
27
|
+
message: "This permanently removes the agent and ALL of its data — every thread, message, attachment, responsibility, access rule, and tool-use record. This cannot be undone. To keep the history but stop the agent instead, archive it.",
|
|
28
|
+
action_url: agent_path(@agent),
|
|
29
|
+
action_text: "Delete agent")
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
= render("protege/shared/confirm_dialog",
|
|
32
32
|
id: "remove-domain",
|
|
33
33
|
title: "Remove #{@email_domain.domain}?",
|
|
34
|
-
message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by
|
|
34
|
+
message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by an agent, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
|
|
35
35
|
action_url: email_domain_path(@email_domain),
|
|
36
36
|
action_text: "Remove domain")
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
= page_header('About Protege')
|
|
3
3
|
|
|
4
4
|
div class="space-y-4 text-sm leading-relaxed mb-10"
|
|
5
|
-
p Protege is an email-native AI agent toolkit. Your
|
|
5
|
+
p Protege is an email-native AI agent toolkit. Your agents receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
|
|
6
6
|
|
|
7
|
-
p To get started, configure at least one email domain with valid DNS records, create
|
|
7
|
+
p To get started, configure at least one email domain with valid DNS records, create an agent with a routable email address, and set up your provider credentials. Once an agent is active and your MTA is running, it will process inbound email automatically.
|
|
8
8
|
|
|
9
9
|
p Protege is a toolkit for developers, not an out-of-the-box solution. Every part of the pipeline — providers, resolvers, tools, hooks — is an extension surface you implement. The #{ui_link("documentation", "https://docs.protege.bot", target: "_blank")} covers each surface in detail with examples and guides.
|
|
10
10
|
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
= heading_2("Extension surfaces")
|
|
14
14
|
|
|
15
15
|
= grid_container(cols: 2) do
|
|
16
|
-
= grid_card("
|
|
17
|
-
|
|
18
|
-
= grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/providers")
|
|
19
|
-
|
|
20
|
-
= grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/resolvers")
|
|
21
|
-
|
|
22
|
-
= grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/tools")
|
|
23
|
-
|
|
24
|
-
= grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/hooks")
|
|
25
|
-
|
|
26
|
-
= grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/gateway")
|
|
16
|
+
= grid_card("Agents", "The addressable identity that receives and responds to email. Each agent has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/reference/agents")
|
|
17
|
+
|
|
18
|
+
= grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/extensions/providers")
|
|
19
|
+
|
|
20
|
+
= grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/extensions/resolvers")
|
|
21
|
+
|
|
22
|
+
= grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/extensions/tools")
|
|
23
|
+
|
|
24
|
+
= grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/extensions/hooks-and-events")
|
|
25
|
+
|
|
26
|
+
= grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/architecture/gateway")
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
= form_with url:, method:, scope: :responsibility, class: "space-y-5" do |f|
|
|
5
5
|
= form_errors(responsibility)
|
|
6
6
|
|
|
7
|
-
= form_select(f, :
|
|
7
|
+
= form_select(f, :agent_id, all_agents.map { |p| [p.name, p.id] }, label: "Agent", prompt: "Choose an agent")
|
|
8
8
|
|
|
9
9
|
= form_field(f, :name, placeholder: "e.g. Morning digest")
|
|
10
10
|
|
|
11
11
|
= form_field(f, :schedule, label: "Schedule", placeholder: "0 9 * * 1-5", hint: "A 5-field cron expression (minute hour day-of-month month day-of-week) in the app timezone — e.g. \"0 9 * * 1-5\" is 9am on weekdays, \"*/15 * * * *\" every 15 minutes.")
|
|
12
12
|
|
|
13
|
-
= form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The
|
|
13
|
+
= form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The agent's responsibility_resolvers chain supplies the system context around it.")
|
|
14
14
|
|
|
15
15
|
- unless responsibility.new_record?
|
|
16
16
|
= form_checkbox(f, :active, label: "Active")
|