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,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Subscribers
|
|
5
|
+
# The hooks-only event dispatcher — the runtime that activates hooks against the engine's event
|
|
6
|
+
# stream. Installed once at boot by +Protege::Engine+ (a peer of +Subscribers::Tracing+ and
|
|
7
|
+
# +Subscribers::IntrospectionBroadcaster+); it subscribes one dispatcher per +Protege::Event.all+
|
|
8
|
+
# that, when an event fires, runs every registered hook's matching handlers.
|
|
9
|
+
#
|
|
10
|
+
# Kept separate from the hook *contract* (+Protege::HookMixin+, the +on+/+stateful+ DSL a hook
|
|
11
|
+
# declares with): this module only reads that contract off each hook class
|
|
12
|
+
# (+hook_class.subscriptions+, +hook_class.stateful?+) — a one-way dependency, dispatcher → contract.
|
|
13
|
+
#
|
|
14
|
+
# Hooks are pulled live from +Hook.descendants+ (via {registered}) when an event fires, so a Zeitwerk
|
|
15
|
+
# reload just changes the descendant set; the dispatchers are stable and never need re-subscribing.
|
|
16
|
+
# Handler blocks run via +instance_exec+ on a hook instance — fresh per event for stateless hooks, or
|
|
17
|
+
# a shared per-run instance (keyed by +correlation_id+) for +stateful+ ones — inside a +rescue+ that
|
|
18
|
+
# logs and swallows, so one hook can never break inference or a sibling hook.
|
|
19
|
+
module HookDispatcher
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# Event classes that end a run; a stateful hook's per-run instance is evicted after these fire.
|
|
23
|
+
# Includes +InferenceFailedEvent+ so a run that raises before completing never leaves a stale
|
|
24
|
+
# instance behind.
|
|
25
|
+
TERMINAL_EVENTS = [
|
|
26
|
+
InferenceCompletedEvent,
|
|
27
|
+
InferenceFailedEvent,
|
|
28
|
+
InferenceMaxTurnsReachedEvent
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Live event-bus dispatcher handles (one per event class), retained so {reset!} can detach them.
|
|
32
|
+
@handles = []
|
|
33
|
+
|
|
34
|
+
# Per-run instances for stateful hooks, keyed by +[hook_class, correlation_id]+.
|
|
35
|
+
@instances = {}
|
|
36
|
+
|
|
37
|
+
# Guards (re)installation of the dispatchers.
|
|
38
|
+
@install_mutex = Mutex.new
|
|
39
|
+
|
|
40
|
+
# Guards the stateful-instance map, mutated from event-dispatch threads.
|
|
41
|
+
@instances_mutex = Mutex.new
|
|
42
|
+
|
|
43
|
+
# Wire the event bus to hook dispatch — the subscriber's +install!+ contract. Called once at boot
|
|
44
|
+
# by the engine; idempotent, so a repeat call is a no-op until {reset!} runs. Installs one
|
|
45
|
+
# dispatcher per +Protege::Event.all+ that fans the event out to every registered hook's handlers.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def install!
|
|
49
|
+
@install_mutex.synchronize do
|
|
50
|
+
return unless @handles.empty?
|
|
51
|
+
|
|
52
|
+
@handles = Protege::Event.all.map do |event_class|
|
|
53
|
+
event_class.subscribe { |event| dispatch(event_class, event) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Detach every dispatcher and drop all per-run state — the subscriber's +reset!+ contract, for boot
|
|
59
|
+
# re-wiring and tests.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def reset!
|
|
63
|
+
@install_mutex.synchronize do
|
|
64
|
+
@handles.each { |handle| ActiveSupport::Notifications.unsubscribe(handle) }
|
|
65
|
+
@handles = []
|
|
66
|
+
@instances_mutex.synchronize { @instances.clear }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# List all concrete hook classes to dispatch to.
|
|
73
|
+
#
|
|
74
|
+
# Resolved via +Hook.descendants+ so it survives Zeitwerk reloads without manual bookkeeping.
|
|
75
|
+
# Anonymous classes (nil name) are excluded.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<Class>] the registered hook classes
|
|
78
|
+
def registered
|
|
79
|
+
Hook.descendants.reject { _1.name.nil? }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Fan one fired event out to every registered hook that declared a handler for its class.
|
|
83
|
+
#
|
|
84
|
+
# @param event_class [Class] the fired event's class
|
|
85
|
+
# @param event [Protege::Event] the typed event instance
|
|
86
|
+
# @return [void]
|
|
87
|
+
def dispatch(event_class, event)
|
|
88
|
+
registered.each do |hook_class|
|
|
89
|
+
# A hook only has a subscriptions key for the events it knew about when its map was seeded.
|
|
90
|
+
# An event class it has no entry for (an unknown or newly-added event) yields nil — treat that
|
|
91
|
+
# as "no handlers" and skip, rather than crashing on nil.empty?.
|
|
92
|
+
blocks = hook_class.subscriptions[event_class]
|
|
93
|
+
run(hook_class:, event_class:, blocks:, event:) if blocks&.any?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Run a hook's handlers for one event, isolated and on the right instance.
|
|
98
|
+
#
|
|
99
|
+
# @param hook_class [Class] the hook class
|
|
100
|
+
# @param event_class [Class] the event class being dispatched
|
|
101
|
+
# @param blocks [Array<Proc>] the handler blocks for this event
|
|
102
|
+
# @param event [Protege::Event] the typed event
|
|
103
|
+
# @return [void]
|
|
104
|
+
def run(hook_class:, event_class:, blocks:, event:)
|
|
105
|
+
instance = instance_for(hook_class, event)
|
|
106
|
+
|
|
107
|
+
blocks.each do |block|
|
|
108
|
+
instance.instance_exec(event, &block)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
Protege.configuration.logger.error(
|
|
111
|
+
"[Protege] hook #{hook_class} on #{event_class} failed: #{e.class}: #{e.message}"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
evict(hook_class, event) if hook_class.stateful? && TERMINAL_EVENTS.include?(event_class)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The instance to run handlers on: fresh per event for stateless hooks, or the run's shared
|
|
119
|
+
# instance (keyed by +correlation_id+) for stateful hooks.
|
|
120
|
+
#
|
|
121
|
+
# @param hook_class [Class] the hook class
|
|
122
|
+
# @param event [Protege::Event] the event being dispatched
|
|
123
|
+
# @return [Object] the hook instance
|
|
124
|
+
def instance_for(hook_class, event)
|
|
125
|
+
return hook_class.new unless hook_class.stateful?
|
|
126
|
+
|
|
127
|
+
@instances_mutex.synchronize { @instances[[hook_class, event.correlation_id]] ||= hook_class.new }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Drop a stateful hook's instance for a finished run.
|
|
131
|
+
#
|
|
132
|
+
# @param hook_class [Class] the hook class
|
|
133
|
+
# @param event [Protege::Event] the terminal event
|
|
134
|
+
# @return [void]
|
|
135
|
+
def evict(hook_class, event)
|
|
136
|
+
@instances_mutex.synchronize { @instances.delete([hook_class, event.correlation_id]) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Subscribers
|
|
5
|
+
# The tracing subscriber — the one place that knows the +Protege::Trace+ table exists. Subscribes to
|
|
6
|
+
# +InferenceGeneratedEvent+ (emitted once per turn by the Harness, only when +config.tracing+ is on)
|
|
7
|
+
# and snapshots the turn into a durable, self-contained row for later fine-tuning. A peer of
|
|
8
|
+
# +Subscribers::HookDispatcher+ and +Subscribers::IntrospectionBroadcaster+, installed at boot.
|
|
9
|
+
#
|
|
10
|
+
# Kept deliberately decoupled: the Harness gates *whether* the event fires; this module gates nothing
|
|
11
|
+
# and simply persists what arrives. Delete the +install!+ call and tracing disappears with the
|
|
12
|
+
# Harness untouched. Writing is **best-effort** — any failure is logged and swallowed, never
|
|
13
|
+
# propagated, so a tracing hiccup can't abort an inference run (the reply may already have been sent).
|
|
14
|
+
#
|
|
15
|
+
# A trace records only what affects training: the wire +request+ and +response+ (each already
|
|
16
|
+
# serialized by the value object's own +#to_wire+, the same bytes the API exchange uses) and the
|
|
17
|
+
# inference settings (+model+ + +settings+). No persona/provider identity is stored, and this module
|
|
18
|
+
# does no serialization of its own — it just persists what the event carries.
|
|
19
|
+
module Tracing
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# The subscription handle, retained so {reset!} can detach it.
|
|
23
|
+
@handle = nil
|
|
24
|
+
|
|
25
|
+
# Subscribe the tracing recorder to the per-turn event — the subscriber's +install!+ contract.
|
|
26
|
+
# Idempotent; called once at boot from the engine's +after_initialize+.
|
|
27
|
+
#
|
|
28
|
+
# @return [void]
|
|
29
|
+
def install!
|
|
30
|
+
return if @handle
|
|
31
|
+
|
|
32
|
+
@handle = InferenceGeneratedEvent.subscribe { |event| record(event) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Detach the subscription — the subscriber's +reset!+ contract, for boot re-wiring and tests.
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def reset!
|
|
39
|
+
return unless @handle
|
|
40
|
+
|
|
41
|
+
ActiveSupport::Notifications.unsubscribe(@handle)
|
|
42
|
+
@handle = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Snapshot one generated turn into a +Trace+ row. Best-effort: logs and swallows any error.
|
|
48
|
+
#
|
|
49
|
+
# @param event [Protege::InferenceGeneratedEvent] the per-turn event
|
|
50
|
+
# @return [void]
|
|
51
|
+
def record(event)
|
|
52
|
+
Trace.record(
|
|
53
|
+
correlation_id: event.correlation_id,
|
|
54
|
+
turn_index: event.turn,
|
|
55
|
+
model: event.model,
|
|
56
|
+
settings: event.settings,
|
|
57
|
+
request: event.request,
|
|
58
|
+
response: event.response
|
|
59
|
+
)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Protege.configuration.logger.error("[Protege] failed to record trace: #{e.class}: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One document-typed message part — a sibling of +TextPart+ for multimodal content, used for PDFs.
|
|
5
|
+
# Carries the filename, MIME type, and Base64-encoded bytes; the provider renders it into the wire
|
|
6
|
+
# format its API expects (e.g. an OpenAI +file+ block). Document-capable models read the PDF
|
|
7
|
+
# itself — text and layout — rather than a server-side text extraction.
|
|
8
|
+
DocumentPart = Data.define(
|
|
9
|
+
:filename,
|
|
10
|
+
:media_type,
|
|
11
|
+
:data
|
|
12
|
+
)
|
|
13
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One image-typed message part — a sibling of +TextPart+ for multimodal content. Carries the
|
|
5
|
+
# image's MIME type and its Base64-encoded bytes; the provider renders it into the wire format its
|
|
6
|
+
# API expects (e.g. an OpenAI +image_url+ data URI). Vision-capable models read it as an image.
|
|
7
|
+
ImagePart = Data.define(
|
|
8
|
+
:media_type,
|
|
9
|
+
:data
|
|
10
|
+
)
|
|
11
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One normalized message in a chat-completions conversation — a +role+ plus its +content+ (one or more
|
|
5
|
+
# content parts) and optional tool correlation. This is the pipeline-wide currency the model layer
|
|
6
|
+
# speaks: providers serialize it to the wire (+Inference::Provider::Request#to_wire+), the harness
|
|
7
|
+
# threads it through a run, and resolvers build it (via +Resolver#message+) to contribute context. It
|
|
8
|
+
# lives here at the top level beside the content parts it carries (+TextPart+ / +ImagePart+ /
|
|
9
|
+
# +DocumentPart+) rather than inside +Inference+, because — like them — it is ownerless currency every
|
|
10
|
+
# layer constructs and reads, not any one layer's private type. (It cannot be +Protege::Message+: that
|
|
11
|
+
# name is the stored-email ActiveRecord model.)
|
|
12
|
+
#
|
|
13
|
+
# +role+ and +content+ are always required. The tool-correlation fields are optional: +tool_call_id+
|
|
14
|
+
# is meaningful only when +role+ is +:tool+ (it correlates a tool result back to the call);
|
|
15
|
+
# +tool_calls+ is meaningful only when +role+ is +:assistant+ and the model is requesting tool
|
|
16
|
+
# execution.
|
|
17
|
+
ModelMessage = Data.define(
|
|
18
|
+
:role,
|
|
19
|
+
:content,
|
|
20
|
+
:tool_call_id,
|
|
21
|
+
:tool_calls
|
|
22
|
+
) do
|
|
23
|
+
# @param role [Symbol] one of +:system+, +:user+, +:assistant+, +:tool+.
|
|
24
|
+
# @param content [String, Array<TextPart>] message content; String for the text-only case.
|
|
25
|
+
# @param tool_call_id [String, nil] correlates a +role: :tool+ message to its originating call.
|
|
26
|
+
# @param tool_calls [Array<ToolCall>, nil] tool calls the model requested (assistant only).
|
|
27
|
+
def initialize(role:, content:, tool_call_id: nil, tool_calls: nil)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Return content as a normalized parts array.
|
|
32
|
+
#
|
|
33
|
+
# Array content passes through unchanged; String content is wrapped in a single +TextPart+.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<TextPart>] the content normalized to parts.
|
|
36
|
+
def parts
|
|
37
|
+
content.is_a?(Array) ? content : [TextPart.new(text: content)]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Immutable outcome value — the standard return type passed between system parts across the LOGI
|
|
5
|
+
# pipeline: tools (+Tool#use+), resolvers, and gateway operations all hand back a +Result+. Carrying
|
|
6
|
+
# success/failure as data (rather than raising across layer boundaries) lets the Orchestrator
|
|
7
|
+
# serialize a failed tool outcome straight into the LLM's tool message so the model can
|
|
8
|
+
# self-correct. Frozen on construction; build via +Result.success+ / +Result.failure+.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Result.success(id: 42)
|
|
12
|
+
# Result.failure(reason: 'bad input', field: :email)
|
|
13
|
+
# Result.failure(reason: exception)
|
|
14
|
+
class Result
|
|
15
|
+
# Number of backtrace frames retained in the serialized error.
|
|
16
|
+
#
|
|
17
|
+
# The serialized result is encoded into the LLM's tool message, where a deep backtrace is just
|
|
18
|
+
# token-costly noise — the class and message are what the model reasons over. Operators still get
|
|
19
|
+
# the full backtrace via the engine log (see +ToolMixin.invoke+) and the persisted
|
|
20
|
+
# +Message#last_processing_error+.
|
|
21
|
+
SERIALIZED_BACKTRACE_FRAMES = 3
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] true when the operation succeeded
|
|
24
|
+
# @return [Hash] the payload data attached to the outcome
|
|
25
|
+
# @return [Exception, nil] the captured error on failure, otherwise nil
|
|
26
|
+
attr_reader :success, :data, :error
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] alias of +#success+ — true when the operation succeeded
|
|
29
|
+
alias success? success
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Build a successful Result.
|
|
33
|
+
#
|
|
34
|
+
# @param data [Hash] arbitrary payload keyed by symbol
|
|
35
|
+
# @return [Protege::Result] a frozen successful result
|
|
36
|
+
def success(**data)
|
|
37
|
+
new success: true, data:
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build a failed Result.
|
|
41
|
+
#
|
|
42
|
+
# +reason+ may be a String or an Exception; strings are wrapped in +Protege::GenericError+ so
|
|
43
|
+
# +#error+ is always an Exception.
|
|
44
|
+
#
|
|
45
|
+
# @param reason [String, Exception] the failure cause
|
|
46
|
+
# @param data [Hash] arbitrary payload keyed by symbol
|
|
47
|
+
# @return [Protege::Result] a frozen failed result
|
|
48
|
+
def failure(reason:, **data)
|
|
49
|
+
new success: false, data:, error: wrap_generic_error(reason)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Coerce a failure reason into an Exception.
|
|
55
|
+
#
|
|
56
|
+
# Non-exception reasons become a +Protege::GenericError+ so the result always carries an
|
|
57
|
+
# error class and message.
|
|
58
|
+
#
|
|
59
|
+
# @param reason [String, Exception] the failure cause
|
|
60
|
+
# @return [Exception] the original exception, or a wrapped generic error
|
|
61
|
+
def wrap_generic_error(reason)
|
|
62
|
+
return reason if reason.is_a?(Exception)
|
|
63
|
+
|
|
64
|
+
Protege::GenericError.new(reason.to_s)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Initialize and freeze the result.
|
|
69
|
+
#
|
|
70
|
+
# @param success [Boolean] whether the operation succeeded
|
|
71
|
+
# @param data [Hash] arbitrary payload keyed by symbol
|
|
72
|
+
# @param error [Exception, nil] the captured error, present only on failure
|
|
73
|
+
# @return [void]
|
|
74
|
+
def initialize(success:, data: {}, error: nil)
|
|
75
|
+
@success = success
|
|
76
|
+
@data = data
|
|
77
|
+
@error = error
|
|
78
|
+
|
|
79
|
+
freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] true when the operation failed
|
|
83
|
+
def failure?
|
|
84
|
+
!success
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Serialize the result to a plain hash suitable for JSON encoding into a tool message.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash] +{ success:, data:, error: }+ where +error+ is a serialized hash or nil
|
|
90
|
+
def to_h
|
|
91
|
+
{
|
|
92
|
+
success:,
|
|
93
|
+
data:,
|
|
94
|
+
error: serialized_error
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Render +#error+ as a transport-safe hash.
|
|
101
|
+
#
|
|
102
|
+
# @return [Hash, nil] +{ message:, class:, backtrace: }+, or nil when there is no error
|
|
103
|
+
def serialized_error
|
|
104
|
+
return nil unless error
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
message: error.message,
|
|
108
|
+
class: error.class.name,
|
|
109
|
+
backtrace: error.backtrace&.first(SERIALIZED_BACKTRACE_FRAMES)
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
# Domain wrapper around a stored +ActiveStorage::Blob+ — the single source of truth for how the
|
|
7
|
+
# engine interprets a file: its kind (image / document / text / viewable), a one-line human summary,
|
|
8
|
+
# and how it renders into the shapes other layers speak. It renders itself two ways: +#to_part+
|
|
9
|
+
# produces the provider's multimodal content block (an +ImagePart+ / +DocumentPart+) that inference
|
|
10
|
+
# feeds a vision model, and +#to_mail_attachment+ produces the transport payload the send path
|
|
11
|
+
# attaches to outbound mail. +ReadAttachmentTool+ and +ThreadHistoryResolver+ read its kind and
|
|
12
|
+
# summary. MIME classification (and the parameter-stripping that makes +image/jpeg; name=…+ match)
|
|
13
|
+
# lives here and nowhere else, so provider and tool authors classify files without re-deriving the
|
|
14
|
+
# rules.
|
|
15
|
+
#
|
|
16
|
+
# A file's durable identity is its *blob* — the per-message +ActiveStorage::Attachment+ is just a
|
|
17
|
+
# join that is recreated whenever a file rides on a new message. So this wraps the blob directly:
|
|
18
|
+
# the +#id+ it reports (and the id in +#summary+) is the *blob* id, which is the currency the
|
|
19
|
+
# +read_attachment+, +send_email+, and +create_file+ tools all speak.
|
|
20
|
+
class StoredFile
|
|
21
|
+
extend Forwardable
|
|
22
|
+
|
|
23
|
+
# Content types the vision models accept as inline images (jpeg/png/gif/webp). Other image types
|
|
24
|
+
# (HEIC, BMP, TIFF, SVG) are deliberately excluded — the models can't view them.
|
|
25
|
+
IMAGE_TYPES = %w[image/png image/jpeg image/jpg image/gif image/webp].freeze
|
|
26
|
+
|
|
27
|
+
# Content types sent as a native document block. PDF is the only type the models read as a
|
|
28
|
+
# document today; listed as a set so a newly-supported type is a one-line addition.
|
|
29
|
+
DOCUMENT_TYPES = %w[application/pdf].freeze
|
|
30
|
+
|
|
31
|
+
# Non-+text/*+ content types whose payloads are safe to return as text.
|
|
32
|
+
TEXT_TYPES = %w[application/json application/xml application/csv].freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Look up a stored file by its blob id, or +nil+ when no blob matches. Nil-safe (uses +find_by+,
|
|
36
|
+
# not +find+), so a tool turns a missing id into a failed +Result+ rather than raising. Single-
|
|
37
|
+
# argument lookup, so it stays positional by convention.
|
|
38
|
+
#
|
|
39
|
+
# @param blob_id [Integer, String] the +ActiveStorage::Blob+ id
|
|
40
|
+
# @return [StoredFile, nil] the wrapped blob, or nil when the id is unknown
|
|
41
|
+
def find(blob_id)
|
|
42
|
+
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
43
|
+
blob && new(blob)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [ActiveStorage::Blob] the wrapped blob
|
|
48
|
+
attr_reader :blob
|
|
49
|
+
|
|
50
|
+
# Read-through to the wrapped blob for the values consumers need verbatim.
|
|
51
|
+
def_delegators :@blob, :content_type, :byte_size, :id, :download
|
|
52
|
+
|
|
53
|
+
# @param blob [ActiveStorage::Blob] the stored blob to wrap
|
|
54
|
+
def initialize(blob)
|
|
55
|
+
@blob = blob
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] the original filename as a plain string
|
|
59
|
+
def filename
|
|
60
|
+
blob.filename.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Render this file as the provider content part a vision model reads, or +nil+ when its type can't
|
|
64
|
+
# be viewed (video, Office formats, archives). Images become an +ImagePart+, PDFs a +DocumentPart+;
|
|
65
|
+
# the bytes are downloaded and Base64-encoded only when there is a part to build.
|
|
66
|
+
#
|
|
67
|
+
# @return [ImagePart, DocumentPart, nil] the vision part, or nil when unviewable
|
|
68
|
+
def to_part
|
|
69
|
+
if image?
|
|
70
|
+
ImagePart.new(media_type:, data: encoded_bytes)
|
|
71
|
+
elsif document?
|
|
72
|
+
DocumentPart.new(filename:, media_type:, data: encoded_bytes)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build the outbound mail payload for this file — the typed attachment the send path attaches to a
|
|
77
|
+
# message. Downloads the bytes for transport; the blob itself remains the durable identity.
|
|
78
|
+
#
|
|
79
|
+
# @return [Gateway::Mail::Attachment] the transport payload
|
|
80
|
+
def to_mail_attachment
|
|
81
|
+
Gateway::Mail::Attachment.new(filename:, content_type:, bytes: download)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] true when the file is an image a vision model can view
|
|
85
|
+
def image?
|
|
86
|
+
IMAGE_TYPES.include?(media_type)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Boolean] true when the file is a document a model can read natively (PDF)
|
|
90
|
+
def document?
|
|
91
|
+
DOCUMENT_TYPES.include?(media_type)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] true when the file's content can be returned as plain text
|
|
95
|
+
def text?
|
|
96
|
+
media_type.start_with?('text/') || TEXT_TYPES.include?(media_type)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Boolean] true when the file can be shown to the model as a vision part (image or PDF)
|
|
100
|
+
def viewable?
|
|
101
|
+
image? || document?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The bare MIME type, lowercased and stripped of header parameters (e.g.
|
|
105
|
+
# +"image/jpeg; name=p.jpg"+ → +"image/jpeg"+), so classification and part media types match cleanly.
|
|
106
|
+
#
|
|
107
|
+
# @return [String] the bare, lowercased media type
|
|
108
|
+
def media_type
|
|
109
|
+
content_type.to_s.split(';').first.to_s.strip.downcase
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A concise one-line description for the model — filename, type, human size, and the blob id the
|
|
113
|
+
# tools accept.
|
|
114
|
+
#
|
|
115
|
+
# @return [String] e.g. +"invoice.pdf (application/pdf, 42 KB, id: 12)"+
|
|
116
|
+
def summary
|
|
117
|
+
"#{filename} (#{content_type}, #{ActiveSupport::NumberHelper.number_to_human_size(byte_size)}, id: #{id})"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# @return [String] the file's bytes, Base64-encoded for a provider data block
|
|
123
|
+
def encoded_bytes
|
|
124
|
+
Base64.strict_encode64(download)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One text-typed message part — the general multimodal-content currency shared across the pipeline
|
|
5
|
+
# (providers serialize it, tools and resolvers build it). The text-only common case; its siblings
|
|
6
|
+
# +ImagePart+ and +DocumentPart+ carry the other content kinds.
|
|
7
|
+
TextPart = Data.define(:text)
|
|
8
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Pairs a tool call with its outcome. Built by +ToolMixin.invoke+ and consumed by
|
|
5
|
+
# +Inference.extend_request+ to build the next turn's messages — +tool_call+ identifies which call this
|
|
6
|
+
# answers and +result+ carries the +Protege::Result+ the model sees. An immutable +Data+ value object.
|
|
7
|
+
#
|
|
8
|
+
# It belongs to no single layer — it bridges an Inference +Provider::ToolCall+, a top-level +Result+,
|
|
9
|
+
# and Orchestrator's tool dispatch — so it lives here at the top level alongside +Result+ and +Event+.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] tool_call
|
|
12
|
+
# @return [Object] the originating tool call
|
|
13
|
+
# @!attribute [r] result
|
|
14
|
+
# @return [Protege::Result] the outcome of invoking the tool
|
|
15
|
+
ToolResult = Data.define(:tool_call, :result)
|
|
16
|
+
end
|
data/lib/protege.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'protege/version'
|
|
4
|
+
require 'protege/configuration'
|
|
5
|
+
require 'protege/gateway'
|
|
6
|
+
require 'protege/engine'
|
|
7
|
+
|
|
8
|
+
# Root namespace for the Protege engine — an email-native AI agent toolkit that turns a host
|
|
9
|
+
# Rails application into an agentic workspace.
|
|
10
|
+
#
|
|
11
|
+
# Protege is structured around the LOGI pipeline:
|
|
12
|
+
# - **L**oop — scheduled responsibilities that drive personas on a cron tick.
|
|
13
|
+
# - **O**rchestrator — the +Harness+ multi-turn tool loop that runs inference and dispatches tools.
|
|
14
|
+
# - **G**ateway — Action Mailbox inbound delivery and SMTP outbound delivery.
|
|
15
|
+
# - **I**nference — provider adapters that talk to model APIs.
|
|
16
|
+
#
|
|
17
|
+
# Email is the channel, the host Rails app is the workspace, and personas, tools, providers,
|
|
18
|
+
# resolvers, and hooks all participate as extensions. Requiring this file loads only the engine
|
|
19
|
+
# entry points; runtime constructs are resolved through Rails autoloading once the engine boots.
|
|
20
|
+
#
|
|
21
|
+
# @see https://github.com/sebscholl/protege
|
|
22
|
+
module Protege
|
|
23
|
+
extend self
|
|
24
|
+
|
|
25
|
+
# Yield the configuration object for mutation. Call once in a Rails initializer.
|
|
26
|
+
#
|
|
27
|
+
# @yield [config] the configuration to mutate
|
|
28
|
+
# @yieldparam config [Protege::Configuration] the process-wide configuration
|
|
29
|
+
# @return [void]
|
|
30
|
+
def configure
|
|
31
|
+
yield configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Return the process-wide configuration, creating it on first access.
|
|
35
|
+
#
|
|
36
|
+
# @return [Protege::Configuration] the memoized configuration instance
|
|
37
|
+
def configuration
|
|
38
|
+
@configuration ||= Configuration.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reset configuration to defaults. Primarily for test isolation between examples.
|
|
42
|
+
#
|
|
43
|
+
# @return [Protege::Configuration] the freshly built configuration
|
|
44
|
+
def reset_configuration!
|
|
45
|
+
@configuration = Configuration.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :protege do
|
|
4
|
+
namespace :css do
|
|
5
|
+
desc "Build the Protege engine's compiled Tailwind CSS. " \
|
|
6
|
+
'Run this after changing engine views. Requires tailwindcss-rails in the host app.'
|
|
7
|
+
task :build do
|
|
8
|
+
gem_root = File.expand_path('../../..', __dir__)
|
|
9
|
+
input = File.join(gem_root, 'app/assets/tailwind/application.css')
|
|
10
|
+
output = File.join(gem_root, 'app/assets/builds/protege.css')
|
|
11
|
+
content = "#{gem_root}/app/views/**/*.html.erb,#{gem_root}/app/helpers/**/*.rb"
|
|
12
|
+
|
|
13
|
+
sh "tailwindcss -i #{input} -o #{output} --content #{content}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|