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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/README.md +81 -14
  4. data/app/controllers/concerns/silas/api/serialization.rb +63 -0
  5. data/app/controllers/silas/api/base_controller.rb +24 -0
  6. data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
  7. data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
  8. data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
  9. data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
  11. data/app/controllers/silas/inbox/turns_controller.rb +51 -0
  12. data/app/helpers/silas/inbox/trace_helper.rb +5 -0
  13. data/app/jobs/silas/agent_loop_job.rb +46 -43
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  15. data/app/models/silas/session.rb +6 -0
  16. data/app/models/silas/tool_invocation.rb +13 -1
  17. data/app/models/silas/turn.rb +10 -0
  18. data/app/views/layouts/silas/inbox.html.erb +20 -0
  19. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  20. data/app/views/silas/inbox/sessions/index.html.erb +31 -5
  21. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  22. data/app/views/silas/inbox/steps/_step.html.erb +9 -1
  23. data/app/views/silas/inbox/turns/_header.html.erb +19 -1
  24. data/config/routes.rb +28 -1
  25. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  26. data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
  27. data/lib/generators/silas/install/install_generator.rb +34 -14
  28. data/lib/generators/silas/install/templates/agent.yml +10 -1
  29. data/lib/generators/silas/install/templates/bin_ci +2 -2
  30. data/lib/generators/silas/install/templates/initializer.rb +30 -5
  31. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  32. data/lib/silas/agent.rb +4 -0
  33. data/lib/silas/chat.rb +47 -13
  34. data/lib/silas/configuration.rb +77 -37
  35. data/lib/silas/delta_buffer.rb +50 -0
  36. data/lib/silas/doctor.rb +155 -0
  37. data/lib/silas/engine.rb +6 -0
  38. data/lib/silas/engines/base.rb +6 -8
  39. data/lib/silas/engines/ruby_llm.rb +28 -6
  40. data/lib/silas/errors.rb +3 -3
  41. data/lib/silas/eval/assertions.rb +18 -0
  42. data/lib/silas/eval/scripted_engine.rb +0 -2
  43. data/lib/silas/eval/transcript.rb +1 -0
  44. data/lib/silas/inbox/cost.rb +37 -13
  45. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  46. data/lib/silas/ledger.rb +26 -10
  47. data/lib/silas/mcp/handler.rb +6 -5
  48. data/lib/silas/mcp/server.rb +9 -9
  49. data/lib/silas/message_builder.rb +9 -2
  50. data/lib/silas/named_agent.rb +1 -0
  51. data/lib/silas/registry.rb +18 -8
  52. data/lib/silas/step_runner.rb +33 -6
  53. data/lib/silas/tool.rb +5 -0
  54. data/lib/silas/version.rb +1 -1
  55. data/lib/silas.rb +10 -9
  56. data/lib/tasks/silas_doctor.rake +17 -0
  57. metadata +14 -6
  58. data/lib/silas/agent_sdk/cli.rb +0 -59
  59. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  60. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  61. data/lib/silas/engines/agent_sdk.rb +0 -75
  62. 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
- entry = <<~YAML
73
+ path = File.expand_path(recurring, destination_root)
59
74
 
60
- # Silas: retries jobs failed by dead-worker reaping/pruning and sweeps
61
- # expired approvals. Part of the durability contract — do not remove.
62
- silas_dead_job_rescuer:
63
- class: Silas::DeadJobRescuerJob
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.exist?(File.expand_path(recurring, destination_root))
69
- append_to_file recurring, entry.indent(2)
70
- else
71
- create_file recurring, "production:#{entry.indent(2)}"
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: live at /silas/inbox once you set config.inbox_auth
95
- (deny-by-default until you do).
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 (deterministic; non-zero on failure).
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 2>/dev/null || true
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
- # :agent_sdk (a `claude -p` subprocess; API-key auth only). See silas/README.
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 Claude models
7
- # may need `RubyLLM.models.refresh!` first). Alternatives: "claude-sonnet-5"
8
- # (balanced), "claude-haiku-4-5" (fastest/cheapest).
9
- config.default_model = "claude-opus-4-8"
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
- if @session && pending_for(@session).exists? # resuming a session that parked last time
26
- settle_parked
27
- print_outcome(@session.turns.reload.last)
28
- end
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
- loop do
31
- @out.print "\nyou> "
32
- line = @in.gets
33
- break if line.nil?
31
+ loop do
32
+ @out.print "\nyou> "
33
+ line = @in.gets
34
+ break if line.nil?
34
35
 
35
- line = line.strip
36
- next if line.empty?
37
- break if %w[exit quit].include?(line.downcase)
36
+ line = line.strip
37
+ next if line.empty?
38
+ break if %w[exit quit].include?(line.downcase)
38
39
 
39
- submit(line)
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
- @out.puts "agent> #{turn.answer_text}"
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 " \
@@ -1,9 +1,7 @@
1
1
  module Silas
2
2
  class Configuration
3
- # Inference engine seam: :ruby_llm (Phase 1) or :agent_sdk (stub).
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
- # :agent_sdk engine knobs.
41
- attr_accessor :agent_sdk_claude_bin, :agent_sdk_model, :agent_sdk_mcp_host,
42
- :agent_sdk_mcp_timeout_ms, :agent_sdk_cli_version_range
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 1k tokens,
49
- # where 1_000_000 units = $1 (so a $3/M-token input rate
50
- # is 3000 units/1k). Cost.format divides by 1e6.
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
- @default_model = "claude-opus-4-8"
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
- @agent_sdk_claude_bin = "claude"
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
- # Current Claude rates as cost-units per 1k tokens (1e6 units = $1):
117
- # Opus 4.8 $5/$25, Sonnet $3/$15, Haiku $1/$5 per million tokens.
118
- @model_prices = {
119
- "claude-opus-4-8" => { in: 5000, out: 25_000 },
120
- "claude-sonnet-5" => { in: 3000, out: 15_000 },
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
- # PLAN.md §1, non-negotiable: raise if subscription OAuth is configured
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 && auth == :oauth && ENV["ANTHROPIC_API_KEY"].present?
145
+ if engine == :agent_sdk
138
146
  raise BootGuardError,
139
- "engine :agent_sdk with auth: :oauth is configured, but ANTHROPIC_API_KEY exists " \
140
- "in the environment. The key would silently override subscription OAuth and drain " \
141
- "API credits. Unset ANTHROPIC_API_KEY or switch to auth: :api_key."
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
- if engine == :agent_sdk && auth == :api_key && ENV["ANTHROPIC_API_KEY"].blank?
145
- raise BootGuardError,
146
- "engine :agent_sdk uses --bare (API-key auth only) but ANTHROPIC_API_KEY is not set."
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
- warn_unsafe_queue_adapter!
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
@@ -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?