envoy_ai 0.0.1 → 0.0.3
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 +236 -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
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
|
data/lib/envoy/runner.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Envoy
|
|
|
22
22
|
|
|
23
23
|
def toolset = @conversation.toolset
|
|
24
24
|
|
|
25
|
-
def read_only? = @conversation.
|
|
25
|
+
def read_only? = @conversation.read_only?
|
|
26
26
|
|
|
27
27
|
def compiled_tools
|
|
28
28
|
toolset.tools_for(read_only: read_only?).map do |definition|
|
|
@@ -30,9 +30,23 @@ module Envoy
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Ordered stable-prefix-first: the leading segments are identical across
|
|
34
|
+
# turns and so are cacheable, while context is re-resolved every turn and
|
|
35
|
+
# never is. A resumed conversation outlives the page state it started with,
|
|
36
|
+
# which is exactly why context cannot be a snapshot.
|
|
33
37
|
def instructions
|
|
34
|
-
[ Envoy.config.system_preamble,
|
|
35
|
-
|
|
38
|
+
[ Envoy.config.system_preamble,
|
|
39
|
+
toolset.description,
|
|
40
|
+
ERROR_CONVENTION,
|
|
41
|
+
@conversation.prompt_body,
|
|
42
|
+
surface_context,
|
|
43
|
+
@conversation.effective_system_prompt ].compact_blank.join("\n\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def surface_context
|
|
47
|
+
surface = @conversation.surface
|
|
48
|
+
return nil unless surface
|
|
49
|
+
surface.context_for(actor: @conversation.actor, key: @conversation.context_key)
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
def backfill_statuses!
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module Envoy
|
|
2
|
+
# Binds a page to an agent: which tools, which prompt, which model, and how to
|
|
3
|
+
# describe what the user is looking at.
|
|
4
|
+
#
|
|
5
|
+
# A surface is the source of truth for a conversation opened against it —
|
|
6
|
+
# toolset, prompt and model are read here on every turn, so editing a surface
|
|
7
|
+
# reaches conversations that already exist.
|
|
8
|
+
#
|
|
9
|
+
# References are stored as keys and resolved lazily (Envoy.validate! checks
|
|
10
|
+
# them in one pass at boot), so definition load order does not matter.
|
|
11
|
+
class Surface
|
|
12
|
+
attr_reader :key
|
|
13
|
+
|
|
14
|
+
def initialize(key)
|
|
15
|
+
@key = key.to_s
|
|
16
|
+
@toolset_key = nil
|
|
17
|
+
@model_id = nil
|
|
18
|
+
@read_only = false
|
|
19
|
+
@prompt = nil
|
|
20
|
+
@context_block = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- DSL ---
|
|
24
|
+
def toolset(name = nil)
|
|
25
|
+
return @toolset_key if name.nil?
|
|
26
|
+
@toolset_key = name.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def model(id = nil)
|
|
30
|
+
return @model_id if id.nil?
|
|
31
|
+
@model_id = id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read_only(value = nil)
|
|
35
|
+
return @read_only if value.nil?
|
|
36
|
+
@read_only = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A registry key (Symbol) or a literal String.
|
|
40
|
+
def system_prompt(ref = nil)
|
|
41
|
+
return @prompt if ref.nil?
|
|
42
|
+
@prompt = ref
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Runs on every turn, in the job — so it gets actor and key and nothing else.
|
|
46
|
+
# No request, no params, no session. This is also where a host enforces that
|
|
47
|
+
# this actor may see this subject: raise Envoy::Forbidden and Guard handles it.
|
|
48
|
+
def context(&block)
|
|
49
|
+
@context_block = block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- introspection ---
|
|
53
|
+
def toolset_key = @toolset_key
|
|
54
|
+
def model_id = @model_id
|
|
55
|
+
def read_only? = !!@read_only
|
|
56
|
+
|
|
57
|
+
def prompt_body
|
|
58
|
+
case @prompt
|
|
59
|
+
when nil then nil
|
|
60
|
+
when String then @prompt
|
|
61
|
+
else Envoy.prompt(@prompt).full_body
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def context_for(actor:, key:)
|
|
66
|
+
return nil unless @context_block
|
|
67
|
+
@context_block.call(actor: actor, key: key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolve every lazy reference, naming this surface in any failure.
|
|
71
|
+
def validate!
|
|
72
|
+
raise Envoy::Error, "surface #{key.inspect} has no toolset" if @toolset_key.blank?
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
# tools_for walks the whole `use` graph, not just this toolset's own
|
|
76
|
+
# tools — so a typo in a composed toolset's `use` list fails here,
|
|
77
|
+
# at boot, instead of surfacing mid-conversation inside RunJob.
|
|
78
|
+
Envoy.toolset(@toolset_key).tools_for
|
|
79
|
+
rescue Envoy::UnknownToolset, Envoy::ToolsetCycle => e
|
|
80
|
+
raise e.class, "surface #{key.inspect}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
prompt_body
|
|
85
|
+
rescue Envoy::UnknownPrompt => e
|
|
86
|
+
raise Envoy::UnknownPrompt, "surface #{key.inspect}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -23,8 +23,18 @@ module Envoy
|
|
|
23
23
|
@access = level
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# enum declares a closed vocabulary for a param. RubyLLM::Parameter has no
|
|
27
|
+
# enum support (its initializer would raise on the keyword), so the values
|
|
28
|
+
# are folded into the description the model reads, and Guard enforces them —
|
|
29
|
+
# yielding the same {"error":..,"type":"invalid"} shape as any other bad argument.
|
|
30
|
+
def param(name, desc, type: :string, required: true, enum: nil)
|
|
31
|
+
enum = enum&.map(&:to_s)
|
|
32
|
+
desc = "#{desc} One of: #{enum.join(', ')}." if enum
|
|
33
|
+
@params << { name: name.to_sym, desc: desc, type: type, required: required, enum: enum }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enum_for(param_name)
|
|
37
|
+
@params.find { |p| p[:name] == param_name.to_sym }&.fetch(:enum)
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
def perform(&block)
|
data/lib/envoy/version.rb
CHANGED
data/lib/envoy.rb
CHANGED
|
@@ -8,6 +8,10 @@ require "envoy/engine"
|
|
|
8
8
|
require "envoy/errors"
|
|
9
9
|
require "envoy/tool_definition"
|
|
10
10
|
require "envoy/toolset"
|
|
11
|
+
require "envoy/prompt"
|
|
12
|
+
require "envoy/library"
|
|
13
|
+
require "envoy/surface"
|
|
14
|
+
require "envoy/page_surface"
|
|
11
15
|
require "envoy/guard"
|
|
12
16
|
require "envoy/compiled_tool"
|
|
13
17
|
require "envoy/llm"
|
|
@@ -41,5 +45,60 @@ module Envoy
|
|
|
41
45
|
def toolset(key)
|
|
42
46
|
toolsets.fetch(key.to_s) { raise UnknownToolset, "no toolset #{key.inspect}" }
|
|
43
47
|
end
|
|
48
|
+
|
|
49
|
+
def prompts
|
|
50
|
+
@prompts ||= {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def define_prompt(key, &block)
|
|
54
|
+
prompt = Prompt.new(key)
|
|
55
|
+
prompt.instance_eval(&block)
|
|
56
|
+
prompts[prompt.key] = prompt
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def prompt(key)
|
|
60
|
+
prompts.fetch(key.to_s) { raise UnknownPrompt, "no prompt #{key.inspect}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def libraries
|
|
64
|
+
@libraries ||= {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Registers both the Library (for introspection) and its compiled Toolset
|
|
68
|
+
# under the same key, so `use :principles` works like any other toolset.
|
|
69
|
+
def define_library(key, &block)
|
|
70
|
+
library = Library.new(key)
|
|
71
|
+
library.instance_eval(&block)
|
|
72
|
+
libraries[library.key] = library
|
|
73
|
+
toolsets[library.key] = library.to_toolset
|
|
74
|
+
library
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def library(key)
|
|
78
|
+
libraries.fetch(key.to_s) { raise UnknownLibrary, "no library #{key.inspect}" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def surfaces
|
|
82
|
+
@surfaces ||= {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def define_surface(key, &block)
|
|
86
|
+
surface = Surface.new(key)
|
|
87
|
+
surface.instance_eval(&block)
|
|
88
|
+
surfaces[surface.key] = surface
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def surface(key)
|
|
92
|
+
surfaces.fetch(key.to_s) { raise UnknownSurface, "no surface #{key.inspect}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolve every surface's lazy references in one pass. Hosts call this at the
|
|
96
|
+
# end of the to_prepare block that loads their definitions, so a typo fails
|
|
97
|
+
# on boot rather than inside a job — where the failure is invisible and the
|
|
98
|
+
# user watches "working…" forever.
|
|
99
|
+
def validate!
|
|
100
|
+
surfaces.each_value(&:validate!)
|
|
101
|
+
true
|
|
102
|
+
end
|
|
44
103
|
end
|
|
45
104
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: envoy_ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Travis Petticrew
|
|
@@ -62,11 +62,12 @@ extra_rdoc_files: []
|
|
|
62
62
|
files:
|
|
63
63
|
- MIT-LICENSE
|
|
64
64
|
- README.md
|
|
65
|
+
- app/assets/stylesheets/envoy/chat.css
|
|
65
66
|
- app/assets/stylesheets/envoy/console.css
|
|
66
67
|
- app/controllers/envoy/application_controller.rb
|
|
67
68
|
- app/controllers/envoy/conversations_controller.rb
|
|
68
69
|
- app/controllers/envoy/messages_controller.rb
|
|
69
|
-
- app/controllers/envoy/
|
|
70
|
+
- app/controllers/envoy/panels_controller.rb
|
|
70
71
|
- app/javascript/envoy/controllers/autoscroll_controller.js
|
|
71
72
|
- app/javascript/envoy/controllers/composer_controller.js
|
|
72
73
|
- app/jobs/envoy/application_job.rb
|
|
@@ -74,30 +75,25 @@ files:
|
|
|
74
75
|
- app/models/envoy/application_record.rb
|
|
75
76
|
- app/models/envoy/conversation.rb
|
|
76
77
|
- app/models/envoy/message.rb
|
|
77
|
-
- app/models/envoy/system_prompt.rb
|
|
78
|
-
- app/models/envoy/system_prompt_version.rb
|
|
79
78
|
- app/models/envoy/tool_call.rb
|
|
80
79
|
- app/views/envoy/conversations/_composer_input.html.erb
|
|
81
80
|
- app/views/envoy/conversations/index.html.erb
|
|
82
81
|
- app/views/envoy/conversations/new.html.erb
|
|
83
82
|
- app/views/envoy/conversations/show.html.erb
|
|
83
|
+
- app/views/envoy/messages/_error.html.erb
|
|
84
84
|
- app/views/envoy/messages/_message.html.erb
|
|
85
85
|
- app/views/envoy/messages/_streaming.html.erb
|
|
86
86
|
- app/views/envoy/messages/_tool_call.html.erb
|
|
87
87
|
- app/views/envoy/messages/create.turbo_stream.erb
|
|
88
|
-
- app/views/envoy/
|
|
89
|
-
- app/views/envoy/
|
|
90
|
-
- app/views/envoy/system_prompts/index.html.erb
|
|
91
|
-
- app/views/envoy/system_prompts/new.html.erb
|
|
92
|
-
- app/views/envoy/system_prompts/show.html.erb
|
|
88
|
+
- app/views/envoy/panels/_panel.html.erb
|
|
89
|
+
- app/views/envoy/panels/_transcript.html.erb
|
|
93
90
|
- app/views/layouts/envoy/application.html.erb
|
|
94
91
|
- config/routes.rb
|
|
95
92
|
- db/migrate/20260710000001_create_envoy_conversations.rb
|
|
96
93
|
- db/migrate/20260710000002_create_envoy_messages.rb
|
|
97
94
|
- db/migrate/20260710000003_create_envoy_tool_calls.rb
|
|
98
|
-
- db/migrate/
|
|
99
|
-
- db/migrate/
|
|
100
|
-
- db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb
|
|
95
|
+
- db/migrate/20260716000001_drop_envoy_system_prompts.rb
|
|
96
|
+
- db/migrate/20260716000002_add_surface_to_envoy_conversations.rb
|
|
101
97
|
- lib/envoy.rb
|
|
102
98
|
- lib/envoy/badges.rb
|
|
103
99
|
- lib/envoy/compiled_tool.rb
|
|
@@ -105,9 +101,13 @@ files:
|
|
|
105
101
|
- lib/envoy/engine.rb
|
|
106
102
|
- lib/envoy/errors.rb
|
|
107
103
|
- lib/envoy/guard.rb
|
|
104
|
+
- lib/envoy/library.rb
|
|
108
105
|
- lib/envoy/llm.rb
|
|
109
106
|
- lib/envoy/markdown.rb
|
|
107
|
+
- lib/envoy/page_surface.rb
|
|
108
|
+
- lib/envoy/prompt.rb
|
|
110
109
|
- lib/envoy/runner.rb
|
|
110
|
+
- lib/envoy/surface.rb
|
|
111
111
|
- lib/envoy/testing/fake_llm.rb
|
|
112
112
|
- lib/envoy/tool_definition.rb
|
|
113
113
|
- lib/envoy/toolset.rb
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
module Envoy
|
|
2
|
-
class SystemPromptsController < ApplicationController
|
|
3
|
-
def index
|
|
4
|
-
@system_prompts = SystemPrompt.order(:name)
|
|
5
|
-
end
|
|
6
|
-
|
|
7
|
-
def show
|
|
8
|
-
@system_prompt = SystemPrompt.find(params[:id])
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def new
|
|
12
|
-
@system_prompt = SystemPrompt.new
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def create
|
|
16
|
-
@system_prompt = SystemPrompt.new(prompt_params.except(:body))
|
|
17
|
-
body = prompt_params[:body].to_s
|
|
18
|
-
if body.blank?
|
|
19
|
-
@system_prompt.errors.add(:base, "Body can't be blank")
|
|
20
|
-
return render :new, status: :unprocessable_entity
|
|
21
|
-
end
|
|
22
|
-
SystemPrompt.transaction do
|
|
23
|
-
@system_prompt.save!
|
|
24
|
-
@system_prompt.add_version!(body)
|
|
25
|
-
end
|
|
26
|
-
redirect_to system_prompt_path(@system_prompt), notice: "Prompt created."
|
|
27
|
-
rescue ActiveRecord::RecordInvalid
|
|
28
|
-
render :new, status: :unprocessable_entity
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def edit
|
|
32
|
-
@system_prompt = SystemPrompt.find(params[:id])
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def update
|
|
36
|
-
@system_prompt = SystemPrompt.find(params[:id])
|
|
37
|
-
if @system_prompt.update(prompt_params.except(:body))
|
|
38
|
-
body = prompt_params[:body].to_s
|
|
39
|
-
@system_prompt.add_version!(body) if body.present? && body != @system_prompt.current_body
|
|
40
|
-
redirect_to system_prompt_path(@system_prompt), notice: "Prompt updated."
|
|
41
|
-
else
|
|
42
|
-
render :edit, status: :unprocessable_entity
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def prompt_params
|
|
49
|
-
params.require(:system_prompt).permit(:name, :description, :body)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
|
@@ -1,14 +0,0 @@
|
|
|
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 %>
|
|
@@ -1,14 +0,0 @@
|
|
|
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>
|