envoy_ai 0.0.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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +22 -0
  3. data/README.md +399 -0
  4. data/app/assets/stylesheets/envoy/console.css +371 -0
  5. data/app/controllers/envoy/application_controller.rb +18 -0
  6. data/app/controllers/envoy/conversations_controller.rb +36 -0
  7. data/app/controllers/envoy/messages_controller.rb +14 -0
  8. data/app/controllers/envoy/system_prompts_controller.rb +52 -0
  9. data/app/javascript/envoy/controllers/autoscroll_controller.js +46 -0
  10. data/app/javascript/envoy/controllers/composer_controller.js +30 -0
  11. data/app/jobs/envoy/application_job.rb +4 -0
  12. data/app/jobs/envoy/run_job.rb +65 -0
  13. data/app/models/envoy/application_record.rb +5 -0
  14. data/app/models/envoy/conversation.rb +20 -0
  15. data/app/models/envoy/message.rb +8 -0
  16. data/app/models/envoy/system_prompt.rb +24 -0
  17. data/app/models/envoy/system_prompt_version.rb +14 -0
  18. data/app/models/envoy/tool_call.rb +28 -0
  19. data/app/views/envoy/conversations/_composer_input.html.erb +4 -0
  20. data/app/views/envoy/conversations/index.html.erb +15 -0
  21. data/app/views/envoy/conversations/new.html.erb +19 -0
  22. data/app/views/envoy/conversations/show.html.erb +18 -0
  23. data/app/views/envoy/messages/_message.html.erb +7 -0
  24. data/app/views/envoy/messages/_streaming.html.erb +6 -0
  25. data/app/views/envoy/messages/_tool_call.html.erb +8 -0
  26. data/app/views/envoy/messages/create.turbo_stream.erb +25 -0
  27. data/app/views/envoy/system_prompts/_form.html.erb +14 -0
  28. data/app/views/envoy/system_prompts/edit.html.erb +2 -0
  29. data/app/views/envoy/system_prompts/index.html.erb +14 -0
  30. data/app/views/envoy/system_prompts/new.html.erb +2 -0
  31. data/app/views/envoy/system_prompts/show.html.erb +15 -0
  32. data/app/views/layouts/envoy/application.html.erb +20 -0
  33. data/config/routes.rb +7 -0
  34. data/db/migrate/20260710000001_create_envoy_conversations.rb +14 -0
  35. data/db/migrate/20260710000002_create_envoy_messages.rb +14 -0
  36. data/db/migrate/20260710000003_create_envoy_tool_calls.rb +14 -0
  37. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +10 -0
  38. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +13 -0
  39. data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +6 -0
  40. data/lib/envoy/badges.rb +22 -0
  41. data/lib/envoy/compiled_tool.rb +43 -0
  42. data/lib/envoy/configuration.rb +21 -0
  43. data/lib/envoy/engine.rb +19 -0
  44. data/lib/envoy/errors.rb +6 -0
  45. data/lib/envoy/guard.rb +28 -0
  46. data/lib/envoy/llm.rb +24 -0
  47. data/lib/envoy/markdown.rb +16 -0
  48. data/lib/envoy/runner.rb +44 -0
  49. data/lib/envoy/testing/fake_llm.rb +41 -0
  50. data/lib/envoy/tool_definition.rb +38 -0
  51. data/lib/envoy/toolset.rb +72 -0
  52. data/lib/envoy/version.rb +3 -0
  53. data/lib/envoy.rb +45 -0
  54. data/lib/envoy_ai.rb +3 -0
  55. metadata +138 -0
