envoy_ai 0.0.1 → 0.0.2
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 +4 -4
- data/README.md +181 -2
- data/app/assets/stylesheets/envoy/chat.css +209 -0
- data/app/assets/stylesheets/envoy/console.css +50 -176
- data/app/controllers/envoy/conversations_controller.rb +1 -4
- data/app/controllers/envoy/panels_controller.rb +30 -0
- data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
- data/app/jobs/envoy/run_job.rb +25 -4
- data/app/models/envoy/conversation.rb +33 -6
- data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
- data/app/views/envoy/conversations/index.html.erb +0 -2
- data/app/views/envoy/conversations/new.html.erb +1 -7
- data/app/views/envoy/conversations/show.html.erb +1 -17
- data/app/views/envoy/messages/_error.html.erb +6 -0
- data/app/views/envoy/messages/_streaming.html.erb +1 -1
- data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
- data/app/views/envoy/panels/_panel.html.erb +3 -0
- data/app/views/envoy/panels/_transcript.html.erb +21 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
- data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
- data/lib/envoy/engine.rb +6 -0
- data/lib/envoy/errors.rb +4 -0
- data/lib/envoy/guard.rb +13 -1
- data/lib/envoy/library.rb +82 -0
- data/lib/envoy/llm.rb +1 -1
- data/lib/envoy/page_surface.rb +34 -0
- data/lib/envoy/prompt.rb +55 -0
- data/lib/envoy/runner.rb +17 -3
- data/lib/envoy/surface.rb +90 -0
- data/lib/envoy/tool_definition.rb +12 -2
- data/lib/envoy/version.rb +1 -1
- data/lib/envoy.rb +59 -0
- metadata +12 -12
- data/app/controllers/envoy/system_prompts_controller.rb +0 -52
- data/app/models/envoy/system_prompt.rb +0 -24
- data/app/models/envoy/system_prompt_version.rb +0 -14
- data/app/views/envoy/system_prompts/_form.html.erb +0 -14
- data/app/views/envoy/system_prompts/edit.html.erb +0 -2
- data/app/views/envoy/system_prompts/index.html.erb +0 -14
- data/app/views/envoy/system_prompts/new.html.erb +0 -2
- data/app/views/envoy/system_prompts/show.html.erb +0 -15
- data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
- data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
- data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +0 -6
|
@@ -13,9 +13,6 @@ module Envoy
|
|
|
13
13
|
@conversation.actor = envoy_current_actor
|
|
14
14
|
@conversation.model_id ||= Envoy.config.default_model
|
|
15
15
|
@conversation.provider = Envoy.config.provider.to_s
|
|
16
|
-
if (pid = params.dig(:conversation, :system_prompt_id)).present?
|
|
17
|
-
@conversation.system_prompt_version = Envoy::SystemPrompt.find(pid).latest_version
|
|
18
|
-
end
|
|
19
16
|
if @conversation.save
|
|
20
17
|
redirect_to conversation_path(@conversation)
|
|
21
18
|
else
|
|
@@ -30,7 +27,7 @@ module Envoy
|
|
|
30
27
|
private
|
|
31
28
|
|
|
32
29
|
def conversation_params
|
|
33
|
-
params.require(:conversation).permit(:title, :model_id, :toolset_key, :system_prompt)
|
|
30
|
+
params.require(:conversation).permit(:title, :model_id, :toolset_key, :system_prompt, :prompt_key)
|
|
34
31
|
end
|
|
35
32
|
end
|
|
36
33
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
class PanelsController < ApplicationController
|
|
3
|
+
# POST, not GET behind a lazy frame: this creates a record. The semantics
|
|
4
|
+
# genuinely are "create or resume", so one path serves open and reopen and a
|
|
5
|
+
# reopen always re-fetches a current transcript.
|
|
6
|
+
def create
|
|
7
|
+
surface = Envoy.surface(params.require(:surface))
|
|
8
|
+
conversation = find_or_resume(surface)
|
|
9
|
+
render partial: "envoy/panels/panel", locals: { conversation: conversation }
|
|
10
|
+
rescue Envoy::UnknownSurface
|
|
11
|
+
# surface and context_key arrive from the browser; only registered
|
|
12
|
+
# surfaces are addressable. Whether this ACTOR may see this SUBJECT is the
|
|
13
|
+
# surface's context block to enforce.
|
|
14
|
+
head :not_found
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def find_or_resume(surface)
|
|
20
|
+
Conversation.find_or_create_by!(
|
|
21
|
+
actor: envoy_current_actor,
|
|
22
|
+
surface_key: surface.key,
|
|
23
|
+
context_key: params[:context_key].presence
|
|
24
|
+
) do |conversation|
|
|
25
|
+
conversation.provider = Envoy.config.provider.to_s
|
|
26
|
+
conversation.model_id = surface.model_id || Envoy.config.default_model
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,46 +1,40 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Sticks the transcript to the bottom as messages stream in, unless the reader
|
|
4
|
+
// has scrolled up to read back.
|
|
5
|
+
//
|
|
6
|
+
// Scrolls its own log container rather than the window: in an embedded panel
|
|
7
|
+
// the window is the host page, so scrolling it jumps the page the user is
|
|
8
|
+
// reading AND never moves the panel.
|
|
6
9
|
export default class extends Controller {
|
|
7
|
-
|
|
8
|
-
this.stick = true
|
|
9
|
-
|
|
10
|
-
// Content changes (appends + streaming replaces) trigger a scroll-to-bottom.
|
|
11
|
-
this.observer = new MutationObserver(() => {
|
|
12
|
-
if (this.stick) this.toBottom()
|
|
13
|
-
})
|
|
14
|
-
this.observer.observe(this.element, {
|
|
15
|
-
childList: true, subtree: true, characterData: true
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
// wheel/touch are unambiguously user-initiated (our own scrollTo doesn't
|
|
19
|
-
// fire them), so use them to decide whether to keep sticking to the bottom.
|
|
20
|
-
this.onUserScroll = () => { this.stick = this.atBottom() }
|
|
21
|
-
window.addEventListener("wheel", this.onUserScroll, { passive: true })
|
|
22
|
-
window.addEventListener("touchmove", this.onUserScroll, { passive: true })
|
|
10
|
+
static targets = ["log"]
|
|
23
11
|
|
|
24
|
-
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this.toBottom()
|
|
12
|
+
connect() {
|
|
13
|
+
this.observer = new MutationObserver(() => this.stick())
|
|
14
|
+
this.observer.observe(this.scroller, { childList: true, subtree: true, characterData: true })
|
|
15
|
+
this.scrollToBottom()
|
|
29
16
|
}
|
|
30
17
|
|
|
31
18
|
disconnect() {
|
|
32
19
|
this.observer?.disconnect()
|
|
33
|
-
window.removeEventListener("wheel", this.onUserScroll)
|
|
34
|
-
window.removeEventListener("touchmove", this.onUserScroll)
|
|
35
|
-
document.removeEventListener("turbo:submit-end", this.onSubmit)
|
|
36
20
|
}
|
|
37
21
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
stick() {
|
|
23
|
+
if (this.atBottom) this.scrollToBottom()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
scrollToBottom() {
|
|
27
|
+
this.scroller.scrollTop = this.scroller.scrollHeight
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A tolerance, because a streaming reply grows under the reader; without it
|
|
31
|
+
// one stray pixel would strand them mid-transcript for the rest of the turn.
|
|
32
|
+
get atBottom() {
|
|
33
|
+
const { scrollTop, clientHeight, scrollHeight } = this.scroller
|
|
34
|
+
return scrollHeight - (scrollTop + clientHeight) < 48
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
get scroller() {
|
|
38
|
+
return this.hasLogTarget ? this.logTarget : this.element
|
|
45
39
|
}
|
|
46
40
|
}
|
data/app/jobs/envoy/run_job.rb
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
module Envoy
|
|
2
2
|
class RunJob < ApplicationJob
|
|
3
|
-
|
|
3
|
+
extend ActionView::RecordIdentifier
|
|
4
|
+
|
|
5
|
+
# Scoped per conversation: a flat constant meant two panels — or a panel and
|
|
6
|
+
# a console tab — would fight over the same target on one page.
|
|
7
|
+
def self.stream_target(conversation)
|
|
8
|
+
dom_id(conversation, :streaming)
|
|
9
|
+
end
|
|
4
10
|
|
|
5
11
|
# GlobalID serialization of the Conversation (and its polymorphic actor)
|
|
6
12
|
# is handled by ActiveJob automatically.
|
|
@@ -14,6 +20,12 @@ module Envoy
|
|
|
14
20
|
stream(conversation, event)
|
|
15
21
|
end
|
|
16
22
|
finalize(conversation)
|
|
23
|
+
rescue StandardError
|
|
24
|
+
# Without this the panel sits on "working…" forever: nothing else ever
|
|
25
|
+
# replaces the streaming target. Re-raise so the queue still records the
|
|
26
|
+
# failure and any retry policy applies.
|
|
27
|
+
broadcast_error(conversation)
|
|
28
|
+
raise
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
private
|
|
@@ -28,9 +40,9 @@ module Envoy
|
|
|
28
40
|
@streamed << payload.to_s
|
|
29
41
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
30
42
|
conversation, "messages",
|
|
31
|
-
target:
|
|
43
|
+
target: self.class.stream_target(conversation),
|
|
32
44
|
partial: "envoy/messages/streaming",
|
|
33
|
-
locals: { content: @streamed }
|
|
45
|
+
locals: { content: @streamed, conversation: conversation }
|
|
34
46
|
)
|
|
35
47
|
end
|
|
36
48
|
|
|
@@ -44,7 +56,7 @@ module Envoy
|
|
|
44
56
|
|
|
45
57
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
46
58
|
conversation, "messages",
|
|
47
|
-
target:
|
|
59
|
+
target: self.class.stream_target(conversation),
|
|
48
60
|
partial: "envoy/messages/message",
|
|
49
61
|
collection: messages,
|
|
50
62
|
as: :message
|
|
@@ -61,5 +73,14 @@ module Envoy
|
|
|
61
73
|
scope = scope.where("id > ?", last_user_id) if last_user_id
|
|
62
74
|
scope.to_a
|
|
63
75
|
end
|
|
76
|
+
|
|
77
|
+
def broadcast_error(conversation)
|
|
78
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
79
|
+
conversation, "messages",
|
|
80
|
+
target: self.class.stream_target(conversation),
|
|
81
|
+
partial: "envoy/messages/error",
|
|
82
|
+
locals: { conversation: conversation }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
64
85
|
end
|
|
65
86
|
end
|
|
@@ -3,18 +3,45 @@ module Envoy
|
|
|
3
3
|
acts_as_chat message_class: "Envoy::Message", tool_call_class: "Envoy::ToolCall"
|
|
4
4
|
|
|
5
5
|
belongs_to :actor, polymorphic: true
|
|
6
|
-
belongs_to :system_prompt_version, class_name: "Envoy::SystemPromptVersion", optional: true
|
|
7
6
|
|
|
8
|
-
validates :model_id,
|
|
7
|
+
validates :model_id, presence: true
|
|
8
|
+
validate :toolset_or_surface_present
|
|
9
|
+
|
|
10
|
+
# Embed conversations resolve everything through their surface, so editing a
|
|
11
|
+
# surface reaches chats that already exist. Console conversations have no
|
|
12
|
+
# surface and fall back to their own columns.
|
|
13
|
+
def surface
|
|
14
|
+
surface_key.present? ? Envoy.surface(surface_key) : nil
|
|
15
|
+
end
|
|
9
16
|
|
|
10
17
|
def toolset
|
|
11
|
-
Envoy.toolset(toolset_key)
|
|
18
|
+
Envoy.toolset(surface&.toolset_key || toolset_key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolved_model_id
|
|
22
|
+
surface&.model_id || model_id || Envoy.config.default_model
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prompt_body
|
|
26
|
+
return surface.prompt_body if surface
|
|
27
|
+
prompt_key.present? ? Envoy.prompt(prompt_key).full_body : nil
|
|
12
28
|
end
|
|
13
29
|
|
|
14
|
-
#
|
|
15
|
-
|
|
30
|
+
# A surface may pin a chat read-only; so may the conversation's own status.
|
|
31
|
+
def read_only?
|
|
32
|
+
status == "read_only" || !!surface&.read_only?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Per-conversation ad-hoc instructions, appended last by Runner.
|
|
16
36
|
def effective_system_prompt
|
|
17
|
-
|
|
37
|
+
system_prompt.presence
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def toolset_or_surface_present
|
|
43
|
+
return if toolset_key.present? || surface_key.present?
|
|
44
|
+
errors.add(:base, "toolset_key or surface_key must be present")
|
|
18
45
|
end
|
|
19
46
|
end
|
|
20
47
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<%= text_area_tag "message[content]", nil,
|
|
2
|
-
id:
|
|
2
|
+
id: dom_id(conversation, :composer), rows: 2, autofocus: true,
|
|
3
3
|
class: "envoy-input",
|
|
4
4
|
data: { controller: "envoy-composer", action: "keydown.enter->envoy-composer#submit" } %>
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<div class="envoy-header-row envoy-mb-4">
|
|
2
2
|
<h1 class="envoy-title">Envoy chats</h1>
|
|
3
3
|
<div class="envoy-btn-group">
|
|
4
|
-
<%= link_to "System prompts", system_prompts_path,
|
|
5
|
-
class: "envoy-btn envoy-btn--secondary" %>
|
|
6
4
|
<%= link_to "New chat", new_conversation_path,
|
|
7
5
|
class: "envoy-btn" %>
|
|
8
6
|
</div>
|
|
@@ -5,14 +5,8 @@
|
|
|
5
5
|
<%= f.text_field :title, placeholder: "Title (optional)", class: field %>
|
|
6
6
|
<%= f.select :toolset_key, Envoy.toolsets.keys, {}, class: field %>
|
|
7
7
|
<%= f.select :model_id, Envoy.config.available_models, {}, class: field %>
|
|
8
|
-
<div>
|
|
9
|
-
<label class="envoy-label">Saved system prompt</label>
|
|
10
|
-
<%= select_tag "conversation[system_prompt_id]",
|
|
11
|
-
options_for_select([["None", ""]] + Envoy::SystemPrompt.order(:name).map { |p| [p.name, p.id] }),
|
|
12
|
-
class: field %>
|
|
13
|
-
</div>
|
|
14
8
|
<%= f.text_area :system_prompt, rows: 4,
|
|
15
|
-
placeholder: "Additional instructions (optional)
|
|
9
|
+
placeholder: "Additional instructions (optional)",
|
|
16
10
|
class: field %>
|
|
17
11
|
<%= f.submit "Start", class: "envoy-btn" %>
|
|
18
12
|
</div>
|
|
@@ -1,18 +1,2 @@
|
|
|
1
|
-
<%= turbo_stream_from @conversation, "messages" %>
|
|
2
1
|
<h1 class="envoy-title--sm envoy-mb-3"><%= @conversation.title.presence || "Chat ##{@conversation.id}" %></h1>
|
|
3
|
-
|
|
4
|
-
<div data-controller="envoy-autoscroll">
|
|
5
|
-
<div id="envoy_messages" class="envoy-stack envoy-mb-4">
|
|
6
|
-
<% @conversation.messages.where(role: %w[user assistant]).each do |message| %>
|
|
7
|
-
<%= render "envoy/messages/message", message: message %>
|
|
8
|
-
<% end %>
|
|
9
|
-
</div>
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<%= form_with url: conversation_messages_path(@conversation), method: :post do |f| %>
|
|
13
|
-
<div class="envoy-composer">
|
|
14
|
-
<%= render "envoy/conversations/composer_input" %>
|
|
15
|
-
<%= f.submit "Send", class: "envoy-btn" %>
|
|
16
|
-
</div>
|
|
17
|
-
<p class="envoy-hint">Enter to send · Shift/Option+Enter for a new line</p>
|
|
18
|
-
<% end %>
|
|
2
|
+
<%= render "envoy/panels/transcript", conversation: @conversation %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div id="<%= Envoy::RunJob.stream_target(conversation) %>">
|
|
2
|
+
<div class="envoy-msg envoy-msg--assistant envoy-error">
|
|
3
|
+
<div class="envoy-msg__role">assistant</div>
|
|
4
|
+
<div class="envoy-md">Something went wrong on my side — your message was not answered. Try again.</div>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%# Append the user message, then a fresh streaming target — both into the ordered
|
|
2
2
|
message list so the assistant reply commits in place and the next turn appends
|
|
3
3
|
after it (fixes cross-turn ordering). %>
|
|
4
|
-
<%= turbo_stream.append
|
|
4
|
+
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
5
5
|
<div class="envoy-msg">
|
|
6
6
|
<div class="envoy-msg__role">user</div>
|
|
7
7
|
<div class="envoy-md"><%= Envoy::Markdown.render(@user_content) %></div>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<%# The streaming target starts with a subtle "working…" indicator so there is a
|
|
11
11
|
live sign of activity before the first token (e.g. during tool calls, which
|
|
12
12
|
emit no text deltas). The first delta / finalize replaces this. %>
|
|
13
|
-
<%= turbo_stream.append
|
|
14
|
-
<div id="
|
|
13
|
+
<%= turbo_stream.append dom_id(@conversation, :messages) do %>
|
|
14
|
+
<div id="<%= Envoy::RunJob.stream_target(@conversation) %>">
|
|
15
15
|
<div class="envoy-msg envoy-msg--assistant">
|
|
16
16
|
<div class="envoy-msg__role">assistant</div>
|
|
17
17
|
<div class="envoy-working envoy-muted">working…</div>
|
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
<% end %>
|
|
21
21
|
<%# Clear the composer by replacing it with a fresh, empty textarea (server-driven,
|
|
22
22
|
so it works even if the Stimulus controller didn't load). %>
|
|
23
|
-
<%= turbo_stream.replace
|
|
24
|
-
<%= render "envoy/conversations/composer_input" %>
|
|
23
|
+
<%= turbo_stream.replace dom_id(@conversation, :composer) do %>
|
|
24
|
+
<%= render "envoy/conversations/composer_input", conversation: @conversation %>
|
|
25
25
|
<% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# The single definition of a chat transcript, used by both the embedded panel
|
|
2
|
+
and the full-page console. Ids are conversation-scoped so more than one may
|
|
3
|
+
live on a page. Styling is CSS-var driven — the host owns the chrome. %>
|
|
4
|
+
<%= turbo_stream_from conversation, "messages" %>
|
|
5
|
+
|
|
6
|
+
<div class="envoy-chat" data-controller="envoy-autoscroll">
|
|
7
|
+
<div id="<%= dom_id(conversation, :messages) %>" class="envoy-stack envoy-chat__log"
|
|
8
|
+
data-envoy-autoscroll-target="log">
|
|
9
|
+
<% conversation.messages.where(role: %w[user assistant]).each do |message| %>
|
|
10
|
+
<%= render "envoy/messages/message", message: message %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<%= form_with url: conversation_messages_path(conversation), method: :post do |f| %>
|
|
15
|
+
<div class="envoy-composer">
|
|
16
|
+
<%= render "envoy/conversations/composer_input", conversation: conversation %>
|
|
17
|
+
<%= f.submit "Send", class: "envoy-btn" %>
|
|
18
|
+
</div>
|
|
19
|
+
<p class="envoy-hint">Enter to send · Shift/Option+Enter for a new line</p>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class DropEnvoySystemPrompts < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# The DB-backed prompt store never held a row and its controller was the one
|
|
4
|
+
# unscoped surface in the engine. Prompts are program behaviour and now live
|
|
5
|
+
# in the DSL registry, versioned by git.
|
|
6
|
+
remove_reference :envoy_conversations, :system_prompt_version, foreign_key: false, index: true
|
|
7
|
+
add_column :envoy_conversations, :prompt_key, :string
|
|
8
|
+
|
|
9
|
+
drop_table :envoy_system_prompt_versions
|
|
10
|
+
drop_table :envoy_system_prompts
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class AddSurfaceToEnvoyConversations < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :envoy_conversations, :surface_key, :string
|
|
4
|
+
add_column :envoy_conversations, :context_key, :string
|
|
5
|
+
change_column_null :envoy_conversations, :toolset_key, true
|
|
6
|
+
|
|
7
|
+
# Resumption is a uniqueness rule, so let the database hold it rather than a
|
|
8
|
+
# racy find_or_create_by. Partial: console conversations have no surface and
|
|
9
|
+
# must stay unconstrained.
|
|
10
|
+
add_index :envoy_conversations,
|
|
11
|
+
%i[actor_type actor_id surface_key context_key],
|
|
12
|
+
unique: true, where: "surface_key IS NOT NULL",
|
|
13
|
+
name: "index_envoy_conversations_on_surface_subject"
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/envoy/engine.rb
CHANGED
|
@@ -15,5 +15,11 @@ module Envoy
|
|
|
15
15
|
app.config.assets.paths << root.join("app/javascript") if app.config.respond_to?(:assets)
|
|
16
16
|
app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
initializer "envoy.page_surface" do
|
|
20
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
21
|
+
include Envoy::PageSurface
|
|
22
|
+
end
|
|
23
|
+
end
|
|
18
24
|
end
|
|
19
25
|
end
|
data/lib/envoy/errors.rb
CHANGED
data/lib/envoy/guard.rb
CHANGED
|
@@ -5,7 +5,9 @@ module Envoy
|
|
|
5
5
|
# Runs a tool definition's perform block, translating exceptions into
|
|
6
6
|
# model-legible results. Never raises for expected failures.
|
|
7
7
|
def run(definition, actor:, args:)
|
|
8
|
-
|
|
8
|
+
args = args.symbolize_keys
|
|
9
|
+
validate_enums!(definition, args)
|
|
10
|
+
value = definition.perform_block.call(actor: actor, **args)
|
|
9
11
|
if value.is_a?(Hash) && value[:error]
|
|
10
12
|
{ value: value, status: :failed }
|
|
11
13
|
else
|
|
@@ -24,5 +26,15 @@ module Envoy
|
|
|
24
26
|
def failure(type, message, status)
|
|
25
27
|
{ value: { error: message, type: type }, status: status }
|
|
26
28
|
end
|
|
29
|
+
|
|
30
|
+
# A closed vocabulary is a contract: reject an unknown value with the valid
|
|
31
|
+
# list rather than letting the perform block improvise on a hallucinated key.
|
|
32
|
+
def validate_enums!(definition, args)
|
|
33
|
+
args.each do |name, value|
|
|
34
|
+
allowed = definition.enum_for(name)
|
|
35
|
+
next if allowed.nil? || allowed.include?(value.to_s)
|
|
36
|
+
raise ArgumentError, "#{name} must be one of: #{allowed.join(', ')} (got #{value.inspect})"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
27
39
|
end
|
|
28
40
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
# A set of reference documents the model can pull on demand.
|
|
3
|
+
#
|
|
4
|
+
# Progressive disclosure: preloading every reference doc costs thousands of
|
|
5
|
+
# tokens on turns that never need them. A library puts a short index in the
|
|
6
|
+
# instructions and hands the model one tool to fetch a document when the task
|
|
7
|
+
# actually calls for it.
|
|
8
|
+
#
|
|
9
|
+
# Compiles to an ordinary Toolset, so it composes via `use`, merges its
|
|
10
|
+
# description into instructions, and honours read_only — all for free.
|
|
11
|
+
class Library
|
|
12
|
+
attr_reader :key
|
|
13
|
+
|
|
14
|
+
def initialize(key)
|
|
15
|
+
@key = key.to_s
|
|
16
|
+
@description = ""
|
|
17
|
+
@documents = {} # slug => { summary:, body: callable }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description(text = nil)
|
|
21
|
+
return rendered_description if text.nil?
|
|
22
|
+
@description = text
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register one document. The block is the lazy body.
|
|
26
|
+
def document(slug, summary = nil, &block)
|
|
27
|
+
@documents[slug.to_s] = { summary: summary, body: block }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Ingest a directory of markdown: README.md becomes the description, every
|
|
31
|
+
# other *.md becomes a document keyed by its slug. Flat, non-recursive.
|
|
32
|
+
#
|
|
33
|
+
# No markdown parsing: a README that documents its own directory already
|
|
34
|
+
# carries the human-written index, and the tool's enum carries the
|
|
35
|
+
# machine-readable key list. Explicit `document` calls win over files.
|
|
36
|
+
def directory(path)
|
|
37
|
+
path = Pathname(path)
|
|
38
|
+
readme = path.join("README.md")
|
|
39
|
+
@description = readme.read if readme.exist?
|
|
40
|
+
|
|
41
|
+
path.glob("*.md").sort.each do |file|
|
|
42
|
+
slug = file.basename(".md").to_s
|
|
43
|
+
next if slug.casecmp("README").zero?
|
|
44
|
+
@documents[slug] ||= { summary: nil, body: -> { file.read } }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def document_keys
|
|
49
|
+
@documents.keys.sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read(slug)
|
|
53
|
+
entry = @documents[slug.to_s]
|
|
54
|
+
raise ArgumentError, "no document #{slug.inspect}" unless entry
|
|
55
|
+
entry[:body].call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The description plus an index of any documents that carry their own
|
|
59
|
+
# summary. Directory-ingested docs are indexed by the README itself, so
|
|
60
|
+
# listing them again would just duplicate it.
|
|
61
|
+
def rendered_description
|
|
62
|
+
summarised = @documents.select { |_, entry| entry[:summary].present? }
|
|
63
|
+
return @description if summarised.empty?
|
|
64
|
+
|
|
65
|
+
index = summarised.map { |slug, entry| " #{slug} — #{entry[:summary]}" }
|
|
66
|
+
[ @description, "Also available:", *index ].compact_blank.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_toolset
|
|
70
|
+
library = self
|
|
71
|
+
toolset = Envoy::Toolset.new(key)
|
|
72
|
+
toolset.description(rendered_description)
|
|
73
|
+
toolset.tool("read_#{key}") do
|
|
74
|
+
description "Read one #{library.key} reference document in full."
|
|
75
|
+
access :read
|
|
76
|
+
param :key, "Which document to read.", enum: library.document_keys
|
|
77
|
+
perform { |actor:, key:| library.read(key) }
|
|
78
|
+
end
|
|
79
|
+
toolset
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/envoy/llm.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Envoy
|
|
|
11
11
|
# (lib/ruby_llm/active_record/chat_methods.rb and lib/ruby_llm/chat.rb).
|
|
12
12
|
def run(content:, tools:, instructions:)
|
|
13
13
|
chat = @conversation
|
|
14
|
-
.with_model(@conversation.
|
|
14
|
+
.with_model(@conversation.resolved_model_id,
|
|
15
15
|
provider: Envoy.config.provider,
|
|
16
16
|
assume_exists: true)
|
|
17
17
|
.with_instructions(instructions)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
# Declares which surface a page opens a chat against.
|
|
3
|
+
#
|
|
4
|
+
# class TodayController < ApplicationController
|
|
5
|
+
# envoy_surface :today, context_key: -> { "day:#{@date.to_fs(:iso8601)}" }
|
|
6
|
+
# end
|
|
7
|
+
#
|
|
8
|
+
# The context_key lambda is instance_exec'd in the controller, so instance
|
|
9
|
+
# variables set by the action are in scope. Pages that do not declare a
|
|
10
|
+
# surface get no bubble — opt-in by default.
|
|
11
|
+
module PageSurface
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :envoy_surface_config, instance_writer: false, default: nil
|
|
16
|
+
helper_method :envoy_page_surface
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class_methods do
|
|
20
|
+
def envoy_surface(key, context_key: nil)
|
|
21
|
+
self.envoy_surface_config = { key: key.to_s, context_key: context_key }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Nil when the controller never declared one — the layout renders nothing.
|
|
26
|
+
def envoy_page_surface
|
|
27
|
+
config = envoy_surface_config
|
|
28
|
+
return nil if config.nil?
|
|
29
|
+
|
|
30
|
+
{ surface: config[:key],
|
|
31
|
+
context_key: config[:context_key] && instance_exec(&config[:context_key]) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/envoy/prompt.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
# A named block of system-prompt text, composable like a Toolset.
|
|
3
|
+
#
|
|
4
|
+
# Prompts are code, not data: git is the version store. Bodies may be a String
|
|
5
|
+
# or a block; a block stays lazy so a host reading from disk picks up edits on
|
|
6
|
+
# `to_prepare` reload without a boot.
|
|
7
|
+
class Prompt
|
|
8
|
+
attr_reader :key
|
|
9
|
+
|
|
10
|
+
def initialize(key)
|
|
11
|
+
@key = key.to_s
|
|
12
|
+
@body = nil
|
|
13
|
+
@composed_keys = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Setter with text or a block; reader (no args) returns this prompt's own
|
|
17
|
+
# resolved body only. Use #full_body for the composed result.
|
|
18
|
+
def body(text = nil, &block)
|
|
19
|
+
return own_body if text.nil? && block.nil?
|
|
20
|
+
@body = block || text
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Compose other prompts into this one. Keys resolve lazily (at full_body
|
|
24
|
+
# time), so definition load order does not matter.
|
|
25
|
+
def use(*keys)
|
|
26
|
+
@composed_keys.concat(keys.map(&:to_s))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Composed bodies first, then this prompt's own — so shared framing (safety
|
|
30
|
+
# rules, draft status) precedes the specialisation that builds on it.
|
|
31
|
+
def full_body
|
|
32
|
+
collect_bodies.compact_blank.join("\n\n").presence
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def collect_bodies(seen = [])
|
|
38
|
+
guard_cycle!(seen)
|
|
39
|
+
bodies = @composed_keys.flat_map { |k| Envoy.prompt(k).collect_bodies(seen + [ key ]) }
|
|
40
|
+
bodies << own_body
|
|
41
|
+
bodies
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def own_body
|
|
47
|
+
@body.respond_to?(:call) ? @body.call : @body
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def guard_cycle!(seen)
|
|
51
|
+
return unless seen.include?(key)
|
|
52
|
+
raise Envoy::PromptCycle, "prompt composition cycle: #{(seen + [ key ]).join(' -> ')}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|