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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Inference
|
|
5
|
+
module Provider
|
|
6
|
+
# One normalized generation request handed to a provider's +#generate+ method — the conversation
|
|
7
|
+
# only: the ordered messages plus the tool schemas to expose. Model, sampling, and credentials are
|
|
8
|
+
# not carried here; each provider reads those from its own +config.providers+ slice.
|
|
9
|
+
#
|
|
10
|
+
# +messages+ is the complete ordered message array for this
|
|
11
|
+
# generation call — resolver context messages (role: :system,
|
|
12
|
+
# :user, :assistant) prepended by the harness, followed by the
|
|
13
|
+
# current inbound as a +:user+ message. Providers handle
|
|
14
|
+
# role: :system messages according to their API requirements
|
|
15
|
+
# (OpenRouter normalises for all underlying models; a direct
|
|
16
|
+
# Anthropic provider would extract system messages into its
|
|
17
|
+
# top-level +system:+ parameter).
|
|
18
|
+
#
|
|
19
|
+
# == Wire serialization
|
|
20
|
+
#
|
|
21
|
+
# +#to_wire+ renders the request into the standard (OpenAI-compatible) +{ messages:, tools: }+
|
|
22
|
+
# body. This lives here — not on each provider — because it is the *same* serialization the API
|
|
23
|
+
# call needs and a trace needs: +OpenRouterProvider#generate+ builds its payload by merging
|
|
24
|
+
# model/sampling onto +to_wire+, and the tracing subscriber snapshots +to_wire+ directly. Keeping
|
|
25
|
+
# it in one place means a trace records the exact conversation that was sent, never a re-derivation.
|
|
26
|
+
Request = Data.define(
|
|
27
|
+
:messages,
|
|
28
|
+
:tools
|
|
29
|
+
) do
|
|
30
|
+
# @param messages [Array<Protege::ModelMessage>] the ordered message array for this generation call
|
|
31
|
+
# @param tools [Array<Protege::Inference::Tool>] the tools available to the model (default empty)
|
|
32
|
+
def initialize(messages:, tools: [])
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Render this request as the standard chat-completions body: the serialized message array and the
|
|
37
|
+
# function-tool schemas. Model, sampling, and credentials are the provider's to add.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] +{ messages: Array<Hash>, tools: Array<Hash> }+ (tools omitted when none)
|
|
40
|
+
def to_wire
|
|
41
|
+
{
|
|
42
|
+
messages: messages.map { |message| wire_message(message) },
|
|
43
|
+
tools: tools.presence&.map { |tool| wire_tool(tool) }
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Serialize one message: role, content (a joined string when all-text, else typed blocks), and
|
|
50
|
+
# any tool linkage (an assistant turn's +tool_calls+, a tool result's +tool_call_id+).
|
|
51
|
+
#
|
|
52
|
+
# @param message [Protege::ModelMessage] the message to serialize
|
|
53
|
+
# @return [Hash] the wire message
|
|
54
|
+
def wire_message(message)
|
|
55
|
+
body = { role: message.role.to_s, content: wire_content(message.parts) }
|
|
56
|
+
body[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
57
|
+
body[:tool_calls] = message.tool_calls.map(&:to_wire) if message.tool_calls
|
|
58
|
+
body
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Render message parts as content: a plain joined string when every part is text (the common
|
|
62
|
+
# case), otherwise an array of typed content blocks for a multimodal message.
|
|
63
|
+
#
|
|
64
|
+
# @param parts [Array<Object>] the normalized message parts
|
|
65
|
+
# @return [String, Array<Hash>] joined text, or an array of content blocks
|
|
66
|
+
def wire_content(parts)
|
|
67
|
+
return parts.map(&:text).join if parts.all?(TextPart)
|
|
68
|
+
|
|
69
|
+
parts.map { |part| wire_part(part) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Serialize one content part into a typed content block, with attachment bytes inline as a data
|
|
73
|
+
# URI (so the request — and any trace of it — is self-contained).
|
|
74
|
+
#
|
|
75
|
+
# @param part [Object] a TextPart, ImagePart, or DocumentPart
|
|
76
|
+
# @return [Hash] the typed content block (+text+, +image_url+, or +file+)
|
|
77
|
+
def wire_part(part)
|
|
78
|
+
case part
|
|
79
|
+
when TextPart
|
|
80
|
+
{ type: 'text', text: part.text }
|
|
81
|
+
when ImagePart
|
|
82
|
+
{ type: 'image_url', image_url: { url: data_uri(part.media_type, part.data) } }
|
|
83
|
+
when DocumentPart
|
|
84
|
+
{ type: 'file', file: { filename: part.filename, file_data: data_uri(part.media_type, part.data) } }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build a base64 data URI for an inline content block.
|
|
89
|
+
#
|
|
90
|
+
# @param media_type [String] the MIME type
|
|
91
|
+
# @param data [String] the Base64-encoded bytes
|
|
92
|
+
# @return [String] the +data:+ URI
|
|
93
|
+
def data_uri(media_type, data)
|
|
94
|
+
"data:#{media_type};base64,#{data}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Serialize a tool definition into the function-tool schema advertised to the model.
|
|
98
|
+
#
|
|
99
|
+
# @param tool [Protege::Tool] the tool to advertise
|
|
100
|
+
# @return [Hash] the function-typed tool descriptor
|
|
101
|
+
def wire_tool(tool)
|
|
102
|
+
{
|
|
103
|
+
type: 'function',
|
|
104
|
+
function: { name: tool.id.to_s, description: tool.description, parameters: tool.input_schema }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
module Inference
|
|
7
|
+
module Provider
|
|
8
|
+
# One normalized generation result — Protege's **internal** representation of a model turn.
|
|
9
|
+
#
|
|
10
|
+
# Providers never construct this: they return the wire **response hash** (an OpenAI assistant
|
|
11
|
+
# message + finish reason — see the providers doc), and Protege wraps it here via {from_wire} at
|
|
12
|
+
# the Inference↔Provider seam. The harness and tracing read this object.
|
|
13
|
+
#
|
|
14
|
+
# +text+: assistant text (nil when the turn is tool calls only).
|
|
15
|
+
# +tool_calls+: +Array<ToolCall>+ (empty when none were requested).
|
|
16
|
+
# +finish_reason+: provider-reported reason (e.g. +'stop'+, +'tool_calls'+, +'length'+).
|
|
17
|
+
Response = Data.define(
|
|
18
|
+
:text,
|
|
19
|
+
:tool_calls,
|
|
20
|
+
:finish_reason
|
|
21
|
+
) do
|
|
22
|
+
# Build a Response from a provider's wire response hash:
|
|
23
|
+
# +{ content:, tool_calls: [ {id:, function: {name:, arguments: <JSON string>}} ], finish_reason: }+.
|
|
24
|
+
#
|
|
25
|
+
# @param hash [Hash] the provider's response hash (symbol keys)
|
|
26
|
+
# @return [Response] the wrapped response
|
|
27
|
+
def self.from_wire(hash)
|
|
28
|
+
new(
|
|
29
|
+
text: hash[:content],
|
|
30
|
+
tool_calls: Array(hash[:tool_calls]).map { |call| ToolCall.from_wire(call) },
|
|
31
|
+
finish_reason: hash[:finish_reason]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Render the model's output as a compact wire snapshot — the completion a trace trains toward:
|
|
36
|
+
# assistant text, any tool calls (in the shared function-call shape), and the finish reason.
|
|
37
|
+
# Mirrors +Request#to_wire+; the tracing subscriber stores this verbatim. Nil/empty fields drop.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] the response snapshot
|
|
40
|
+
def to_wire
|
|
41
|
+
{
|
|
42
|
+
text:,
|
|
43
|
+
tool_calls: tool_calls.presence&.map(&:to_wire),
|
|
44
|
+
finish_reason:
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
module Inference
|
|
7
|
+
module Provider
|
|
8
|
+
# One tool call emitted by the model in a +Response+ (Protege's internal representation).
|
|
9
|
+
#
|
|
10
|
+
# +id+: provider-assigned identifier used to correlate the eventual tool-result message back to
|
|
11
|
+
# this call.
|
|
12
|
+
# +name+: snake_case tool name to invoke.
|
|
13
|
+
# +input+: parsed input hash matching the tool's input schema.
|
|
14
|
+
ToolCall = Data.define(
|
|
15
|
+
:id,
|
|
16
|
+
:name,
|
|
17
|
+
:input
|
|
18
|
+
) do
|
|
19
|
+
# Build a ToolCall from a function-call wire hash:
|
|
20
|
+
# +{ id:, type: "function", function: { name:, arguments: <JSON string> } }+ — the shape the
|
|
21
|
+
# model emits in the response hash. +arguments+ is JSON-decoded into +input+.
|
|
22
|
+
#
|
|
23
|
+
# @param hash [Hash] the function-call wire hash (symbol keys)
|
|
24
|
+
# @return [ToolCall] the parsed call
|
|
25
|
+
def self.from_wire(hash)
|
|
26
|
+
function = hash[:function] || {}
|
|
27
|
+
new(id: hash[:id], name: function[:name], input: parse_arguments(function[:arguments]))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Decode the JSON +arguments+ string into an input hash (empty when blank).
|
|
31
|
+
#
|
|
32
|
+
# @param raw [String, nil] the JSON-encoded arguments
|
|
33
|
+
# @return [Hash] the parsed input
|
|
34
|
+
def self.parse_arguments(raw)
|
|
35
|
+
raw.present? ? JSON.parse(raw) : {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Render this call in the standard function-call wire shape, with JSON-encoded arguments — the
|
|
39
|
+
# same serialization a request's assistant turn and a trace's response snapshot both use.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash] +{ id:, type: 'function', function: { name:, arguments: } }+
|
|
42
|
+
def to_wire
|
|
43
|
+
{
|
|
44
|
+
id:,
|
|
45
|
+
type: 'function',
|
|
46
|
+
function: { name:, arguments: input.to_json }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Namespace for all inference concerns — providers, requests,
|
|
5
|
+
# responses, and the value objects they exchange.
|
|
6
|
+
#
|
|
7
|
+
# Module methods here are the cross-namespace entry points. Other namespaces (Orchestrator, Gateway)
|
|
8
|
+
# and the Rails layer call these rather than reaching into +Provider::*+ internals directly.
|
|
9
|
+
module Inference
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
# Build a +Provider::Request+ for the first turn of a generation.
|
|
13
|
+
#
|
|
14
|
+
# The message list is the resolver-supplied +context_messages+. When +user_content+ is given, it
|
|
15
|
+
# is appended as a trailing +role: :user+ message — used by the reply path to inject the live
|
|
16
|
+
# inbound email body (a stripped string, or multimodal parts when it has attachments). When
|
|
17
|
+
# +user_content+ is +nil+ the request is exactly the resolver messages: a scheduled run assembles
|
|
18
|
+
# its whole turn — instructions included — through its resolver chain, so the harness injects
|
|
19
|
+
# nothing. The request carries only the conversation and the tool classes; the active provider reads
|
|
20
|
+
# its own model / sampling / credentials from +config.providers+.
|
|
21
|
+
#
|
|
22
|
+
# @param user_content [String, Array<Object>, nil] the trailing user turn, or nil to add none.
|
|
23
|
+
# @param tools [Array<Class>] tool classes to expose for this generation.
|
|
24
|
+
# @param context_messages [Array<ModelMessage>] the resolver-assembled message list.
|
|
25
|
+
# @return [Provider::Request] the assembled first-turn request.
|
|
26
|
+
def build_request(user_content: nil, tools: [], context_messages: [])
|
|
27
|
+
messages = Array(context_messages)
|
|
28
|
+
messages += [ModelMessage.new(role: :user, content: user_content)] unless user_content.nil?
|
|
29
|
+
|
|
30
|
+
Provider::Request.new(messages:, tools:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build the next-turn +Provider::Request+ by appending the assistant turn and tool results.
|
|
34
|
+
#
|
|
35
|
+
# Used by the Orchestrator tool loop after the model requested tools: it appends the assistant
|
|
36
|
+
# +ModelMessage+ (carrying its +tool_calls+) followed by one +role: :tool+ message per
|
|
37
|
+
# result, then re-issues the request with the same tools as +previous+ (model and sampling come
|
|
38
|
+
# from the provider's config on each turn). Each tool result's payload is correlated back via
|
|
39
|
+
# +tool_call.id+ and serialized as JSON.
|
|
40
|
+
#
|
|
41
|
+
# When +review_parts+ are given (a tool asked to show the model an image/PDF it can't return in a
|
|
42
|
+
# text-only tool result), a final +role: :user+ message carrying those vision parts is appended
|
|
43
|
+
# after the tool messages — the only place the OpenAI schema lets visual content reach the model.
|
|
44
|
+
#
|
|
45
|
+
# @param previous [Provider::Request] the request that produced +response+.
|
|
46
|
+
# @param response [Provider::Response] the assistant response containing the requested tool
|
|
47
|
+
# calls.
|
|
48
|
+
# @param tool_results [Array<Protege::ToolResult>] one result per executed tool call.
|
|
49
|
+
# @param review_parts [Array<Object>] vision parts to show the model on a trailing user turn.
|
|
50
|
+
# @return [Provider::Request] the request for the next tool-loop turn.
|
|
51
|
+
def extend_request(previous:, response:, tool_results:, review_parts: [])
|
|
52
|
+
assistant_msg = ModelMessage.new(
|
|
53
|
+
role: :assistant,
|
|
54
|
+
content: response.text || '',
|
|
55
|
+
tool_calls: response.tool_calls
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
tool_messages = tool_results.map do |tr|
|
|
59
|
+
ModelMessage.new(
|
|
60
|
+
role: :tool,
|
|
61
|
+
tool_call_id: tr.tool_call.id,
|
|
62
|
+
content: tr.result.to_h.to_json
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
messages = previous.messages + [assistant_msg] + tool_messages
|
|
67
|
+
messages += [review_message(review_parts)] if review_parts.any?
|
|
68
|
+
|
|
69
|
+
Provider::Request.new(messages:, tools: previous.tools)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Instantiate the provider class matching the configured +provider_id+.
|
|
73
|
+
#
|
|
74
|
+
# Resolves against +ProviderMixin.registered+ (every +Provider+ descendant) by symbolic
|
|
75
|
+
# id, so the lookup stays correct across Zeitwerk reloads.
|
|
76
|
+
#
|
|
77
|
+
# @return [ProviderMixin] a fresh provider instance for the configured id.
|
|
78
|
+
# @raise [Protege::ContractViolationError] when no registered provider declares the configured id.
|
|
79
|
+
def provider
|
|
80
|
+
id = Protege.configuration.provider_id
|
|
81
|
+
klass = ProviderMixin.registered.find { |k| k.id == id }
|
|
82
|
+
|
|
83
|
+
raise Protege::ContractViolationError, "no provider registered for #{id.inspect}" unless klass
|
|
84
|
+
|
|
85
|
+
klass.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Generate against the configured provider — the seam that keeps the provider contract hash-based.
|
|
89
|
+
# Hands the provider the wire request hash (+Request#to_wire+) and wraps its returned response hash
|
|
90
|
+
# back into a +Provider::Response+, so providers never touch Protege's internal value types.
|
|
91
|
+
#
|
|
92
|
+
# @param request [Provider::Request] the request to generate against
|
|
93
|
+
# @return [Provider::Response] the wrapped response
|
|
94
|
+
def generate(request)
|
|
95
|
+
Provider::Response.from_wire(provider.generate(request.to_wire))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Stream a generation against the configured provider, wrapping the final response hash as {generate}
|
|
99
|
+
# does. Yielded chunks pass through untouched.
|
|
100
|
+
#
|
|
101
|
+
# @param request [Provider::Request] the request to generate against
|
|
102
|
+
# @yieldparam chunk [Hash] a +{ type:, content: }+ token chunk
|
|
103
|
+
# @return [Provider::Response] the wrapped final response
|
|
104
|
+
def generate_stream(request, &)
|
|
105
|
+
Provider::Response.from_wire(provider.generate_stream(request.to_wire, &))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Build the trailing +role: :user+ message that shows the model the attachments it asked to review.
|
|
111
|
+
#
|
|
112
|
+
# @param parts [Array<Object>] the vision parts to show
|
|
113
|
+
# @return [ModelMessage] the review user message
|
|
114
|
+
def review_message(parts)
|
|
115
|
+
ModelMessage.new(
|
|
116
|
+
role: :user,
|
|
117
|
+
content: [TextPart.new(text: 'Here are the attachments you asked to review:'), *parts]
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fugit'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
module Loop
|
|
7
|
+
# Domain wrapper over a standard 5-field cron expression — the only knob a responsibility's
|
|
8
|
+
# schedule exposes. Backed by +Fugit::Cron+ so raw cron parsing never scatters through the engine
|
|
9
|
+
# (the gateway/wrapper convention), and deliberately answers just one question: does a given
|
|
10
|
+
# minute match? The Loop scheduler ticks every minute, so there is no "next occurrence" maths to do.
|
|
11
|
+
#
|
|
12
|
+
# The expression is interpreted in the **application timezone** (+Time.zone+) by pinning Fugit to
|
|
13
|
+
# that zone's IANA name, so "0 9 * * *" means 9am app-time regardless of the host's system clock.
|
|
14
|
+
class Schedule
|
|
15
|
+
class << self
|
|
16
|
+
# Whether +expression+ parses as a valid cron string. Never raises — safe for validations.
|
|
17
|
+
#
|
|
18
|
+
# @param expression [String, nil] the cron expression to check
|
|
19
|
+
# @return [Boolean] true when parseable
|
|
20
|
+
def valid?(expression)
|
|
21
|
+
new(expression).valid?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String] the raw cron expression as given
|
|
26
|
+
attr_reader :expression
|
|
27
|
+
|
|
28
|
+
# @param expression [String, nil] a standard 5-field cron expression (e.g. "0 9 * * 1-5")
|
|
29
|
+
# @return [void]
|
|
30
|
+
def initialize(expression)
|
|
31
|
+
@expression = expression.to_s.strip
|
|
32
|
+
@cron = Fugit::Cron.parse(zoned(@expression))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] true when the expression parsed as valid cron
|
|
36
|
+
def valid?
|
|
37
|
+
!@cron.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether +time+ falls on this schedule, at minute granularity. Seconds are zeroed before the
|
|
41
|
+
# match because a 5-field cron fires at second zero, while the tick runs a few seconds into the
|
|
42
|
+
# minute.
|
|
43
|
+
#
|
|
44
|
+
# @param time [Time] the moment to test (typically +Time.current+)
|
|
45
|
+
# @return [Boolean] true when this minute is a scheduled minute
|
|
46
|
+
def due?(time)
|
|
47
|
+
return false unless valid?
|
|
48
|
+
|
|
49
|
+
@cron.match?(time.change(sec: 0, usec: 0))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Append the application timezone's IANA name so the cron is interpreted in app-time. Blank input
|
|
55
|
+
# is passed through unchanged so it parses to +nil+ (and reads as invalid).
|
|
56
|
+
#
|
|
57
|
+
# @param expr [String] the raw cron expression
|
|
58
|
+
# @return [String] the expression pinned to the application timezone
|
|
59
|
+
def zoned(expr)
|
|
60
|
+
return expr if expr.empty?
|
|
61
|
+
|
|
62
|
+
"#{expr} #{(Time.zone || ActiveSupport::TimeZone['UTC']).tzinfo.name}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Loop
|
|
5
|
+
# The dispatch coordinator behind the Loop layer's every-minute clock. Each tick it finds the
|
|
6
|
+
# responsibilities due this minute and tells each to dispatch itself; it owns *which* responsibilities
|
|
7
|
+
# fire, while +Responsibility#dispatch!+ owns *how* (recording a run, enqueuing the job).
|
|
8
|
+
#
|
|
9
|
+
# Intentionally minimal: no overlap guard, no concurrency cap, no stale recovery, no minute-dedup.
|
|
10
|
+
# The recurring tick fires once per minute and the queue adapter bounds parallelism; a failed run is
|
|
11
|
+
# recorded and simply waits for its next scheduled minute.
|
|
12
|
+
class Scheduler
|
|
13
|
+
class << self
|
|
14
|
+
# Dispatch every responsibility due now.
|
|
15
|
+
#
|
|
16
|
+
# @return [void]
|
|
17
|
+
def dispatch
|
|
18
|
+
new.dispatch
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Tell each active, due responsibility to dispatch itself.
|
|
23
|
+
#
|
|
24
|
+
# @return [void]
|
|
25
|
+
def dispatch
|
|
26
|
+
Protege::Responsibility.active.with_active_persona.find_each do |responsibility|
|
|
27
|
+
responsibility.dispatch! if responsibility.due?(now)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The single moment every responsibility this tick is evaluated against, captured once so a tick
|
|
34
|
+
# spanning a minute boundary still tests everyone against the same minute.
|
|
35
|
+
#
|
|
36
|
+
# @return [Time]
|
|
37
|
+
def now
|
|
38
|
+
@now ||= Time.current
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/protege/loop.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# LOGI "L" — the loop layer. Owns scheduled, proactive work: deciding which responsibilities are due
|
|
5
|
+
# on each clock tick and dispatching their runs. The +Scheduler+ drives dispatch and +Schedule+ parses
|
|
6
|
+
# and evaluates cron expressions.
|
|
7
|
+
#
|
|
8
|
+
# Class methods on this module are the cross-namespace entry points. Callers (the recurring tick job,
|
|
9
|
+
# the +Responsibility+ model) use these rather than reaching into +Scheduler+ / +Schedule+ directly.
|
|
10
|
+
module Loop
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
# Dispatch a run for every responsibility due right now. Called once per clock tick.
|
|
14
|
+
#
|
|
15
|
+
# @return [void]
|
|
16
|
+
def dispatch
|
|
17
|
+
Scheduler.dispatch
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Report whether a cron expression is well-formed — for model validation.
|
|
21
|
+
#
|
|
22
|
+
# @param expression [String] the cron expression
|
|
23
|
+
# @return [Boolean] true when parseable
|
|
24
|
+
def schedule_valid?(expression)
|
|
25
|
+
Schedule.valid?(expression)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Report whether a cron expression is due at a given time.
|
|
29
|
+
#
|
|
30
|
+
# @param expression [String] the cron expression
|
|
31
|
+
# @param at [Time] the moment to test
|
|
32
|
+
# @return [Boolean] true when due at +at+
|
|
33
|
+
def schedule_due?(expression, at:)
|
|
34
|
+
Schedule.new(expression).due?(at)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Orchestrator
|
|
5
|
+
# Base context shared by both kinds of run. Carries the per-run runtime data every tool and
|
|
6
|
+
# resolver needs — persona, a frozen config snapshot, logger, correlation id — plus the
|
|
7
|
+
# +#deliver+ affordance for sending mail through the Gateway, without exposing mutable framework
|
|
8
|
+
# state. Frozen after construction so one instance is safely shared across every tool/resolver
|
|
9
|
+
# invocation in a run.
|
|
10
|
+
#
|
|
11
|
+
# The thing that *drove* the run differs by kind, so it is exposed through two accessors that both
|
|
12
|
+
# default to +nil+ here and are each overridden by exactly one subclass: +ReplyContext+ sets
|
|
13
|
+
# +#message+ (reactive, an inbound email) and +ResponsibilityContext+ sets +#responsibility+
|
|
14
|
+
# (proactive, a scheduled Loop run). Tools and resolvers branch on whichever is present.
|
|
15
|
+
class Context
|
|
16
|
+
# @return [Protege::Persona] the persona handling this run
|
|
17
|
+
# @return [Protege::Configuration] the frozen process configuration snapshot
|
|
18
|
+
# @return [Logger] the engine logger
|
|
19
|
+
# @return [String, nil] the correlation id threaded through this run's events
|
|
20
|
+
attr_reader :persona, :config, :logger, :correlation_id
|
|
21
|
+
|
|
22
|
+
# Capture the shared runtime data and freeze. Subclasses set their own subject ivar *before*
|
|
23
|
+
# calling +super+, since this constructor freezes the instance.
|
|
24
|
+
#
|
|
25
|
+
# @param persona [Protege::Persona] the persona handling this run
|
|
26
|
+
# @return [void]
|
|
27
|
+
def initialize(persona:)
|
|
28
|
+
@persona = persona
|
|
29
|
+
@logger = Protege.configuration.logger
|
|
30
|
+
# Snapshot a frozen *copy* so the context is immutable without freezing the process-wide
|
|
31
|
+
# configuration (which boot-time code and tests still mutate).
|
|
32
|
+
@config = Protege.configuration.dup.freeze
|
|
33
|
+
@correlation_id = Protege::Current.correlation_id
|
|
34
|
+
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The inbound message that drove this run, or nil when none did (overridden by +ReplyContext+).
|
|
39
|
+
#
|
|
40
|
+
# @return [Object, nil]
|
|
41
|
+
def message
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The responsibility that drove this run, or nil when none did (overridden by
|
|
46
|
+
# +ResponsibilityContext+).
|
|
47
|
+
#
|
|
48
|
+
# @return [Protege::Responsibility, nil]
|
|
49
|
+
def responsibility
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Deliver an outbound mail via the Gateway, handling transport, thread resolution, and message
|
|
54
|
+
# persistence under the current persona. Any +attachments+ (blobs already in storage) are attached
|
|
55
|
+
# to the persisted outbound message by reference, so a file is never re-uploaded on send.
|
|
56
|
+
#
|
|
57
|
+
# @param mail [Object] the outbound mail to deliver
|
|
58
|
+
# @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound message
|
|
59
|
+
# @return [Object] the Gateway delivery outcome
|
|
60
|
+
def deliver(mail:, attachments: [])
|
|
61
|
+
Gateway.deliver(mail:, persona:, attachments:)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|