silas 0.2.0 → 0.3.0
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/CHANGELOG.md +61 -1
- data/README.md +44 -0
- data/app/controllers/concerns/silas/api/serialization.rb +63 -0
- data/app/controllers/silas/api/base_controller.rb +24 -0
- data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
- data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
- data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
- data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -3
- data/app/controllers/silas/inbox/turns_controller.rb +19 -0
- data/app/helpers/silas/inbox/trace_helper.rb +5 -0
- data/app/models/silas/session.rb +6 -0
- data/app/models/silas/turn.rb +9 -0
- data/app/views/layouts/silas/inbox.html.erb +6 -0
- data/app/views/silas/inbox/sessions/index.html.erb +11 -3
- data/app/views/silas/inbox/steps/_step.html.erb +3 -0
- data/app/views/silas/inbox/turns/_header.html.erb +12 -1
- data/config/routes.rb +23 -1
- data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
- data/lib/generators/silas/install/install_generator.rb +1 -0
- data/lib/generators/silas/install/templates/agent.yml +10 -1
- data/lib/generators/silas/install/templates/initializer.rb +5 -4
- data/lib/silas/agent.rb +4 -0
- data/lib/silas/chat.rb +2 -0
- data/lib/silas/configuration.rb +30 -34
- data/lib/silas/doctor.rb +155 -0
- data/lib/silas/engines/ruby_llm.rb +13 -2
- data/lib/silas/eval/assertions.rb +18 -0
- data/lib/silas/eval/transcript.rb +1 -0
- data/lib/silas/inbox/cost.rb +37 -13
- data/lib/silas/message_builder.rb +9 -2
- data/lib/silas/named_agent.rb +1 -0
- data/lib/silas/registry.rb +18 -8
- data/lib/silas/step_runner.rb +12 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +1 -0
- data/lib/tasks/silas_doctor.rake +17 -0
- metadata +10 -1
data/config/routes.rb
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
Silas::Engine.routes.draw do
|
|
2
|
+
namespace :api do
|
|
3
|
+
namespace :v1 do
|
|
4
|
+
resources :sessions, only: %i[create show] do
|
|
5
|
+
resources :turns, only: :create
|
|
6
|
+
resources :approvals, only: :index
|
|
7
|
+
get :stream, on: :member, to: "streams#show"
|
|
8
|
+
end
|
|
9
|
+
resources :turns, only: [] do
|
|
10
|
+
member { post :cancel }
|
|
11
|
+
end
|
|
12
|
+
resources :approvals, only: [] do
|
|
13
|
+
member do
|
|
14
|
+
post :approve
|
|
15
|
+
post :decline
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
2
21
|
namespace :channels do
|
|
3
22
|
post "slack/events", to: "slack#events"
|
|
4
23
|
post "slack/actions", to: "slack#actions"
|
|
@@ -12,7 +31,10 @@ Silas::Engine.routes.draw do
|
|
|
12
31
|
resources :turns, only: :create
|
|
13
32
|
end
|
|
14
33
|
resources :turns, only: [] do
|
|
15
|
-
member
|
|
34
|
+
member do
|
|
35
|
+
post :cancel
|
|
36
|
+
post :raise_budget
|
|
37
|
+
end
|
|
16
38
|
end
|
|
17
39
|
resources :invocations, only: [] do
|
|
18
40
|
member do
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class AddProviderToSilasSteps < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# The provider RubyLLM's resolution picked for the step's model, stamped at
|
|
4
|
+
# persist time — cost lookups price against (model, provider) forever
|
|
5
|
+
# after, immune to registry tie-break changes (85/1081 registry ids exist
|
|
6
|
+
# under multiple providers at different prices).
|
|
7
|
+
add_column :silas_steps, :provider, :string
|
|
8
|
+
|
|
9
|
+
# Declared in 0.1.0, defaulted to 0, never written — a silent lie to
|
|
10
|
+
# anyone who queried it. Cost is derived at read time from step tokens.
|
|
11
|
+
remove_column :silas_turns, :cost_microcents, :integer, null: false, default: 0
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -102,6 +102,7 @@ module Silas
|
|
|
102
102
|
Silas installed. Next:
|
|
103
103
|
1. bin/rails db:migrate
|
|
104
104
|
2. export ANTHROPIC_API_KEY=sk-ant-... (config/initializers/ruby_llm.rb reads it)
|
|
105
|
+
then `bin/rails silas:doctor` to verify the whole setup
|
|
105
106
|
3. Edit app/agent/instructions.md (your agent's persona)
|
|
106
107
|
4. Write tools in app/agent/tools/ (keyword signature = schema)
|
|
107
108
|
5. Talk to it: bin/rails silas:chat (or Silas.agent.start(input: "hello"))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Data-only agent config. Model defaults to Silas.config.default_model.
|
|
2
|
-
# model: claude-sonnet-5
|
|
2
|
+
# model: claude-sonnet-4-5
|
|
3
3
|
description: This application's agent.
|
|
4
4
|
limits:
|
|
5
5
|
max_steps: 25 # model calls per turn
|
|
@@ -7,3 +7,12 @@ limits:
|
|
|
7
7
|
# max_cost: 1.00 # dollars per turn
|
|
8
8
|
# timeout: 300 # wall-clock seconds per turn
|
|
9
9
|
|
|
10
|
+
# Structured final answers: give the turn's answer a JSON schema and read the
|
|
11
|
+
# parsed Hash from Turn#answer_data (answer_text stays for prose agents).
|
|
12
|
+
# final_answer:
|
|
13
|
+
# type: object
|
|
14
|
+
# properties:
|
|
15
|
+
# verdict: { type: string }
|
|
16
|
+
# amount_pence: { type: integer }
|
|
17
|
+
# required: [verdict]
|
|
18
|
+
|
|
@@ -4,11 +4,11 @@ Silas.configure do |config|
|
|
|
4
4
|
config.engine = :ruby_llm
|
|
5
5
|
|
|
6
6
|
# Any model your installed ruby_llm's registry resolves (newer models may
|
|
7
|
-
# need `RubyLLM.models.refresh!` first). "claude-sonnet-5" is the balanced
|
|
7
|
+
# need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
|
|
8
8
|
# default; "claude-haiku-4-5" is fastest/cheapest; Opus models are the most
|
|
9
9
|
# capable and the most expensive — set per-turn budgets in agent.yml before
|
|
10
10
|
# reaching for one.
|
|
11
|
-
config.default_model = "claude-sonnet-5"
|
|
11
|
+
config.default_model = "claude-sonnet-4-5"
|
|
12
12
|
|
|
13
13
|
# The operator inbox (mounted at /silas/inbox) is DENY-BY-DEFAULT — invisible
|
|
14
14
|
# until you wire auth. The lambda DENIES by rendering (or head-ing) and
|
|
@@ -34,8 +34,9 @@ Silas.configure do |config|
|
|
|
34
34
|
# disables memory entirely.
|
|
35
35
|
# config.memory_approval = :always
|
|
36
36
|
|
|
37
|
-
# Cost accounting
|
|
38
|
-
#
|
|
37
|
+
# Cost accounting prices itself from RubyLLM's model registry. Override per
|
|
38
|
+
# model for fine-tunes / custom deployments / models newer than your
|
|
39
|
+
# installed registry (units per 1k tokens; 1e6 units = $1):
|
|
39
40
|
# config.model_prices["your-fine-tune"] = { in: 3000, out: 15_000 }
|
|
40
41
|
|
|
41
42
|
# Where eval scenarios live (bin/rails silas:eval).
|
data/lib/silas/agent.rb
CHANGED
|
@@ -16,6 +16,10 @@ module Silas
|
|
|
16
16
|
|
|
17
17
|
def model = @attrs["model"] || Silas.config.default_model
|
|
18
18
|
def description = @attrs["description"].to_s
|
|
19
|
+
# Optional JSON schema for the turn's final answer (raw Hash, passed to
|
|
20
|
+
# RubyLLM's with_schema). Model-visible state: folded into the definitions
|
|
21
|
+
# digest when present, so a mid-turn change fails loudly.
|
|
22
|
+
def final_answer = @attrs["final_answer"]
|
|
19
23
|
def limits = @attrs["limits"] || {}
|
|
20
24
|
def max_steps = limits["max_steps"] || Silas.config.max_steps
|
|
21
25
|
def max_input_tokens = limits["max_input_tokens"] # cumulative input tokens per turn
|
data/lib/silas/chat.rb
CHANGED
|
@@ -120,6 +120,8 @@ module Silas
|
|
|
120
120
|
when "completed"
|
|
121
121
|
if @last_streamed.present? && @last_streamed == turn.answer_text
|
|
122
122
|
@out.puts # the streamed line IS the answer; just terminate it
|
|
123
|
+
elsif turn.answer_text.blank? && (data = turn.answer_data)
|
|
124
|
+
@out.puts "agent> #{JSON.generate(data)}" # final_answer schema: the payload IS the answer
|
|
123
125
|
else
|
|
124
126
|
@out.puts "agent> #{turn.answer_text}"
|
|
125
127
|
end
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -39,35 +39,29 @@ module Silas
|
|
|
39
39
|
# tools as MCP" seam).
|
|
40
40
|
attr_accessor :mcp_server_host
|
|
41
41
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def warn_removed_agent_sdk_option(option)
|
|
59
|
-
message = "[Silas] config.#{option} was removed in 0.2 with the :agent_sdk engine and " \
|
|
60
|
-
"is now a no-op — delete it from your initializer (hard removal in 0.3)."
|
|
61
|
-
(defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
|
|
62
|
-
end
|
|
42
|
+
# config.auth and the agent_sdk_* options were removed with the :agent_sdk
|
|
43
|
+
# engine in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
|
|
44
|
+
# a leftover write now raises NoMethodError. Delete them from your
|
|
45
|
+
# initializer.
|
|
46
|
+
# JSON API (mounted under /silas/api/v1).
|
|
47
|
+
# api_auth — deny-by-default lambda, same contract as inbox_auth: the
|
|
48
|
+
# host DENIES by rendering (or head-ing) and PASSES by not
|
|
49
|
+
# rendering. Wire a token check, Devise, whatever you run.
|
|
50
|
+
# api_actor — controller -> identity string recorded on approvals made
|
|
51
|
+
# through the API (approved_by / declined by).
|
|
52
|
+
# api_stream_poll_interval — seconds between SSE row polls.
|
|
53
|
+
# api_stream_max_duration — seconds before an SSE stream closes itself
|
|
54
|
+
# (clients reconnect with Last-Event-ID); bounds thread hold.
|
|
55
|
+
attr_accessor :api_auth, :api_actor, :api_stream_poll_interval, :api_stream_max_duration
|
|
63
56
|
# Inbox (mountable UI at /silas/inbox).
|
|
64
57
|
# inbox_auth — deny-by-default lambda; the host renders/head-404s to
|
|
65
58
|
# DENY and passes by NOT rendering (resilience pattern).
|
|
66
59
|
# inbox_public_read — reads render for anyone; approve/decline still gated.
|
|
67
60
|
# inbox_actor — controller -> identity string (approved_by/decline by:).
|
|
68
|
-
# model_prices — model id -> {in:, out:} cost-units per
|
|
69
|
-
#
|
|
70
|
-
#
|
|
61
|
+
# model_prices — OVERRIDE map: model id -> {in:, out:} cost-units per
|
|
62
|
+
# 1k tokens, 1e6 units = $1 (a $3/M-token rate is
|
|
63
|
+
# 3000). Beats the RubyLLM registry, which prices
|
|
64
|
+
# everything else per (model, provider).
|
|
71
65
|
attr_accessor :inbox_auth, :inbox_public_read, :inbox_actor, :model_prices
|
|
72
66
|
# Force-disable live broadcasting even when turbo-rails is present (nil = auto).
|
|
73
67
|
attr_accessor :inbox_streaming
|
|
@@ -92,7 +86,9 @@ module Silas
|
|
|
92
86
|
@engine = :ruby_llm
|
|
93
87
|
# Must be resolvable by the installed ruby_llm's model registry — newer
|
|
94
88
|
# Claude models may need `RubyLLM.models.refresh!` before they resolve.
|
|
95
|
-
|
|
89
|
+
# (Sonnet 4.5 ships in every supported registry; never default a first
|
|
90
|
+
# run to the priciest model.)
|
|
91
|
+
@default_model = "claude-sonnet-4-5"
|
|
96
92
|
@queue_name = :default
|
|
97
93
|
@around_model_call = nil
|
|
98
94
|
@approval_ttl = 7.days
|
|
@@ -125,18 +121,18 @@ module Silas
|
|
|
125
121
|
@sandbox_workdir = "/workspace"
|
|
126
122
|
@sandbox_docker_bin = "docker"
|
|
127
123
|
@sandbox_timeout = 30
|
|
124
|
+
@api_auth = ->(controller) { controller.head :not_found } # deny by default
|
|
125
|
+
@api_actor = ->(_controller) { "api" }
|
|
126
|
+
@api_stream_poll_interval = 0.5
|
|
127
|
+
@api_stream_max_duration = 300
|
|
128
128
|
@inbox_auth = ->(controller) { controller.head :not_found } # deny by default
|
|
129
129
|
@inbox_public_read = false
|
|
130
130
|
@inbox_actor = ->(controller) { controller.try(:current_user)&.try(:email) || "inbox" }
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"claude-sonnet-4-6" => { in: 3000, out: 15_000 },
|
|
137
|
-
"claude-haiku-4-5" => { in: 1000, out: 5000 },
|
|
138
|
-
"claude-haiku-4-5-20251001" => { in: 1000, out: 5000 }
|
|
139
|
-
}
|
|
131
|
+
# OVERRIDE map only — pricing comes from RubyLLM's model registry
|
|
132
|
+
# (1,100+ models, refreshed upstream from models.dev). List a model here
|
|
133
|
+
# to beat the registry: fine-tunes, custom deployments, models newer
|
|
134
|
+
# than the installed registry. Units: per 1k tokens, 1e6 units = $1.
|
|
135
|
+
@model_prices = {}
|
|
140
136
|
end
|
|
141
137
|
|
|
142
138
|
def validate!
|
data/lib/silas/doctor.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module Silas
|
|
5
|
+
# One command for every known first-run failure mode: provider key, queue
|
|
6
|
+
# adapter, model resolution, migrations, tool validation, the rescuer
|
|
7
|
+
# entry, cable adapter for live streaming, and auth wiring. Each check was
|
|
8
|
+
# already written somewhere in the codebase — this makes them reachable as
|
|
9
|
+
# `bin/rails silas:doctor`.
|
|
10
|
+
class Doctor
|
|
11
|
+
Check = Struct.new(:status, :label, :detail) # status: :pass | :warn | :fail
|
|
12
|
+
|
|
13
|
+
def self.run(root: Rails.root) = new(root: root).run
|
|
14
|
+
|
|
15
|
+
def initialize(root:)
|
|
16
|
+
@root = Pathname(root)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
[
|
|
21
|
+
provider_credentials, queue_adapter, model_resolution, migrations,
|
|
22
|
+
agent_directory, rescuer_entry, streaming_cable, auth_wiring
|
|
23
|
+
].flatten.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def provider_credentials
|
|
29
|
+
configured = ::RubyLLM::Provider.providers.select do |_slug, provider|
|
|
30
|
+
requirements = provider.configuration_requirements
|
|
31
|
+
requirements.any? && requirements.all? { |key| ::RubyLLM.config.public_send(key).present? }
|
|
32
|
+
end.keys
|
|
33
|
+
if configured.any?
|
|
34
|
+
Check.new(:pass, "provider credentials", configured.join(", "))
|
|
35
|
+
else
|
|
36
|
+
Check.new(:fail, "provider credentials",
|
|
37
|
+
"no API key on RubyLLM.config — set one in config/initializers/ruby_llm.rb; " \
|
|
38
|
+
"the first turn will fail without it")
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Check.new(:warn, "provider credentials", "could not inspect RubyLLM config (#{e.class})")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def queue_adapter
|
|
45
|
+
name = ActiveJob::Base.queue_adapter.class.name.to_s
|
|
46
|
+
case name
|
|
47
|
+
when /SolidQueue/
|
|
48
|
+
Check.new(:pass, "queue adapter", "solid_queue (durable)")
|
|
49
|
+
when /AsyncAdapter/
|
|
50
|
+
Check.new(:fail, "queue adapter",
|
|
51
|
+
"in-process :async double-executes continuation steps and voids the durability " \
|
|
52
|
+
"contract — use :solid_queue (see DEPLOY.md)")
|
|
53
|
+
when /InlineAdapter/
|
|
54
|
+
Check.new(:warn, "queue adapter", "inline — fine for scripts and demos, no durability")
|
|
55
|
+
else
|
|
56
|
+
Check.new(:warn, "queue adapter", "#{name.demodulize} — durability requires a serializing, DB-backed adapter")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def model_resolution
|
|
61
|
+
model = Silas.agent.model
|
|
62
|
+
info = ::RubyLLM.models.find(model)
|
|
63
|
+
Check.new(:pass, "model #{model}",
|
|
64
|
+
"#{info.provider} · $#{info.input_price_per_million}/$#{info.output_price_per_million} per MTok")
|
|
65
|
+
rescue StandardError
|
|
66
|
+
Check.new(:fail, "model #{model || '?'}",
|
|
67
|
+
"not in ruby_llm's registry — `RubyLLM.models.refresh!` or pick a registry model")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def migrations
|
|
71
|
+
missing = %w[silas_sessions silas_turns silas_steps silas_tool_invocations]
|
|
72
|
+
.reject { |t| ActiveRecord::Base.connection.table_exists?(t) }
|
|
73
|
+
if missing.any?
|
|
74
|
+
return Check.new(:fail, "migrations",
|
|
75
|
+
"missing #{missing.join(', ')} — bin/rails silas:install:migrations db:migrate")
|
|
76
|
+
end
|
|
77
|
+
unless ActiveRecord::Base.connection.column_exists?(:silas_steps, :provider)
|
|
78
|
+
return Check.new(:warn, "migrations", "0.3 migration pending — bin/rails silas:install:migrations db:migrate")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Check.new(:pass, "migrations", "all silas tables present")
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Check.new(:fail, "database", "#{e.class}: #{e.message.lines.first&.strip}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def agent_directory
|
|
87
|
+
dir = @root.join("app/agent")
|
|
88
|
+
return Check.new(:fail, "app/agent", "missing — bin/rails generate silas:install") unless dir.exist?
|
|
89
|
+
|
|
90
|
+
checks = []
|
|
91
|
+
checks << Check.new(:warn, "instructions", "app/agent/instructions.md missing") unless dir.join("instructions.md").exist?
|
|
92
|
+
begin
|
|
93
|
+
registry = Silas::Registry.new(root: @root)
|
|
94
|
+
checks << Check.new(:pass, "tools", "#{registry.tools.size} tool(s) validate")
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
checks << Check.new(:fail, "tools", e.message)
|
|
97
|
+
end
|
|
98
|
+
checks
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def rescuer_entry
|
|
102
|
+
path = @root.join("config/recurring.yml")
|
|
103
|
+
unless path.exist?
|
|
104
|
+
return Check.new(:warn, "rescuer",
|
|
105
|
+
"config/recurring.yml missing — the dead-job rescuer is part of the durability contract")
|
|
106
|
+
end
|
|
107
|
+
if path.read.include?("silas_dead_job_rescuer")
|
|
108
|
+
Check.new(:pass, "rescuer", "recurring entry present")
|
|
109
|
+
else
|
|
110
|
+
Check.new(:warn, "rescuer", "no silas_dead_job_rescuer entry — SIGKILL recovery won't run")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def streaming_cable
|
|
115
|
+
unless Silas::Inbox.streaming_available?
|
|
116
|
+
return Check.new(:warn, "live streaming", "turbo-rails not bundled — the inbox falls back to a polling refresh")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
cable = @root.join("config/cable.yml")
|
|
120
|
+
adapter = begin
|
|
121
|
+
cable.exist? ? YAML.safe_load(ERB.new(cable.read).result, aliases: true)&.dig(Rails.env.to_s, "adapter") : nil
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
case adapter
|
|
126
|
+
when "async"
|
|
127
|
+
Check.new(:warn, "live streaming",
|
|
128
|
+
"cable adapter :async is single-process — token deltas emitted in the worker never " \
|
|
129
|
+
"reach the browser; use solid_cable or redis")
|
|
130
|
+
when nil
|
|
131
|
+
Check.new(:warn, "live streaming", "could not read a cable adapter from config/cable.yml")
|
|
132
|
+
else
|
|
133
|
+
Check.new(:pass, "live streaming", "cable adapter #{adapter}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def auth_wiring
|
|
138
|
+
[
|
|
139
|
+
auth_check("inbox auth", Silas.config.inbox_auth, "/silas/inbox", "config.inbox_auth"),
|
|
140
|
+
auth_check("api auth", Silas.config.api_auth, "/silas/api/v1", "config.api_auth")
|
|
141
|
+
]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The deny-by-default lambdas are defined inside silas/configuration.rb;
|
|
145
|
+
# anything the host wired has a different source_location.
|
|
146
|
+
def auth_check(label, auth_lambda, surface, option)
|
|
147
|
+
if auth_lambda.respond_to?(:source_location) &&
|
|
148
|
+
auth_lambda.source_location&.first.to_s.include?("silas/configuration")
|
|
149
|
+
Check.new(:warn, label, "deny-by-default — #{surface} is invisible until you set #{option}")
|
|
150
|
+
else
|
|
151
|
+
Check.new(:pass, label, "configured")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -43,6 +43,10 @@ module Silas
|
|
|
43
43
|
"or pick a registry-known model in config.default_model / agent.yml."
|
|
44
44
|
end
|
|
45
45
|
chat.with_instructions(context[:system]) if context[:system].present?
|
|
46
|
+
# agent.yml's final_answer schema: RubyLLM renders the provider's
|
|
47
|
+
# structured-output dialect and JSON-parses the response back to a
|
|
48
|
+
# Hash — which to_result persists as a "structured" block.
|
|
49
|
+
chat.with_schema(context[:final_answer]) if context[:final_answer].present?
|
|
46
50
|
context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
|
|
47
51
|
|
|
48
52
|
replay_history(chat, context[:messages])
|
|
@@ -117,8 +121,15 @@ module Silas
|
|
|
117
121
|
assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
|
|
118
122
|
|
|
119
123
|
blocks = []
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
content = assistant.content
|
|
125
|
+
if content.is_a?(Hash)
|
|
126
|
+
# with_schema active: RubyLLM parsed the response to a Hash. Persist
|
|
127
|
+
# it as its own block type — content.to_s here would write Ruby's
|
|
128
|
+
# Hash#inspect string into the transcript as "text".
|
|
129
|
+
blocks << { "type" => "structured", "data" => content }
|
|
130
|
+
elsif content.to_s.present?
|
|
131
|
+
blocks << { "type" => "text", "text" => content.to_s }
|
|
132
|
+
end
|
|
122
133
|
|
|
123
134
|
tool_calls = (assistant.tool_calls || {}).values.map do |tc|
|
|
124
135
|
blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
|
|
@@ -39,6 +39,24 @@ module Silas
|
|
|
39
39
|
check(matcher === @t.final_text, "final answer #{@t.final_text.inspect} does not match #{matcher.inspect}")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Structured (final_answer) assertions. With a Hash, expects the whole
|
|
43
|
+
# payload; with key/value, one field; with a block, a predicate over
|
|
44
|
+
# the payload. String keys — the payload is stored jsonb.
|
|
45
|
+
def assert_answer_data(expected = :__unset, key: nil, value: :__unset, &pred)
|
|
46
|
+
data = @t.answer_data
|
|
47
|
+
return check(false, "no structured answer (final_answer schema not set, or turn unfinished)") if data.nil?
|
|
48
|
+
|
|
49
|
+
if pred
|
|
50
|
+
check(pred.call(data), "answer_data predicate failed for #{data.inspect}")
|
|
51
|
+
elsif key
|
|
52
|
+
check(data[key.to_s] == value, "answer_data.#{key} expected #{value.inspect}, got #{data[key.to_s].inspect}")
|
|
53
|
+
elsif expected != :__unset
|
|
54
|
+
check(data == expected, "answer_data expected #{expected.inspect}, got #{data.inspect}")
|
|
55
|
+
else
|
|
56
|
+
check(true, nil) # presence alone
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
42
60
|
# No-hallucinated-price guard: every money amount in the final answer must
|
|
43
61
|
# trace to a number the agent actually saw (tool results or the user input),
|
|
44
62
|
# allowing pence<->pounds scaling.
|
|
@@ -14,6 +14,7 @@ module Silas
|
|
|
14
14
|
def completed? = @turn.completed?
|
|
15
15
|
def parked? = @turn.parked?
|
|
16
16
|
def final_text = @turn.answer_text.to_s
|
|
17
|
+
def answer_data = @turn.answer_data
|
|
17
18
|
def invocations = @turn.tool_invocations.order(:id).to_a
|
|
18
19
|
def invocations_for(name) = invocations.select { |i| i.tool_name == name.to_s }
|
|
19
20
|
def results = invocations.map(&:result).compact
|
data/lib/silas/inbox/cost.rb
CHANGED
|
@@ -1,45 +1,47 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
module Inbox
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Cost is derived at read time from step tokens. Prices come from
|
|
4
|
+
# config.model_prices (the OVERRIDE map — custom deployments, fine-tunes,
|
|
5
|
+
# models newer than the installed registry) and fall back to RubyLLM's
|
|
6
|
+
# model registry, priced per (model, provider) — 85/1081 registry ids
|
|
7
|
+
# exist under multiple providers at different prices, which is why steps
|
|
8
|
+
# stamp the provider. Unknown stays `unpriced`, never a lying $0.00.
|
|
7
9
|
module Cost
|
|
8
10
|
module_function
|
|
9
11
|
|
|
10
12
|
def for_session(session)
|
|
11
13
|
rows = Silas::Step.joins(:turn)
|
|
12
14
|
.where(silas_turns: { session_id: session.id })
|
|
13
|
-
.group(:model)
|
|
14
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
15
|
+
.group(:model, :provider)
|
|
16
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
15
17
|
aggregate(rows)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def for_turn(turn)
|
|
19
21
|
rows = Silas::Step.where(turn_id: turn.id)
|
|
20
|
-
.group(:model)
|
|
21
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
22
|
+
.group(:model, :provider)
|
|
23
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
22
24
|
aggregate(rows)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def for_agent(agent_name)
|
|
26
28
|
rows = Silas::Step.joins(turn: :session)
|
|
27
29
|
.where(silas_sessions: { agent_name: agent_name })
|
|
28
|
-
.group(:model)
|
|
29
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
30
|
+
.group(:model, :provider)
|
|
31
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
30
32
|
aggregate(rows)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def aggregate(rows)
|
|
34
36
|
input = output = microcents = 0
|
|
35
37
|
unpriced = false
|
|
36
|
-
rows.each do |model, in_tok, out_tok|
|
|
38
|
+
rows.each do |model, provider, in_tok, out_tok|
|
|
37
39
|
in_tok = in_tok.to_i
|
|
38
40
|
out_tok = out_tok.to_i
|
|
39
41
|
input += in_tok
|
|
40
42
|
output += out_tok
|
|
41
|
-
if (
|
|
42
|
-
microcents += (in_tok *
|
|
43
|
+
if (rate = rate_for(model, provider))
|
|
44
|
+
microcents += (in_tok * rate[:in] + out_tok * rate[:out]) / 1000
|
|
43
45
|
else
|
|
44
46
|
unpriced = true
|
|
45
47
|
end
|
|
@@ -47,6 +49,28 @@ module Silas
|
|
|
47
49
|
{ input_tokens: input, output_tokens: output, microcents: microcents, unpriced: unpriced }
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# {in:, out:} in cost-units per 1k tokens (1e6 units = $1), or nil when
|
|
53
|
+
# the model can't be priced. Override map first; then the registry —
|
|
54
|
+
# two-arg find when the step stamped a provider (the bare form
|
|
55
|
+
# tie-breaks by a hardcoded preference list and can price the wrong
|
|
56
|
+
# provider), registry $/MTok converted at x1000. A model with no price
|
|
57
|
+
# data returns nil: unknown is never zero.
|
|
58
|
+
def rate_for(model, provider)
|
|
59
|
+
if (price = Silas.config.model_prices[model])
|
|
60
|
+
return price
|
|
61
|
+
end
|
|
62
|
+
return nil if model.nil?
|
|
63
|
+
|
|
64
|
+
info = provider.present? ? ::RubyLLM.models.find(model, provider) : ::RubyLLM.models.find(model)
|
|
65
|
+
in_pm = info.input_price_per_million
|
|
66
|
+
out_pm = info.output_price_per_million
|
|
67
|
+
return nil unless in_pm && out_pm
|
|
68
|
+
|
|
69
|
+
{ in: (in_pm * 1000).round, out: (out_pm * 1000).round }
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
50
74
|
# microcents -> "$0.0123" (or nil when unpriced with no priced tokens)
|
|
51
75
|
def format(cents)
|
|
52
76
|
return nil if cents.nil?
|
|
@@ -50,9 +50,16 @@ module Silas
|
|
|
50
50
|
|
|
51
51
|
# Text comes from the model's own blocks; tool_use blocks are rebuilt from
|
|
52
52
|
# the settled invocations so the assistant message and the tool results that
|
|
53
|
-
# follow are always a matched set (same ids, same count).
|
|
53
|
+
# follow are always a matched set (same ids, same count). A structured
|
|
54
|
+
# (final_answer) block replays as its JSON text — deterministic (same rows
|
|
55
|
+
# -> same string), and providers need message content, not our block type.
|
|
54
56
|
def assistant_blocks(step, settled)
|
|
55
|
-
text = Array(step.response_blocks).
|
|
57
|
+
text = Array(step.response_blocks).filter_map do |b|
|
|
58
|
+
case b["type"]
|
|
59
|
+
when "text" then b
|
|
60
|
+
when "structured" then { "type" => "text", "text" => JSON.generate(b["data"]) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
56
63
|
tools = settled.map do |inv|
|
|
57
64
|
{ "type" => "tool_call", "id" => inv.tool_call_id,
|
|
58
65
|
"name" => inv.tool_name, "arguments" => inv.arguments || {} }
|
data/lib/silas/named_agent.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Silas
|
|
|
23
23
|
# Definition readers delegate to the scope's parsed agent.yml.
|
|
24
24
|
def model = scope.agent.model
|
|
25
25
|
def description = scope.agent.description
|
|
26
|
+
def final_answer = scope.agent.final_answer
|
|
26
27
|
def limits = scope.agent.limits
|
|
27
28
|
def max_steps = scope.agent.max_steps
|
|
28
29
|
def max_input_tokens = scope.agent.max_input_tokens
|
data/lib/silas/registry.rb
CHANGED
|
@@ -100,13 +100,20 @@ module Silas
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
# Stable across boots for the same agent definition; changes when any tool
|
|
103
|
-
# schema (incl. the delegate roster + remote connection tools),
|
|
104
|
-
# description, changes.
|
|
103
|
+
# schema (incl. the delegate roster + remote connection tools), skill
|
|
104
|
+
# description, or final_answer schema changes.
|
|
105
|
+
#
|
|
106
|
+
# final_answer is appended ONLY when present: schema-less agents keep a
|
|
107
|
+
# byte-identical digest across upgrades, so turns parked over a deploy
|
|
108
|
+
# never fail NondeterminismError for a key they don't use.
|
|
105
109
|
def digest
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
|
|
111
|
+
payload[:final_answer] = root_agent.final_answer if root_agent.final_answer.present?
|
|
112
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def root_agent
|
|
116
|
+
@root_agent ||= Silas::Agent.load(root: @root)
|
|
110
117
|
end
|
|
111
118
|
|
|
112
119
|
# --- named agents (app/agents/<name>/ — the staff pattern) ---------------
|
|
@@ -192,9 +199,12 @@ module Silas
|
|
|
192
199
|
builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
|
|
193
200
|
resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
|
|
194
201
|
definitions = (tools.values + builtins.values).map(&:schema)
|
|
195
|
-
|
|
202
|
+
loaded_agent = agent || Silas::Agent.load(dir: dir)
|
|
203
|
+
payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
|
|
204
|
+
payload[:final_answer] = loaded_agent.final_answer if loaded_agent.final_answer.present?
|
|
205
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
196
206
|
|
|
197
|
-
Silas::AgentScope.new(name: name, dir: dir, agent:
|
|
207
|
+
Silas::AgentScope.new(name: name, dir: dir, agent: loaded_agent,
|
|
198
208
|
resolver: resolver, definitions: definitions, digest: digest, skills: skills)
|
|
199
209
|
end
|
|
200
210
|
end
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Silas
|
|
|
22
22
|
stop_reason: result.stop_reason,
|
|
23
23
|
terminal: result.terminal?,
|
|
24
24
|
model: turn_model(turn),
|
|
25
|
+
provider: provider_for(turn_model(turn)),
|
|
25
26
|
input_tokens: result.usage&.dig(:input_tokens),
|
|
26
27
|
output_tokens: result.usage&.dig(:output_tokens)
|
|
27
28
|
)
|
|
@@ -58,6 +59,7 @@ module Silas
|
|
|
58
59
|
messages: MessageBuilder.call(turn, upto_index: index),
|
|
59
60
|
tools: Silas.tool_definitions,
|
|
60
61
|
model: turn_model(turn),
|
|
62
|
+
final_answer: Silas.agent.final_answer,
|
|
61
63
|
limits: { max_steps: Silas.agent.max_steps }
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -109,5 +111,15 @@ module Silas
|
|
|
109
111
|
def turn_model(_turn)
|
|
110
112
|
Silas.agent.model
|
|
111
113
|
end
|
|
114
|
+
|
|
115
|
+
# The provider RubyLLM's own resolution picks for this model id, stamped
|
|
116
|
+
# on the row so cost lookups price against (model, provider) forever
|
|
117
|
+
# after — the registry's tie-break can change; the row shouldn't. nil for
|
|
118
|
+
# ids the installed registry doesn't know (fakes, custom engines).
|
|
119
|
+
def provider_for(model)
|
|
120
|
+
::RubyLLM.models.find(model).provider
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
112
124
|
end
|
|
113
125
|
end
|
data/lib/silas/version.rb
CHANGED