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,255 @@
|
|
|
1
|
+
# Resolvers (engine-internal reference)
|
|
2
|
+
|
|
3
|
+
> Engineering reference for the engine's built-in resolvers and the exact payload they produce.
|
|
4
|
+
> User-facing docs live in `site/developer-experience/extensions/resolvers.md`; this file is the
|
|
5
|
+
> precise contract for contributors working in the engine.
|
|
6
|
+
|
|
7
|
+
A **resolver** assembles the context the LLM sees *before* an inference run. Each persona declares an
|
|
8
|
+
ordered chain of resolvers (`message_resolvers` for replies, `responsibility_resolvers` for scheduled
|
|
9
|
+
runs); the harness runs the chain, concatenates every resolver's output, and hands the result to the
|
|
10
|
+
provider as the opening messages of the request.
|
|
11
|
+
|
|
12
|
+
Resolvers are **not** auto-discovered — they are added to a chain explicitly, and **order matters**
|
|
13
|
+
(the chain's output is concatenated in declaration order). Built-in resolvers live in
|
|
14
|
+
`protege/resolvers/` under this directory and subclass `Protege::Resolver`
|
|
15
|
+
(`engine/lib/protege/resolver_base.rb`).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The contract
|
|
20
|
+
|
|
21
|
+
Every resolver implements one method:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
def resolve(context:) # => ModelMessage | Array<ModelMessage> | nil
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **Input:** a `Protege::Orchestrator::Context` (see [Context](#the-context)).
|
|
28
|
+
- **Output (this is the whole spec):** one **`ModelMessage`**, an **`Array`** of them, or **`nil`**
|
|
29
|
+
to contribute nothing — the chain coerces all three shapes. Build messages with the `message` helper
|
|
30
|
+
(`message(role:, content:, tool_call_id: nil, tool_calls: nil)`) rather than the raw constructor.
|
|
31
|
+
|
|
32
|
+
There is no other return type. Anything a resolver wants to put in front of the model — a system
|
|
33
|
+
prompt, prior conversation, retrieved records, tool history — is expressed as one or more
|
|
34
|
+
`ModelMessage` objects. That single object type is the payload every resolver produces and the
|
|
35
|
+
common currency the chain concatenates.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## The payload: `ModelMessage`
|
|
40
|
+
|
|
41
|
+
Defined in `engine/lib/protege/inference/provider/message.rb`. It is the *same* value object used
|
|
42
|
+
throughout the inference layer (requests and responses), so what a resolver emits is exactly what the
|
|
43
|
+
provider serializes onto the wire.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
Message = Data.define(:role, :content, :tool_call_id, :tool_calls)
|
|
47
|
+
# initialize(role:, content:, tool_call_id: nil, tool_calls: nil)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Field | Type | Required | Meaning |
|
|
51
|
+
|----------------|-----------------------------------|----------|---------|
|
|
52
|
+
| `role` | `Symbol` | yes | One of `:system`, `:user`, `:assistant`, `:tool`. |
|
|
53
|
+
| `content` | `String` **or** `Array<Part>` | yes | Text, or an array of typed parts (multimodal). May be `""`. |
|
|
54
|
+
| `tool_call_id` | `String` | no | **Only** meaningful when `role: :tool` — correlates a tool result to its call. |
|
|
55
|
+
| `tool_calls` | `Array<ToolCall>` | no | **Only** meaningful when `role: :assistant` — tool calls the model is requesting. |
|
|
56
|
+
|
|
57
|
+
### `content` normalization
|
|
58
|
+
|
|
59
|
+
`Message#parts` normalizes `content` to an array of parts:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
def parts
|
|
63
|
+
content.is_a?(Array) ? content : [TextPart.new(text: content)]
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
So a `String` is the text-only shorthand for `[TextPart.new(text: content)]`. Pass an `Array<Part>`
|
|
68
|
+
directly for multimodal content.
|
|
69
|
+
|
|
70
|
+
### Part value objects
|
|
71
|
+
|
|
72
|
+
Under `engine/lib/protege/inference/provider/`:
|
|
73
|
+
|
|
74
|
+
| Part | Definition | Notes |
|
|
75
|
+
|----------------|---------------------------------------------|-------|
|
|
76
|
+
| `TextPart` | `Data.define(:text)` | Plain text. |
|
|
77
|
+
| `ImagePart` | `Data.define(:media_type, :data)` | `data` is **Base64** bytes; `media_type` e.g. `"image/png"`. Vision models read it. |
|
|
78
|
+
| `DocumentPart` | `Data.define(:filename, :media_type, :data)`| `data` is **Base64** bytes; used for PDFs. |
|
|
79
|
+
|
|
80
|
+
### `ToolCall` (for `role: :assistant` messages)
|
|
81
|
+
|
|
82
|
+
`engine/lib/protege/inference/provider/tool_call.rb`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
ToolCall = Data.define(:id, :name, :input)
|
|
86
|
+
# id: String — provider-assigned, correlates the eventual :tool result
|
|
87
|
+
# name: String — snake_case tool name
|
|
88
|
+
# input: Hash — parsed arguments matching the tool's input schema
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Which shape goes with which role
|
|
92
|
+
|
|
93
|
+
| `role` | Typical `content` | `tool_calls` | `tool_call_id` | Emitted by |
|
|
94
|
+
|--------------|--------------------------|--------------|----------------|------------|
|
|
95
|
+
| `:system` | `String` (instructions) | — | — | `LoadFileResolver`, `LoadTextResolver`, `LoadRecordResolver` (default) |
|
|
96
|
+
| `:user` | `String` or `Array<Part>`| — | — | `ThreadHistoryResolver` (inbound turns); any loader with `role: :user` |
|
|
97
|
+
| `:assistant` | `String` (may be `""`) | `Array<ToolCall>` when replaying a tool round | — | `ThreadHistoryResolver` (outbound turns, and the tool-call turns) |
|
|
98
|
+
| `:tool` | `String` (result JSON) | — | `String` (the call id) | `ThreadHistoryResolver` (replayed tool results) |
|
|
99
|
+
|
|
100
|
+
> Every `:tool` message must be preceded by an `:assistant` message whose `tool_calls` include a
|
|
101
|
+
> matching `id`. `ThreadHistoryResolver` reconstructs these as a paired block, and the resolver chain
|
|
102
|
+
> preserves order — never interleave a lone `:tool` message.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## How the chain assembles the final payload
|
|
107
|
+
|
|
108
|
+
`Protege::Orchestrator::ResolverChain#run` (`engine/lib/protege/orchestrator/resolver_chain.rb`):
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
def run(context)
|
|
112
|
+
@entries.flat_map do |klass, args, kwargs, block|
|
|
113
|
+
Array(klass.new(*args, **kwargs, &block).resolve(context:))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- Each resolver is instantiated fresh per run and `resolve(context:)` is called.
|
|
119
|
+
- Its return is wrapped in `Array(...)`: **`nil` → `[]`**, a single message → `[message]`, an array →
|
|
120
|
+
unchanged.
|
|
121
|
+
- `flat_map` concatenates every resolver's contribution into **one flat, ordered
|
|
122
|
+
`Array<ModelMessage>`** — in chain declaration order.
|
|
123
|
+
|
|
124
|
+
That array becomes `context_messages` in `Inference.build_request`
|
|
125
|
+
(`engine/lib/protege/inference.rb`):
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
def build_request(user_content: nil, tools: [], context_messages: [])
|
|
129
|
+
messages = Array(context_messages)
|
|
130
|
+
messages += [ModelMessage.new(role: :user, content: user_content)] unless user_content.nil?
|
|
131
|
+
|
|
132
|
+
Provider::Request.new(messages:, tools:)
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
So the request's message list is, in order:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
[ …every resolver's messages, concatenated in chain order… ] + [ optional trailing :user turn ]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Reply runs** pass the inbound email body (a `String`, or `Array<Part>` when it has attachments) as
|
|
143
|
+
`user_content`, appended as the closing `:user` message.
|
|
144
|
+
- **Scheduled (responsibility) runs** pass `user_content: nil` — the resolver chain assembles the
|
|
145
|
+
*entire* message list itself (including the responsibility's instructions, typically via
|
|
146
|
+
`LoadTextResolver`).
|
|
147
|
+
|
|
148
|
+
**The complete payload the resolvers produce together = the flat, ordered array of `ModelMessage`
|
|
149
|
+
objects above.** Nothing else. Get the roles, order, and (for tool turns) the assistant/tool pairing
|
|
150
|
+
right, and the request is well-formed.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## The context
|
|
155
|
+
|
|
156
|
+
`resolve(context:)` receives a `Protege::Orchestrator::Context`. Attributes available:
|
|
157
|
+
|
|
158
|
+
| Attribute | Type | Reply run | Scheduled run |
|
|
159
|
+
|------------------|-------------------------------|---------------------|---------------|
|
|
160
|
+
| `persona` | `Protege::Persona` | present | present |
|
|
161
|
+
| `config` | `Protege::Configuration` | present (frozen) | present (frozen) |
|
|
162
|
+
| `logger` | `Logger` | present | present |
|
|
163
|
+
| `correlation_id` | `String, nil` | present | present |
|
|
164
|
+
| `message` | `Protege::Message` | **present** | `nil` |
|
|
165
|
+
| `responsibility` | `Protege::Responsibility` | `nil` | **present** |
|
|
166
|
+
|
|
167
|
+
A resolver used in both chains must treat the field it doesn't expect as `nil` (e.g. guard
|
|
168
|
+
`context.message` before reading `.email_thread`). The context is frozen, so resolvers read from it —
|
|
169
|
+
they never mutate it.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Built-in resolvers
|
|
174
|
+
|
|
175
|
+
All live in `app/resolvers/protege/` as flat `Protege::<Name>` constants (e.g.
|
|
176
|
+
`Protege::ThreadHistoryResolver`); a persona names one by that constant. Each returns a `ModelMessage`,
|
|
177
|
+
an array of them, or `nil`.
|
|
178
|
+
|
|
179
|
+
### `ThreadHistoryResolver`
|
|
180
|
+
|
|
181
|
+
Replays a thread's prior conversation **and the agent's tool use between turns**, so the agent has
|
|
182
|
+
memory across exchanges.
|
|
183
|
+
|
|
184
|
+
- **Constructor:** `initialize(limit: 50, include_tool_calls: true)`
|
|
185
|
+
- `limit` — most-recent-N prior messages (chronological); `nil` for all.
|
|
186
|
+
- `include_tool_calls` — weave recorded `ToolUse` rows back in (default `true`).
|
|
187
|
+
- **Returns:** `nil` when the thread has no prior content; otherwise an ordered array of:
|
|
188
|
+
- one message per prior message — `role: :user` (inbound) or `:assistant` (outbound), `content:`
|
|
189
|
+
the readable body (plain text, falling back to stripped HTML) plus an attachment note when present;
|
|
190
|
+
the *current* inbound message is excluded (it arrives as the trailing `:user` turn).
|
|
191
|
+
- for each prior turn's tool use (when `include_tool_calls`): one `:assistant` message with
|
|
192
|
+
`content: ''` and `tool_calls: Array<ToolCall>`, followed by one `:tool` message per result
|
|
193
|
+
(`tool_call_id:` set, `content:` the result JSON truncated to `REPLAY_RESULT_LIMIT = 4_000` chars).
|
|
194
|
+
- **Reads:** `context.message.email_thread` (returns `nil` if there is no thread/message).
|
|
195
|
+
|
|
196
|
+
### `LoadFileResolver`
|
|
197
|
+
|
|
198
|
+
Reads a file and contributes it as one message. General-purpose (system prompt, knowledge, examples).
|
|
199
|
+
|
|
200
|
+
- **Constructor:** `initialize(path = nil, role: :system, &block)` — supply **exactly one** of `path`
|
|
201
|
+
or a block returning a path (per delivery). Relative paths resolve against `Rails.root`; absolute
|
|
202
|
+
paths are used verbatim.
|
|
203
|
+
- **Returns:** `nil` when the file is blank; otherwise one `ModelMessage` (role:, content: <file>).
|
|
204
|
+
- **Raises:** `Protege::ResolverFileNotFoundError` when the file is missing (a missing
|
|
205
|
+
prompt is a misconfiguration — fail loud). Developer-wired; never agent input.
|
|
206
|
+
|
|
207
|
+
### `LoadRecordResolver`
|
|
208
|
+
|
|
209
|
+
Loads ActiveRecord record(s) and contributes them as one message — the DB counterpart to `LoadFile`.
|
|
210
|
+
|
|
211
|
+
- **Constructor:** `initialize(role: :system, serialize: nil, &loader)` — the **required** `loader`
|
|
212
|
+
block receives the context and returns record(s) (`ActiveRecord::Base`, a `Relation`, an `Array`, or
|
|
213
|
+
`nil`). `serialize` is a `Symbol` (a method on each record returning text), a `Proc` (called with each
|
|
214
|
+
record), or `nil` for pretty-printed JSON (`JSON.pretty_generate(record.as_json)`).
|
|
215
|
+
- **Returns:** `nil` when the loader yields nothing (empty is a normal runtime state); otherwise one
|
|
216
|
+
`ModelMessage` (role:, content: <records joined by "\n\n">).
|
|
217
|
+
|
|
218
|
+
### `LoadTextResolver`
|
|
219
|
+
|
|
220
|
+
The computed-text primitive: a block returns a `String`, contributed as one message. The clean way to
|
|
221
|
+
inject a persona's DB-column system prompt.
|
|
222
|
+
|
|
223
|
+
- **Constructor:** `initialize(role: :system, &block)` — the **required** block receives the context and
|
|
224
|
+
returns the text.
|
|
225
|
+
- **Returns:** `nil` when the block returns blank/`nil` (so an unset column adds nothing); otherwise one
|
|
226
|
+
`ModelMessage` (role:, content: <stripped text>).
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Writing a resolver
|
|
231
|
+
|
|
232
|
+
Subclass `Protege::Resolver`, implement `resolve(context:)`, and build messages with the `message`
|
|
233
|
+
helper — the resolver counterpart of `Tool`'s `success`/`failure`, so you never construct `ModelMessage`
|
|
234
|
+
by hand. Return one message, an array of them, or `nil`:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class CustomerContextResolver < Protege::Resolver
|
|
238
|
+
def resolve(context:)
|
|
239
|
+
customer = Customer.find_by(email: context.message&.from_address)
|
|
240
|
+
return nil unless customer
|
|
241
|
+
|
|
242
|
+
message(role: :system, content: "You are speaking with #{customer.name} (plan: #{customer.plan}).")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`message(role:, content:, tool_call_id: nil, tool_calls: nil)` returns one
|
|
248
|
+
`ModelMessage`; the chain coerces a lone message, an array, or `nil`.
|
|
249
|
+
|
|
250
|
+
Rules of thumb:
|
|
251
|
+
- Return `nil` (not `[]` — both work, but `nil` is the idiom) when there's nothing to contribute.
|
|
252
|
+
- Pick the `role` deliberately; default to `:system` for injected knowledge.
|
|
253
|
+
- Only emit `:assistant`-with-`tool_calls` + paired `:tool` messages if you're replaying a real tool
|
|
254
|
+
exchange; keep them adjacent and correctly `tool_call_id`-matched.
|
|
255
|
+
- Read the context; never mutate it (it's frozen).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# General-purpose resolver that reads a file and contributes its contents as one context message.
|
|
5
|
+
# It is deliberately *not* named for a use case: the developer decides what the file is — a system
|
|
6
|
+
# prompt, a knowledge document, few-shot examples — by choosing the +role+ of the emitted message.
|
|
7
|
+
#
|
|
8
|
+
# The path is given either statically or as a block that receives the resolver context, so it can
|
|
9
|
+
# be derived from the inbound message or persona (e.g. a per-persona prompt file). Path resolution
|
|
10
|
+
# mirrors ordinary shell intuition, so the same resolver works in development and in any production
|
|
11
|
+
# deployment:
|
|
12
|
+
# - a relative path (+config/personas/agent.md+, +./agent.md+) resolves against the host app root
|
|
13
|
+
# (+Rails.root+) — i.e. a file deployed with the app, which is reliably readable everywhere.
|
|
14
|
+
# - an absolute path (+/data/prompts/agent.md+) is used as-is, so an attached volume or mounted
|
|
15
|
+
# secret just needs its fully-qualified path.
|
|
16
|
+
#
|
|
17
|
+
# LoadFileResolver is a *developer-wired* resolver, never a model-callable tool — the path is author-trusted
|
|
18
|
+
# code, so even absolute reads carry the same trust as any code in the persona. A missing file is a
|
|
19
|
+
# misconfiguration and raises +Protege::ResolverFileNotFoundError+; a blank file contributes
|
|
20
|
+
# nothing (returns +nil+).
|
|
21
|
+
#
|
|
22
|
+
# resolvers do |chain|
|
|
23
|
+
# chain.use Protege::LoadFileResolver, 'config/personas/agent.md'
|
|
24
|
+
# chain.use(Protege::LoadFileResolver) { |ctx| "config/personas/#{ctx.persona.name.parameterize}.md" }
|
|
25
|
+
# end
|
|
26
|
+
class LoadFileResolver < Protege::Resolver
|
|
27
|
+
# @param path [String, nil] file to read; relative paths resolve against +Rails.root+, absolute
|
|
28
|
+
# paths are used verbatim. Omit when supplying a block instead.
|
|
29
|
+
# @param role [Symbol] role for the emitted message (+:system+ for a prompt, +:user+/+:assistant+
|
|
30
|
+
# to seed conversation); defaults to +:system+
|
|
31
|
+
# @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
|
|
32
|
+
# @yieldreturn [String] the path to read, computed per delivery
|
|
33
|
+
# @raise [ArgumentError] unless exactly one of +path+ or a block is given
|
|
34
|
+
def initialize(path = nil, role: :system, &block)
|
|
35
|
+
super()
|
|
36
|
+
raise ArgumentError, 'LoadFileResolver requires exactly one of a path or a block' unless path.nil? ^ block.nil?
|
|
37
|
+
|
|
38
|
+
@path = path
|
|
39
|
+
@role = role
|
|
40
|
+
@block = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Read the file and emit its contents as a single message, or +nil+ when the file is blank.
|
|
44
|
+
#
|
|
45
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery context
|
|
46
|
+
# @return [Protege::ModelMessage, nil] one message, or +nil+ when blank
|
|
47
|
+
# @raise [Protege::ResolverFileNotFoundError] when the path does not exist
|
|
48
|
+
def resolve(context:)
|
|
49
|
+
content = read(path_for(context))
|
|
50
|
+
return nil if content.blank?
|
|
51
|
+
|
|
52
|
+
message(role: @role, content:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# The path for this delivery: the block's return value when given a block, else the static path.
|
|
58
|
+
#
|
|
59
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery context
|
|
60
|
+
# @return [String] the (unexpanded) path to read
|
|
61
|
+
def path_for(context)
|
|
62
|
+
@block ? @block.call(context) : @path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Read the resolved path, translating a missing file into a domain error.
|
|
66
|
+
#
|
|
67
|
+
# @param path [String] the (unexpanded) path to read
|
|
68
|
+
# @return [String] the file contents
|
|
69
|
+
# @raise [Protege::ResolverFileNotFoundError] when the path does not exist
|
|
70
|
+
def read(path)
|
|
71
|
+
absolute = resolved_path(path)
|
|
72
|
+
File.read(absolute)
|
|
73
|
+
rescue Errno::ENOENT
|
|
74
|
+
raise Protege::ResolverFileNotFoundError.new(path:, absolute:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Expand a path: absolute paths pass through, relative paths anchor to +Rails.root+.
|
|
78
|
+
#
|
|
79
|
+
# @param path [String] the (unexpanded) path
|
|
80
|
+
# @return [String] the absolute filesystem path to read
|
|
81
|
+
def resolved_path(path)
|
|
82
|
+
str = path.to_s
|
|
83
|
+
str.start_with?('/') ? str : Rails.root.join(str).to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# General-purpose resolver that loads ActiveRecord record(s) and contributes them as one context
|
|
5
|
+
# message — the database counterpart to LoadFileResolver. Like LoadFileResolver, it is deliberately not named for a
|
|
6
|
+
# use case: the developer decides what the records mean (account state, open invoices, reference
|
|
7
|
+
# data) and what message +role+ carries them.
|
|
8
|
+
#
|
|
9
|
+
# The single primitive is a **loader block** that returns record(s). It receives the resolver
|
|
10
|
+
# context, so loading can key off the inbound message — e.g. find the account that emailed in. A
|
|
11
|
+
# static query is just a block that ignores the context. We intentionally do not offer a
|
|
12
|
+
# +model:+/+where:+ DSL: it would be strictly less capable than the block (no scopes, joins, or
|
|
13
|
+
# +find+ vs +where+) and would collide column names with resolver options.
|
|
14
|
+
#
|
|
15
|
+
# Serialization is per-record and always produces text. By default each record is rendered as
|
|
16
|
+
# pretty-printed JSON; +serialize:+ overrides that with a Symbol (a method on the record returning
|
|
17
|
+
# text) or a +Proc+ (called with the record, returning text). The rendered records are joined into
|
|
18
|
+
# one message. A +nil+ or empty result contributes nothing (returns +nil+), since "no matching
|
|
19
|
+
# rows" is a normal runtime state — unlike LoadFileResolver's missing-file misconfiguration.
|
|
20
|
+
#
|
|
21
|
+
# resolvers do |chain|
|
|
22
|
+
# chain.use(Protege::LoadRecordResolver) { |ctx| Account.find_by(email: ctx.message.from_address) }
|
|
23
|
+
# chain.use(Protege::LoadRecordResolver, serialize: :to_llm_markdown) { Setting.active }
|
|
24
|
+
# end
|
|
25
|
+
class LoadRecordResolver < Protege::Resolver
|
|
26
|
+
# Separator between each record's text rendering when more than one is loaded.
|
|
27
|
+
RECORD_SEPARATOR = "\n\n"
|
|
28
|
+
|
|
29
|
+
# @param role [Symbol] role for the emitted message; defaults to +:system+
|
|
30
|
+
# @param serialize [Symbol, Proc, nil] per-record serializer returning text — a method name to
|
|
31
|
+
# call on each record, or a +Proc+ receiving each record; defaults to pretty-printed JSON
|
|
32
|
+
# @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
|
|
33
|
+
# @yieldreturn [ActiveRecord::Base, ActiveRecord::Relation, Array, nil] the record(s) to load
|
|
34
|
+
# @raise [ArgumentError] when no loader block is given
|
|
35
|
+
def initialize(role: :system, serialize: nil, &loader)
|
|
36
|
+
super()
|
|
37
|
+
raise ArgumentError, 'LoadRecordResolver requires a block returning record(s)' unless loader
|
|
38
|
+
|
|
39
|
+
@role = role
|
|
40
|
+
@serialize = serialize
|
|
41
|
+
@loader = loader
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Run the loader and emit its record(s) as one message, or +nil+ when nothing is loaded.
|
|
45
|
+
#
|
|
46
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery context
|
|
47
|
+
# @return [Protege::ModelMessage, nil] one message, or +nil+ when empty
|
|
48
|
+
def resolve(context:)
|
|
49
|
+
records = Array(@loader.call(context))
|
|
50
|
+
return nil if records.empty?
|
|
51
|
+
|
|
52
|
+
content = render(records)
|
|
53
|
+
message(role: @role, content:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Render the records into the message string: serialize each to text, then join. Every
|
|
59
|
+
# serializer returns text (that is the contract), so no per-value coercion is needed.
|
|
60
|
+
#
|
|
61
|
+
# @param records [Array] the loaded records
|
|
62
|
+
# @return [String] the message content
|
|
63
|
+
def render(records)
|
|
64
|
+
records.map { serialize_record(_1) }.join(RECORD_SEPARATOR)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Serialize one record to text. A serializer always returns a String: a Symbol names a method on
|
|
68
|
+
# the record, a +Proc+ is called with the record, and the default is pretty-printed JSON.
|
|
69
|
+
#
|
|
70
|
+
# @param record [Object] the record to serialize
|
|
71
|
+
# @return [String] the record's text representation
|
|
72
|
+
def serialize_record(record)
|
|
73
|
+
case @serialize
|
|
74
|
+
when Symbol then record.public_send(@serialize)
|
|
75
|
+
when Proc then @serialize.call(record)
|
|
76
|
+
else JSON.pretty_generate(record.as_json)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# General-purpose resolver that runs a block and contributes its returned String as one context
|
|
5
|
+
# message — the computed-text sibling of +LoadFileResolver+ (text from a file) and +LoadRecordResolver+ (text from
|
|
6
|
+
# records). The block receives the resolver context, so the text can be derived from the persona or
|
|
7
|
+
# the inbound message. The canonical use is a persona's system prompt held in a column:
|
|
8
|
+
#
|
|
9
|
+
# resolvers do |chain|
|
|
10
|
+
# chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# +role+ chooses how the text enters the conversation (+:system+ for a prompt, +:user+/+:assistant+
|
|
14
|
+
# to seed turns) and defaults to +:system+. A +nil+ or blank result contributes nothing (returns
|
|
15
|
+
# +nil+), so an unset column simply adds no message.
|
|
16
|
+
class LoadTextResolver < Protege::Resolver
|
|
17
|
+
# @param role [Symbol] role for the emitted message; defaults to +:system+
|
|
18
|
+
# @yieldparam context [Protege::Orchestrator::Context] the per-delivery context
|
|
19
|
+
# @yieldreturn [String, nil] the text to contribute
|
|
20
|
+
# @raise [ArgumentError] when no block is given
|
|
21
|
+
def initialize(role: :system, &block)
|
|
22
|
+
super()
|
|
23
|
+
raise ArgumentError, 'LoadTextResolver requires a block returning text' unless block
|
|
24
|
+
|
|
25
|
+
@role = role
|
|
26
|
+
@block = block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Run the block and emit its text as a single message, or +nil+ when the result is blank.
|
|
30
|
+
#
|
|
31
|
+
# @param context [Protege::Orchestrator::Context] the per-delivery context
|
|
32
|
+
# @return [Protege::ModelMessage, nil] one message, or +nil+ when blank
|
|
33
|
+
def resolve(context:)
|
|
34
|
+
content = @block.call(context).to_s.strip
|
|
35
|
+
return nil if content.empty?
|
|
36
|
+
|
|
37
|
+
message(role: @role, content:)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in resolver that replays a thread's prior messages so the agent has conversational
|
|
5
|
+
# memory across exchanges. For each persisted message in the current message's thread it
|
|
6
|
+
# contributes an ordered +ModelMessage+ — inbound mapped to +:user+, outbound to
|
|
7
|
+
# +:assistant+ — with the current inbound message excluded (it arrives as the final +:user+
|
|
8
|
+
# message through the normal harness flow).
|
|
9
|
+
#
|
|
10
|
+
# Each turn is the message's +Protege::Message#to_llm_text+: a standard header block (From / To / Cc /
|
|
11
|
+
# Date / Subject, always rendered) followed by the body — plain text, or the tag-stripped HTML
|
|
12
|
+
# fallback — and, when the message carries attachments, the note the app builds listing each (filename,
|
|
13
|
+
# content type, size, and the attachment id the +read_attachment+ / +send_email+ tools accept). So the
|
|
14
|
+
# agent knows who wrote each turn and when, not just what was said, and an attachment-only message
|
|
15
|
+
# still contributes. A message with neither a body nor attachments is skipped.
|
|
16
|
+
#
|
|
17
|
+
# It also replays the agent's **tool use** between turns: the +ToolUse+ rows recorded during each
|
|
18
|
+
# prior run are woven back in right after the inbound message that triggered them, as the same
|
|
19
|
+
# structured messages a live run builds — one +:assistant+ message carrying the round's +tool_calls+,
|
|
20
|
+
# followed by one +:tool+ message per result. Without this the agent would forget, on the next turn,
|
|
21
|
+
# what it searched, fetched, or read earlier. Each replayed result is truncated to
|
|
22
|
+
# +REPLAY_RESULT_LIMIT+ so past tool output can't dominate the context. Pass +include_tool_calls:
|
|
23
|
+
# false+ to replay only the plain message turns.
|
|
24
|
+
#
|
|
25
|
+
# Returns +nil+ when the thread has no prior content.
|
|
26
|
+
#
|
|
27
|
+
# By default it replays only the most recent +DEFAULT_LIMIT+ prior messages (still in chronological
|
|
28
|
+
# order) — a bounded verbatim window that pairs well with a running summary recapping everything
|
|
29
|
+
# older, and a guard against unbounded context on long threads. Pass +limit:+ to change the window, or
|
|
30
|
+
# +limit: nil+ to replay the entire thread.
|
|
31
|
+
#
|
|
32
|
+
# resolvers { |chain| chain.use Protege::ThreadHistoryResolver } # most recent DEFAULT_LIMIT
|
|
33
|
+
# resolvers { |chain| chain.use Protege::ThreadHistoryResolver, limit: 10 } # most recent 10
|
|
34
|
+
# resolvers { |chain| chain.use Protege::ThreadHistoryResolver, limit: nil } # the entire thread
|
|
35
|
+
class ThreadHistoryResolver < Protege::Resolver
|
|
36
|
+
# Most-recent prior messages replayed when no +limit:+ is given.
|
|
37
|
+
DEFAULT_LIMIT = 50
|
|
38
|
+
|
|
39
|
+
# Maximum characters of each prior tool result replayed into history.
|
|
40
|
+
REPLAY_RESULT_LIMIT = 4_000
|
|
41
|
+
|
|
42
|
+
# @param limit [Integer, nil] keep only the most recent N prior messages; +nil+ keeps all
|
|
43
|
+
# @param include_tool_calls [Boolean] weave prior tool calls/results back into the history
|
|
44
|
+
def initialize(limit: DEFAULT_LIMIT, include_tool_calls: true)
|
|
45
|
+
super()
|
|
46
|
+
@limit = limit
|
|
47
|
+
@include_tool_calls = include_tool_calls
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build the thread's prior messages — and the tool use between them — as ordered provider messages.
|
|
51
|
+
#
|
|
52
|
+
# @param context [Protege::Orchestrator::Context] exposes the current +message+
|
|
53
|
+
# @return [Array<Protege::ModelMessage>, nil] ordered history, or +nil+ when empty
|
|
54
|
+
def resolve(context:)
|
|
55
|
+
thread = context.message.email_thread
|
|
56
|
+
return nil unless thread
|
|
57
|
+
|
|
58
|
+
prior = thread.messages
|
|
59
|
+
.with_attached_attachments
|
|
60
|
+
.where.not(message_id: context.message.message_id)
|
|
61
|
+
.order(:sent_at)
|
|
62
|
+
prior = prior.includes(:tool_uses) if @include_tool_calls # eager-load each turn's tool history
|
|
63
|
+
prior = prior.last(@limit) if @limit # most recent N, still ascending by sent_at
|
|
64
|
+
|
|
65
|
+
prior.flat_map { |message| [history_message(message), *tool_turns(message)] }
|
|
66
|
+
.compact.presence
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Rebuild a message's tool activity as structured provider messages: per tool-calling round, one
|
|
72
|
+
# +:assistant+ message carrying the round's calls, then one +:tool+ message per (truncated) result.
|
|
73
|
+
# Reads the eager-loaded +tool_uses+ association and orders it in memory, so no extra query runs.
|
|
74
|
+
#
|
|
75
|
+
# @param message [Protege::Message] the message whose recorded tool use is replayed
|
|
76
|
+
# @return [Array<Protege::ModelMessage>] the reconstructed turns (empty when none)
|
|
77
|
+
def tool_turns(message)
|
|
78
|
+
return [] unless @include_tool_calls
|
|
79
|
+
|
|
80
|
+
message.tool_uses
|
|
81
|
+
.sort_by { |use| [use.run_id.to_s, use.turn_index, use.position] }
|
|
82
|
+
.group_by { |use| [use.run_id, use.turn_index] }
|
|
83
|
+
.flat_map { |_round, rows| round_messages(rows) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Rebuild one tool-calling round: the +:assistant+ message carrying its calls, then one +:tool+
|
|
87
|
+
# message per (truncated) result.
|
|
88
|
+
#
|
|
89
|
+
# @param rows [Array<Protege::ToolUse>] the round's tool uses, in call order
|
|
90
|
+
# @return [Array<Protege::ModelMessage>] the assistant turn followed by its results
|
|
91
|
+
def round_messages(rows)
|
|
92
|
+
assistant = message(role: :assistant, content: '', tool_calls: rows.map(&:to_provider_tool_call))
|
|
93
|
+
|
|
94
|
+
results = rows.map do |row|
|
|
95
|
+
message(role: :tool, tool_call_id: row.tool_call_id, content: row.replay_result(limit: REPLAY_RESULT_LIMIT))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[assistant, *results]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Map one stored message to a provider message, or +nil+ when it carries no usable content.
|
|
102
|
+
#
|
|
103
|
+
# The turn's content is the message's {Protege::Message#to_llm_text} — the standard header
|
|
104
|
+
# (sender/recipients/subject/timestamp) plus body and any attachment note — so a replayed turn is
|
|
105
|
+
# self-describing. Skips a message with neither a body nor attachments (nothing worth replaying);
|
|
106
|
+
# an attachment-only turn still contributes.
|
|
107
|
+
#
|
|
108
|
+
# @param record [Protege::Message] the stored message
|
|
109
|
+
# @return [Protege::ModelMessage, nil]
|
|
110
|
+
def history_message(record)
|
|
111
|
+
return nil if record.readable_body.blank? && !record.attachments.attached?
|
|
112
|
+
|
|
113
|
+
message(role: record.inbound? ? :user : :assistant, content: record.to_llm_text)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|