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,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# A persona's standing, cron-scheduled duty — the record behind the Loop layer (the "L" of LOGI).
|
|
5
|
+
#
|
|
6
|
+
# Where an inbound email drives the reactive path, a responsibility drives the *proactive* one: on
|
|
7
|
+
# its +schedule+ (a 5-field cron string) the Loop scheduler runs the owning persona's
|
|
8
|
+
# +responsibility_resolvers+ chain, seeded with this responsibility's +instructions+ as the opening user
|
|
9
|
+
# turn. The persona decides what to do from there (typically sending mail via +send_email+).
|
|
10
|
+
#
|
|
11
|
+
# Responsibilities are lean, dashboard-managed records — not a developer subclass; the per-persona
|
|
12
|
+
# resolver chain lives on the +Persona+, so a responsibility is just "which persona, what task, how
|
|
13
|
+
# often." Each execution is tracked by a +ResponsibilityRun+.
|
|
14
|
+
class Responsibility < ApplicationRecord
|
|
15
|
+
self.table_name = 'protege_responsibilities'
|
|
16
|
+
|
|
17
|
+
# ── Concerns ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# Keep the persona's Responsibilities sidebar live (append/replace/remove).
|
|
20
|
+
include BroadcastableResponsibility
|
|
21
|
+
|
|
22
|
+
# ── Associations ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# The persona that carries out this duty; its +responsibility_resolvers+ chain assembles context
|
|
25
|
+
# and its identity is the enforced +From+ on any mail the run sends.
|
|
26
|
+
belongs_to :persona, class_name: 'Protege::Persona'
|
|
27
|
+
|
|
28
|
+
# Every execution of this responsibility, newest interesting for the dashboard; destroyed with it.
|
|
29
|
+
has_many :responsibility_runs,
|
|
30
|
+
class_name: 'Protege::ResponsibilityRun',
|
|
31
|
+
dependent: :destroy,
|
|
32
|
+
inverse_of: :responsibility
|
|
33
|
+
|
|
34
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
with_options presence: true do
|
|
37
|
+
validates :name
|
|
38
|
+
validates :instructions
|
|
39
|
+
validates :schedule
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
validate :schedule_must_be_valid_cron
|
|
43
|
+
|
|
44
|
+
# ── Scopes ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
default_scope { order(:name) }
|
|
47
|
+
|
|
48
|
+
# Only responsibilities currently enabled to fire.
|
|
49
|
+
scope :active, -> { where(active: true) }
|
|
50
|
+
|
|
51
|
+
# Only responsibilities whose persona is active (not archived) — archiving a persona stops its
|
|
52
|
+
# scheduled runs. The scheduler intersects this with {.active}.
|
|
53
|
+
scope :with_active_persona, -> { joins(:persona).merge(Protege::Persona.active) }
|
|
54
|
+
|
|
55
|
+
# ── Instance methods ────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
# Whether this responsibility should fire at +time+ — active and its cron matches that minute.
|
|
58
|
+
# The scheduler calls this each tick (over the {.with_active_persona} set, so an archived persona's
|
|
59
|
+
# duties are already excluded); there is no precomputed next-run, only minute matching.
|
|
60
|
+
#
|
|
61
|
+
# @param time [Time] the moment to test (the tick's +Time.current+)
|
|
62
|
+
# @return [Boolean] true when due
|
|
63
|
+
def due?(time)
|
|
64
|
+
active? && Loop.schedule_due?(schedule, at: time)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Enqueue a run of this responsibility. Records a +pending+ +ResponsibilityRun+ now — its
|
|
68
|
+
# +created_at+ marks enqueue time, so the later gap to the run's +started_at+ surfaces a backed-up
|
|
69
|
+
# queue — then hands off to +ResponsibilityJob+, which advances it to running and on to
|
|
70
|
+
# completed/failed. No timestamp is taken here: nothing has run yet (the job stamps +last_run_at+).
|
|
71
|
+
#
|
|
72
|
+
# The run's +correlation_id+ is minted here, at birth, and the worker adopts it — so the whole
|
|
73
|
+
# lifecycle (+loop_run_enqueued+ → +started+ → +completed+/+failed+) and the run row share one trace
|
|
74
|
+
# id. +Protege::Current+ is set so the enqueued event carries it.
|
|
75
|
+
#
|
|
76
|
+
# @return [Protege::ResponsibilityRun] the pending run that was enqueued
|
|
77
|
+
def dispatch!
|
|
78
|
+
run = responsibility_runs.create!(status: :pending, correlation_id: SecureRandom.uuid)
|
|
79
|
+
Protege::Current.correlation_id = run.correlation_id
|
|
80
|
+
|
|
81
|
+
LoopRunEnqueuedEvent.emit(name:, persona_name: persona.name)
|
|
82
|
+
ResponsibilityJob.perform_later(run_id: run.id)
|
|
83
|
+
run
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Reject a schedule that is not a parseable cron expression (presence is validated separately, so
|
|
89
|
+
# a blank value is left to that rule).
|
|
90
|
+
#
|
|
91
|
+
# @return [void]
|
|
92
|
+
def schedule_must_be_valid_cron
|
|
93
|
+
return if schedule.blank?
|
|
94
|
+
|
|
95
|
+
errors.add(:schedule, 'is not a valid cron expression') unless Loop.schedule_valid?(schedule)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One execution of a +Responsibility+ — the Loop layer's run-state record and audit trail.
|
|
5
|
+
#
|
|
6
|
+
# The scheduler creates a run +pending+ and enqueues a +ResponsibilityJob+; the job marks it
|
|
7
|
+
# +running+ (stamping +started_at+) and then +completed+ or +failed+ (stamping +finished_at+ and, on
|
|
8
|
+
# failure, the error class and message). The dashboard reads these rows to show a responsibility's
|
|
9
|
+
# recent history.
|
|
10
|
+
class ResponsibilityRun < ApplicationRecord
|
|
11
|
+
self.table_name = 'protege_responsibility_runs'
|
|
12
|
+
|
|
13
|
+
# ── Associations ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
belongs_to :responsibility,
|
|
16
|
+
class_name: 'Protege::Responsibility',
|
|
17
|
+
inverse_of: :responsibility_runs
|
|
18
|
+
|
|
19
|
+
# Tool calls+results the agent recorded during this scheduled run — an audit trail beside the run's
|
|
20
|
+
# own status; destroyed with the run.
|
|
21
|
+
has_many :tool_uses, as: :source, class_name: 'Protege::ToolUse', dependent: :destroy
|
|
22
|
+
|
|
23
|
+
# ── Status ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# Lifecycle of a run. String-backed (matching +AccessRule+'s enum convention) so the stored value
|
|
26
|
+
# reads plainly in the database and dashboard.
|
|
27
|
+
enum :status, {
|
|
28
|
+
pending: 'pending',
|
|
29
|
+
running: 'running',
|
|
30
|
+
completed: 'completed',
|
|
31
|
+
failed: 'failed'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
# Mark this run as running now: stamp +started_at+, snapshot the current correlation id (minted by
|
|
37
|
+
# the job, so the run row links to the events it emits), and keep the responsibility's denormalised
|
|
38
|
+
# +last_run_at+ fresh (it is running, so the responsibility has just run) — mirroring how +Message+
|
|
39
|
+
# keeps its thread's counters current.
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
def start!
|
|
43
|
+
now = Time.current
|
|
44
|
+
update!(status: :running, started_at: now, correlation_id: Protege::Current.correlation_id)
|
|
45
|
+
responsibility.update!(last_run_at: now)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Mark this run completed, stamping +finished_at+.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def mark_completed!
|
|
52
|
+
update!(status: :completed, finished_at: Time.current)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mark this run failed, recording the error class and message and stamping +finished_at+.
|
|
56
|
+
#
|
|
57
|
+
# @param error [Exception] the failure to record
|
|
58
|
+
# @return [void]
|
|
59
|
+
def mark_failed!(error)
|
|
60
|
+
update!(
|
|
61
|
+
status: :failed,
|
|
62
|
+
finished_at: Time.current,
|
|
63
|
+
error_class: error.class.name,
|
|
64
|
+
error_message: error.message
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# One tool call the agent made and the result it got back, recorded during a run so a run's tool
|
|
5
|
+
# activity is durable. During a single run the calls/results live only in the in-memory
|
|
6
|
+
# +Provider::Request+; persisting them here gives two things: a reply run's tool use can be replayed by
|
|
7
|
+
# +ThreadHistoryResolver+ on later turns (as real +:assistant+/+:tool+ messages, so the agent remembers
|
|
8
|
+
# what it searched, fetched, or read), and a scheduled run's tool use is captured for audit beside the
|
|
9
|
+
# +ResponsibilityRun+ status trail.
|
|
10
|
+
#
|
|
11
|
+
# The owning record is polymorphic +source+: a +Protege::Message+ for a reply run (the triggering
|
|
12
|
+
# inbound message) or a +Protege::ResponsibilityRun+ for a scheduled run. A row pairs a call with its
|
|
13
|
+
# result (the tool loop produces them 1:1). +run_id+ groups one harness run, +turn_index+ the
|
|
14
|
+
# tool-calling round within it, and +position+ the order within a round (an assistant turn may request
|
|
15
|
+
# several calls at once). +tool_call_id+ correlates the call to its result when history is replayed.
|
|
16
|
+
# +persona_id+ is denormalized for per-persona queries.
|
|
17
|
+
class ToolUse < ApplicationRecord
|
|
18
|
+
# Upper bound on a stored +arguments+/+result+ JSON payload, so a huge tool result (e.g. a large
|
|
19
|
+
# +web_fetch+ body) can't bloat the row. Replay truncates further still (see +#replay_result+).
|
|
20
|
+
STORED_PAYLOAD_LIMIT = 16_000
|
|
21
|
+
|
|
22
|
+
# Marker appended to any payload clipped for length.
|
|
23
|
+
TRUNCATION_MARKER = '…[truncated]'
|
|
24
|
+
|
|
25
|
+
# ── Associations ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
belongs_to :source, polymorphic: true
|
|
28
|
+
belongs_to :persona, class_name: 'Protege::Persona'
|
|
29
|
+
|
|
30
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
with_options presence: true do
|
|
33
|
+
validates :tool_name
|
|
34
|
+
validates :turn_index
|
|
35
|
+
validates :position
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ── Scopes ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
# Replay order: by run, then round, then call position within the round.
|
|
41
|
+
scope :in_replay_order, -> { order(:run_id, :turn_index, :position) }
|
|
42
|
+
|
|
43
|
+
# ── Instance methods ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
# Rebuild the provider tool call for structured replay.
|
|
46
|
+
#
|
|
47
|
+
# @return [Protege::Inference::Provider::ToolCall] the call as the model originally emitted it
|
|
48
|
+
def to_provider_tool_call
|
|
49
|
+
Protege::Inference::Provider::ToolCall.new(
|
|
50
|
+
id: tool_call_id,
|
|
51
|
+
name: tool_name,
|
|
52
|
+
input: arguments.present? ? JSON.parse(arguments) : {}
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The stored result, truncated for replay so past results never dominate the context window.
|
|
57
|
+
#
|
|
58
|
+
# @param limit [Integer] maximum characters of result to replay
|
|
59
|
+
# @return [String] the (possibly truncated) result JSON text
|
|
60
|
+
def replay_result(limit:)
|
|
61
|
+
self.class.clip(result.to_s, limit:)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ── Class methods ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# Record one tool-calling round: one row per call, paired index-for-index with its result (the
|
|
68
|
+
# tool loop builds +tool_calls+ and +tool_results+ in the same order).
|
|
69
|
+
#
|
|
70
|
+
# **Atomic:** the whole round is written in a single transaction, so a serialization or validation
|
|
71
|
+
# failure on one call rolls the whole round back rather than leaving a half-written round (which
|
|
72
|
+
# would replay as an +:assistant+ with unmatched +tool_calls+ and break the next provider request).
|
|
73
|
+
# The transaction wraps **only these inserts** — it is entered after the tools have run and exits
|
|
74
|
+
# before the next inference call, so no provider request is ever made inside an open transaction.
|
|
75
|
+
#
|
|
76
|
+
# **Idempotent:** rows are keyed by +[source, run_id, turn_index, position]+, so re-recording a
|
|
77
|
+
# round (e.g. when the job retries after a transient provider error, replaying the same +run_id+)
|
|
78
|
+
# finds the existing rows instead of duplicating them.
|
|
79
|
+
#
|
|
80
|
+
# @param source [Protege::Message, Protege::ResponsibilityRun] the run's owning record
|
|
81
|
+
# @param persona [Protege::Persona] the persona handling the run
|
|
82
|
+
# @param run_id [String, nil] id grouping this harness run
|
|
83
|
+
# @param turn_index [Integer] the tool-calling round within the run (0-based)
|
|
84
|
+
# @param tool_calls [Array<Protege::Inference::Provider::ToolCall>] the calls the model requested
|
|
85
|
+
# @param tool_results [Array<Protege::ToolResult>] the paired results
|
|
86
|
+
# @return [Array<Protege::ToolUse>] the recorded rows
|
|
87
|
+
def record_round(source:, persona:, run_id:, turn_index:, tool_calls:, tool_results:)
|
|
88
|
+
transaction do
|
|
89
|
+
tool_calls.each_with_index.map do |call, position|
|
|
90
|
+
result = tool_results[position]
|
|
91
|
+
find_or_create_by!(source:, run_id:, turn_index:, position:) do |use|
|
|
92
|
+
use.persona = persona
|
|
93
|
+
use.tool_call_id = call.id
|
|
94
|
+
use.tool_name = call.name
|
|
95
|
+
use.arguments = clip(call.input.to_json, limit: STORED_PAYLOAD_LIMIT)
|
|
96
|
+
use.result = clip(result.result.to_h.to_json, limit: STORED_PAYLOAD_LIMIT)
|
|
97
|
+
use.succeeded = result.result.success?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Clip a string to +limit+ characters, appending {TRUNCATION_MARKER} when it had to be cut.
|
|
104
|
+
#
|
|
105
|
+
# @param text [String] the text to clip
|
|
106
|
+
# @param limit [Integer] the maximum length before truncation
|
|
107
|
+
# @return [String] the original text, or a truncated copy with the marker
|
|
108
|
+
def clip(text, limit:)
|
|
109
|
+
return text if text.length <= limit
|
|
110
|
+
|
|
111
|
+
"#{text[0, limit - TRUNCATION_MARKER.length]}#{TRUNCATION_MARKER}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# A durable, self-contained snapshot of one inference turn (one provider API call), captured for later
|
|
5
|
+
# fine-tuning when tracing is enabled. Each row is a training example in the making: the exact
|
|
6
|
+
# +request+ as sent (the wire conversation — resolved system prompt, history, user turn, tool catalog,
|
|
7
|
+
# attachment bytes inline) paired with the +response+ it produced and the inference settings
|
|
8
|
+
# (+model+ + +settings+) that shaped it, frozen at that instant — unlike +ToolUse+, which stores only
|
|
9
|
+
# enough to *replay* against the *current* prompts.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately **isolated and minimal**: no associations, no foreign keys, and no identity columns
|
|
12
|
+
# (persona/provider) — a trace records only what affects training, plus a human review verdict. The
|
|
13
|
+
# table can be truncated or dropped without touching anything else. +correlation_id+ + +turn_index+
|
|
14
|
+
# group the turns of one run for retry-safe dedup. Written only by the tracing subscriber
|
|
15
|
+
# (+Protege::Subscribers::Tracing+); reviewed and labeled in the dashboard.
|
|
16
|
+
class Trace < ApplicationRecord
|
|
17
|
+
# The request/response/settings snapshots are stored as JSON text (SQLite has no native JSON type),
|
|
18
|
+
# the same coder convention +Message+ uses for its address lists.
|
|
19
|
+
with_options coder: JSON do
|
|
20
|
+
serialize :request
|
|
21
|
+
serialize :response
|
|
22
|
+
serialize :settings
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Human review verdict on the turn's quality — the label a fine-tuning corpus is filtered by. Backed
|
|
26
|
+
# by strings (matching the engine's enum convention), defaulting to +unlabeled+ until a reviewer acts.
|
|
27
|
+
enum :label, { unlabeled: 'unlabeled', good: 'good', bad: 'bad', neutral: 'neutral' }, default: 'unlabeled'
|
|
28
|
+
|
|
29
|
+
# ── Scopes ────────────────────────────────────────────────────────────────
|
|
30
|
+
#
|
|
31
|
+
# The label enum supplies the per-verdict scopes (+unlabeled+/+good+/+bad+/+neutral+) the review
|
|
32
|
+
# filter selects from; these add ordering and the "any verdict applied" queue.
|
|
33
|
+
|
|
34
|
+
# Newest first — the review view's default order.
|
|
35
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
36
|
+
|
|
37
|
+
# Turns a human has judged, whatever the verdict (the complement of the enum's +unlabeled+ scope).
|
|
38
|
+
scope :reviewed, -> { where.not(label: 'unlabeled') }
|
|
39
|
+
|
|
40
|
+
# ── Instance methods ──────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
# Apply a human review verdict — the label plus an optional free-text note — stamping the review time
|
|
43
|
+
# so the turn leaves the {unlabeled} queue.
|
|
44
|
+
#
|
|
45
|
+
# @param label [String, Symbol] one of the {label} values
|
|
46
|
+
# @param annotation [String, nil] a free-text reviewer note
|
|
47
|
+
# @return [void]
|
|
48
|
+
def apply_label(label:, annotation: nil)
|
|
49
|
+
update!(label:, annotation:, reviewed_at: Time.current)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Record one turn's snapshot. Idempotent on +[correlation_id, turn_index]+ so a retried job (which
|
|
54
|
+
# replays the same run from turn 0) re-snapshots each turn in place rather than duplicating training
|
|
55
|
+
# rows — the same retry-safety +ToolUse.record_round+ provides. When no correlation id is present
|
|
56
|
+
# (a run kind that carries none), rows can't be keyed for dedup, so each call simply inserts.
|
|
57
|
+
#
|
|
58
|
+
# @param correlation_id [String, nil] the id grouping this run's turns
|
|
59
|
+
# @param turn_index [Integer] this generation's turn index within the run
|
|
60
|
+
# @param attributes [Hash] the snapshot columns (model, settings, request, response)
|
|
61
|
+
# @return [Protege::Trace] the recorded row
|
|
62
|
+
def record(correlation_id:, turn_index:, **attributes)
|
|
63
|
+
return create!(correlation_id:, turn_index:, **attributes) if correlation_id.blank?
|
|
64
|
+
|
|
65
|
+
find_or_create_by!(correlation_id:, turn_index:) do |trace|
|
|
66
|
+
trace.assign_attributes(attributes)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Protege
|
|
8
|
+
# Built-in OpenRouter provider. Hits OpenRouter's OpenAI-compatible /chat/completions endpoint.
|
|
9
|
+
# Model selection lives in the Request model field so one configured OpenRouter instance
|
|
10
|
+
# dispatches across every model OpenRouter routes to.
|
|
11
|
+
#
|
|
12
|
+
# Plugs into Protege by subclassing +Protege::Provider+ and registering under
|
|
13
|
+
# +protege_id :openrouter+, so it satisfies the +Protege::Inference::Provider+ contract
|
|
14
|
+
# (+generate+ and +generate_stream+) that the harness drives. Activated when
|
|
15
|
+
# +config.provider_id = :openrouter+.
|
|
16
|
+
#
|
|
17
|
+
# Auth and endpoint come from this provider's slice of +config.providers+ — +:api_key+ and
|
|
18
|
+
# +:base_url+ under the +:openrouter+ key — resolved lazily on first request, so construction never
|
|
19
|
+
# touches them and the built-in is harmless when unused. The host sets both in the Protege
|
|
20
|
+
# initializer (typically defaulted to ENV vars there; the provider itself does not read ENV).
|
|
21
|
+
class OpenRouterProvider < Protege::Provider
|
|
22
|
+
protege_id :openrouter
|
|
23
|
+
|
|
24
|
+
# Run a blocking, non-streaming completion.
|
|
25
|
+
#
|
|
26
|
+
# @param request [Hash] the request hash (messages + tools; see the providers doc)
|
|
27
|
+
# @return [Hash] the response hash (content + tool_calls + finish_reason)
|
|
28
|
+
# @raise [Protege::Error] on a non-2xx HTTP status
|
|
29
|
+
def generate(request)
|
|
30
|
+
response = http_client.post('chat/completions') do |req|
|
|
31
|
+
req.body = build_payload(request).to_json
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise_for_status(response)
|
|
35
|
+
parse_response(JSON.parse(response.body))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run a streaming completion over SSE.
|
|
39
|
+
#
|
|
40
|
+
# Streams tokens as they arrive: text deltas are yielded to +block+ as
|
|
41
|
+
# +{ type: :text, content: ... }+ hashes, tool-call deltas are accumulated, and the final
|
|
42
|
+
# assembled response hash is returned once the stream closes.
|
|
43
|
+
#
|
|
44
|
+
# @param request [Hash] the request hash (messages + tools; see the providers doc)
|
|
45
|
+
# @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
|
|
46
|
+
# @return [Hash] the response hash (content + tool_calls + finish_reason)
|
|
47
|
+
def generate_stream(request, &block)
|
|
48
|
+
payload = build_payload(request).merge(stream: true)
|
|
49
|
+
accumulator = { text_parts: [], tool_calls: {}, finish_reason: nil }
|
|
50
|
+
|
|
51
|
+
stream_chat_completion(payload) do |line|
|
|
52
|
+
process_stream_line(line, accumulator, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
build_stream_response(accumulator)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The sampling settings this provider applies beyond the model — the same knobs merged into the
|
|
59
|
+
# wire payload — surfaced for tracing. Overrides the mixin default so a trace records the
|
|
60
|
+
# generation settings that shaped the output.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] the temperature/max-tokens settings in effect (nils dropped)
|
|
63
|
+
def settings
|
|
64
|
+
{ temperature:, max_tokens: max_output_tokens }.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The configured API key, resolved lazily here (never at construction, so the unused built-in
|
|
70
|
+
# is harmless). The host sets it in the initializer — typically defaulted to an ENV var.
|
|
71
|
+
#
|
|
72
|
+
# @return [String] the bearer token
|
|
73
|
+
# @raise [Protege::UnauthorizedError] when no key is configured
|
|
74
|
+
def api_key
|
|
75
|
+
options[:api_key] ||
|
|
76
|
+
raise(Protege::UnauthorizedError,
|
|
77
|
+
'OpenRouter api_key is not configured — set ' \
|
|
78
|
+
'config.providers[:openrouter][:api_key] in the Protege initializer')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The configured base URL (set in the initializer, typically defaulted to an ENV var).
|
|
82
|
+
#
|
|
83
|
+
# @return [String] the API base URL
|
|
84
|
+
# @raise [Protege::UnauthorizedError] when no base URL is configured
|
|
85
|
+
def base_url
|
|
86
|
+
options[:base_url] ||
|
|
87
|
+
raise(Protege::UnauthorizedError,
|
|
88
|
+
'OpenRouter base_url is not configured — set ' \
|
|
89
|
+
'config.providers[:openrouter][:base_url] in the Protege initializer')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The configured sampling temperature, or +nil+ to defer to the model's own default.
|
|
93
|
+
#
|
|
94
|
+
# @return [Float, nil] the temperature
|
|
95
|
+
def temperature
|
|
96
|
+
options[:temperature]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The configured output-token cap, or +nil+ to defer to the model's own default.
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer, nil] the max output tokens
|
|
102
|
+
def max_output_tokens
|
|
103
|
+
options[:max_output_tokens]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# This provider's options Hash from the Protege config, keyed by its own +protege_id+
|
|
107
|
+
# (+:openrouter+). Empty when the host hasn't configured this provider.
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash] the +:openrouter+ slice of +config.providers+ (or +{}+)
|
|
110
|
+
def options
|
|
111
|
+
Protege.configuration.provider_options(self.class.id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Open the SSE connection and yield each complete response line as it arrives.
|
|
115
|
+
#
|
|
116
|
+
# Owns the Net::HTTP lifecycle and the chunk-to-line buffering so the caller only sees whole,
|
|
117
|
+
# stripped lines.
|
|
118
|
+
#
|
|
119
|
+
# @param payload [Hash] the request body (already merged with +stream: true+)
|
|
120
|
+
# @yieldparam line [String] one stripped line of the SSE response body
|
|
121
|
+
# @return [void]
|
|
122
|
+
def stream_chat_completion(payload)
|
|
123
|
+
uri = URI.join("#{base_url}/", 'chat/completions')
|
|
124
|
+
http = build_stream_http(uri)
|
|
125
|
+
post = build_stream_post(uri, payload)
|
|
126
|
+
|
|
127
|
+
http.request(post) do |response|
|
|
128
|
+
buffer = ''
|
|
129
|
+
|
|
130
|
+
response.read_body do |chunk|
|
|
131
|
+
buffer += chunk
|
|
132
|
+
|
|
133
|
+
while (line_end = buffer.index("\n"))
|
|
134
|
+
yield buffer.slice!(0..line_end).strip
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Construct the Net::HTTP transport for an SSE request.
|
|
141
|
+
#
|
|
142
|
+
# @param uri [URI] the fully resolved chat-completions endpoint
|
|
143
|
+
# @return [Net::HTTP] the configured (TLS-aware) transport
|
|
144
|
+
def build_stream_http(uri)
|
|
145
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
146
|
+
http.use_ssl = uri.scheme == 'https'
|
|
147
|
+
http
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Construct the SSE POST request with auth and streaming headers.
|
|
151
|
+
#
|
|
152
|
+
# @param uri [URI] the endpoint whose path the POST targets
|
|
153
|
+
# @param payload [Hash] the JSON-serializable request body
|
|
154
|
+
# @return [Net::HTTP::Post] the prepared POST request
|
|
155
|
+
def build_stream_post(uri, payload)
|
|
156
|
+
post = Net::HTTP::Post.new(uri.path)
|
|
157
|
+
post['Authorization'] = "Bearer #{api_key}"
|
|
158
|
+
post['Content-Type'] = 'application/json'
|
|
159
|
+
post['Accept'] = 'text/event-stream'
|
|
160
|
+
post.body = payload.to_json
|
|
161
|
+
post
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Parse one SSE line and fold its delta into the streaming accumulator.
|
|
165
|
+
#
|
|
166
|
+
# Skips keep-alive blanks, the terminal +[DONE]+ sentinel, and non-+data:+ lines. For a valid
|
|
167
|
+
# delta it records the finish reason (first wins), streams text to +block+, and merges any
|
|
168
|
+
# tool-call fragments.
|
|
169
|
+
#
|
|
170
|
+
# @param line [String] one stripped SSE line
|
|
171
|
+
# @param accumulator [Hash] mutable state with +:text_parts+, +:tool_calls+, +:finish_reason+
|
|
172
|
+
# @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
|
|
173
|
+
# @return [void]
|
|
174
|
+
def process_stream_line(line, accumulator, &)
|
|
175
|
+
return if line.empty? || line == 'data: [DONE]'
|
|
176
|
+
return unless line.start_with?('data: ')
|
|
177
|
+
|
|
178
|
+
data = JSON.parse(line.delete_prefix('data: '))
|
|
179
|
+
delta = data.dig('choices', 0, 'delta')
|
|
180
|
+
return unless delta
|
|
181
|
+
|
|
182
|
+
accumulator[:finish_reason] ||= data.dig('choices', 0, 'finish_reason')
|
|
183
|
+
|
|
184
|
+
accumulate_text(delta, accumulator, &)
|
|
185
|
+
accumulate_tool_calls(delta, accumulator)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Append a text delta to the accumulator and stream it to the caller.
|
|
189
|
+
#
|
|
190
|
+
# @param delta [Hash] the choice delta from one SSE event
|
|
191
|
+
# @param accumulator [Hash] mutable state whose +:text_parts+ is appended to
|
|
192
|
+
# @yieldparam chunk [Hash] a +{ type: :text, content: String }+ token chunk
|
|
193
|
+
# @return [void]
|
|
194
|
+
def accumulate_text(delta, accumulator)
|
|
195
|
+
return unless delta['content']
|
|
196
|
+
|
|
197
|
+
accumulator[:text_parts] << delta['content']
|
|
198
|
+
yield({ type: :text, content: delta['content'] })
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Merge tool-call fragments from a delta into the accumulator by index.
|
|
202
|
+
#
|
|
203
|
+
# Tool calls stream incrementally: name and argument strings arrive in pieces across events
|
|
204
|
+
# and are concatenated per +index+; the id is set once seen.
|
|
205
|
+
#
|
|
206
|
+
# @param delta [Hash] the choice delta from one SSE event
|
|
207
|
+
# @param accumulator [Hash] mutable state whose +:tool_calls+ map is updated
|
|
208
|
+
# @return [void]
|
|
209
|
+
def accumulate_tool_calls(delta, accumulator)
|
|
210
|
+
return unless delta['tool_calls']
|
|
211
|
+
|
|
212
|
+
tool_calls = accumulator[:tool_calls]
|
|
213
|
+
|
|
214
|
+
delta['tool_calls'].each do |tc|
|
|
215
|
+
idx = tc['index']
|
|
216
|
+
tool_calls[idx] ||= { 'id' => nil,
|
|
217
|
+
'function' => { 'name' => '', 'arguments' => '' } }
|
|
218
|
+
tool_calls[idx]['id'] = tc['id'] if tc['id']
|
|
219
|
+
tool_calls[idx]['function']['name'] += tc.dig('function', 'name').to_s
|
|
220
|
+
tool_calls[idx]['function']['arguments'] += tc.dig('function', 'arguments').to_s
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Assemble the final +Response+ from accumulated streaming state.
|
|
225
|
+
#
|
|
226
|
+
# @param accumulator [Hash] state with +:text_parts+, +:tool_calls+, +:finish_reason+
|
|
227
|
+
# @return [Hash] the response hash with joined text, the accumulated tool calls, and finish reason
|
|
228
|
+
def build_stream_response(accumulator)
|
|
229
|
+
{
|
|
230
|
+
content: accumulator[:text_parts].join.presence,
|
|
231
|
+
tool_calls: wire_tool_calls(accumulator[:tool_calls].values),
|
|
232
|
+
finish_reason: accumulator[:finish_reason]
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Build (and memoize) the Faraday client for non-streaming requests.
|
|
237
|
+
#
|
|
238
|
+
# @return [Faraday::Connection] the configured HTTP client
|
|
239
|
+
def http_client
|
|
240
|
+
@http_client ||= Faraday.new(url: base_url) do |c|
|
|
241
|
+
c.headers['Authorization'] = "Bearer #{api_key}"
|
|
242
|
+
c.headers['Content-Type'] = 'application/json'
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Build the OpenRouter chat-completions body: the standard request hash Protege hands in (messages
|
|
247
|
+
# + tools) plus this provider's model and sampling. Nil fields are dropped. Enforces model presence
|
|
248
|
+
# here — a missing model is a misconfiguration that surfaces on first use, not at construction (the
|
|
249
|
+
# mixin's +#model+ reader is intentionally soft).
|
|
250
|
+
#
|
|
251
|
+
# @param request [Hash] the request hash (messages + tools)
|
|
252
|
+
# @return [Hash] the compacted request payload
|
|
253
|
+
# @raise [Protege::ContractViolationError] when no model is configured
|
|
254
|
+
def build_payload(request)
|
|
255
|
+
model.presence ||
|
|
256
|
+
raise(Protege::ContractViolationError,
|
|
257
|
+
'OpenRouter model is not configured — set ' \
|
|
258
|
+
'config.providers[:openrouter][:model] in the Protege initializer')
|
|
259
|
+
|
|
260
|
+
request.merge(
|
|
261
|
+
model:,
|
|
262
|
+
temperature:,
|
|
263
|
+
max_tokens: max_output_tokens
|
|
264
|
+
).compact
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Map a non-2xx HTTP status onto the matching +Protege::Error+.
|
|
268
|
+
#
|
|
269
|
+
# @param response [Faraday::Response] the HTTP response to inspect
|
|
270
|
+
# @return [nil] when the status is 2xx
|
|
271
|
+
# @raise [Protege::Error] the error class matching the status code
|
|
272
|
+
def raise_for_status(response)
|
|
273
|
+
case response.status
|
|
274
|
+
when 200..299 then nil
|
|
275
|
+
when 400 then raise Protege::BadRequestError, response.body
|
|
276
|
+
when 401 then raise Protege::UnauthorizedError, response.body
|
|
277
|
+
when 429 then raise Protege::RateLimitedError, response.body
|
|
278
|
+
when 500..599 then raise Protege::UnavailableError, response.body
|
|
279
|
+
else raise Protege::InternalError,
|
|
280
|
+
"HTTP #{response.status}: #{response.body}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Parse a non-streaming response body into the response hash Protege expects
|
|
285
|
+
# (+{ content:, tool_calls:, finish_reason: }+ — the OpenAI assistant message + finish reason,
|
|
286
|
+
# symbol-keyed with tool-call arguments left as JSON strings).
|
|
287
|
+
#
|
|
288
|
+
# @param body [Hash] the decoded JSON response body
|
|
289
|
+
# @return [Hash] the response hash
|
|
290
|
+
# @raise [Protege::ResponseParseFailedError] when no choice is present
|
|
291
|
+
def parse_response(body)
|
|
292
|
+
choice = body.dig('choices', 0) || raise(Protege::ResponseParseFailedError, body.to_s)
|
|
293
|
+
message = choice.fetch('message')
|
|
294
|
+
|
|
295
|
+
{
|
|
296
|
+
content: message['content'],
|
|
297
|
+
tool_calls: wire_tool_calls(message['tool_calls']),
|
|
298
|
+
finish_reason: choice['finish_reason']
|
|
299
|
+
}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Normalize the API's (string-keyed) tool_calls into the function-call wire shape Protege parses:
|
|
303
|
+
# +{ id:, type: 'function', function: { name:, arguments: <JSON string> } }+.
|
|
304
|
+
#
|
|
305
|
+
# @param raw [Array<Hash>, nil] the message's +tool_calls+ from the API
|
|
306
|
+
# @return [Array<Hash>] the wire tool calls (empty when none)
|
|
307
|
+
def wire_tool_calls(raw)
|
|
308
|
+
Array(raw).map do |tc|
|
|
309
|
+
{
|
|
310
|
+
id: tc['id'],
|
|
311
|
+
type: 'function',
|
|
312
|
+
function: { name: tc.dig('function', 'name'), arguments: tc.dig('function', 'arguments') }
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|