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,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired when a scheduled responsibility run is recorded pending and enqueued, before a worker picks
|
|
5
|
+
# it up (the Loop layer).
|
|
6
|
+
class LoopRunEnqueuedEvent < Event
|
|
7
|
+
channel 'protege.loop.run.enqueued'
|
|
8
|
+
|
|
9
|
+
def name = self[:name]
|
|
10
|
+
def persona_name = self[:persona_name]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired when a scheduled responsibility run raises (the Loop layer).
|
|
5
|
+
class LoopRunFailedEvent < Event
|
|
6
|
+
channel 'protege.loop.run.failed'
|
|
7
|
+
|
|
8
|
+
def name = self[:name]
|
|
9
|
+
def persona_name = self[:persona_name]
|
|
10
|
+
def error = self[:error]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired when a scheduled responsibility run begins (the Loop layer).
|
|
5
|
+
class LoopRunStartedEvent < Event
|
|
6
|
+
channel 'protege.loop.run.started'
|
|
7
|
+
|
|
8
|
+
def name = self[:name]
|
|
9
|
+
def persona_name = self[:persona_name]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired after a tool call returns a successful result.
|
|
5
|
+
class ToolCallCompletedEvent < Event
|
|
6
|
+
channel 'protege.inference.tool_call.completed'
|
|
7
|
+
|
|
8
|
+
def persona = self[:persona]
|
|
9
|
+
def tool_call = self[:tool_call]
|
|
10
|
+
def result = self[:result]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired when a tool call raises an unhandled exception.
|
|
5
|
+
class ToolCallFailedEvent < Event
|
|
6
|
+
channel 'protege.inference.tool_call.failed'
|
|
7
|
+
|
|
8
|
+
def persona = self[:persona]
|
|
9
|
+
def tool_call = self[:tool_call]
|
|
10
|
+
def error = self[:error]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired immediately before a tool call is dispatched.
|
|
5
|
+
class ToolCallStartedEvent < Event
|
|
6
|
+
channel 'protege.inference.tool_call.started'
|
|
7
|
+
|
|
8
|
+
def persona = self[:persona]
|
|
9
|
+
def tool_call = self[:tool_call]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Fired when the provider returns tool calls in a single turn.
|
|
5
|
+
class ToolCallsReceivedEvent < Event
|
|
6
|
+
channel 'protege.inference.tool_calls.received'
|
|
7
|
+
|
|
8
|
+
def persona = self[:persona]
|
|
9
|
+
def tool_calls = self[:tool_calls]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Base class for hooks — the surface host apps use to react to engine lifecycle events. Host apps
|
|
5
|
+
# subclass +Protege::Hook+ and declare handlers with the class-level +on+ DSL; each handler block
|
|
6
|
+
# receives a typed +Protege::Event+. The declaration contract (the +on+/+stateful+ DSL) lives in
|
|
7
|
+
# +HookMixin+, which this includes; the runtime that wires those declarations to the event bus and
|
|
8
|
+
# runs them (isolated dispatch, stateful-instance lifecycle) is +Protege::Subscribers::HookDispatcher+,
|
|
9
|
+
# installed once at boot.
|
|
10
|
+
class Hook
|
|
11
|
+
include HookMixin
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Contract mixin for hooks — the surface host apps use to *declare* reactions to engine lifecycle
|
|
5
|
+
# events. A concrete hook subclasses +Protege::Hook+ (which includes this module) and declares
|
|
6
|
+
# handlers with the class-level +on+ DSL, naming the event classes it cares about; each handler block
|
|
7
|
+
# receives an instance of that +Protege::Event+ subclass.
|
|
8
|
+
#
|
|
9
|
+
# This module is *declaration only*. +on+ merely records the block in the class's +subscriptions+
|
|
10
|
+
# data, so a Zeitwerk reload just rebuilds that data. *Activation* — subscribing to the event bus and
|
|
11
|
+
# running handlers — is a separate concern owned by +Protege::Subscribers::HookDispatcher+, which
|
|
12
|
+
# reads this contract (+subscriptions+, +stateful?+) off each hook class when an event fires.
|
|
13
|
+
#
|
|
14
|
+
# Handler blocks run (by the dispatcher) via +instance_exec+ on a hook instance, so instance helper
|
|
15
|
+
# methods — and, for +stateful+ hooks, instance variables across a run — are available inside them.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# class EventLoggerHook < Protege::Hook
|
|
19
|
+
# on Protege::InferenceStartedEvent do |event|
|
|
20
|
+
# log "START #{event.persona&.name}"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# on Protege::ToolCallStartedEvent, Protege::ToolCallCompletedEvent do |event|
|
|
24
|
+
# log "TOOL #{event.tool_call&.name}"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# private
|
|
28
|
+
#
|
|
29
|
+
# def log(line) = Protege.configuration.logger.info(line)
|
|
30
|
+
# end
|
|
31
|
+
module HookMixin
|
|
32
|
+
# Extend the includer with +ClassMethods+ so the +on+/+stateful+ DSL becomes available.
|
|
33
|
+
#
|
|
34
|
+
# @param base [Class] the class including +Hook+
|
|
35
|
+
# @return [void]
|
|
36
|
+
def self.included(base)
|
|
37
|
+
base.extend(ClassMethods)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Class-level DSL mixed into every hook class via +Hook.included+.
|
|
41
|
+
module ClassMethods
|
|
42
|
+
# Declare a handler for one or more event classes.
|
|
43
|
+
#
|
|
44
|
+
# Pure declaration: records the block in +subscriptions+; the event bus is wired separately by
|
|
45
|
+
# +Protege::Subscribers::HookDispatcher+. Each argument must be a known +Protege::Event+ subclass.
|
|
46
|
+
# Listing several registers the same block under each. A single event may carry several handlers;
|
|
47
|
+
# they run in declaration order. Each block is invoked with the event instance via +instance_exec+.
|
|
48
|
+
#
|
|
49
|
+
# @param event_classes [Array<Class>] the event classes to handle (e.g. +Protege::InferenceStartedEvent+)
|
|
50
|
+
# @param block [Proc] the handler, receiving the event instance
|
|
51
|
+
# @return [void]
|
|
52
|
+
# @raise [ArgumentError] when an argument is not a known event class
|
|
53
|
+
def on(*event_classes, &block)
|
|
54
|
+
event_classes.each do |event_class|
|
|
55
|
+
unless subscriptions.key?(event_class)
|
|
56
|
+
raise ArgumentError, "unknown event #{event_class.inspect} (known: #{Protege::Event.all.join(', ')})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
subscriptions[event_class] << block
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Declare that this hook keeps state across a single run.
|
|
64
|
+
#
|
|
65
|
+
# A stateful hook reuses one instance per +correlation_id+ for the lifetime of a run, so instance
|
|
66
|
+
# variables set in one handler are visible to later handlers; the instance is evicted when the run
|
|
67
|
+
# ends. Stateless hooks (the default) get a fresh instance per event.
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def stateful
|
|
71
|
+
@stateful = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] true when this hook was declared +stateful+
|
|
75
|
+
def stateful?
|
|
76
|
+
@stateful || false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The event subscriptions, keyed by every known event class.
|
|
80
|
+
#
|
|
81
|
+
# Pre-seeded with one entry per +Protege::Event.all+ (each an empty array), so the map is also the
|
|
82
|
+
# set of valid +on+ targets. Handler blocks accumulate in declaration order.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash{Class => Array<Proc>}] each known event class mapped to its handler blocks
|
|
85
|
+
def subscriptions
|
|
86
|
+
@subscriptions ||= Protege::Event.all.index_with { [] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Base class for LLM providers. Host apps subclass +Protege::Provider+, declare their id with
|
|
5
|
+
# +protege_id :symbol+, and implement +#generate(request)+ (optionally +#generate_stream+). The
|
|
6
|
+
# provider contract lives in +ProviderMixin+, which this includes; subclasses are discovered via
|
|
7
|
+
# +Provider.descendants+ (see +ProviderMixin.registered+). The request/response value objects it
|
|
8
|
+
# exchanges are the +Inference::Provider::*+ types.
|
|
9
|
+
class Provider
|
|
10
|
+
include ProviderMixin
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Marker mixin for LLM provider extensions.
|
|
5
|
+
#
|
|
6
|
+
# A concrete provider class subclasses +Protege::Provider+ (which includes this module) and
|
|
7
|
+
# declares its symbolic identifier via the +protege_id+ class macro:
|
|
8
|
+
#
|
|
9
|
+
# class OpenRouterProvider < Protege::Provider
|
|
10
|
+
# protege_id :openrouter
|
|
11
|
+
#
|
|
12
|
+
# def initialize(api_key:); @api_key = api_key; end
|
|
13
|
+
# def generate(request); ...; end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Providers are referenced by id (the symbol passed to +protege_id+). On include, the class is
|
|
17
|
+
# auto-tracked in +Provider.registered+; the registry uses this list to map a configured
|
|
18
|
+
# +provider_id+ to its class without scanning ObjectSpace or requiring explicit registration calls.
|
|
19
|
+
#
|
|
20
|
+
# The generation request/response value objects it exchanges live in the +Inference::Provider+
|
|
21
|
+
# namespace (+Inference::Provider::Request+, +::Response+, …).
|
|
22
|
+
#
|
|
23
|
+
# Contract:
|
|
24
|
+
#
|
|
25
|
+
# def self.id; end # returns the Symbol declared above (provided by the DSL)
|
|
26
|
+
# def generate(request); end # Request -> Response, raises a Protege::*Error on failure
|
|
27
|
+
module ProviderMixin
|
|
28
|
+
class << self
|
|
29
|
+
# Extend the including class with the +protege_id+/+id+ DSL.
|
|
30
|
+
#
|
|
31
|
+
# @param base [Class] the class that included +Provider+.
|
|
32
|
+
# @return [void]
|
|
33
|
+
def included(base)
|
|
34
|
+
base.extend(ClassMethods)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# List every concrete provider class.
|
|
38
|
+
#
|
|
39
|
+
# Reads from +Provider.descendants+ so the registry survives Zeitwerk reloads without a
|
|
40
|
+
# manual registration step.
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<Class>] all provider subclasses currently loaded.
|
|
43
|
+
def registered
|
|
44
|
+
Provider.descendants
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Generate a response for +request+ (abstract).
|
|
49
|
+
#
|
|
50
|
+
# The provider contract is **hash in, hash out**: +request+ is the standard (OpenAI-compatible)
|
|
51
|
+
# request hash — +{ messages:, tools: }+ — and the return is the response hash —
|
|
52
|
+
# +{ content:, tool_calls:, finish_reason: }+ (see the providers doc). The provider adds its own
|
|
53
|
+
# model/sampling/credentials from config, and raises a +Protege::Error+ subclass on transport, auth,
|
|
54
|
+
# or parse failure. Providers never touch Protege's internal value types. Concrete providers must
|
|
55
|
+
# override this; the default body exists only to give a clear failure when one forgets to.
|
|
56
|
+
#
|
|
57
|
+
# @param _request [Hash] the request hash (unused in the default).
|
|
58
|
+
# @return [Hash] never returns; always raises.
|
|
59
|
+
# @raise [NotImplementedError] always, naming the class that failed to implement +#generate+.
|
|
60
|
+
def generate(_request)
|
|
61
|
+
raise NotImplementedError,
|
|
62
|
+
"#{self.class} must implement #generate(request_hash) -> response hash"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate a response, yielding incremental chunks as they arrive.
|
|
66
|
+
#
|
|
67
|
+
# The default implementation does not truly stream: it calls +#generate+ and yields the whole
|
|
68
|
+
# text as one +:text+ chunk (only when non-blank), then returns the response hash. Providers with
|
|
69
|
+
# native streaming should override this to yield finer-grained chunks.
|
|
70
|
+
#
|
|
71
|
+
# @param request [Hash] the request hash.
|
|
72
|
+
# @yield [chunk] each streamed chunk.
|
|
73
|
+
# @yieldparam chunk [Hash] +{ type: :text | :thinking | :tool_call, content: String }+.
|
|
74
|
+
# @return [Hash] the final, complete response hash.
|
|
75
|
+
def generate_stream(request, &)
|
|
76
|
+
response = generate(request)
|
|
77
|
+
yield({ type: :text, content: response[:content] }) if response[:content].present?
|
|
78
|
+
response
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The model id this provider targets, read from its own +config.providers+ slice (+:model+).
|
|
82
|
+
# Public so the harness can record which model produced a turn (for tracing); resolved per call, so
|
|
83
|
+
# a future persona-level override is reflected here. Providers may treat a missing model as a hard
|
|
84
|
+
# error at generation time — that enforcement is theirs, not this soft reader's.
|
|
85
|
+
#
|
|
86
|
+
# @return [String, nil] the configured model id, or nil when unset
|
|
87
|
+
def model
|
|
88
|
+
Protege.configuration.provider_options(self.class.id)[:model]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The sampling settings this provider applies beyond the model — the generation knobs (temperature,
|
|
92
|
+
# max tokens, …) that shape the output and so matter for training/reproducibility. The default is
|
|
93
|
+
# empty; a provider overrides this to expose its own knobs. Captured alongside +#model+ in a trace.
|
|
94
|
+
#
|
|
95
|
+
# @return [Hash] the sampling settings (empty by default)
|
|
96
|
+
def settings
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Class-level DSL provided by the mixin.
|
|
101
|
+
module ClassMethods
|
|
102
|
+
# Declare the symbolic identifier this provider responds to.
|
|
103
|
+
#
|
|
104
|
+
# Call inside the class body; the configured +provider_id+ references the provider by this symbol.
|
|
105
|
+
#
|
|
106
|
+
# @param symbol [Symbol] the provider's unique identifier (e.g. +:openrouter+).
|
|
107
|
+
# @return [Symbol] the stored identifier.
|
|
108
|
+
def protege_id(symbol)
|
|
109
|
+
@protege_id = symbol
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Return the declared symbolic id.
|
|
113
|
+
#
|
|
114
|
+
# @return [Symbol] the id set via +protege_id+.
|
|
115
|
+
# @raise [Protege::ContractViolationError] when the class never called +protege_id+.
|
|
116
|
+
def id
|
|
117
|
+
@protege_id || raise(
|
|
118
|
+
Protege::ContractViolationError,
|
|
119
|
+
"#{self} must declare its id with `protege_id :<symbol>` inside the class body"
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Base class for resolvers — the Orchestrator's mechanism for assembling inference context (system
|
|
5
|
+
# prompts, history, retrieved knowledge). Host apps subclass +Protege::Resolver+ and implement
|
|
6
|
+
# +#resolve(context:)+, returning an +Array<Protege::ModelMessage>+ or nil. The contract lives
|
|
7
|
+
# in +ResolverMixin+, which this includes. Unlike tools, resolvers are not auto-registered — they are
|
|
8
|
+
# added explicitly to a persona's +ResolverChain+.
|
|
9
|
+
class Resolver
|
|
10
|
+
include ResolverMixin
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Contract mixin for resolver classes. A resolver receives a +Context+ and returns an
|
|
5
|
+
# +Array<Protege::ModelMessage>+ (one or more messages to prepend to the inference request)
|
|
6
|
+
# or +nil+ to contribute nothing. Resolvers are the Orchestrator's mechanism for assembling
|
|
7
|
+
# inference context — system prompts, conversation history, retrieved knowledge.
|
|
8
|
+
#
|
|
9
|
+
# Unlike +Tool+, resolvers are NOT auto-registered. They are explicitly added to a persona's
|
|
10
|
+
# +ResolverChain+ at declaration time. Host resolvers subclass +Protege::Resolver+, which
|
|
11
|
+
# includes this module.
|
|
12
|
+
#
|
|
13
|
+
# The role of returned messages is the developer's choice:
|
|
14
|
+
# - +role: :system+ → contributes to the system prompt
|
|
15
|
+
# - +role: :user+ / +:assistant+ → contributes to conversation history
|
|
16
|
+
#
|
|
17
|
+
# Build messages with the +#message+ helper rather than constructing +Protege::ModelMessage+ by hand
|
|
18
|
+
# — the resolver counterpart of +Tool+'s +success+/+failure+ sugar:
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# class MyResolver < Protege::Resolver
|
|
22
|
+
# def resolve(context:)
|
|
23
|
+
# message(role: :system, content: "You are #{context.persona.name}.")
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
module ResolverMixin
|
|
27
|
+
# Produce context messages for the inference request. Concrete resolvers must override.
|
|
28
|
+
#
|
|
29
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery resolver context
|
|
30
|
+
# @return [Array<Object>, nil] messages to prepend, or nil to contribute nothing
|
|
31
|
+
# @raise [NotImplementedError] always, when not overridden by the including class
|
|
32
|
+
def resolve(context:)
|
|
33
|
+
raise NotImplementedError,
|
|
34
|
+
"#{self.class} must implement #resolve(context:) -> Array<ModelMessage> | nil"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build one context message to contribute from +#resolve+, without constructing
|
|
38
|
+
# +Protege::ModelMessage+ by hand — the resolver counterpart of +Tool+'s +success+/+failure+ sugar.
|
|
39
|
+
#
|
|
40
|
+
# Return the message directly (a lone message) or collect several in an array; the +ResolverChain+
|
|
41
|
+
# coerces either shape and skips a +nil+ return, so a resolver that contributes nothing just
|
|
42
|
+
# returns +nil+.
|
|
43
|
+
#
|
|
44
|
+
# @example a system prompt
|
|
45
|
+
# message(role: :system, content: summary.to_prompt)
|
|
46
|
+
#
|
|
47
|
+
# @param role [Symbol] +:system+ (prompt), +:user+ / +:assistant+ (history), or +:tool+ (a
|
|
48
|
+
# replayed tool result)
|
|
49
|
+
# @param content [String, Array] the text, or multimodal parts (+TextPart+ / +ImagePart+ /
|
|
50
|
+
# +DocumentPart+)
|
|
51
|
+
# @param tool_call_id [String, nil] correlates a +role: :tool+ message back to its originating call
|
|
52
|
+
# @param tool_calls [Array, nil] the tool calls carried by a +role: :assistant+ message
|
|
53
|
+
# @return [Protege::ModelMessage] the assembled message
|
|
54
|
+
def message(role:, content:, tool_call_id: nil, tool_calls: nil)
|
|
55
|
+
Protege::ModelMessage.new(role:, content:, tool_call_id:, tool_calls:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Base class for harness tools — the surface the agent uses to take action. Host apps subclass
|
|
5
|
+
# +Protege::Tool+, declare +description+/+input_schema+ via the class DSL, and implement
|
|
6
|
+
# +#use(context:, **input)+ returning a +Protege::Result+. The tool contract (registry, dispatch, the
|
|
7
|
+
# DSL, and the +#success+/+#failure+ helpers) lives in +ToolMixin+, which this includes; every subclass
|
|
8
|
+
# is auto-discovered via +Tool.descendants+ (see +ToolMixin.registered+).
|
|
9
|
+
class Tool
|
|
10
|
+
include ToolMixin
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Contract mixin for harness tools — the surface the agent uses to take action within the LOGI
|
|
5
|
+
# Orchestrator. Concrete tools subclass +Protege::Tool+ (which includes this module), declare
|
|
6
|
+
# their description and JSON Schema via the class-level DSL, and implement +#use(context:, **input)+.
|
|
7
|
+
#
|
|
8
|
+
# Registration is automatic: every +Tool+ subclass is discoverable via +Tool.registered+
|
|
9
|
+
# (resolved from +Tool.descendants+, so it survives Zeitwerk reloads), and the +Harness+
|
|
10
|
+
# enumerates that list to build the LLM-visible tool catalog. Tools have no +protege_id+ DSL — the
|
|
11
|
+
# id is derived from the class basename with the +Tool+ suffix stripped, then snake_cased
|
|
12
|
+
# (+SendEmailTool+ → +:send_email+, +HTTPFetchTool+ → +:http_fetch+).
|
|
13
|
+
#
|
|
14
|
+
# Tools must return a +Protege::Result+ from +#use+ — construct one via +Result.success(**data)+ /
|
|
15
|
+
# +Result.failure(reason:, **data)+ (or the +#success+/+#failure+ helpers). The +Harness+ serializes
|
|
16
|
+
# the result to JSON for the LLM's tool message, so a failed result lets the model self-correct.
|
|
17
|
+
#
|
|
18
|
+
# @example A concrete tool
|
|
19
|
+
# class SendEmailTool < Protege::Tool
|
|
20
|
+
# description 'Sends an email reply to the inbound sender.'
|
|
21
|
+
#
|
|
22
|
+
# input_schema(
|
|
23
|
+
# type: 'object',
|
|
24
|
+
# required: %w[subject body],
|
|
25
|
+
# properties: {
|
|
26
|
+
# subject: { type: 'string' },
|
|
27
|
+
# body: { type: 'string' }
|
|
28
|
+
# }
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# def use(context:, subject:, body:)
|
|
32
|
+
# # ...
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# == Contract
|
|
37
|
+
#
|
|
38
|
+
# def self.id; end # Symbol derived from class basename
|
|
39
|
+
# def self.description; end # String declared via the DSL
|
|
40
|
+
# def self.input_schema; end # Hash declared via the DSL (JSON Schema)
|
|
41
|
+
# def use(context:, **input); end # Context + parsed input → Protege::Result
|
|
42
|
+
module ToolMixin
|
|
43
|
+
class << self
|
|
44
|
+
# Extend the includer with +ClassMethods+ so the class-level DSL becomes available.
|
|
45
|
+
#
|
|
46
|
+
# @param base [Class] the class including +Tool+
|
|
47
|
+
# @return [void]
|
|
48
|
+
def included(base)
|
|
49
|
+
base.extend(ClassMethods)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# List all concrete tool classes.
|
|
53
|
+
#
|
|
54
|
+
# Resolved via +Tool.descendants+ so the registry survives Zeitwerk reloads without manual
|
|
55
|
+
# bookkeeping. Anonymous classes (nil name) are excluded.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Class>] the registered tool classes
|
|
58
|
+
def registered
|
|
59
|
+
Tool.descendants.reject { _1.name.nil? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Find a tool instance by its string id.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] the tool id to look up
|
|
65
|
+
# @return [Object] a fresh instance of the matching tool class
|
|
66
|
+
# @raise [Protege::ToolNotFoundError] when no tool matches +name+
|
|
67
|
+
def find!(name)
|
|
68
|
+
klass = registered.find { _1.id.to_s == name }
|
|
69
|
+
raise Protege::ToolNotFoundError.new(name:) unless klass
|
|
70
|
+
|
|
71
|
+
klass.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Look up a tool by name, execute it, and pair the call with its outcome.
|
|
75
|
+
#
|
|
76
|
+
# Both failure modes resolve to a failed +Result+ so the caller (the +Harness+) never sees a
|
|
77
|
+
# raw error — the model gets a serialized failure it can self-correct from:
|
|
78
|
+
#
|
|
79
|
+
# * an exception raised inside +#use+ (or by +find!+) is caught, and
|
|
80
|
+
# * a +#use+ that returns a non-+Result+ violates the tool contract and is reported as
|
|
81
|
+
# +Protege::InvalidToolResultError+, rather than blowing up later when the +Harness+
|
|
82
|
+
# calls +#success?+ on the bad value.
|
|
83
|
+
#
|
|
84
|
+
# Enforcement of tool scoping lives here: a call to any tool outside the persona's effective scope
|
|
85
|
+
# (its code grant minus +disabled_tool_ids+, read from +context.persona+) is refused before +#use+
|
|
86
|
+
# runs. Advertising a narrowed catalogue in the request isn't enough — a tool disabled mid-thread
|
|
87
|
+
# stays visible to the model in earlier turns and could still be called — so dispatch is the
|
|
88
|
+
# backstop.
|
|
89
|
+
#
|
|
90
|
+
# @param tool_call [Object] the tool call carrying name and input
|
|
91
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery tool context
|
|
92
|
+
# @return [Protege::ToolResult] the call paired with its result
|
|
93
|
+
def invoke(tool_call, context:)
|
|
94
|
+
if context.persona.available_tool_ids.exclude?(tool_call.name.to_sym)
|
|
95
|
+
return failed_tool_result(tool_call:,
|
|
96
|
+
error: Protege::ToolNotAvailableError.new(name: tool_call.name))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
tool = find!(tool_call.name)
|
|
100
|
+
input = (tool_call.input || {}).symbolize_keys
|
|
101
|
+
result = tool.use(context:, **input)
|
|
102
|
+
|
|
103
|
+
return Protege::ToolResult.new(tool_call:, result:) if result.is_a?(Result)
|
|
104
|
+
|
|
105
|
+
failed_tool_result(tool_call:,
|
|
106
|
+
error: Protege::InvalidToolResultError.new(tool: tool.class, got: result.class))
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
failed_tool_result(tool_call:, error: e)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Build a failed +ToolResult+, logging the full error for operators.
|
|
114
|
+
#
|
|
115
|
+
# The agent only ever receives the serialized (backtrace-truncated) failure via the tool
|
|
116
|
+
# message; the complete error and backtrace are written to the log here so debugging context
|
|
117
|
+
# is never lost.
|
|
118
|
+
#
|
|
119
|
+
# @param tool_call [Object] the tool call that failed
|
|
120
|
+
# @param error [Exception] the captured error or contract violation
|
|
121
|
+
# @return [Protege::ToolResult] a failed result wrapping +error+
|
|
122
|
+
def failed_tool_result(tool_call:, error:)
|
|
123
|
+
Protege.configuration.logger.error(
|
|
124
|
+
"[Protege] tool #{tool_call.name} failed: #{error.class}: #{error.message}\n" \
|
|
125
|
+
"#{error.backtrace&.join("\n")}"
|
|
126
|
+
)
|
|
127
|
+
Protege::ToolResult.new(tool_call:,
|
|
128
|
+
result: Result.failure(reason: error))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Execute the tool's work. Concrete tools must override.
|
|
133
|
+
#
|
|
134
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery tool context
|
|
135
|
+
# @param _input [Hash] the parsed tool input keyed by symbol
|
|
136
|
+
# @return [Protege::Result] the outcome of the tool's work
|
|
137
|
+
# @raise [NotImplementedError] always, when not overridden by the including class
|
|
138
|
+
def use(context:, **_input)
|
|
139
|
+
raise NotImplementedError,
|
|
140
|
+
"#{self.class} must implement #use(context:, **input) -> Protege::Result"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build a successful +Protege::Result+.
|
|
144
|
+
#
|
|
145
|
+
# Call from +#use+ to signal the tool completed its work. +data+ becomes the payload the LLM sees
|
|
146
|
+
# in the tool-result message.
|
|
147
|
+
#
|
|
148
|
+
# @example
|
|
149
|
+
# success(message_id: reply.message_id)
|
|
150
|
+
#
|
|
151
|
+
# @param data [Hash] arbitrary payload keyed by symbol
|
|
152
|
+
# @return [Protege::Result] a successful result
|
|
153
|
+
def success(**data)
|
|
154
|
+
Result.success(**data)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build a failed +Protege::Result+.
|
|
158
|
+
#
|
|
159
|
+
# Call from +#use+ to signal the tool could not complete its work. +reason+ may be a String or an
|
|
160
|
+
# Exception; strings are wrapped in +Protege::GenericError+. +data+ is merged into the payload.
|
|
161
|
+
#
|
|
162
|
+
# @example
|
|
163
|
+
# failure(reason: 'recipient looks invalid', to: to)
|
|
164
|
+
# failure(reason: e, transport: 'smtp')
|
|
165
|
+
#
|
|
166
|
+
# @param reason [String, Exception] the failure cause
|
|
167
|
+
# @param data [Hash] arbitrary payload keyed by symbol
|
|
168
|
+
# @return [Protege::Result] a failed result
|
|
169
|
+
def failure(reason:, **data)
|
|
170
|
+
Result.failure(reason:, **data)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Class-level DSL mixed into every tool class via +Tool.included+.
|
|
174
|
+
module ClassMethods
|
|
175
|
+
# Declare or read the natural-language description shown to the LLM in the tool catalog.
|
|
176
|
+
#
|
|
177
|
+
# With an argument, sets the description; with no argument, returns the previously declared
|
|
178
|
+
# value.
|
|
179
|
+
#
|
|
180
|
+
# @param text [String, nil] the description to set, or nil to read
|
|
181
|
+
# @return [String, nil] the current description
|
|
182
|
+
def description(text = nil)
|
|
183
|
+
return @description if text.nil?
|
|
184
|
+
|
|
185
|
+
@description = text
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Declare or read the JSON Schema describing this tool's input parameters.
|
|
189
|
+
#
|
|
190
|
+
# With an argument, sets the schema; with no argument, returns the previously declared value.
|
|
191
|
+
#
|
|
192
|
+
# @param schema [Hash, nil] the JSON Schema to set, or nil to read
|
|
193
|
+
# @return [Hash, nil] the current input schema
|
|
194
|
+
def input_schema(schema = nil)
|
|
195
|
+
return @input_schema if schema.nil?
|
|
196
|
+
|
|
197
|
+
@input_schema = schema
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Return the tool's symbolic identifier, derived from the class basename with the +Tool+
|
|
201
|
+
# suffix stripped and the rest snake_cased. Every concrete tool is named +<Purpose>Tool+ by
|
|
202
|
+
# convention (so tool classes never collide with same-named models), yet the id the agent sees
|
|
203
|
+
# stays clean. Memoized per class.
|
|
204
|
+
#
|
|
205
|
+
# @example
|
|
206
|
+
# SendEmailTool # => :send_email
|
|
207
|
+
# HTTPFetchTool # => :http_fetch
|
|
208
|
+
# EchoTool # => :echo
|
|
209
|
+
# Read2Tool # => :read2
|
|
210
|
+
#
|
|
211
|
+
# @return [Symbol] the tool id
|
|
212
|
+
def id
|
|
213
|
+
@id ||= name.demodulize.delete_suffix('Tool').underscore.to_sym
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|