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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +22 -0
- data/README.md +399 -0
- data/app/assets/stylesheets/envoy/console.css +371 -0
- data/app/controllers/envoy/application_controller.rb +18 -0
- data/app/controllers/envoy/conversations_controller.rb +36 -0
- data/app/controllers/envoy/messages_controller.rb +14 -0
- data/app/controllers/envoy/system_prompts_controller.rb +52 -0
- data/app/javascript/envoy/controllers/autoscroll_controller.js +46 -0
- data/app/javascript/envoy/controllers/composer_controller.js +30 -0
- data/app/jobs/envoy/application_job.rb +4 -0
- data/app/jobs/envoy/run_job.rb +65 -0
- data/app/models/envoy/application_record.rb +5 -0
- data/app/models/envoy/conversation.rb +20 -0
- data/app/models/envoy/message.rb +8 -0
- data/app/models/envoy/system_prompt.rb +24 -0
- data/app/models/envoy/system_prompt_version.rb +14 -0
- data/app/models/envoy/tool_call.rb +28 -0
- data/app/views/envoy/conversations/_composer_input.html.erb +4 -0
- data/app/views/envoy/conversations/index.html.erb +15 -0
- data/app/views/envoy/conversations/new.html.erb +19 -0
- data/app/views/envoy/conversations/show.html.erb +18 -0
- data/app/views/envoy/messages/_message.html.erb +7 -0
- data/app/views/envoy/messages/_streaming.html.erb +6 -0
- data/app/views/envoy/messages/_tool_call.html.erb +8 -0
- data/app/views/envoy/messages/create.turbo_stream.erb +25 -0
- data/app/views/envoy/system_prompts/_form.html.erb +14 -0
- data/app/views/envoy/system_prompts/edit.html.erb +2 -0
- data/app/views/envoy/system_prompts/index.html.erb +14 -0
- data/app/views/envoy/system_prompts/new.html.erb +2 -0
- data/app/views/envoy/system_prompts/show.html.erb +15 -0
- data/app/views/layouts/envoy/application.html.erb +20 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260710000001_create_envoy_conversations.rb +14 -0
- data/db/migrate/20260710000002_create_envoy_messages.rb +14 -0
- data/db/migrate/20260710000003_create_envoy_tool_calls.rb +14 -0
- data/db/migrate/20260711000001_create_envoy_system_prompts.rb +10 -0
- data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +13 -0
- data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +6 -0
- data/lib/envoy/badges.rb +22 -0
- data/lib/envoy/compiled_tool.rb +43 -0
- data/lib/envoy/configuration.rb +21 -0
- data/lib/envoy/engine.rb +19 -0
- data/lib/envoy/errors.rb +6 -0
- data/lib/envoy/guard.rb +28 -0
- data/lib/envoy/llm.rb +24 -0
- data/lib/envoy/markdown.rb +16 -0
- data/lib/envoy/runner.rb +44 -0
- data/lib/envoy/testing/fake_llm.rb +41 -0
- data/lib/envoy/tool_definition.rb +38 -0
- data/lib/envoy/toolset.rb +72 -0
- data/lib/envoy/version.rb +3 -0
- data/lib/envoy.rb +45 -0
- data/lib/envoy_ai.rb +3 -0
- 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,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,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,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,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,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,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
|
data/lib/envoy/badges.rb
ADDED
|
@@ -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
|
data/lib/envoy/engine.rb
ADDED
|
@@ -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
|
data/lib/envoy/errors.rb
ADDED
data/lib/envoy/guard.rb
ADDED
|
@@ -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
|
data/lib/envoy/runner.rb
ADDED
|
@@ -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
|