protege 0.1.0.alpha.1
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 +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for rendering a +Message+ within a thread — sender, timestamp,
|
|
5
|
+
# recipients, subject, and expandable raw headers. Liveness (processing/reasoning/tool use) is not
|
|
6
|
+
# shown on the message; it lives in the console's introspection panel.
|
|
7
|
+
module MessagesHelper
|
|
8
|
+
# The search-result row title: the email subject, or a placeholder when blank.
|
|
9
|
+
#
|
|
10
|
+
# @param message [Protege::Message] the matched message
|
|
11
|
+
# @return [String] the subject or "(no subject)"
|
|
12
|
+
def search_result_title(message)
|
|
13
|
+
message.subject.presence || '(no subject)'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The search-result row's right-aligned date — the email's sent time in short +m/d/yy+ form.
|
|
17
|
+
#
|
|
18
|
+
# @param message [Protege::Message] the matched message
|
|
19
|
+
# @return [String, nil] the short date, or nil when unsent
|
|
20
|
+
def search_result_date(message)
|
|
21
|
+
message.sent_at&.strftime('%-m/%-d/%y')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Render the sender line — persona name for outbound mail, local part of the
|
|
25
|
+
# address for inbound mail — followed by the full address.
|
|
26
|
+
#
|
|
27
|
+
# @param message [Protege::Message] the message to render the sender for
|
|
28
|
+
# @return [ActiveSupport::SafeBuffer] the bold +"name <address>"+ span
|
|
29
|
+
def message_sender(message)
|
|
30
|
+
name = if message.outbound?
|
|
31
|
+
message.persona.name
|
|
32
|
+
else
|
|
33
|
+
message.from_address.to_s.split('@').first
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
tag.span("#{name} <#{message.from_address}>", class: 'font-semibold')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Render the message's send time as a +<time>+ element.
|
|
40
|
+
#
|
|
41
|
+
# @param message [Protege::Message] the message whose timestamp to render
|
|
42
|
+
# @return [ActiveSupport::SafeBuffer] a formatted +<time>+ tag
|
|
43
|
+
def message_timestamp(message)
|
|
44
|
+
tag.time(
|
|
45
|
+
message.sent_at.strftime('%b %-d, %Y %H:%M'),
|
|
46
|
+
datetime: message.sent_at.iso8601,
|
|
47
|
+
class: 'flex-shrink-0 text-xs',
|
|
48
|
+
style: 'color: var(--text-faint)'
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Render the To line, appending a Cc line only when Cc recipients exist.
|
|
53
|
+
#
|
|
54
|
+
# @param message [Protege::Message] the message whose recipients to render
|
|
55
|
+
# @return [ActiveSupport::SafeBuffer] the recipients line
|
|
56
|
+
def message_recipients(message)
|
|
57
|
+
to = tag.span("To: #{Array(message.to_addresses).join(', ')}")
|
|
58
|
+
cc = if Array(message.cc_addresses).any?
|
|
59
|
+
tag.span("Cc: #{message.cc_addresses.join(', ')}", class: 'ml-2')
|
|
60
|
+
else
|
|
61
|
+
''.html_safe
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
tag.div(class: 'mt-1 text-xs', style: 'color: var(--text-muted)') { safe_join([to, cc]) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Render the subject line, but only when it differs from the thread subject.
|
|
68
|
+
#
|
|
69
|
+
# @param message [Protege::Message] the message whose subject to consider
|
|
70
|
+
# @return [ActiveSupport::SafeBuffer] the subject line, or an empty buffer
|
|
71
|
+
def message_subject(message)
|
|
72
|
+
if message.subject.present? && message.subject != message.email_thread.subject
|
|
73
|
+
tag.div("Subject: #{message.subject}", class: 'mt-1 text-xs font-medium')
|
|
74
|
+
else
|
|
75
|
+
''.html_safe
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Render an expandable +<details>+ block listing the message's raw headers.
|
|
80
|
+
#
|
|
81
|
+
# @param message [Protege::Message] the message whose headers to render
|
|
82
|
+
# @return [ActiveSupport::SafeBuffer] the collapsible headers block
|
|
83
|
+
def message_headers(message)
|
|
84
|
+
tag.details(class: 'mt-1') do
|
|
85
|
+
tag.summary('Headers', class: 'cursor-pointer text-xs', style: 'color: var(--text-faint)') +
|
|
86
|
+
message_headers_list(message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Render a message's attachments as a row of download-link chips, or nothing when there are none.
|
|
91
|
+
#
|
|
92
|
+
# Each chip links to the blob via Active Storage's host-app route (+main_app.rails_blob_path+, since
|
|
93
|
+
# those routes live on the host, not the engine) and shows the filename and a human-readable size.
|
|
94
|
+
# +link_to+ escapes the filename, which is copied verbatim from attacker-controlled inbound headers.
|
|
95
|
+
#
|
|
96
|
+
# The link forces a download two ways: +disposition: "attachment"+ makes the service set
|
|
97
|
+
# +Content-Disposition: attachment+ (so even an +.html+ blob downloads rather than rendering), and
|
|
98
|
+
# the +download+ attribute both hints the filename and opts the anchor out of Turbo Drive — without
|
|
99
|
+
# it Turbo intercepts the click and renders an HTML attachment as a page instead of downloading it.
|
|
100
|
+
#
|
|
101
|
+
# @param message [Protege::Message] the message whose attachments to render
|
|
102
|
+
# @return [ActiveSupport::SafeBuffer] the chips row, or an empty buffer
|
|
103
|
+
def message_attachments(message)
|
|
104
|
+
return ''.html_safe unless message.attachments.attached?
|
|
105
|
+
|
|
106
|
+
chips = message.attachments.map do |attachment|
|
|
107
|
+
label = "#{attachment.filename} (#{number_to_human_size(attachment.byte_size)})"
|
|
108
|
+
link_to(label,
|
|
109
|
+
main_app.rails_blob_path(attachment, disposition: 'attachment', only_path: true),
|
|
110
|
+
download: attachment.filename.to_s,
|
|
111
|
+
class: 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium hover:underline',
|
|
112
|
+
style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
tag.div(class: 'mt-3 flex flex-wrap gap-2') { safe_join(chips) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Build the ordered label/value header pairs shown in the headers block.
|
|
121
|
+
#
|
|
122
|
+
# Conditional headers are included only when their value is present.
|
|
123
|
+
#
|
|
124
|
+
# @param message [Protege::Message] the message to read header values from
|
|
125
|
+
# @return [Array<Array(String, String)>] +[label, value]+ pairs in display order
|
|
126
|
+
def message_header_pairs(message)
|
|
127
|
+
pairs = [['Message-ID', message.message_id]]
|
|
128
|
+
|
|
129
|
+
pairs << ['In-Reply-To', message.in_reply_to] if message.in_reply_to.present?
|
|
130
|
+
pairs << ['References', message.references_header] if message.references_header.present?
|
|
131
|
+
pairs << ['Direction', message.direction]
|
|
132
|
+
pairs << ['Correlation-ID', message.correlation_id] if message.correlation_id.present?
|
|
133
|
+
|
|
134
|
+
pairs
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Render the +<dl>+ body of the headers block from the message's header pairs.
|
|
138
|
+
#
|
|
139
|
+
# @param message [Protege::Message] the message whose headers to list
|
|
140
|
+
# @return [ActiveSupport::SafeBuffer] the definition list of headers
|
|
141
|
+
def message_headers_list(message)
|
|
142
|
+
tag.dl(class: 'mt-1 space-y-0.5 text-xs', style: 'color: var(--text-muted)') do
|
|
143
|
+
safe_join(message_header_pairs(message).map do |label, value|
|
|
144
|
+
tag.div(class: 'flex gap-1') do
|
|
145
|
+
tag.dt("#{label}:", class: 'font-medium') +
|
|
146
|
+
tag.dd(value, class: 'break-all')
|
|
147
|
+
end
|
|
148
|
+
end)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for persona pages — selectors, labels, status, and resolver display.
|
|
5
|
+
module PersonasHelper
|
|
6
|
+
# List the loaded +Persona+ subclasses for the new-persona type selector.
|
|
7
|
+
#
|
|
8
|
+
# @return [Array<Class>] the +Persona+ descendant classes
|
|
9
|
+
def available_persona_types
|
|
10
|
+
Persona.descendants
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# List active email domains for the persona form's domain selector.
|
|
14
|
+
#
|
|
15
|
+
# @return [ActiveRecord::Relation<Protege::EmailDomain>] domains eligible for selection
|
|
16
|
+
def available_email_domains
|
|
17
|
+
EmailDomain.all
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetch every +Persona+ ordered by name for sidebar listing.
|
|
21
|
+
#
|
|
22
|
+
# @return [ActiveRecord::Relation<Protege::Persona>] personas ordered alphabetically
|
|
23
|
+
def all_personas
|
|
24
|
+
Persona.order(:name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Format a persona's name together with its type for display.
|
|
28
|
+
#
|
|
29
|
+
# persona_label(persona) # => "Bob <Agent>"
|
|
30
|
+
#
|
|
31
|
+
# @param persona [Protege::Persona] the persona to label
|
|
32
|
+
# @return [String] the +"name <Type>"+ label
|
|
33
|
+
def persona_label(persona)
|
|
34
|
+
"#{persona.name} <#{persona.class.display_name}>"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Render a status dot reflecting whether a persona is available.
|
|
38
|
+
#
|
|
39
|
+
# @param persona [Protege::Persona] the persona whose availability to show
|
|
40
|
+
# @return [ActiveSupport::SafeBuffer] a green dot when active, red otherwise
|
|
41
|
+
def persona_status(persona)
|
|
42
|
+
if persona.active?
|
|
43
|
+
status_dot('Available', :green)
|
|
44
|
+
else
|
|
45
|
+
status_dot('Unavailable', :red)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Format a persona's resolver chain as a hash suitable for +info_card+ (rendered full-width, since
|
|
50
|
+
# the resolver class names are long). Each entry maps the resolver's short class name to a summary
|
|
51
|
+
# of its positional args, keyword args, and whether it was given a block.
|
|
52
|
+
#
|
|
53
|
+
# @param persona [Protege::Persona] the persona whose reply (+message_resolvers+) chain to format
|
|
54
|
+
# @return [Hash{String => String}] resolver-name => configuration summary
|
|
55
|
+
def resolver_pairs(persona)
|
|
56
|
+
pairs = persona.message_resolvers.entries.each_with_object({}) do |(klass, args, kwargs, block), accumulator|
|
|
57
|
+
accumulator[klass.name.demodulize] = resolver_config(args, kwargs, block)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
pairs.any? ? pairs : { 'Resolvers' => 'None configured' }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The tool classes a persona's operator may toggle — the checkbox universe on the persona form.
|
|
64
|
+
#
|
|
65
|
+
# This is the persona's *grant* (its code-declared ceiling), or every registered tool when no grant
|
|
66
|
+
# was declared. It is deliberately the grant rather than the effective set: the operator toggles
|
|
67
|
+
# within what the code allows, so a tool disabled in the DB still appears (unchecked) and can be
|
|
68
|
+
# switched back on, while a tool outside the grant is never offered.
|
|
69
|
+
#
|
|
70
|
+
# @param persona [Protege::Persona] the persona being edited
|
|
71
|
+
# @return [Array<Class>] the toggleable tool classes
|
|
72
|
+
def persona_tool_options(persona)
|
|
73
|
+
grant = persona.class.tool_grant
|
|
74
|
+
|
|
75
|
+
grant ? ToolMixin.registered.select { grant.include?(_1.id) } : ToolMixin.registered
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Summarize a resolver entry's positional args, keyword args, and whether it was given a block.
|
|
81
|
+
#
|
|
82
|
+
# @return [String] a comma-joined summary, or "" when the resolver takes no configuration
|
|
83
|
+
def resolver_config(args, kwargs, block)
|
|
84
|
+
parts = args.map(&:inspect)
|
|
85
|
+
parts += kwargs.map { |key, value| "#{key}: #{value.inspect}" }
|
|
86
|
+
parts << '{ … }' if block
|
|
87
|
+
parts.join(', ')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for responsibility pages — status display and run-history formatting.
|
|
5
|
+
module ResponsibilitiesHelper
|
|
6
|
+
# Fetch every +Responsibility+ (persona eager-loaded) for the global sidebar listing. The model's
|
|
7
|
+
# default scope already orders by name.
|
|
8
|
+
#
|
|
9
|
+
# @return [ActiveRecord::Relation<Protege::Responsibility>] all responsibilities
|
|
10
|
+
def all_responsibilities
|
|
11
|
+
Responsibility.includes(:persona)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Render a status dot reflecting whether a responsibility is active.
|
|
15
|
+
#
|
|
16
|
+
# @param responsibility [Protege::Responsibility] the responsibility whose state to show
|
|
17
|
+
# @return [ActiveSupport::SafeBuffer] a green dot when active, grey when paused
|
|
18
|
+
def responsibility_status(responsibility)
|
|
19
|
+
if responsibility.active?
|
|
20
|
+
status_dot('Active', :green)
|
|
21
|
+
else
|
|
22
|
+
status_dot('Paused', :gray)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Render a run's status as a colour-coded badge.
|
|
27
|
+
#
|
|
28
|
+
# @param run [Protege::ResponsibilityRun] the run whose status to badge
|
|
29
|
+
# @return [ActiveSupport::SafeBuffer] the badge markup
|
|
30
|
+
def run_status_badge(run)
|
|
31
|
+
colors = { 'completed' => :green, 'failed' => :red, 'running' => :gray, 'pending' => :gray }
|
|
32
|
+
badge(run.status, colors.fetch(run.status, :gray))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build the responsibility Show page's recent-runs table — its headers and one
|
|
36
|
+
# +[status badge, when, detail link]+ row per run, newest first (capped at ten).
|
|
37
|
+
#
|
|
38
|
+
# @param responsibility [Protege::Responsibility] the responsibility whose runs to format
|
|
39
|
+
# @return [Protege::Components::TablesHelper::Table] the headers + rows for +table+
|
|
40
|
+
def responsibility_runs_table(responsibility)
|
|
41
|
+
rows = responsibility.responsibility_runs.order(created_at: :desc).limit(10).map do |run|
|
|
42
|
+
[
|
|
43
|
+
run_status_badge(run),
|
|
44
|
+
run.correlation_id,
|
|
45
|
+
run_time(run),
|
|
46
|
+
ui_link('Details', responsibility_run_path(run))
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Components::TablesHelper::Table.new(headers: ['Status', 'Correlation ID', 'When', ''], rows:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The most relevant timestamp for a run: when it started, falling back to when it was enqueued.
|
|
56
|
+
#
|
|
57
|
+
# @param run [Protege::ResponsibilityRun]
|
|
58
|
+
# @return [String] a formatted timestamp
|
|
59
|
+
def run_time(run)
|
|
60
|
+
(run.started_at || run.created_at).strftime('%Y-%m-%d %H:%M')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for thread pages — the inbox list, compose selectors, sidebar
|
|
5
|
+
# display, and the thread detail header.
|
|
6
|
+
module ThreadsHelper
|
|
7
|
+
# Fetch every +EmailThread+ ordered by most recent activity.
|
|
8
|
+
#
|
|
9
|
+
# @return [ActiveRecord::Relation<Protege::EmailThread>] threads newest-activity first
|
|
10
|
+
def inbox_threads
|
|
11
|
+
EmailThread.order(last_message_at: :desc)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# List the active personas eligible for the compose form.
|
|
15
|
+
#
|
|
16
|
+
# @return [ActiveRecord::Relation<Protege::Persona>] personas eligible for selection
|
|
17
|
+
def available_personas
|
|
18
|
+
Persona.active
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Format the available personas as +[label, id]+ pairs for a select dropdown.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Array(String, Integer)>] option label/value pairs
|
|
24
|
+
def available_persona_options
|
|
25
|
+
available_personas.map { |persona| ["#{persona.name} <#{persona.email_address}>", persona.id] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Format a thread subject with its message count for sidebar display.
|
|
29
|
+
# Truncation is handled by the +sidebar_item+ helper.
|
|
30
|
+
#
|
|
31
|
+
# thread_title(thread) # => "Hello (3)"
|
|
32
|
+
#
|
|
33
|
+
# @param thread [Protege::EmailThread] the thread to title
|
|
34
|
+
# @return [String] the subject, suffixed with the count when it exceeds one
|
|
35
|
+
def thread_title(thread)
|
|
36
|
+
subject = thread.subject.presence || '(no subject)'
|
|
37
|
+
thread.message_count > 1 ? "#{thread.persona.name} - #{subject} (#{thread.message_count})" : subject
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Format the thread's last-activity date for sidebar display.
|
|
41
|
+
#
|
|
42
|
+
# @param thread [Protege::EmailThread] the thread to date
|
|
43
|
+
# @return [String, nil] the short +m/d/yy+ date, or nil when no activity
|
|
44
|
+
def thread_date(thread)
|
|
45
|
+
thread.last_message_at&.strftime('%-m/%-d/%y')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a one-line preview from the first line of the latest message.
|
|
49
|
+
#
|
|
50
|
+
# @param thread [Protege::EmailThread] the thread to preview
|
|
51
|
+
# @return [String] the trimmed first line, or an em dash when empty
|
|
52
|
+
def thread_preview(thread)
|
|
53
|
+
thread.latest_message&.text_body.to_s.lines.first.to_s.strip.presence || '—'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Render the thread detail header — subject, persona, message count, last
|
|
57
|
+
# activity, and thread ID. An optional block renders actions beside the title.
|
|
58
|
+
#
|
|
59
|
+
# @param thread [Protege::EmailThread] the thread whose header to render
|
|
60
|
+
# @yield optional content rendered to the right of the subject
|
|
61
|
+
# @return [ActiveSupport::SafeBuffer] the header markup
|
|
62
|
+
def thread_header(thread, &block)
|
|
63
|
+
tag.div(class: 'flex-shrink-0 px-5 py-3') do
|
|
64
|
+
thread_header_title(thread, &block) +
|
|
65
|
+
thread_header_meta(thread) +
|
|
66
|
+
thread_header_id(thread)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Render the title row — the subject and any caller-supplied actions block.
|
|
73
|
+
#
|
|
74
|
+
# @param thread [Protege::EmailThread] the thread whose subject to render
|
|
75
|
+
# @yield optional content rendered to the right of the subject
|
|
76
|
+
# @return [ActiveSupport::SafeBuffer] the title row markup
|
|
77
|
+
def thread_header_title(thread, &block)
|
|
78
|
+
tag.div(class: 'flex items-center justify-between') do
|
|
79
|
+
heading_3(thread.subject.presence || '(no subject)') +
|
|
80
|
+
(block ? capture(&block) : ''.html_safe)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Render the metadata row — persona, message count, and last activity.
|
|
85
|
+
#
|
|
86
|
+
# @param thread [Protege::EmailThread] the thread whose metadata to render
|
|
87
|
+
# @return [ActiveSupport::SafeBuffer] the metadata row markup
|
|
88
|
+
def thread_header_meta(thread)
|
|
89
|
+
tag.div(class: 'mt-1 flex items-center gap-3 text-xs', style: 'color: var(--text-muted)') do
|
|
90
|
+
tag.span do
|
|
91
|
+
'Persona: '.html_safe + tag.span(thread.persona.name, class: 'font-medium')
|
|
92
|
+
end +
|
|
93
|
+
tag.span(pluralize(thread.message_count, 'message')) +
|
|
94
|
+
thread_header_last_activity(thread)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Render the last-activity span, or an empty buffer when there is none.
|
|
99
|
+
#
|
|
100
|
+
# @param thread [Protege::EmailThread] the thread whose last activity to render
|
|
101
|
+
# @return [ActiveSupport::SafeBuffer] the last-activity span, or an empty buffer
|
|
102
|
+
def thread_header_last_activity(thread)
|
|
103
|
+
return ''.html_safe unless thread.last_message_at
|
|
104
|
+
|
|
105
|
+
tag.span("Last activity: #{thread.last_message_at.strftime('%b %-d, %Y %H:%M')}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Render the thread ID row.
|
|
109
|
+
#
|
|
110
|
+
# @param thread [Protege::EmailThread] the thread whose ID to render
|
|
111
|
+
# @return [ActiveSupport::SafeBuffer] the thread ID row markup
|
|
112
|
+
def thread_header_id(thread)
|
|
113
|
+
tag.div(class: 'mt-1 text-xs', style: 'color: var(--text-faint)') do
|
|
114
|
+
"Thread ID: #{thread.thread_id}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for the tracing dashboard — the one place the trace views ask about tracing state, so
|
|
5
|
+
# the +config.tracing+ hash shape stays out of the templates.
|
|
6
|
+
module TraceHelper
|
|
7
|
+
# Fetch captured turns newest first — the sidebar list. A helper (like +inbox_threads+) so the +show+
|
|
8
|
+
# page's sidebar sources its own list without the controller re-deriving it into an ivar.
|
|
9
|
+
#
|
|
10
|
+
# @return [ActiveRecord::Relation<Protege::Trace>] traces newest first
|
|
11
|
+
def recent_traces
|
|
12
|
+
Trace.recent
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Whether inference tracing is currently capturing turns — the +config.tracing+ master switch.
|
|
16
|
+
#
|
|
17
|
+
# @return [Boolean] true when tracing is enabled
|
|
18
|
+
def tracing_enabled?
|
|
19
|
+
Protege.configuration.tracing[:enabled] ? true : false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in event logger hook. Subscribes to the full lifecycle of Protege events and writes
|
|
5
|
+
# human-readable log lines to the configured +Protege.configuration.logger+, giving operators a
|
|
6
|
+
# trace of each agent run.
|
|
7
|
+
#
|
|
8
|
+
# Subclassing +Protege::Hook+ means the engine wires these handlers onto the event bus once
|
|
9
|
+
# at boot; each +on+ block receives a typed +Protege::Event+ and runs isolated, so a logging
|
|
10
|
+
# error can never break inference. As a built-in it is always active.
|
|
11
|
+
class EventLoggerHook < Protege::Hook
|
|
12
|
+
# Log the start of an inference run for a persona.
|
|
13
|
+
on InferenceStartedEvent do |event|
|
|
14
|
+
log "[#{ts}] #{cid(event)} INF START persona=#{event.persona&.name}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Log the completion of an inference run for a persona.
|
|
18
|
+
on InferenceCompletedEvent do |event|
|
|
19
|
+
log "[#{ts}] #{cid(event)} INF DONE persona=#{event.persona&.name}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Log the set of tool calls the model requested in a single turn.
|
|
23
|
+
on ToolCallsReceivedEvent do |event|
|
|
24
|
+
names = event.tool_calls&.map(&:name)&.join(', ')
|
|
25
|
+
log "[#{ts}] #{cid(event)} TOOLS RECV [#{names}]"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Log the start of a single tool invocation.
|
|
29
|
+
on ToolCallStartedEvent do |event|
|
|
30
|
+
tc = event.tool_call
|
|
31
|
+
log "[#{ts}] #{cid(event)} TOOL START #{tc&.name} id=#{tc&.id}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Log a successful tool invocation and its result data.
|
|
35
|
+
on ToolCallCompletedEvent do |event|
|
|
36
|
+
tc = event.tool_call
|
|
37
|
+
log "[#{ts}] #{cid(event)} TOOL OK #{tc&.name} id=#{tc&.id} data=#{event.result&.data}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Log a failed tool invocation with its error class, message, and a short backtrace.
|
|
41
|
+
on ToolCallFailedEvent do |event|
|
|
42
|
+
tc = event.tool_call
|
|
43
|
+
log "[#{ts}] #{cid(event)} TOOL FAIL #{tc&.name} id=#{tc&.id} " \
|
|
44
|
+
"#{event.error&.class}: #{event.error&.message}\n #{error_backtrace(event.error)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Log the agent loop hitting its turn cap with tool calls still pending.
|
|
48
|
+
on InferenceMaxTurnsReachedEvent do |event|
|
|
49
|
+
names = event.tool_calls&.map(&:name)&.join(', ')
|
|
50
|
+
log "[#{ts}] #{cid(event)} MAX TURNS turn=#{event.turn} pending=[#{names}]"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Log a scheduled responsibility run being enqueued — pending, awaiting a worker (the Loop layer).
|
|
54
|
+
on LoopRunEnqueuedEvent do |event|
|
|
55
|
+
log "[#{ts}] #{cid(event)} LOOP PENDING #{event.name} persona=#{event.persona_name}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Log the start of a scheduled responsibility run (the Loop layer).
|
|
59
|
+
on LoopRunStartedEvent do |event|
|
|
60
|
+
log "[#{ts}] #{cid(event)} LOOP START #{event.name} persona=#{event.persona_name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Log the successful completion of a scheduled responsibility run.
|
|
64
|
+
on LoopRunCompletedEvent do |event|
|
|
65
|
+
log "[#{ts}] #{cid(event)} LOOP DONE #{event.name} persona=#{event.persona_name}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Log a failed scheduled responsibility run with its error class, message, and short backtrace.
|
|
69
|
+
on LoopRunFailedEvent do |event|
|
|
70
|
+
log "[#{ts}] #{cid(event)} LOOP FAIL #{event.name} persona=#{event.persona_name} " \
|
|
71
|
+
"#{event.error&.class}: #{event.error&.message}\n #{error_backtrace(event.error)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Render the first few backtrace frames of an error for compact logging.
|
|
77
|
+
#
|
|
78
|
+
# @param error [Exception, nil] the captured error, or +nil+
|
|
79
|
+
# @return [String, nil] up to three indented backtrace frames, or +nil+ when unavailable
|
|
80
|
+
def error_backtrace(error)
|
|
81
|
+
return nil unless error
|
|
82
|
+
|
|
83
|
+
error.backtrace&.first(3)&.join("\n ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The correlation id for log line prefixes, falling back to a placeholder.
|
|
87
|
+
#
|
|
88
|
+
# @param event [Protege::Event] the event being logged
|
|
89
|
+
# @return [String] the correlation id, or +'--------'+ when absent
|
|
90
|
+
def cid(event)
|
|
91
|
+
event.correlation_id || '--------'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Write a single line to the configured Protege logger at info level.
|
|
95
|
+
#
|
|
96
|
+
# @param line [String] the formatted log line
|
|
97
|
+
# @return [void]
|
|
98
|
+
def log(line)
|
|
99
|
+
Protege.configuration.logger.info(line)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Produce a UTC ISO-8601 timestamp for log line prefixes.
|
|
103
|
+
#
|
|
104
|
+
# @return [String] the current time in ISO-8601 UTC
|
|
105
|
+
def ts
|
|
106
|
+
Time.now.utc.iso8601
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Abstract base class for every Active Job in the Protege engine.
|
|
5
|
+
#
|
|
6
|
+
# Both +InferenceJob+ and +ConsoleInferenceJob+ inherit from this class rather than
|
|
7
|
+
# +ActiveJob::Base+ directly, matching the host-app convention of an +ApplicationJob+ base. It
|
|
8
|
+
# provides a single place to configure cross-cutting job behaviour — retry policy, queue adapter
|
|
9
|
+
# selection, and around-perform callbacks — so individual jobs stay focused on their own work.
|
|
10
|
+
#
|
|
11
|
+
# == Failure policy
|
|
12
|
+
#
|
|
13
|
+
# Inference can fail for two very different reasons, and they are handled differently:
|
|
14
|
+
#
|
|
15
|
+
# * +Protege::Error::TRANSIENT+ are provider hiccups that a later attempt may clear (rate
|
|
16
|
+
# limits, timeouts, outages). These are retried with polynomial backoff.
|
|
17
|
+
# * +Protege::Error::PERMANENT+ cannot be fixed by retrying — a malformed request, bad
|
|
18
|
+
# credentials, an unknown model/capability, an unparseable response (plus a vanished record).
|
|
19
|
+
# Retrying only wastes work, so these are discarded after the job has recorded the failure.
|
|
20
|
+
#
|
|
21
|
+
# Any other error falls through to the queue adapter's default handling (no retry), still recorded
|
|
22
|
+
# on the message by each job's own +rescue+.
|
|
23
|
+
class ApplicationJob < ActiveJob::Base
|
|
24
|
+
# Failures where retrying cannot help; discarded after the failure is recorded.
|
|
25
|
+
discard_on(ActiveRecord::RecordNotFound, *Protege::Error::PERMANENT)
|
|
26
|
+
# Provider failures a later attempt may clear.
|
|
27
|
+
retry_on(*Protege::Error::TRANSIENT, attempts: 3, wait: :polynomially_longer)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Restore the per-run correlation ID so this job's events share the originating trace token. Every
|
|
32
|
+
# Protege job sets this at the top of +perform+, keeping events correlated from ingress (or the
|
|
33
|
+
# scheduler tick) through inference.
|
|
34
|
+
#
|
|
35
|
+
# @param correlation_id [String] the trace token to thread through this job's events
|
|
36
|
+
# @return [void]
|
|
37
|
+
def restore_correlation_id(correlation_id)
|
|
38
|
+
Protege::Current.correlation_id = correlation_id
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Runs a dashboard-originated message through the inference pipeline.
|
|
5
|
+
#
|
|
6
|
+
# The console counterpart to +InferenceJob+: enqueued by +Gateway.accept_console_message+ when a
|
|
7
|
+
# developer sends a message from the HQ console. Because console messages bypass SMTP entirely,
|
|
8
|
+
# this job operates on a +Message+ that has already been persisted, rather than on an
|
|
9
|
+
# +ActionMailbox::InboundEmail+ it must ingest first. The remainder of the flow — correlation-ID
|
|
10
|
+
# restoration, running the Orchestrator's +Harness+, and recording the outcome — mirrors
|
|
11
|
+
# +InferenceJob+.
|
|
12
|
+
#
|
|
13
|
+
# == Lifecycle
|
|
14
|
+
#
|
|
15
|
+
# 1. Restore the correlation ID for event tracing.
|
|
16
|
+
# 2. Find the +Message+ and +Persona+ from their IDs.
|
|
17
|
+
# 3. Transition the message to +processing+.
|
|
18
|
+
# 4. Run the inference loop (+Harness+).
|
|
19
|
+
# 5. Mark the message +processed+ on success, +failed+ on error.
|
|
20
|
+
class ConsoleInferenceJob < ApplicationJob
|
|
21
|
+
queue_as :default
|
|
22
|
+
|
|
23
|
+
# Process one pre-persisted console message end to end.
|
|
24
|
+
#
|
|
25
|
+
# @param message_id [Integer] primary key of the pre-persisted +Message+ record
|
|
26
|
+
# @param persona_id [Integer] primary key of the +Persona+; STI loads the correct subclass
|
|
27
|
+
# @param correlation_id [String] trace token forwarded from the console request
|
|
28
|
+
# @return [void]
|
|
29
|
+
# @raise [StandardError] re-raised after being recorded on the message, to trigger retries
|
|
30
|
+
def perform(message_id:, persona_id:, correlation_id:)
|
|
31
|
+
restore_correlation_id(correlation_id)
|
|
32
|
+
|
|
33
|
+
message = Message.find(message_id)
|
|
34
|
+
persona = Persona.find(persona_id)
|
|
35
|
+
|
|
36
|
+
process(message:, persona:)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
message&.mark_failed!(e)
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Run the message through the inference loop and record success.
|
|
45
|
+
#
|
|
46
|
+
# @param message [Protege::Message] the message to process
|
|
47
|
+
# @param persona [Protege::Persona] the persona handling it
|
|
48
|
+
# @return [void]
|
|
49
|
+
def process(message:, persona:)
|
|
50
|
+
message.start_processing!
|
|
51
|
+
|
|
52
|
+
Orchestrator.reply(persona:, message:)
|
|
53
|
+
|
|
54
|
+
message.mark_processed!
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|