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
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.status == "read_only"
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, toolset.description, ERROR_CONVENTION,
35
- @conversation.effective_system_prompt ].compact_blank.join("\n\n")
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
- def param(name, desc, type: :string, required: true)
27
- @params << { name: name.to_sym, desc: desc, type: type, required: required }
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
@@ -1,3 +1,3 @@
1
1
  module Envoy
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2"
3
3
  end
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.1
4
+ version: 0.0.2
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/system_prompts_controller.rb
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/system_prompts/_form.html.erb
89
- - app/views/envoy/system_prompts/edit.html.erb
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/20260711000001_create_envoy_system_prompts.rb
99
- - db/migrate/20260711000002_create_envoy_system_prompt_versions.rb
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,2 +0,0 @@
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" %>
@@ -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>
@@ -1,2 +0,0 @@
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" %>
@@ -1,15 +0,0 @@
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>
@@ -1,10 +0,0 @@
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
@@ -1,13 +0,0 @@
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
@@ -1,6 +0,0 @@
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