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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +181 -2
  3. data/app/assets/stylesheets/envoy/chat.css +209 -0
  4. data/app/assets/stylesheets/envoy/console.css +50 -176
  5. data/app/controllers/envoy/conversations_controller.rb +1 -4
  6. data/app/controllers/envoy/panels_controller.rb +30 -0
  7. data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
  8. data/app/jobs/envoy/run_job.rb +25 -4
  9. data/app/models/envoy/conversation.rb +33 -6
  10. data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
  11. data/app/views/envoy/conversations/index.html.erb +0 -2
  12. data/app/views/envoy/conversations/new.html.erb +1 -7
  13. data/app/views/envoy/conversations/show.html.erb +1 -17
  14. data/app/views/envoy/messages/_error.html.erb +6 -0
  15. data/app/views/envoy/messages/_streaming.html.erb +1 -1
  16. data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
  17. data/app/views/envoy/panels/_panel.html.erb +3 -0
  18. data/app/views/envoy/panels/_transcript.html.erb +21 -0
  19. data/config/routes.rb +1 -1
  20. data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
  21. data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
  22. data/lib/envoy/engine.rb +6 -0
  23. data/lib/envoy/errors.rb +4 -0
  24. data/lib/envoy/guard.rb +13 -1
  25. data/lib/envoy/library.rb +82 -0
  26. data/lib/envoy/llm.rb +1 -1
  27. data/lib/envoy/page_surface.rb +34 -0
  28. data/lib/envoy/prompt.rb +55 -0
  29. data/lib/envoy/runner.rb +17 -3
  30. data/lib/envoy/surface.rb +90 -0
  31. data/lib/envoy/tool_definition.rb +12 -2
  32. data/lib/envoy/version.rb +1 -1
  33. data/lib/envoy.rb +59 -0
  34. metadata +12 -12
  35. data/app/controllers/envoy/system_prompts_controller.rb +0 -52
  36. data/app/models/envoy/system_prompt.rb +0 -24
  37. data/app/models/envoy/system_prompt_version.rb +0 -14
  38. data/app/views/envoy/system_prompts/_form.html.erb +0 -14
  39. data/app/views/envoy/system_prompts/edit.html.erb +0 -2
  40. data/app/views/envoy/system_prompts/index.html.erb +0 -14
  41. data/app/views/envoy/system_prompts/new.html.erb +0 -2
  42. data/app/views/envoy/system_prompts/show.html.erb +0 -15
  43. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
  44. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
  45. 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
- // Wraps the message region. Keeps the window scrolled to the newest content as
4
- // it streams in. If the user scrolls away from the bottom, autoscroll pauses;
5
- // it resumes when they scroll back to the bottom or send a new message.
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
- connect() {
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
- // A new message re-enables autoscroll for that response.
25
- this.onSubmit = () => { this.stick = true; this.toBottom() }
26
- document.addEventListener("turbo:submit-end", this.onSubmit)
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
- atBottom() {
39
- const doc = document.documentElement
40
- return window.innerHeight + window.scrollY >= doc.scrollHeight - 48
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
- toBottom() {
44
- window.scrollTo({ top: document.documentElement.scrollHeight })
37
+ get scroller() {
38
+ return this.hasLogTarget ? this.logTarget : this.element
45
39
  }
46
40
  }
@@ -1,6 +1,12 @@
1
1
  module Envoy
2
2
  class RunJob < ApplicationJob
3
- STREAM_TARGET = "envoy_streaming_reply"
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: STREAM_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: STREAM_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, :toolset_key, presence: true
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
- # Version body (if pinned) is primary; custom text (if any) is appended as
15
- # chat-specific context. Either stands alone; both compose; neither -> nil.
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
- [ system_prompt_version&.body, system_prompt ].compact_blank.join("\n\n").presence
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: "envoy_composer_input", rows: 2, autofocus: true,
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) — appended to the selected saved prompt, or used alone as a custom prompt",
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,4 +1,4 @@
1
- <div id="envoy_streaming_reply">
1
+ <div id="<%= Envoy::RunJob.stream_target(conversation) %>">
2
2
  <div class="envoy-msg envoy-msg--assistant">
3
3
  <div class="envoy-msg__role">assistant</div>
4
4
  <div class="envoy-md"><%= Envoy::Markdown.render(content) %></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 "envoy_messages" do %>
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 "envoy_messages" do %>
14
- <div id="envoy_streaming_reply">
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 "envoy_composer_input" do %>
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,3 @@
1
+ <%= turbo_frame_tag "envoy_panel" do %>
2
+ <%= render "envoy/panels/transcript", conversation: conversation %>
3
+ <% 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
@@ -1,7 +1,7 @@
1
1
  Envoy::Engine.routes.draw do
2
+ resource :panel, only: :create
2
3
  resources :conversations, only: %i[index new create show] do
3
4
  resources :messages, only: :create
4
5
  end
5
- resources :system_prompts, except: :destroy
6
6
  root to: "conversations#index"
7
7
  end
@@ -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
@@ -3,4 +3,8 @@ module Envoy
3
3
  class Forbidden < Error; end
4
4
  class UnknownToolset < Error; end
5
5
  class ToolsetCycle < Error; end
6
+ class UnknownPrompt < Error; end
7
+ class PromptCycle < Error; end
8
+ class UnknownSurface < Error; end
9
+ class UnknownLibrary < Error; end
6
10
  end
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
- value = definition.perform_block.call(actor: actor, **args.symbolize_keys)
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.model_id,
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
@@ -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