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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Presents one live tool call for the console **introspection panel** — a PORO presentation helper for
|
|
5
|
+
# the console experience, not part of the engine's inference core. The panel is an event-driven feed
|
|
6
|
+
# (not a view of persisted rows): as the harness emits +tool_call.started/completed/failed+ events,
|
|
7
|
+
# +IntrospectionBroadcaster+ builds an +IntrospectionToolCallPresentation+ from the event and renders the
|
|
8
|
+
# +tool_call+ partial with it — first as a pending card, then replaced in place with the outcome.
|
|
9
|
+
# Keeping the presentation here (rather than in the partial) keeps the card markup dumb and the
|
|
10
|
+
# formatting unit-testable.
|
|
11
|
+
#
|
|
12
|
+
# Build via the state factories: +.pending+ (call just requested), +.completed+ (tool returned a
|
|
13
|
+
# successful +Result+), +.failed+ (tool returned a failed +Result+, so an error).
|
|
14
|
+
class IntrospectionToolCallPresentation
|
|
15
|
+
# Longest inline preview/args summary before it is clipped or replaced by a size count.
|
|
16
|
+
PREVIEW_LIMIT = 80
|
|
17
|
+
|
|
18
|
+
# Longest error message shown on a failed card before clipping.
|
|
19
|
+
ERROR_PREVIEW_LIMIT = 200
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# A card for a call the model just requested, before its result is known.
|
|
23
|
+
#
|
|
24
|
+
# @param tool_call [Protege::Inference::Provider::ToolCall] the requested call
|
|
25
|
+
# @return [IntrospectionToolCallPresentation] the pending presentation
|
|
26
|
+
def pending(tool_call:)
|
|
27
|
+
new(tool_call:, status: :pending)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A card for a call that returned successfully.
|
|
31
|
+
#
|
|
32
|
+
# @param tool_call [Protege::Inference::Provider::ToolCall] the call
|
|
33
|
+
# @param result [Protege::Result] the successful outcome (its +data+ drives the preview)
|
|
34
|
+
# @return [IntrospectionToolCallPresentation] the completed presentation
|
|
35
|
+
def completed(tool_call:, result:)
|
|
36
|
+
new(tool_call:, status: :done, result:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A card for a call that failed.
|
|
40
|
+
#
|
|
41
|
+
# @param tool_call [Protege::Inference::Provider::ToolCall] the call
|
|
42
|
+
# @param error [Exception] the failure (its message drives the preview)
|
|
43
|
+
# @return [IntrospectionToolCallPresentation] the failed presentation
|
|
44
|
+
def failed(tool_call:, error:)
|
|
45
|
+
new(tool_call:, status: :failed, error:)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Symbol] the card state — +:pending+, +:done+, or +:failed+
|
|
50
|
+
attr_reader :status
|
|
51
|
+
|
|
52
|
+
# @param tool_call [Protege::Inference::Provider::ToolCall] the call being presented
|
|
53
|
+
# @param status [Symbol] +:pending+, +:done+, or +:failed+
|
|
54
|
+
# @param result [Protege::Result, nil] the successful outcome, when done
|
|
55
|
+
# @param error [Exception, nil] the failure, when failed
|
|
56
|
+
def initialize(tool_call:, status:, result: nil, error: nil)
|
|
57
|
+
@tool_call = tool_call
|
|
58
|
+
@status = status
|
|
59
|
+
@result = result
|
|
60
|
+
@error = error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stable DOM id for the card, so the pending card can be replaced in place by its outcome.
|
|
64
|
+
#
|
|
65
|
+
# @return [String] the card's DOM id
|
|
66
|
+
def card_id
|
|
67
|
+
"tool_call_#{@tool_call.id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String] the tool's name
|
|
71
|
+
def tool_name
|
|
72
|
+
@tool_call.name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A one-line rendering of the call's arguments, clipped for length.
|
|
76
|
+
#
|
|
77
|
+
# @return [String, nil] the args summary, or nil when there are none
|
|
78
|
+
def arguments_summary
|
|
79
|
+
input = @tool_call.input
|
|
80
|
+
return nil if input.blank?
|
|
81
|
+
|
|
82
|
+
clip(input.map { |key, value| "#{key}: #{value.inspect}" }.join(', '), limit: PREVIEW_LIMIT)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The response line: a short rendering of the result on success (or a size count when large), the
|
|
86
|
+
# error message on failure, and nothing while pending.
|
|
87
|
+
#
|
|
88
|
+
# @return [String, nil] the preview, or nil when pending / empty
|
|
89
|
+
def preview
|
|
90
|
+
case status
|
|
91
|
+
when :failed then clip(@error&.message.to_s, limit: ERROR_PREVIEW_LIMIT)
|
|
92
|
+
when :done then result_preview
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Render the successful result's data as a short string, or its character count when too long to
|
|
99
|
+
# show inline.
|
|
100
|
+
#
|
|
101
|
+
# @return [String, nil] the response preview, or nil when there is no data
|
|
102
|
+
def result_preview
|
|
103
|
+
rendered = (@result&.data || {}).map { |key, value| "#{key}: #{preview_value(value)}" }.join(', ')
|
|
104
|
+
|
|
105
|
+
return nil if rendered.empty?
|
|
106
|
+
return rendered if rendered.length <= PREVIEW_LIMIT
|
|
107
|
+
|
|
108
|
+
"#{ActiveSupport::NumberHelper.number_to_delimited(rendered.length)} chars"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A compact stand-in for a single result value — collections become a count, scalars their string.
|
|
112
|
+
#
|
|
113
|
+
# @param value [Object] the value to render
|
|
114
|
+
# @return [String] the compact rendering
|
|
115
|
+
def preview_value(value)
|
|
116
|
+
case value
|
|
117
|
+
when Array then "#{value.size} items"
|
|
118
|
+
when Hash then "#{value.size} keys"
|
|
119
|
+
else value.to_s
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Clip a string to +limit+ characters, appending an ellipsis when cut.
|
|
124
|
+
#
|
|
125
|
+
# @param text [String] the text to clip
|
|
126
|
+
# @param limit [Integer] the maximum length
|
|
127
|
+
# @return [String] the original or a clipped copy
|
|
128
|
+
def clip(text, limit:)
|
|
129
|
+
return text if text.length <= limit
|
|
130
|
+
|
|
131
|
+
"#{text[0, limit]}…"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Cross-persona email search behind the console's search page — a PORO service object (not an
|
|
5
|
+
# ActiveRecord model, and not the persona-scoped +search_emails+ agent tool).
|
|
6
|
+
#
|
|
7
|
+
# Each field narrows the +Message+ relation: +from+ scans the sender, +recipients+ spans the
|
|
8
|
+
# to/cc/bcc lists, +subject+ and +body+ (text + html) scan their columns, and +attachments+ matches
|
|
9
|
+
# by stored filename. Filled fields are ANDed onto the *same* message row, so a message only matches
|
|
10
|
+
# when it satisfies every supplied field at once. +#results+ returns the matching messages
|
|
11
|
+
# themselves (newest sent first) — the console lists the emails and links each to its thread — rather
|
|
12
|
+
# than collapsing to threads, so the result grain matches the email-grained query.
|
|
13
|
+
#
|
|
14
|
+
# Matching mirrors +search_emails+: case-insensitive +LIKE+ with +sanitize_sql_like+ so the term's
|
|
15
|
+
# own +%+/+_+ are literals — SQLite-safe, which is the dashboard's database.
|
|
16
|
+
class MessageSearch
|
|
17
|
+
# Maps each column-backed search field to the message columns it scans. Columns are a fixed
|
|
18
|
+
# allowlist, so they are safe to interpolate into the +LIKE+ clause (the term is a bound param).
|
|
19
|
+
FIELD_COLUMNS = {
|
|
20
|
+
from: %w[from_address],
|
|
21
|
+
recipients: %w[to_addresses cc_addresses bcc_addresses],
|
|
22
|
+
subject: %w[subject],
|
|
23
|
+
body: %w[text_body html_body]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Build a search from request params, reading the field keys it understands.
|
|
28
|
+
#
|
|
29
|
+
# @param params [ActionController::Parameters, Hash] the request params
|
|
30
|
+
# @return [Protege::MessageSearch] the configured search
|
|
31
|
+
def from_params(params)
|
|
32
|
+
new(from: params[:from],
|
|
33
|
+
recipients: params[:recipients],
|
|
34
|
+
subject: params[:subject],
|
|
35
|
+
body: params[:body],
|
|
36
|
+
attachments: params[:attachments])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param from [String, nil] sender term
|
|
41
|
+
# @param recipients [String, nil] term matched against to/cc/bcc
|
|
42
|
+
# @param subject [String, nil] subject term
|
|
43
|
+
# @param body [String, nil] term matched against the text and html bodies
|
|
44
|
+
# @param attachments [String, nil] attachment-filename term
|
|
45
|
+
def initialize(from: nil, recipients: nil, subject: nil, body: nil, attachments: nil)
|
|
46
|
+
@column_terms = { from:, recipients:, subject:, body: }
|
|
47
|
+
@attachments = attachments
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether any field carries a term to search on.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def active?
|
|
54
|
+
(@column_terms.values + [@attachments]).any?(&:present?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The matching messages, newest sent first, each with its thread preloaded for the result row;
|
|
58
|
+
# empty when no field is filled.
|
|
59
|
+
#
|
|
60
|
+
# +preload+ (a separate query) rather than +includes+ avoids a JOIN — the search columns +subject+
|
|
61
|
+
# and +from_address+ also exist on +protege_email_threads+, so a join would make them ambiguous.
|
|
62
|
+
#
|
|
63
|
+
# @return [ActiveRecord::Relation<Protege::Message>]
|
|
64
|
+
def results
|
|
65
|
+
return Message.none unless active?
|
|
66
|
+
|
|
67
|
+
message_scope.preload(:email_thread).order(sent_at: :desc)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# The +Message+ relation narrowed by every filled field (each ANDed onto the same row).
|
|
73
|
+
#
|
|
74
|
+
# @return [ActiveRecord::Relation<Protege::Message>] the narrowed relation
|
|
75
|
+
def message_scope
|
|
76
|
+
scope = FIELD_COLUMNS.reduce(Message.all) do |relation, (field, columns)|
|
|
77
|
+
term = @column_terms[field]
|
|
78
|
+
next relation if term.blank?
|
|
79
|
+
|
|
80
|
+
relation.where(message_column_match_fragment(columns), term: like_term(term))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@attachments.present? ? scope.where(id: attachment_message_ids) : scope
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The SQL fragment for a single field's columns, ORed together, with a bound +:term+ param. The
|
|
87
|
+
# caller must supply the term value in the +where+ call.
|
|
88
|
+
#
|
|
89
|
+
# @param columns [Array<String>] the message columns to scan for the field
|
|
90
|
+
# @return [String] the SQL fragment, e.g. "LOWER(subject) LIKE :term OR LOWER(body) LIKE :term"
|
|
91
|
+
def message_column_match_fragment(columns)
|
|
92
|
+
columns.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Message ids that own an attachment whose stored filename matches the attachments term.
|
|
96
|
+
#
|
|
97
|
+
# @return [ActiveRecord::Relation<Integer>] the matching message ids
|
|
98
|
+
def attachment_message_ids
|
|
99
|
+
ActiveStorage::Attachment
|
|
100
|
+
.where(record_type: 'Protege::Message', name: 'attachments')
|
|
101
|
+
.joins(:blob)
|
|
102
|
+
.where('LOWER(active_storage_blobs.filename) LIKE :term', term: like_term(@attachments))
|
|
103
|
+
.select(:record_id)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Wrap a term as a case-insensitive, wildcard-escaped +LIKE+ pattern.
|
|
107
|
+
#
|
|
108
|
+
# @param value [String] the raw search term
|
|
109
|
+
# @return [String] the +%term%+ pattern, lowercased and escaped
|
|
110
|
+
def like_term(value)
|
|
111
|
+
"%#{ActiveRecord::Base.sanitize_sql_like(value.strip.downcase)}%"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Console-side search over captured inference traces (see +Protege::Trace+) — a PORO service, the trace
|
|
5
|
+
# analog of +MessageSearch+.
|
|
6
|
+
#
|
|
7
|
+
# Each free-text field narrows the +Trace+ relation with a case-insensitive +LIKE+; +label+ filters by
|
|
8
|
+
# the review verdict. Filled fields are ANDed onto the *same* row, so a trace matches only when it
|
|
9
|
+
# satisfies every supplied field at once. +request+/+response+ are JSON-text columns, so those terms
|
|
10
|
+
# match against the serialized JSON of the turn (the prompt/tool catalog and the produced output),
|
|
11
|
+
# not structured fields.
|
|
12
|
+
#
|
|
13
|
+
# Matching mirrors +MessageSearch+: case-insensitive +LIKE+ with +sanitize_sql_like+ so the term's own
|
|
14
|
+
# +%+/+_+ are literals — SQLite-safe, the dashboard's database.
|
|
15
|
+
class TraceSearch
|
|
16
|
+
# Maps each free-text search field to the trace columns it scans. Columns are a fixed allowlist, so
|
|
17
|
+
# they are safe to interpolate into the +LIKE+ clause (the term is always a bound param).
|
|
18
|
+
FIELD_COLUMNS = {
|
|
19
|
+
correlation_id: %w[correlation_id],
|
|
20
|
+
model: %w[model],
|
|
21
|
+
request: %w[request],
|
|
22
|
+
response: %w[response],
|
|
23
|
+
annotation: %w[annotation]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Build a search from request params, reading the field keys it understands.
|
|
28
|
+
#
|
|
29
|
+
# @param params [ActionController::Parameters, Hash] the request params
|
|
30
|
+
# @return [Protege::TraceSearch] the configured search
|
|
31
|
+
def from_params(params)
|
|
32
|
+
new(correlation_id: params[:correlation_id],
|
|
33
|
+
model: params[:model],
|
|
34
|
+
request: params[:request],
|
|
35
|
+
response: params[:response],
|
|
36
|
+
annotation: params[:annotation],
|
|
37
|
+
label: params[:label])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param correlation_id [String, nil] run-id term
|
|
42
|
+
# @param model [String, nil] model term
|
|
43
|
+
# @param request [String, nil] term matched against the serialized request JSON
|
|
44
|
+
# @param response [String, nil] term matched against the serialized response JSON
|
|
45
|
+
# @param annotation [String, nil] reviewer-note term
|
|
46
|
+
# @param label [String, nil] a review verdict to filter by (ignored unless a real +Trace+ label)
|
|
47
|
+
def initialize(correlation_id: nil, model: nil, request: nil, response: nil, annotation: nil, label: nil)
|
|
48
|
+
@column_terms = { correlation_id:, model:, request:, response:, annotation: }
|
|
49
|
+
@label = label if Trace.labels.key?(label)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether any field carries something to search on — a text term or a valid label.
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def active?
|
|
56
|
+
@column_terms.values.any?(&:present?) || @label.present?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The matching traces, newest captured first; empty when nothing is filled.
|
|
60
|
+
#
|
|
61
|
+
# @return [ActiveRecord::Relation<Protege::Trace>]
|
|
62
|
+
def results
|
|
63
|
+
return Trace.none unless active?
|
|
64
|
+
|
|
65
|
+
trace_scope.order(created_at: :desc)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# The +Trace+ relation narrowed by every filled text field (each ANDed onto the same row), then by
|
|
71
|
+
# the label verdict when one is set.
|
|
72
|
+
#
|
|
73
|
+
# @return [ActiveRecord::Relation<Protege::Trace>] the narrowed relation
|
|
74
|
+
def trace_scope
|
|
75
|
+
scope = FIELD_COLUMNS.reduce(Trace.all) do |relation, (field, columns)|
|
|
76
|
+
term = @column_terms[field]
|
|
77
|
+
next relation if term.blank?
|
|
78
|
+
|
|
79
|
+
relation.where(column_match_fragment(columns), term: like_term(term))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@label.present? ? scope.where(label: @label) : scope
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The SQL fragment for a field's columns, ORed together, with a bound +:term+ param. The caller
|
|
86
|
+
# supplies the term value in the +where+ call.
|
|
87
|
+
#
|
|
88
|
+
# @param columns [Array<String>] the trace columns to scan for the field
|
|
89
|
+
# @return [String] the SQL fragment, e.g. "LOWER(request) LIKE :term"
|
|
90
|
+
def column_match_fragment(columns)
|
|
91
|
+
columns.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wrap a term as a case-insensitive, wildcard-escaped +LIKE+ pattern.
|
|
95
|
+
#
|
|
96
|
+
# @param value [String] the raw search term
|
|
97
|
+
# @return [String] the +%term%+ pattern, lowercased and escaped
|
|
98
|
+
def like_term(value)
|
|
99
|
+
"%#{ActiveRecord::Base.sanitize_sql_like(value.strip.downcase)}%"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Drives the console's **introspection panel** from inference events — an app-level subscriber for the
|
|
5
|
+
# console experience, not part of the engine's inference core. A peer of +Subscribers::HookDispatcher+
|
|
6
|
+
# and +Subscribers::Tracing+, installed once at boot.
|
|
7
|
+
#
|
|
8
|
+
# It subscribes to Protege events and translates streaming + tool-call notifications into Turbo Stream
|
|
9
|
+
# broadcasts, building the panel's live activity feed: one chronological stream where the model's
|
|
10
|
+
# reasoning tokens and its tool calls are interleaved in the order they happen. Installed via the
|
|
11
|
+
# +install!+ contract (see the engine initializer) and holds process-local state for the lifetime of
|
|
12
|
+
# the process; +reset!+ detaches its subscriptions and clears that state.
|
|
13
|
+
#
|
|
14
|
+
# What each event does (all broadcast to the thread's Turbo channel, target +dom_id(thread, :activity)+
|
|
15
|
+
# — the feed container in +_introspection.html.slim+):
|
|
16
|
+
#
|
|
17
|
+
# * +InferenceChunkEvent+ — stream the token into the run's current reasoning block, opening a fresh block
|
|
18
|
+
# after each tool call so reasoning is grouped into spaced paragraphs. Interleaving is automatic:
|
|
19
|
+
# every append lands at the feed's end, so reasoning blocks and cards stay in chronological order.
|
|
20
|
+
# * +ToolCallStartedEvent+ — append a pending tool card the instant the model requests the call.
|
|
21
|
+
# * +ToolCallCompletedEvent+ / +ToolCallFailedEvent+ — replace that card in place with the outcome
|
|
22
|
+
# (a response preview, or the error message).
|
|
23
|
+
#
|
|
24
|
+
# **Routing.** Only +InferenceChunkEvent+ carries the thread; the tool-call events carry just the
|
|
25
|
+
# +correlation_id+ that +Protege::Event.emit+ injects into every event. So the broadcaster keeps a
|
|
26
|
+
# +correlation_id → thread+ map, populated from +InferenceStartedEvent+ (which carries the message, hence
|
|
27
|
+
# its thread) and cleared on +InferenceCompletedEvent+/+InferenceFailedEvent+. This is the single stream-
|
|
28
|
+
# routing convention — no +emit_*+ signature carries the thread just for the panel. +@mutex+ guards
|
|
29
|
+
# the map because callbacks may run concurrently across threads. Runs with no thread (scheduled
|
|
30
|
+
# +ResponsibilityRun+s) never populate the map, so their tool events broadcast nothing.
|
|
31
|
+
#
|
|
32
|
+
# The feed is live-only by design: it is not rehydrated from persistence, so a page reload shows an
|
|
33
|
+
# empty feed until the next run streams in.
|
|
34
|
+
module IntrospectionBroadcaster
|
|
35
|
+
# Maps a run's +correlation_id+ to the +EmailThread+ its events broadcast to. Populated on
|
|
36
|
+
# +InferenceStartedEvent+, cleared on completion/failure.
|
|
37
|
+
@threads = {}
|
|
38
|
+
|
|
39
|
+
# Maps a run's +correlation_id+ to its currently open reasoning block's DOM id (tokens append into
|
|
40
|
+
# it), or nil when none is open — a tool call closes the block so the next token opens a fresh one.
|
|
41
|
+
@reasoning = {}
|
|
42
|
+
|
|
43
|
+
# Maps a run's +correlation_id+ to a counter that makes each reasoning block's DOM id unique.
|
|
44
|
+
@reasoning_seq = {}
|
|
45
|
+
|
|
46
|
+
# Live event-bus subscription handles, retained so {reset!} can detach them.
|
|
47
|
+
@handles = []
|
|
48
|
+
|
|
49
|
+
# Guards concurrent access to the maps from callbacks running on multiple threads.
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
|
|
52
|
+
extend self
|
|
53
|
+
|
|
54
|
+
# Wire the event listeners — the subscriber's +install!+ contract. Idempotent: retains the
|
|
55
|
+
# subscription handles and no-ops until {reset!} runs, so a repeat call never double-subscribes.
|
|
56
|
+
# Called once at boot from the engine's +after_initialize+.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def install!
|
|
60
|
+
return unless @handles.empty?
|
|
61
|
+
|
|
62
|
+
@handles = [
|
|
63
|
+
install_start_listener,
|
|
64
|
+
install_chunk_listener,
|
|
65
|
+
*install_tool_listeners,
|
|
66
|
+
*install_completion_listeners
|
|
67
|
+
]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Detach every subscription and drop all per-run state — the subscriber's +reset!+ contract, for
|
|
71
|
+
# boot re-wiring and tests.
|
|
72
|
+
#
|
|
73
|
+
# @return [void]
|
|
74
|
+
def reset!
|
|
75
|
+
@handles.each { |handle| ActiveSupport::Notifications.unsubscribe(handle) }
|
|
76
|
+
@handles = []
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@threads.clear
|
|
79
|
+
@reasoning.clear
|
|
80
|
+
@reasoning_seq.clear
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Remember which thread a run's later (thread-less) events belong to, and open the run in the feed
|
|
87
|
+
# with a pulsing "Processing…" marker.
|
|
88
|
+
#
|
|
89
|
+
# @return [Object] the subscription handle
|
|
90
|
+
def install_start_listener
|
|
91
|
+
InferenceStartedEvent.subscribe do |event|
|
|
92
|
+
thread = event.message&.email_thread
|
|
93
|
+
next unless thread
|
|
94
|
+
|
|
95
|
+
# The correlation_id is the run's stable DOM id, so we can key the reasoning block and the marker on it.
|
|
96
|
+
remember(correlation_id: event.correlation_id, thread:)
|
|
97
|
+
|
|
98
|
+
broadcast_run_marker(thread:,
|
|
99
|
+
correlation_id: event.correlation_id,
|
|
100
|
+
status: :processing,
|
|
101
|
+
action: :append)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Append each streamed reasoning token to the run's current reasoning block. Chunks carry the thread
|
|
106
|
+
# directly; empty content and thread-less payloads are dropped.
|
|
107
|
+
#
|
|
108
|
+
# @return [Object] the subscription handle
|
|
109
|
+
def install_chunk_listener
|
|
110
|
+
InferenceChunkEvent.subscribe do |event|
|
|
111
|
+
thread = event.thread
|
|
112
|
+
content = event.chunk_content
|
|
113
|
+
next if thread.nil? || content.nil? || content.empty?
|
|
114
|
+
|
|
115
|
+
append_reasoning(thread:,
|
|
116
|
+
correlation_id: event.correlation_id,
|
|
117
|
+
content:)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Stream the tool-call lifecycle into the feed: a pending card on start, then the outcome in place.
|
|
122
|
+
# Starting a call also closes the run's open reasoning block, so post-tool reasoning opens a new one.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<Object>] the subscription handles
|
|
125
|
+
def install_tool_listeners
|
|
126
|
+
started = ToolCallStartedEvent.subscribe do |event|
|
|
127
|
+
close_reasoning(event.correlation_id)
|
|
128
|
+
presenter = IntrospectionToolCallPresentation.pending(tool_call: event.tool_call)
|
|
129
|
+
broadcast_card(event, presenter, :append)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
completed = ToolCallCompletedEvent.subscribe do |event|
|
|
133
|
+
presenter = IntrospectionToolCallPresentation.completed(tool_call: event.tool_call,
|
|
134
|
+
result: event.result)
|
|
135
|
+
broadcast_card(event, presenter, :replace)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
failed = ToolCallFailedEvent.subscribe do |event|
|
|
139
|
+
presenter = IntrospectionToolCallPresentation.failed(tool_call: event.tool_call, error: event.error)
|
|
140
|
+
broadcast_card(event, presenter, :replace)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
[started, completed, failed]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Close the run in the feed — remove its marker on success, replace it with a red error on failure
|
|
147
|
+
# — and forget its thread.
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<Object>] the subscription handles
|
|
150
|
+
def install_completion_listeners
|
|
151
|
+
completed = InferenceCompletedEvent.subscribe do |event|
|
|
152
|
+
finish_run(correlation_id: event.correlation_id)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
failed = InferenceFailedEvent.subscribe do |event|
|
|
156
|
+
finish_run(correlation_id: event.correlation_id, error: event.error)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
[completed, failed]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Resolve the run's marker (remove it when the run succeeded, replace it with the error when it
|
|
163
|
+
# failed), then drop its thread mapping.
|
|
164
|
+
#
|
|
165
|
+
# @param correlation_id [String, nil] the run's id
|
|
166
|
+
# @param error [Exception, nil] the failure, or nil on success
|
|
167
|
+
# @return [void]
|
|
168
|
+
def finish_run(correlation_id:, error: nil)
|
|
169
|
+
thread = thread_for(correlation_id)
|
|
170
|
+
|
|
171
|
+
if thread && error
|
|
172
|
+
broadcast_run_marker(thread:,
|
|
173
|
+
correlation_id:,
|
|
174
|
+
status: :failed,
|
|
175
|
+
detail: error.message,
|
|
176
|
+
action: :replace)
|
|
177
|
+
elsif thread
|
|
178
|
+
Turbo::StreamsChannel.broadcast_remove_to(thread, target: run_marker_id(correlation_id))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
forget(correlation_id:)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Append (on start) or replace (on finish) a run's lifecycle marker in the feed.
|
|
185
|
+
#
|
|
186
|
+
# @param thread [EmailThread] the run's thread
|
|
187
|
+
# @param correlation_id [String] the run's id (its marker's stable DOM id)
|
|
188
|
+
# @param status [Symbol] +:processing+ or +:failed+
|
|
189
|
+
# @param action [Symbol] +:append+ (start) or +:replace+ (finish)
|
|
190
|
+
# @param detail [String, nil] the error message, for +:failed+
|
|
191
|
+
# @return [void]
|
|
192
|
+
def broadcast_run_marker(thread:, correlation_id:, status:, action:, detail: nil)
|
|
193
|
+
id = run_marker_id(correlation_id)
|
|
194
|
+
locals = { id:, status:, detail: }
|
|
195
|
+
partial = 'protege/threads/run_marker'
|
|
196
|
+
|
|
197
|
+
if action == :append
|
|
198
|
+
Turbo::StreamsChannel.broadcast_append_to(thread, target: activity_target(thread), partial:, locals:)
|
|
199
|
+
else
|
|
200
|
+
Turbo::StreamsChannel.broadcast_replace_to(thread, target: id, partial:, locals:)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Stream a reasoning token: open a fresh reasoning block in the feed if none is open for this run
|
|
205
|
+
# (carrying the first token), otherwise append the token into the open block.
|
|
206
|
+
#
|
|
207
|
+
# @param thread [EmailThread] the thread whose feed to update
|
|
208
|
+
# @param correlation_id [String] the run's id (keys its open reasoning block)
|
|
209
|
+
# @param content [String] the raw token text
|
|
210
|
+
# @return [void]
|
|
211
|
+
def append_reasoning(thread:, correlation_id:, content:)
|
|
212
|
+
block_id, opened = reasoning_block(correlation_id)
|
|
213
|
+
|
|
214
|
+
if opened
|
|
215
|
+
Turbo::StreamsChannel.broadcast_append_to(thread,
|
|
216
|
+
target: activity_target(thread),
|
|
217
|
+
partial: 'protege/threads/reasoning',
|
|
218
|
+
locals: { id: block_id, content: })
|
|
219
|
+
else
|
|
220
|
+
html = ERB::Util.html_escape(content)
|
|
221
|
+
Turbo::StreamsChannel.broadcast_append_to(thread, target: block_id, html:)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The next sequence number for a run's reasoning blocks, so each block's DOM id is unique within the run.
|
|
226
|
+
#
|
|
227
|
+
# @param correlation_id [String] the run's id
|
|
228
|
+
# @return [Integer] the next sequence number
|
|
229
|
+
def reasoning_block_id(correlation_id)
|
|
230
|
+
(@reasoning_seq[correlation_id] || 0) + 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The run's open reasoning block, opening a new one when none is active.
|
|
234
|
+
#
|
|
235
|
+
# @param correlation_id [String] the run's id
|
|
236
|
+
# @return [Array(String, Boolean)] the block's DOM id and whether it was just opened
|
|
237
|
+
def reasoning_block(correlation_id)
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
existing = @reasoning[correlation_id]
|
|
240
|
+
next [existing, false] if existing
|
|
241
|
+
|
|
242
|
+
reasoning_block_id(correlation_id).tap do |seq|
|
|
243
|
+
@reasoning_seq[correlation_id] = seq
|
|
244
|
+
@reasoning[correlation_id] = "reasoning_#{correlation_id}_#{seq}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
[@reasoning[correlation_id], true]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Close the run's open reasoning block, so the next token opens a fresh one.
|
|
252
|
+
#
|
|
253
|
+
# @param correlation_id [String] the run's id
|
|
254
|
+
# @return [void]
|
|
255
|
+
def close_reasoning(correlation_id)
|
|
256
|
+
@mutex.synchronize { @reasoning.delete(correlation_id) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Broadcast a tool card, appending it (pending) or replacing it in place (outcome). No-op when the
|
|
260
|
+
# event's run has no mapped thread.
|
|
261
|
+
#
|
|
262
|
+
# @param event [Protege::Event] the tool-call event
|
|
263
|
+
# @param card [IntrospectionToolCallPresentation] the card to render
|
|
264
|
+
# @param action [Symbol] +:append+ (pending) or +:replace+ (outcome)
|
|
265
|
+
# @return [void]
|
|
266
|
+
def broadcast_card(event, card, action)
|
|
267
|
+
thread = thread_for(event.correlation_id)
|
|
268
|
+
return unless thread
|
|
269
|
+
|
|
270
|
+
locals = { presentation: card }
|
|
271
|
+
partial = 'protege/threads/tool_call'
|
|
272
|
+
|
|
273
|
+
if action == :append
|
|
274
|
+
Turbo::StreamsChannel.broadcast_append_to(thread,
|
|
275
|
+
target: activity_target(thread),
|
|
276
|
+
partial:, locals:)
|
|
277
|
+
else
|
|
278
|
+
Turbo::StreamsChannel.broadcast_replace_to(thread,
|
|
279
|
+
target: card.card_id,
|
|
280
|
+
partial:,
|
|
281
|
+
locals:)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The feed container's DOM id for a thread (matches +_introspection.html.slim+).
|
|
286
|
+
#
|
|
287
|
+
# @param thread [EmailThread] the thread
|
|
288
|
+
# @return [String] the activity feed target id
|
|
289
|
+
def activity_target(thread)
|
|
290
|
+
ActionView::RecordIdentifier.dom_id(thread, :activity)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The stable DOM id for a run's lifecycle marker.
|
|
294
|
+
#
|
|
295
|
+
# @param correlation_id [String] the run's id
|
|
296
|
+
# @return [String] the marker's DOM id
|
|
297
|
+
def run_marker_id(correlation_id)
|
|
298
|
+
"run_#{correlation_id}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @param correlation_id [String] the run's id
|
|
302
|
+
# @param thread [EmailThread] the run's thread
|
|
303
|
+
# @return [void]
|
|
304
|
+
def remember(correlation_id:, thread:)
|
|
305
|
+
@mutex.synchronize { @threads[correlation_id] = thread }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @param correlation_id [String, nil] the run's id
|
|
309
|
+
# @return [EmailThread, nil] the mapped thread, or nil when the run has none
|
|
310
|
+
def thread_for(correlation_id)
|
|
311
|
+
@mutex.synchronize { @threads[correlation_id] }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# @param correlation_id [String, nil] the run's id
|
|
315
|
+
# @return [void]
|
|
316
|
+
def forget(correlation_id:)
|
|
317
|
+
@mutex.synchronize do
|
|
318
|
+
@threads.delete(correlation_id)
|
|
319
|
+
@reasoning.delete(correlation_id)
|
|
320
|
+
@reasoning_seq.delete(correlation_id)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|