@@ -0,0 +1,24 @@
1
+ module Envoy
2
+ class SystemPrompt < ApplicationRecord
3
+ has_many :versions, -> { order(:version_number) },
4
+ class_name: "Envoy::SystemPromptVersion",
5
+ foreign_key: "system_prompt_id",
6
+ inverse_of: :system_prompt,
7
+ dependent: :destroy
8
+
9
+ validates :name, presence: true, uniqueness: true
10
+
11
+ def latest_version
12
+ versions.order(:version_number).last
13
+ end
14
+
15
+ def current_body
16
+ latest_version&.body
17
+ end
18
+
19
+ def add_version!(body)
20
+ next_number = (versions.maximum(:version_number) || 0) + 1
21
+ versions.create!(version_number: next_number, body: body)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ module Envoy
2
+ class SystemPromptVersion < ApplicationRecord
3
+ belongs_to :system_prompt, class_name: "Envoy::SystemPrompt", inverse_of: :versions
4
+
5
+ validates :version_number, presence: true, uniqueness: { scope: :system_prompt_id }
6
+ validates :body, presence: true
7
+
8
+ attr_readonly :body, :version_number, :system_prompt_id
9
+
10
+ before_update do
11
+ raise ActiveRecord::ReadOnlyRecord, "Envoy::SystemPromptVersion is immutable"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,28 @@
1
+ module Envoy
2
+ class ToolCall < ApplicationRecord
3
+ acts_as_tool_call message_class: "Envoy::Message"
4
+
5
+ # The tool-result message that answers this call (role "tool").
6
+ def result_message
7
+ Envoy::Message.find_by(tool_call_id: id)
8
+ end
9
+
10
+ # Derive a governance outcome from the persisted result, tolerant of how the
11
+ # result content was serialized. Returns :denied / :failed / :succeeded.
12
+ def outcome
13
+ payload = parsed_result
14
+ return :succeeded unless payload.is_a?(Hash) && payload["error"]
15
+ payload["type"] == "forbidden" ? :denied : :failed
16
+ end
17
+
18
+ private
19
+
20
+ def parsed_result
21
+ raw = result_message&.content
22
+ return nil if raw.blank?
23
+ JSON.parse(raw)
24
+ rescue JSON::ParserError
25
+ { "error" => raw }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,4 @@
1
+ <%= text_area_tag "message[content]", nil,
2
+ id: "envoy_composer_input", rows: 2, autofocus: true,
3
+ class: "envoy-input",
4
+ data: { controller: "envoy-composer", action: "keydown.enter->envoy-composer#submit" } %>
@@ -0,0 +1,15 @@
1
+ <div class="envoy-header-row envoy-mb-4">
2
+ <h1 class="envoy-title">Envoy chats</h1>
3
+ <div class="envoy-btn-group">
4
+ <%= link_to "System prompts", system_prompts_path,
5
+ class: "envoy-btn envoy-btn--secondary" %>
6
+ <%= link_to "New chat", new_conversation_path,
7
+ class: "envoy-btn" %>
8
+ </div>
9
+ </div>
10
+ <ul class="envoy-list">
11
+ <% @conversations.each do |c| %>
12
+ <li class="envoy-list__item"><%= link_to (c.title.presence || "Chat ##{c.id}"), conversation_path(c), class: "envoy-link" %>
13
+ <span class="envoy-muted">· <%= c.toolset_key %> · <%= c.model_id %></span></li>
14
+ <% end %>
15
+ </ul>
@@ -0,0 +1,19 @@
1
+ <% field = "envoy-field" %>
2
+ <h1 class="envoy-title envoy-mb-4">New chat</h1>
3
+ <%= form_with model: @conversation, url: conversations_path do |f| %>
4
+ <div class="envoy-form">
5
+ <%= f.text_field :title, placeholder: "Title (optional)", class: field %>
6
+ <%= f.select :toolset_key, Envoy.toolsets.keys, {}, class: field %>
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
+ <%= 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",
16
+ class: field %>
17
+ <%= f.submit "Start", class: "envoy-btn" %>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <%= turbo_stream_from @conversation, "messages" %>
2
+ <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 %>
@@ -0,0 +1,7 @@
1
+ <div class="envoy-msg<%= ' envoy-msg--assistant' if message.role != 'user' %>">
2
+ <div class="envoy-msg__role"><%= message.role %></div>
3
+ <div class="envoy-md"><%= Envoy::Markdown.render(message.content) %></div>
4
+ <% message.tool_calls.each do |tc| %>
5
+ <%= render "envoy/messages/tool_call", tool_call: tc %>
6
+ <% end %>
7
+ </div>
@@ -0,0 +1,6 @@
1
+ <div id="envoy_streaming_reply">
2
+ <div class="envoy-msg envoy-msg--assistant">
3
+ <div class="envoy-msg__role">assistant</div>
4
+ <div class="envoy-md"><%= Envoy::Markdown.render(content) %></div>
5
+ </div>
6
+ </div>
@@ -0,0 +1,8 @@
1
+ <details class="envoy-tool">
2
+ <summary class="envoy-tool__summary">
3
+ <span class="envoy-mono"><%= tool_call.name %></span>
4
+ <%= Envoy::Badges.outcome(tool_call) %>
5
+ </summary>
6
+ <pre><%= JSON.pretty_generate(tool_call.arguments) %></pre>
7
+ <pre><%= tool_call.result_message&.content %></pre>
8
+ </details>
@@ -0,0 +1,25 @@
1
+ <%# Append the user message, then a fresh streaming target — both into the ordered
2
+ message list so the assistant reply commits in place and the next turn appends
3
+ after it (fixes cross-turn ordering). %>
4
+ <%= turbo_stream.append "envoy_messages" do %>
5
+ <div class="envoy-msg">
6
+ <div class="envoy-msg__role">user</div>
7
+ <div class="envoy-md"><%= Envoy::Markdown.render(@user_content) %></div>
8
+ </div>
9
+ <% end %>
10
+ <%# The streaming target starts with a subtle "working…" indicator so there is a
11
+ live sign of activity before the first token (e.g. during tool calls, which
12
+ emit no text deltas). The first delta / finalize replaces this. %>
13
+ <%= turbo_stream.append "envoy_messages" do %>
14
+ <div id="envoy_streaming_reply">
15
+ <div class="envoy-msg envoy-msg--assistant">
16
+ <div class="envoy-msg__role">assistant</div>
17
+ <div class="envoy-working envoy-muted">working…</div>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+ <%# Clear the composer by replacing it with a fresh, empty textarea (server-driven,
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" %>
25
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <%= form_with model: system_prompt, url: url do |f| %>
2
+ <% if system_prompt.errors.any? %>
3
+ <div class="envoy-error">
4
+ <% system_prompt.errors.full_messages.each do |m| %><div><%= m %></div><% end %>
5
+ </div>
6
+ <% end %>
7
+ <div class="envoy-form">
8
+ <%= f.text_field :name, placeholder: "Name", class: "envoy-field" %>
9
+ <%= f.text_field :description, placeholder: "Description (optional)", class: "envoy-field" %>
10
+ <%= text_area_tag "system_prompt[body]", system_prompt.current_body, rows: 12,
11
+ placeholder: "System prompt body", class: "envoy-field envoy-field--mono" %>
12
+ <%= f.submit submit_label, class: "envoy-btn" %>
13
+ </div>
14
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1 class="envoy-title envoy-mb-4">Edit system prompt</h1>
2
+ <%= render "form", system_prompt: @system_prompt, url: system_prompt_path(@system_prompt), submit_label: "Save" %>
@@ -0,0 +1,14 @@
1
+ <div class="envoy-header-row envoy-mb-4">
2
+ <h1 class="envoy-title">System prompts</h1>
3
+ <%= link_to "New prompt", new_system_prompt_path, class: "envoy-btn" %>
4
+ </div>
5
+ <ul class="envoy-list">
6
+ <% @system_prompts.each do |p| %>
7
+ <li class="envoy-list__item">
8
+ <%= link_to p.name, system_prompt_path(p), class: "envoy-link" %>
9
+ <span class="envoy-muted">· v<%= p.latest_version&.version_number || 0 %></span>
10
+ <% if p.description.present? %><div class="envoy-muted"><%= p.description %></div><% end %>
11
+ </li>
12
+ <% end %>
13
+ </ul>
14
+ <div class="envoy-mt-4"><%= link_to "← Chats", conversations_path, class: "envoy-link" %></div>
@@ -0,0 +1,2 @@
1
+ <h1 class="envoy-title envoy-mb-4">New system prompt</h1>
2
+ <%= render "form", system_prompt: @system_prompt, url: system_prompts_path, submit_label: "Create" %>
@@ -0,0 +1,15 @@
1
+ <div class="envoy-header-row envoy-mb-3">
2
+ <h1 class="envoy-title"><%= @system_prompt.name %></h1>
3
+ <%= link_to "Edit", edit_system_prompt_path(@system_prompt), class: "envoy-btn" %>
4
+ </div>
5
+ <% if @system_prompt.description.present? %>
6
+ <p class="envoy-muted envoy-mb-3"><%= @system_prompt.description %></p>
7
+ <% end %>
8
+ <pre class="envoy-prose-box envoy-mb-4"><%= @system_prompt.current_body %></pre>
9
+ <h2 class="envoy-subtitle">Versions</h2>
10
+ <ul class="envoy-list">
11
+ <% @system_prompt.versions.order(version_number: :desc).each do |v| %>
12
+ <li class="envoy-list__item envoy-muted">v<%= v.version_number %> · <%= v.created_at.strftime("%Y-%m-%d %H:%M") %></li>
13
+ <% end %>
14
+ </ul>
15
+ <div class="envoy-mt-4"><%= link_to "← System prompts", system_prompts_path, class: "envoy-link" %></div>
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Envoy</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= stylesheet_link_tag "envoy/console", "data-turbo-track": "reload" %>
9
+ <%= javascript_importmap_tags %>
10
+ </head>
11
+ <body class="envoy-body">
12
+ <header class="envoy-header">
13
+ <div class="envoy-header__inner">
14
+ <%= link_to "Envoy", conversations_path, class: "envoy-brand" %>
15
+ <%= link_to "New chat", new_conversation_path, class: "envoy-navlink" %>
16
+ </div>
17
+ </header>
18
+ <main class="envoy-main"><%= yield %></main>
19
+ </body>
20
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Envoy::Engine.routes.draw do
2
+ resources :conversations, only: %i[index new create show] do
3
+ resources :messages, only: :create
4
+ end
5
+ resources :system_prompts, except: :destroy
6
+ root to: "conversations#index"
7
+ end
@@ -0,0 +1,14 @@
1
+ class CreateEnvoyConversations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :envoy_conversations do |t|
4
+ t.references :actor, polymorphic: true, null: false
5
+ t.string :title
6
+ t.string :provider, null: false, default: "anthropic"
7
+ t.string :model_id, null: false
8
+ t.string :toolset_key, null: false
9
+ t.text :system_prompt
10
+ t.string :status, null: false, default: "active"
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ class CreateEnvoyMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :envoy_messages do |t|
4
+ t.references :conversation, null: false, foreign_key: { to_table: :envoy_conversations }
5
+ t.string :role, null: false
6
+ t.text :content
7
+ t.string :model_id
8
+ t.integer :input_tokens
9
+ t.integer :output_tokens
10
+ t.references :tool_call, null: true, index: true
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ class CreateEnvoyToolCalls < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :envoy_tool_calls do |t|
4
+ t.references :message, null: false, foreign_key: { to_table: :envoy_messages }
5
+ t.string :tool_call_id, null: false
6
+ t.string :name, null: false
7
+ t.json :arguments, default: {}
8
+ t.string :status
9
+ t.text :error
10
+ t.timestamps
11
+ end
12
+ add_index :envoy_tool_calls, :tool_call_id
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ class CreateEnvoySystemPrompts < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :envoy_system_prompts do |t|
4
+ t.string :name, null: false
5
+ t.text :description
6
+ t.timestamps
7
+ end
8
+ add_index :envoy_system_prompts, :name, unique: true
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ class CreateEnvoySystemPromptVersions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :envoy_system_prompt_versions do |t|
4
+ t.references :system_prompt, null: false, index: true,
5
+ foreign_key: { to_table: :envoy_system_prompts }
6
+ t.integer :version_number, null: false
7
+ t.text :body, null: false
8
+ t.timestamps
9
+ end
10
+ add_index :envoy_system_prompt_versions, %i[system_prompt_id version_number],
11
+ unique: true, name: "index_envoy_prompt_versions_on_prompt_and_number"
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ class AddSystemPromptVersionToEnvoyConversations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_reference :envoy_conversations, :system_prompt_version, null: true, index: true,
4
+ foreign_key: { to_table: :envoy_system_prompt_versions }
5
+ end
6
+ end
@@ -0,0 +1,22 @@
1
+ module Envoy
2
+ # Renders a tool-call outcome badge as an HTML-safe string. A plain module
3
+ # method (NOT a view helper), for the same reason as Envoy::Markdown: Turbo
4
+ # broadcasts render through the host's ApplicationController, which cannot see
5
+ # engine view helpers. Call directly: `<%= Envoy::Badges.outcome(tool_call) %>`.
6
+ module Badges
7
+ module_function
8
+
9
+ # Modifier classes are styled by the gem's own console.css (semantic, not
10
+ # Tailwind) so the badge stays styled in any host.
11
+ STYLES = {
12
+ denied: [ "denied", "envoy-badge--denied" ],
13
+ failed: [ "failed", "envoy-badge--failed" ]
14
+ }.freeze
15
+ SUCCESS = [ "ok", "envoy-badge--ok" ].freeze
16
+
17
+ def outcome(tool_call)
18
+ label, modifier = STYLES.fetch(tool_call.outcome, SUCCESS)
19
+ %(<span class="envoy-badge #{modifier}">#{label}</span>).html_safe
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ module Envoy
2
+ module CompiledTool
3
+ module_function
4
+
5
+ # Build (once) a RubyLLM::Tool subclass for a ToolDefinition.
6
+ def build(definition)
7
+ definition.tool_class || begin
8
+ klass = Class.new(RubyLLM::Tool) do
9
+ @definition = definition
10
+
11
+ class << self
12
+ attr_reader :definition
13
+ end
14
+
15
+ description definition.description
16
+ definition.params.each do |p|
17
+ param p[:name], desc: p[:desc], type: p[:type], required: p[:required]
18
+ end
19
+
20
+ attr_reader :last_status
21
+
22
+ def initialize(actor:)
23
+ @actor = actor
24
+ @last_status = nil
25
+ end
26
+
27
+ # RubyLLM derives the tool name from the class; override to the DSL name.
28
+ def name
29
+ self.class.definition.name
30
+ end
31
+
32
+ def execute(**args)
33
+ out = Envoy::Guard.run(self.class.definition, actor: @actor, args: args)
34
+ @last_status = out[:status]
35
+ out[:value].to_json
36
+ end
37
+ end
38
+ definition.tool_class = klass
39
+ klass
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,21 @@
1
+ module Envoy
2
+ class Configuration
3
+ attr_accessor :provider, :default_model, :available_models,
4
+ :authenticate, :actor_resolver, :system_preamble
5
+ attr_writer :llm
6
+
7
+ def initialize
8
+ @provider = :anthropic
9
+ @default_model = "claude-sonnet-4-5"
10
+ @available_models = [ "claude-sonnet-4-5", "claude-opus-4-1" ]
11
+ @authenticate = ->(controller) { }
12
+ @actor_resolver = ->(controller) { nil }
13
+ @system_preamble = "You are a helpful assistant embedded in a Rails app."
14
+ end
15
+
16
+ # Returns a fresh LLM runner. Overridable in tests via `config.llm = ...`.
17
+ def llm
18
+ @llm ||= ->(**opts) { Envoy::LLM.new(**opts) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module Envoy
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Envoy
4
+
5
+ config.generators do |g|
6
+ g.test_framework :minitest
7
+ end
8
+
9
+ # Expose the engine's Stimulus controllers to the host's asset pipeline so
10
+ # `pin "envoy/controllers/...", to: "envoy/controllers/..._controller.js"`
11
+ # in the host importmap resolves. (Importmap::Map#paths is for additional
12
+ # importmap.rb-style DSL files, not raw JS directories, so the pin/resolve
13
+ # path here goes through the asset load path instead.)
14
+ initializer "envoy.assets" do |app|
15
+ app.config.assets.paths << root.join("app/javascript") if app.config.respond_to?(:assets)
16
+ app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ module Envoy
2
+ class Error < StandardError; end
3
+ class Forbidden < Error; end
4
+ class UnknownToolset < Error; end
5
+ class ToolsetCycle < Error; end
6
+ end
@@ -0,0 +1,28 @@
1
+ module Envoy
2
+ module Guard
3
+ module_function
4
+
5
+ # Runs a tool definition's perform block, translating exceptions into
6
+ # model-legible results. Never raises for expected failures.
7
+ def run(definition, actor:, args:)
8
+ value = definition.perform_block.call(actor: actor, **args.symbolize_keys)
9
+ if value.is_a?(Hash) && value[:error]
10
+ { value: value, status: :failed }
11
+ else
12
+ { value: value, status: :succeeded }
13
+ end
14
+ rescue Envoy::Forbidden => e
15
+ failure("forbidden", e.message.presence || "Not permitted.", :denied)
16
+ rescue ActiveRecord::RecordNotFound
17
+ failure("not_found", "The requested record was not found.", :failed)
18
+ rescue ArgumentError => e
19
+ failure("invalid", e.message, :failed)
20
+ rescue StandardError
21
+ failure("error", "An unexpected error occurred.", :failed)
22
+ end
23
+
24
+ def failure(type, message, status)
25
+ { value: { error: message, type: type }, status: status }
26
+ end
27
+ end
28
+ end
data/lib/envoy/llm.rb ADDED
@@ -0,0 +1,24 @@
1
+ module Envoy
2
+ class LLM
3
+ def initialize(conversation:)
4
+ @conversation = conversation
5
+ end
6
+
7
+ # Drives a real RubyLLM turn via acts_as_chat. Yields [:delta, text] chunks.
8
+ #
9
+ # NOTE: RubyLLM 1.16's ChatMethods#with_model keyword is `assume_exists:`,
10
+ # not `assume_model_exists:` — verified against the installed gem
11
+ # (lib/ruby_llm/active_record/chat_methods.rb and lib/ruby_llm/chat.rb).
12
+ def run(content:, tools:, instructions:)
13
+ chat = @conversation
14
+ .with_model(@conversation.model_id,
15
+ provider: Envoy.config.provider,
16
+ assume_exists: true)
17
+ .with_instructions(instructions)
18
+ chat = chat.with_tools(*tools) if tools.any?
19
+ chat.ask(content) do |chunk|
20
+ yield [ :delta, chunk.content ] if block_given? && chunk.content.present?
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ require "commonmarker"
2
+
3
+ module Envoy
4
+ # Renders Markdown to sanitized HTML. A plain module method (NOT a view helper)
5
+ # so it works in any render context — including Turbo broadcasts, which render
6
+ # partials through the host's ApplicationController and cannot see engine view
7
+ # helpers. Call directly from partials: `<%= Envoy::Markdown.render(text) %>`.
8
+ module Markdown
9
+ module_function
10
+
11
+ def render(text)
12
+ return "".html_safe if text.blank?
13
+ Commonmarker.to_html(text, options: { render: { unsafe: false } }).html_safe
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ module Envoy
2
+ class Runner
3
+ ERROR_CONVENTION = <<~TXT.freeze
4
+ When a tool returns a JSON object with an "error" key, it failed. The
5
+ "type" tells you why: "not_found" (no such record), "forbidden" (you may
6
+ not do that), "invalid" (bad arguments). Explain the problem to the user
7
+ instead of retrying blindly.
8
+ TXT
9
+
10
+ def initialize(conversation:)
11
+ @conversation = conversation
12
+ end
13
+
14
+ def call(content:, &on_event)
15
+ tools = compiled_tools
16
+ Envoy.llm(conversation: @conversation)
17
+ .run(content: content, tools: tools, instructions: instructions, &on_event)
18
+ backfill_statuses!
19
+ end
20
+
21
+ private
22
+
23
+ def toolset = @conversation.toolset
24
+
25
+ def read_only? = @conversation.status == "read_only"
26
+
27
+ def compiled_tools
28
+ toolset.tools_for(read_only: read_only?).map do |definition|
29
+ Envoy::CompiledTool.build(definition).new(actor: @conversation.actor)
30
+ end
31
+ end
32
+
33
+ def instructions
34
+ [ Envoy.config.system_preamble, toolset.description, ERROR_CONVENTION,
35
+ @conversation.effective_system_prompt ].compact_blank.join("\n\n")
36
+ end
37
+
38
+ def backfill_statuses!
39
+ @conversation.messages.where(role: "assistant").flat_map(&:tool_calls).each do |tc|
40
+ tc.update!(status: tc.outcome.to_s) if tc.status.blank?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,41 @@
1
+ module Envoy
2
+ module Testing
3
+ # Deterministic stand-in for Envoy::LLM. Persists Envoy rows exactly as the
4
+ # real path would and replays a script of {text:} / {tool:, args:} directives.
5
+ class FakeLLM
6
+ attr_accessor :script
7
+
8
+ def initialize(conversation:)
9
+ @conversation = conversation
10
+ @script = []
11
+ end
12
+
13
+ def run(content:, tools:, instructions:)
14
+ @conversation.messages.create!(role: "user", content: content)
15
+ assistant = @conversation.messages.create!(role: "assistant", content: nil)
16
+
17
+ @script.each do |directive|
18
+ if directive[:text]
19
+ # Accumulate like real streaming: multiple text deltas build one message.
20
+ assistant.update!(content: assistant.content.to_s + directive[:text])
21
+ yield [ :delta, directive[:text] ] if block_given?
22
+ else
23
+ tool = tools.find { |t| t.name == directive[:tool].to_s }
24
+ raise "no tool #{directive[:tool]}" unless tool
25
+
26
+ value = tool.execute(**directive[:args])
27
+ tc = assistant.tool_calls.create!(
28
+ tool_call_id: "fake-#{assistant.tool_calls.count + 1}",
29
+ name: tool.name, arguments: directive[:args].stringify_keys,
30
+ status: tool.last_status.to_s
31
+ )
32
+ @conversation.messages.create!(
33
+ role: "tool", tool_call_id: tc.id, content: value
34
+ )
35
+ yield [ :tool, tc ] if block_given?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end