silas 0.1.7 → 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 +153 -0
- data/README.md +81 -14
- 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 +39 -2
- data/app/controllers/silas/inbox/turns_controller.rb +51 -0
- data/app/helpers/silas/inbox/trace_helper.rb +5 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/session.rb +6 -0
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +10 -0
- data/app/views/layouts/silas/inbox.html.erb +20 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +31 -5
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +9 -1
- data/app/views/silas/inbox/turns/_header.html.erb +19 -1
- data/config/routes.rb +28 -1
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
- data/lib/generators/silas/install/install_generator.rb +34 -14
- data/lib/generators/silas/install/templates/agent.yml +10 -1
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +30 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/agent.rb +4 -0
- data/lib/silas/chat.rb +47 -13
- data/lib/silas/configuration.rb +77 -37
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/doctor.rb +155 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +28 -6
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/assertions.rb +18 -0
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/eval/transcript.rb +1 -0
- data/lib/silas/inbox/cost.rb +37 -13
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- 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 +33 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +10 -9
- data/lib/tasks/silas_doctor.rake +17 -0
- metadata +14 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
|
@@ -53,23 +53,41 @@ module Silas
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
RESCUER_ENTRY = <<~YAML.freeze
|
|
57
|
+
# Silas: retries jobs failed by dead-worker reaping/pruning, sweeps
|
|
58
|
+
# expired approvals, and fails turns stranded by dead loop jobs. Part
|
|
59
|
+
# of the durability contract — do not remove.
|
|
60
|
+
silas_dead_job_rescuer:
|
|
61
|
+
class: Silas::DeadJobRescuerJob
|
|
62
|
+
queue: default
|
|
63
|
+
schedule: every 30 seconds
|
|
64
|
+
YAML
|
|
65
|
+
|
|
66
|
+
# Idempotent and environment-aware: the entry is injected directly under
|
|
67
|
+
# EVERY deployable top-level env key (production, staging, …) — never a
|
|
68
|
+
# blind append into whatever block happens to end the file, and never a
|
|
69
|
+
# duplicate key on a re-run. A staging worker needs the rescuer exactly
|
|
70
|
+
# as much as production does.
|
|
56
71
|
def add_rescuer_recurring_task
|
|
57
72
|
recurring = "config/recurring.yml"
|
|
58
|
-
|
|
73
|
+
path = File.expand_path(recurring, destination_root)
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
queue: default
|
|
65
|
-
schedule: every 30 seconds
|
|
66
|
-
YAML
|
|
75
|
+
unless File.exist?(path)
|
|
76
|
+
create_file recurring, "production:\n#{RESCUER_ENTRY.indent(2)}"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
67
79
|
|
|
68
|
-
if File.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
if File.read(path).include?("silas_dead_job_rescuer")
|
|
81
|
+
say_status :skip, "#{recurring} already contains silas_dead_job_rescuer", :yellow
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
injected = false
|
|
86
|
+
gsub_file recurring, /^(?!development:|test:)([a-zA-Z_]+:)[ \t]*$/ do |header|
|
|
87
|
+
injected = true
|
|
88
|
+
"#{header}\n#{RESCUER_ENTRY.indent(2)}"
|
|
72
89
|
end
|
|
90
|
+
append_to_file recurring, "\nproduction:\n#{RESCUER_ENTRY.indent(2)}" unless injected
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
def install_migrations
|
|
@@ -84,6 +102,7 @@ module Silas
|
|
|
84
102
|
Silas installed. Next:
|
|
85
103
|
1. bin/rails db:migrate
|
|
86
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
|
|
87
106
|
3. Edit app/agent/instructions.md (your agent's persona)
|
|
88
107
|
4. Write tools in app/agent/tools/ (keyword signature = schema)
|
|
89
108
|
5. Talk to it: bin/rails silas:chat (or Silas.agent.start(input: "hello"))
|
|
@@ -91,8 +110,9 @@ module Silas
|
|
|
91
110
|
7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
|
|
92
111
|
for Slack; route inbound mail to Silas::AgentMailbox for email.
|
|
93
112
|
Delete app/agent/channels/{slack,email}.rb to disable.
|
|
94
|
-
8. Inbox
|
|
95
|
-
|
|
113
|
+
8. Inbox + web chat: /silas/inbox, deny-by-default — uncomment
|
|
114
|
+
config.inbox_auth in config/initializers/silas.rb to make it visible.
|
|
115
|
+
9. Restart your server if it was running (app/agent/ registers at boot).
|
|
96
116
|
MSG
|
|
97
117
|
end
|
|
98
118
|
end
|
|
@@ -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
|
+
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# CI gate: your app tests, then the agent evals
|
|
2
|
+
# CI gate: your app tests, then the agent evals. Either failing fails the gate.
|
|
3
3
|
set -e
|
|
4
4
|
echo "== app tests =="
|
|
5
|
-
bin/rails test
|
|
5
|
+
bin/rails test
|
|
6
6
|
echo "== agent evals =="
|
|
7
7
|
bin/rails silas:eval
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
Silas.configure do |config|
|
|
2
2
|
# Inference engine: :ruby_llm (API key, any provider RubyLLM supports), or
|
|
3
|
-
#
|
|
3
|
+
# any object responding to #execute_step. See silas/README.
|
|
4
4
|
config.engine = :ruby_llm
|
|
5
5
|
|
|
6
|
-
# Any model your installed ruby_llm's registry resolves (newer
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
6
|
+
# Any model your installed ruby_llm's registry resolves (newer models may
|
|
7
|
+
# need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
|
|
8
|
+
# default; "claude-haiku-4-5" is fastest/cheapest; Opus models are the most
|
|
9
|
+
# capable and the most expensive — set per-turn budgets in agent.yml before
|
|
10
|
+
# reaching for one.
|
|
11
|
+
config.default_model = "claude-sonnet-4-5"
|
|
12
|
+
|
|
13
|
+
# The operator inbox (mounted at /silas/inbox) is DENY-BY-DEFAULT — invisible
|
|
14
|
+
# until you wire auth. The lambda DENIES by rendering (or head-ing) and
|
|
15
|
+
# PASSES by not rendering. Devise-style example:
|
|
16
|
+
# config.inbox_auth = ->(controller) { controller.head :not_found unless controller.current_user&.admin? }
|
|
17
|
+
# config.inbox_public_read = true # read-only demo mode; writes stay gated
|
|
10
18
|
|
|
11
19
|
# Parked approvals expire after this long (the turn fails as approval_expired).
|
|
12
20
|
# config.approval_ttl = 7.days
|
|
@@ -16,4 +24,21 @@ Silas.configure do |config|
|
|
|
16
24
|
|
|
17
25
|
# Wrap every model call — e.g. ruby_llm-resilience:
|
|
18
26
|
# config.around_model_call = ->(ctx, &call) { RubyLLM::Resilience.chain(:anthropic) { call.() } }
|
|
27
|
+
|
|
28
|
+
# Sandbox for the run_code tool: :none (default, code exec off), :docker, or
|
|
29
|
+
# a hermetic backend (gem "hermetic"), e.g. Hermetic.gvisor(image: "python:3.12-slim").
|
|
30
|
+
# config.sandbox = :none
|
|
31
|
+
|
|
32
|
+
# Memory (the remember/recall tools): on by default, and every remember
|
|
33
|
+
# PARKS for human approval. :never auto-approves; config.memory = false
|
|
34
|
+
# disables memory entirely.
|
|
35
|
+
# config.memory_approval = :always
|
|
36
|
+
|
|
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):
|
|
40
|
+
# config.model_prices["your-fine-tune"] = { in: 3000, out: 15_000 }
|
|
41
|
+
|
|
42
|
+
# Where eval scenarios live (bin/rails silas:eval).
|
|
43
|
+
# config.eval_dir = "test/agent_evals"
|
|
19
44
|
end
|
|
@@ -6,4 +6,8 @@
|
|
|
6
6
|
# Any provider RubyLLM supports works the same way (openai_api_key, etc.).
|
|
7
7
|
RubyLLM.configure do |c|
|
|
8
8
|
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
9
|
+
# The per-request timeout (seconds; RubyLLM default 300). Under streaming
|
|
10
|
+
# this is an idle-between-chunks timeout — the hang protection for a stuck
|
|
11
|
+
# provider connection.
|
|
12
|
+
# c.request_timeout = 120
|
|
9
13
|
end if ENV["ANTHROPIC_API_KEY"].present?
|
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
|
@@ -22,21 +22,23 @@ module Silas
|
|
|
22
22
|
|
|
23
23
|
def run
|
|
24
24
|
banner
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
with_delta_stream do
|
|
26
|
+
if @session && pending_for(@session).exists? # resuming a session that parked last time
|
|
27
|
+
settle_parked
|
|
28
|
+
print_outcome(@session.turns.reload.last)
|
|
29
|
+
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
loop do
|
|
32
|
+
@out.print "\nyou> "
|
|
33
|
+
line = @in.gets
|
|
34
|
+
break if line.nil?
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
line = line.strip
|
|
37
|
+
next if line.empty?
|
|
38
|
+
break if %w[exit quit].include?(line.downcase)
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
submit(line)
|
|
41
|
+
end
|
|
40
42
|
end
|
|
41
43
|
@out.puts "bye — session #{@session.id}" if @session
|
|
42
44
|
end
|
|
@@ -56,7 +58,33 @@ module Silas
|
|
|
56
58
|
@out.puts "Resuming session #{@session.id} (#{@session.turns.count} turns)." if @session
|
|
57
59
|
end
|
|
58
60
|
|
|
61
|
+
# The REPL runs inline, in the same process as the loop — so it hears the
|
|
62
|
+
# "silas.delta" notifications and prints tokens as they arrive. Filtered by
|
|
63
|
+
# session id: notifications are process-global.
|
|
64
|
+
def with_delta_stream
|
|
65
|
+
@live = {}
|
|
66
|
+
subscription = ActiveSupport::Notifications.subscribe("silas.delta") do |*args|
|
|
67
|
+
payload = args.last
|
|
68
|
+
print_delta(payload) if @session && payload[:session_id] == @session.id
|
|
69
|
+
end
|
|
70
|
+
yield
|
|
71
|
+
ensure
|
|
72
|
+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_delta(payload)
|
|
76
|
+
printed = @live[payload[:step_id]] || 0
|
|
77
|
+
text = payload[:text].to_s
|
|
78
|
+
@out.print "\nagent> " if printed.zero?
|
|
79
|
+
@out.print text[printed..]
|
|
80
|
+
@out.flush if @out.respond_to?(:flush)
|
|
81
|
+
@live[payload[:step_id]] = text.length
|
|
82
|
+
@last_streamed = text
|
|
83
|
+
end
|
|
84
|
+
|
|
59
85
|
def submit(text)
|
|
86
|
+
@live = {}
|
|
87
|
+
@last_streamed = nil
|
|
60
88
|
turn =
|
|
61
89
|
if @session.nil?
|
|
62
90
|
@session = agent_handle.start(input: text)
|
|
@@ -90,7 +118,13 @@ module Silas
|
|
|
90
118
|
turn.reload
|
|
91
119
|
case turn.status
|
|
92
120
|
when "completed"
|
|
93
|
-
@
|
|
121
|
+
if @last_streamed.present? && @last_streamed == turn.answer_text
|
|
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
|
|
125
|
+
else
|
|
126
|
+
@out.puts "agent> #{turn.answer_text}"
|
|
127
|
+
end
|
|
94
128
|
when "waiting", "in_doubt"
|
|
95
129
|
if turn.budget_parked?
|
|
96
130
|
@out.puts "(parked: #{turn.failure_reason} budget reached — resume with " \
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
class Configuration
|
|
3
|
-
# Inference engine seam: :ruby_llm
|
|
3
|
+
# Inference engine seam: :ruby_llm, or any object responding to #execute_step.
|
|
4
4
|
attr_accessor :engine
|
|
5
|
-
# Auth mode for :agent_sdk — :api_key or :oauth (subscription).
|
|
6
|
-
attr_accessor :auth
|
|
7
5
|
# Default model when agent.yml doesn't specify one.
|
|
8
6
|
attr_accessor :default_model
|
|
9
7
|
# Active Job queue for agent turns.
|
|
@@ -37,17 +35,33 @@ module Silas
|
|
|
37
35
|
# default to credentials.dig(:silas, :slack, ...); nil disables Slack.
|
|
38
36
|
attr_accessor :channel_resolver
|
|
39
37
|
attr_writer :slack_signing_secret, :slack_bot_token
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
# Bind host for the in-process MCP server (Mcp::Server — the "mount your
|
|
39
|
+
# tools as MCP" seam).
|
|
40
|
+
attr_accessor :mcp_server_host
|
|
41
|
+
|
|
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
|
|
43
56
|
# Inbox (mountable UI at /silas/inbox).
|
|
44
57
|
# inbox_auth — deny-by-default lambda; the host renders/head-404s to
|
|
45
58
|
# DENY and passes by NOT rendering (resilience pattern).
|
|
46
59
|
# inbox_public_read — reads render for anyone; approve/decline still gated.
|
|
47
60
|
# inbox_actor — controller -> identity string (approved_by/decline by:).
|
|
48
|
-
# model_prices — model id -> {in:, out:} cost-units per
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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).
|
|
51
65
|
attr_accessor :inbox_auth, :inbox_public_read, :inbox_actor, :model_prices
|
|
52
66
|
# Force-disable live broadcasting even when turbo-rails is present (nil = auto).
|
|
53
67
|
attr_accessor :inbox_streaming
|
|
@@ -70,10 +84,11 @@ module Silas
|
|
|
70
84
|
|
|
71
85
|
def initialize
|
|
72
86
|
@engine = :ruby_llm
|
|
73
|
-
@auth = :api_key
|
|
74
87
|
# Must be resolvable by the installed ruby_llm's model registry — newer
|
|
75
88
|
# Claude models may need `RubyLLM.models.refresh!` before they resolve.
|
|
76
|
-
|
|
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"
|
|
77
92
|
@queue_name = :default
|
|
78
93
|
@around_model_call = nil
|
|
79
94
|
@approval_ttl = 7.days
|
|
@@ -91,11 +106,7 @@ module Silas
|
|
|
91
106
|
@channel_resolver = nil
|
|
92
107
|
@slack_signing_secret = nil
|
|
93
108
|
@slack_bot_token = nil
|
|
94
|
-
@
|
|
95
|
-
@agent_sdk_model = nil # falls back to the agent model; must be a CLI-accepted id
|
|
96
|
-
@agent_sdk_mcp_host = "127.0.0.1"
|
|
97
|
-
@agent_sdk_mcp_timeout_ms = 15_000
|
|
98
|
-
@agent_sdk_cli_version_range = ">= 2.1.150, < 3"
|
|
109
|
+
@mcp_server_host = "127.0.0.1"
|
|
99
110
|
@eval_dir = "test/agent_evals"
|
|
100
111
|
@eval_grader = nil
|
|
101
112
|
@sandbox = :none
|
|
@@ -110,18 +121,18 @@ module Silas
|
|
|
110
121
|
@sandbox_workdir = "/workspace"
|
|
111
122
|
@sandbox_docker_bin = "docker"
|
|
112
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
|
|
113
128
|
@inbox_auth = ->(controller) { controller.head :not_found } # deny by default
|
|
114
129
|
@inbox_public_read = false
|
|
115
130
|
@inbox_actor = ->(controller) { controller.try(:current_user)&.try(:email) || "inbox" }
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"claude-sonnet-4-6" => { in: 3000, out: 15_000 },
|
|
122
|
-
"claude-haiku-4-5" => { in: 1000, out: 5000 },
|
|
123
|
-
"claude-haiku-4-5-20251001" => { in: 1000, out: 5000 }
|
|
124
|
-
}
|
|
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 = {}
|
|
125
136
|
end
|
|
126
137
|
|
|
127
138
|
def validate!
|
|
@@ -129,24 +140,45 @@ module Silas
|
|
|
129
140
|
self
|
|
130
141
|
end
|
|
131
142
|
|
|
132
|
-
#
|
|
133
|
-
# while an API key is present — the key would silently win and bill credits.
|
|
134
|
-
# The inverse also holds: :agent_sdk runs --bare (API-key auth only), so
|
|
135
|
-
# api_key mode needs a key present.
|
|
143
|
+
# Fail-loud misconfiguration checks, run from Silas.configure and at boot.
|
|
136
144
|
def boot_guard!
|
|
137
|
-
if engine == :agent_sdk
|
|
145
|
+
if engine == :agent_sdk
|
|
138
146
|
raise BootGuardError,
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
147
|
+
"the :agent_sdk engine was removed in Silas 0.2 — the claude -p subprocess " \
|
|
148
|
+
"integration is gone (its subscription-auth rationale was unreachable). " \
|
|
149
|
+
"Use engine :ruby_llm, the production path."
|
|
142
150
|
end
|
|
143
151
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
check_provider_credentials!
|
|
153
|
+
warn_unsafe_queue_adapter!
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The most common first-run failure: no provider key configured, so the
|
|
157
|
+
# first turn dies deep inside RubyLLM with a third-party error. Surface it
|
|
158
|
+
# at boot with the fix. Raises in production (a keyless prod deploy is
|
|
159
|
+
# always a misconfiguration); warns in development so a fresh app can
|
|
160
|
+
# still boot and browse the inbox before a key exists.
|
|
161
|
+
def check_provider_credentials!
|
|
162
|
+
return unless engine == :ruby_llm && defined?(::RubyLLM)
|
|
163
|
+
|
|
164
|
+
providers = ::RubyLLM::Provider.providers.values
|
|
165
|
+
configured = providers.any? do |provider|
|
|
166
|
+
requirements = provider.configuration_requirements
|
|
167
|
+
requirements.any? && requirements.all? { |key| ::RubyLLM.config.public_send(key).present? }
|
|
147
168
|
end
|
|
169
|
+
return if configured
|
|
148
170
|
|
|
149
|
-
|
|
171
|
+
message = "[Silas] engine :ruby_llm has no configured provider — no API key is set on " \
|
|
172
|
+
"RubyLLM.config. Set one in config/initializers/ruby_llm.rb, e.g. " \
|
|
173
|
+
"RubyLLM.configure { |c| c.anthropic_api_key = ENV[\"ANTHROPIC_API_KEY\"] } " \
|
|
174
|
+
"— the first agent turn will fail without it."
|
|
175
|
+
raise BootGuardError, message if defined?(::Rails) && ::Rails.env.production?
|
|
176
|
+
|
|
177
|
+
(defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
|
|
178
|
+
rescue BootGuardError
|
|
179
|
+
raise
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil # a diagnostic must never break boot
|
|
150
182
|
end
|
|
151
183
|
|
|
152
184
|
# Silas's durability and exactly-once guarantees rest on the queue adapter:
|
|
@@ -158,6 +190,10 @@ module Silas
|
|
|
158
190
|
# mints new tool_call ids the ledger cannot dedup). Solid Queue — or any
|
|
159
191
|
# durable, serializing, DB-backed adapter — is required to run agents; the
|
|
160
192
|
# synchronous :inline adapter is safe for scripts and demos (no durability).
|
|
193
|
+
#
|
|
194
|
+
# RAISES in production — running agents on the Async adapter there silently
|
|
195
|
+
# voids the durability contract. Warns in development (Rails' dev default
|
|
196
|
+
# is :async and a fresh app must still boot).
|
|
161
197
|
def warn_unsafe_queue_adapter!
|
|
162
198
|
return unless defined?(::ActiveJob::Base)
|
|
163
199
|
|
|
@@ -169,7 +205,11 @@ module Silas
|
|
|
169
205
|
"the original job, which double-executes agent steps and breaks " \
|
|
170
206
|
"exactly-once tool effects. Use :solid_queue (production) or " \
|
|
171
207
|
":inline (scripts/demos). See Silas DEPLOY.md."
|
|
208
|
+
raise BootGuardError, message if defined?(::Rails) && ::Rails.env.production?
|
|
209
|
+
|
|
172
210
|
(defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
|
|
211
|
+
rescue BootGuardError
|
|
212
|
+
raise
|
|
173
213
|
rescue StandardError
|
|
174
214
|
# A diagnostic must never break boot.
|
|
175
215
|
nil
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Coalesces model text deltas into ~10Hz "silas.delta" notifications carrying
|
|
3
|
+
# the ACCUMULATED text so far — subscribers replace rather than append, which
|
|
4
|
+
# is idempotent under a crash-restream (same step id, fresh stream overwrites
|
|
5
|
+
# itself) and ordering-safe under Turbo. Deltas are decoration over the
|
|
6
|
+
# authoritative row render: never persisted, never fed back to the model, and
|
|
7
|
+
# a replayed step (already completed) emits none.
|
|
8
|
+
#
|
|
9
|
+
# Payload: { session_id:, turn_id:, step_id:, step_index:, text: } — every
|
|
10
|
+
# subscriber MUST filter by these ids (notifications are process-global; a
|
|
11
|
+
# busy worker interleaves deltas from concurrent turns).
|
|
12
|
+
class DeltaBuffer
|
|
13
|
+
INTERVAL = 0.1 # seconds between publishes; #finish flushes the tail
|
|
14
|
+
|
|
15
|
+
def initialize(turn:, step:)
|
|
16
|
+
@turn = turn
|
|
17
|
+
@step = step
|
|
18
|
+
@text = +""
|
|
19
|
+
@published = 0
|
|
20
|
+
@last_publish = 0.0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def append(text)
|
|
24
|
+
return if text.empty?
|
|
25
|
+
|
|
26
|
+
@text << text
|
|
27
|
+
publish if clock - @last_publish >= INTERVAL
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The final flush. StepRunner calls this BEFORE the step row commits, so
|
|
31
|
+
# the authoritative after_commit render can never race a straggling batch.
|
|
32
|
+
def finish = publish
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def publish
|
|
37
|
+
return if @text.empty? || @text.length == @published
|
|
38
|
+
|
|
39
|
+
@published = @text.length
|
|
40
|
+
@last_publish = clock
|
|
41
|
+
ActiveSupport::Notifications.instrument(
|
|
42
|
+
"silas.delta",
|
|
43
|
+
session_id: @turn.session_id, turn_id: @turn.id,
|
|
44
|
+
step_id: @step.id, step_index: @step.index, text: @text.dup
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
end
|
|
50
|
+
end
|
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
|
data/lib/silas/engine.rb
CHANGED
|
@@ -37,6 +37,12 @@ module Silas
|
|
|
37
37
|
Silas.config.boot_guard!
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Live token streaming into the inbox trace: one process-wide subscriber on
|
|
41
|
+
# "silas.delta"; a no-op unless turbo-rails is present and streaming is on.
|
|
42
|
+
initializer "silas.delta_broadcaster" do
|
|
43
|
+
Silas::Inbox::DeltaBroadcaster.subscribe!
|
|
44
|
+
end
|
|
45
|
+
|
|
40
46
|
# Registry rebuilds on every code reload in development, once in production.
|
|
41
47
|
initializer "silas.registry" do |app|
|
|
42
48
|
next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
|