silas 0.1.6 → 0.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +55 -14
  4. data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
  5. data/app/controllers/silas/inbox/turns_controller.rb +32 -0
  6. data/app/jobs/silas/agent_loop_job.rb +46 -43
  7. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  8. data/app/models/silas/memory.rb +51 -0
  9. data/app/models/silas/session.rb +5 -3
  10. data/app/models/silas/tool_invocation.rb +13 -1
  11. data/app/models/silas/turn.rb +1 -0
  12. data/app/views/layouts/silas/inbox.html.erb +14 -0
  13. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  14. data/app/views/silas/inbox/sessions/index.html.erb +20 -2
  15. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  16. data/app/views/silas/inbox/steps/_step.html.erb +6 -1
  17. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  18. data/config/routes.rb +6 -1
  19. data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
  20. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  21. data/lib/generators/silas/install/install_generator.rb +33 -14
  22. data/lib/generators/silas/install/templates/bin_ci +2 -2
  23. data/lib/generators/silas/install/templates/initializer.rb +29 -5
  24. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  25. data/lib/silas/chat.rb +45 -13
  26. data/lib/silas/configuration.rb +75 -24
  27. data/lib/silas/delta_buffer.rb +50 -0
  28. data/lib/silas/engine.rb +6 -0
  29. data/lib/silas/engines/base.rb +6 -8
  30. data/lib/silas/engines/ruby_llm.rb +15 -4
  31. data/lib/silas/errors.rb +3 -3
  32. data/lib/silas/eval/scripted_engine.rb +0 -2
  33. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  34. data/lib/silas/instructions.rb +13 -1
  35. data/lib/silas/ledger.rb +26 -10
  36. data/lib/silas/mcp/handler.rb +6 -5
  37. data/lib/silas/mcp/server.rb +9 -9
  38. data/lib/silas/nested_runner.rb +2 -2
  39. data/lib/silas/registry.rb +12 -2
  40. data/lib/silas/step_runner.rb +21 -6
  41. data/lib/silas/tool.rb +5 -0
  42. data/lib/silas/tools/handoff.rb +68 -0
  43. data/lib/silas/tools/recall.rb +18 -0
  44. data/lib/silas/tools/remember.rb +32 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas.rb +20 -9
  47. metadata +10 -6
  48. data/lib/silas/agent_sdk/cli.rb +0 -59
  49. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  50. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  51. data/lib/silas/engines/agent_sdk.rb +0 -75
  52. data/lib/silas/subprocess_runner.rb +0 -41
@@ -1,5 +1,7 @@
1
1
  <% content_for :header_extra do %>
2
- <% if @pending_total.positive? %><span class="badge"><%= @pending_total %> awaiting approval</span><% end %>
2
+ <% if @pending_total.positive? %>
3
+ <%= link_to "#{@pending_total} awaiting approval", inbox_sessions_path(pending: 1), class: "badge" %>
4
+ <% end %>
3
5
  <% end %>
4
6
 
5
7
  <% if @agent_names && @agent_names.size > 1 %>
@@ -12,8 +14,24 @@
12
14
  </div>
13
15
  <% end %>
14
16
 
17
+ <div class="card composer">
18
+ <%= form_with url: inbox_sessions_path do |f| %>
19
+ <% roster = Silas.named_agent_scopes.keys.sort %>
20
+ <% if roster.any? %>
21
+ <%= f.select :agent, [ [ "agent (root)", "" ] ] + roster.map { |a| [ a, a ] },
22
+ {}, class: "composer-agent" %>
23
+ <% end %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Start a new session…" %>
26
+ <div class="composer-row">
27
+ <span></span>
28
+ <%= f.submit "Start session", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+
15
33
  <% if @sessions.empty? %>
16
- <div class="empty">No agent sessions yet.</div>
34
+ <div class="empty">No agent sessions yet — start one above.</div>
17
35
  <% else %>
18
36
  <% @sessions.each do |session| %>
19
37
  <%= link_to inbox_session_path(session), class: "card session-row" do %>
@@ -18,3 +18,14 @@
18
18
  <div id="silas-turns">
19
19
  <%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
20
20
  </div>
21
+
22
+ <div class="card composer">
23
+ <%= form_with url: inbox_session_turns_path(@session) do |f| %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Message #{@session.agent_name}…" %>
26
+ <div class="composer-row">
27
+ <span class="muted"><%= "a turn is running — sending will queue behind it" if @session.active_turn %></span>
28
+ <%= f.submit "Send", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
@@ -1,6 +1,11 @@
1
1
  <div class="step" id="<%= dom_id(step) %>">
2
+ <%# The text container is unconditional: silas-step-<id>-text is the live
3
+ delta target. While the step runs, deltas replace its innerHTML; the
4
+ completed row render (this partial, re-broadcast) supersedes them. %>
2
5
  <% if (text = step_text(step)) %>
3
- <div class="step-text"><%= simple_format(text) %></div>
6
+ <div class="step-text" id="silas-step-<%= step.id %>-text"><%= simple_format(text) %></div>
7
+ <% else %>
8
+ <div class="step-text step-live" id="silas-step-<%= step.id %>-text"></div>
4
9
  <% end %>
5
10
  <div id="silas-step-<%= step.id %>-tools">
6
11
  <%= render partial: "silas/inbox/invocations/invocation", collection: step.tool_invocations, as: :invocation %>
@@ -1,6 +1,13 @@
1
1
  <div class="turn-head">
2
2
  <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
3
  <%= status_pill(turn.status) %>
4
+ <% if turn.active? && !turn.cancel_requested_at %>
5
+ <%= form_with url: cancel_inbox_turn_path(turn), method: :post, class: "inline" do %>
6
+ <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
7
+ <% end %>
8
+ <% elsif turn.cancel_requested_at && turn.active? %>
9
+ <span class="muted">cancel requested…</span>
10
+ <% end %>
4
11
  </div>
5
12
  <% if turn.failure_reason.present? %>
6
13
  <div class="muted">failed: <%= turn.failure_reason %></div>
data/config/routes.rb CHANGED
@@ -8,7 +8,12 @@ Silas::Engine.routes.draw do
8
8
 
9
9
  namespace :inbox do
10
10
  root to: "sessions#index"
11
- resources :sessions, only: %i[index show]
11
+ resources :sessions, only: %i[index show create] do
12
+ resources :turns, only: :create
13
+ end
14
+ resources :turns, only: [] do
15
+ member { post :cancel }
16
+ end
12
17
  resources :invocations, only: [] do
13
18
  member do
14
19
  post :approve
@@ -0,0 +1,22 @@
1
+ class CreateSilasMemories < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Agent memory: entity-attributed facts with provenance and supersession
4
+ # (graph-SHAPED — triples, no edges until the need is proven). Domain
5
+ # memory belongs in YOUR tables; this is only for the fuzzy residue that
6
+ # has no natural home ("author X prefers CSV", "reports arrive on the 15th").
7
+ create_table :silas_memories do |t|
8
+ t.string :agent_name, null: false # whose memory ("agent" = root)
9
+ t.string :scope, null: false, default: "agent" # agent (private) | app (shared)
10
+ t.string :subject, null: false # entity ref, e.g. "author:jane"
11
+ t.string :attribute_name # optional: triple form
12
+ t.text :content, null: false # the fact, plain prose
13
+ t.string :status, null: false, default: "active" # active | superseded
14
+ t.bigint :superseded_by_id
15
+ t.bigint :session_id # provenance
16
+ t.bigint :turn_id
17
+ t.timestamps
18
+ end
19
+ add_index :silas_memories, [ :agent_name, :scope, :status ]
20
+ add_index :silas_memories, [ :subject, :status ]
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ class DropAgentSdkColumnsFromSilasTurns < ActiveRecord::Migration[8.1]
2
+ # The :agent_sdk engine was removed in 0.2. cli_session_id was its fail-closed
3
+ # resume marker; mcp_token was written by Mcp::Server but read by nothing (the
4
+ # token is minted and compared in memory).
5
+ def change
6
+ remove_column :silas_turns, :cli_session_id, :string
7
+ remove_column :silas_turns, :mcp_token, :string
8
+ end
9
+ end
@@ -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
@@ -91,8 +109,9 @@ module Silas
91
109
  7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
92
110
  for Slack; route inbound mail to Silas::AgentMailbox for email.
93
111
  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).
112
+ 8. Inbox + web chat: /silas/inbox, deny-by-default uncomment
113
+ config.inbox_auth in config/initializers/silas.rb to make it visible.
114
+ 9. Restart your server if it was running (app/agent/ registers at boot).
96
115
  MSG
97
116
  end
98
117
  end
@@ -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-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-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,20 @@ 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 price overrides: cost-units per 1k tokens, where
38
+ # 1_000_000 units = $1 (so a $3/M-token rate is 3000).
39
+ # config.model_prices["your-fine-tune"] = { in: 3000, out: 15_000 }
40
+
41
+ # Where eval scenarios live (bin/rails silas:eval).
42
+ # config.eval_dir = "test/agent_evals"
19
43
  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/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,11 @@ 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
+ else
124
+ @out.puts "agent> #{turn.answer_text}"
125
+ end
94
126
  when "waiting", "in_doubt"
95
127
  if turn.budget_parked?
96
128
  @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.
@@ -29,13 +27,39 @@ module Silas
29
27
 
30
28
  # Named top-level agents (app/agents/<name>/): lambda -> { name => AgentScope }.
31
29
  attr_accessor :named_agent_scopes
30
+
31
+ # Memory (silas_memories): memory=false disables entirely; memory_approval
32
+ # :always parks every remember for a human (default), :never auto-approves.
33
+ attr_accessor :memory, :memory_approval, :memory_injection_limit
32
34
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
33
35
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
34
36
  attr_accessor :channel_resolver
35
37
  attr_writer :slack_signing_secret, :slack_bot_token
36
- # :agent_sdk engine knobs.
37
- attr_accessor :agent_sdk_claude_bin, :agent_sdk_model, :agent_sdk_mcp_host,
38
- :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
+ # Removed in 0.2 with the :agent_sdk engine. Accepted as warning no-ops for
43
+ # one release so an existing initializer doesn't crash at boot; hard removal
44
+ # in 0.3.
45
+ REMOVED_AGENT_SDK_OPTIONS = %i[
46
+ auth agent_sdk_claude_bin agent_sdk_model agent_sdk_mcp_host
47
+ agent_sdk_mcp_timeout_ms agent_sdk_cli_version_range
48
+ ].freeze
49
+
50
+ REMOVED_AGENT_SDK_OPTIONS.each do |option|
51
+ define_method("#{option}=") { |_value| warn_removed_agent_sdk_option(option) }
52
+ define_method(option) do
53
+ warn_removed_agent_sdk_option(option)
54
+ nil
55
+ end
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
39
63
  # Inbox (mountable UI at /silas/inbox).
40
64
  # inbox_auth — deny-by-default lambda; the host renders/head-404s to
41
65
  # DENY and passes by NOT rendering (resilience pattern).
@@ -66,7 +90,6 @@ module Silas
66
90
 
67
91
  def initialize
68
92
  @engine = :ruby_llm
69
- @auth = :api_key
70
93
  # Must be resolvable by the installed ruby_llm's model registry — newer
71
94
  # Claude models may need `RubyLLM.models.refresh!` before they resolve.
72
95
  @default_model = "claude-opus-4-8"
@@ -87,14 +110,13 @@ module Silas
87
110
  @channel_resolver = nil
88
111
  @slack_signing_secret = nil
89
112
  @slack_bot_token = nil
90
- @agent_sdk_claude_bin = "claude"
91
- @agent_sdk_model = nil # falls back to the agent model; must be a CLI-accepted id
92
- @agent_sdk_mcp_host = "127.0.0.1"
93
- @agent_sdk_mcp_timeout_ms = 15_000
94
- @agent_sdk_cli_version_range = ">= 2.1.150, < 3"
113
+ @mcp_server_host = "127.0.0.1"
95
114
  @eval_dir = "test/agent_evals"
96
115
  @eval_grader = nil
97
116
  @sandbox = :none
117
+ @memory = true
118
+ @memory_approval = :always
119
+ @memory_injection_limit = 8
98
120
  @sandbox_image = nil
99
121
  @sandbox_network = "none"
100
122
  @sandbox_memory = "512m"
@@ -122,24 +144,45 @@ module Silas
122
144
  self
123
145
  end
124
146
 
125
- # PLAN.md §1, non-negotiable: raise if subscription OAuth is configured
126
- # while an API key is present — the key would silently win and bill credits.
127
- # The inverse also holds: :agent_sdk runs --bare (API-key auth only), so
128
- # api_key mode needs a key present.
147
+ # Fail-loud misconfiguration checks, run from Silas.configure and at boot.
129
148
  def boot_guard!
130
- if engine == :agent_sdk && auth == :oauth && ENV["ANTHROPIC_API_KEY"].present?
149
+ if engine == :agent_sdk
131
150
  raise BootGuardError,
132
- "engine :agent_sdk with auth: :oauth is configured, but ANTHROPIC_API_KEY exists " \
133
- "in the environment. The key would silently override subscription OAuth and drain " \
134
- "API credits. Unset ANTHROPIC_API_KEY or switch to auth: :api_key."
151
+ "the :agent_sdk engine was removed in Silas 0.2 the claude -p subprocess " \
152
+ "integration is gone (its subscription-auth rationale was unreachable). " \
153
+ "Use engine :ruby_llm, the production path."
135
154
  end
136
155
 
137
- if engine == :agent_sdk && auth == :api_key && ENV["ANTHROPIC_API_KEY"].blank?
138
- raise BootGuardError,
139
- "engine :agent_sdk uses --bare (API-key auth only) but ANTHROPIC_API_KEY is not set."
156
+ check_provider_credentials!
157
+ warn_unsafe_queue_adapter!
158
+ end
159
+
160
+ # The most common first-run failure: no provider key configured, so the
161
+ # first turn dies deep inside RubyLLM with a third-party error. Surface it
162
+ # at boot with the fix. Raises in production (a keyless prod deploy is
163
+ # always a misconfiguration); warns in development so a fresh app can
164
+ # still boot and browse the inbox before a key exists.
165
+ def check_provider_credentials!
166
+ return unless engine == :ruby_llm && defined?(::RubyLLM)
167
+
168
+ providers = ::RubyLLM::Provider.providers.values
169
+ configured = providers.any? do |provider|
170
+ requirements = provider.configuration_requirements
171
+ requirements.any? && requirements.all? { |key| ::RubyLLM.config.public_send(key).present? }
140
172
  end
173
+ return if configured
141
174
 
142
- warn_unsafe_queue_adapter!
175
+ message = "[Silas] engine :ruby_llm has no configured provider — no API key is set on " \
176
+ "RubyLLM.config. Set one in config/initializers/ruby_llm.rb, e.g. " \
177
+ "RubyLLM.configure { |c| c.anthropic_api_key = ENV[\"ANTHROPIC_API_KEY\"] } " \
178
+ "— the first agent turn will fail without it."
179
+ raise BootGuardError, message if defined?(::Rails) && ::Rails.env.production?
180
+
181
+ (defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
182
+ rescue BootGuardError
183
+ raise
184
+ rescue StandardError
185
+ nil # a diagnostic must never break boot
143
186
  end
144
187
 
145
188
  # Silas's durability and exactly-once guarantees rest on the queue adapter:
@@ -151,6 +194,10 @@ module Silas
151
194
  # mints new tool_call ids the ledger cannot dedup). Solid Queue — or any
152
195
  # durable, serializing, DB-backed adapter — is required to run agents; the
153
196
  # synchronous :inline adapter is safe for scripts and demos (no durability).
197
+ #
198
+ # RAISES in production — running agents on the Async adapter there silently
199
+ # voids the durability contract. Warns in development (Rails' dev default
200
+ # is :async and a fresh app must still boot).
154
201
  def warn_unsafe_queue_adapter!
155
202
  return unless defined?(::ActiveJob::Base)
156
203
 
@@ -162,7 +209,11 @@ module Silas
162
209
  "the original job, which double-executes agent steps and breaks " \
163
210
  "exactly-once tool effects. Use :solid_queue (production) or " \
164
211
  ":inline (scripts/demos). See Silas DEPLOY.md."
212
+ raise BootGuardError, message if defined?(::Rails) && ::Rails.env.production?
213
+
165
214
  (defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
215
+ rescue BootGuardError
216
+ raise
166
217
  rescue StandardError
167
218
  # A diagnostic must never break boot.
168
219
  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/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?
@@ -1,8 +1,10 @@
1
1
  module Silas
2
- # Streamed event from an engine (or the framework) during a step.
3
- # Types: :text_delta, :tool_call, :thinking, :usage and :approval_request,
4
- # which only :engine-owned loops (agent_sdk) emit; in :framework-owned loops
5
- # approvals are a Ledger concern, never an engine event.
2
+ # Streamed event from an engine during a step. The type vocabulary is an open
3
+ # set consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
4
+ # :message_start once per model call (before_message)
5
+ # :text_delta — { text: } chunks as the response streams
6
+ # StepRunner coalesces :text_delta into "silas.delta" notifications (see
7
+ # DeltaBuffer); everything else is available to custom engines/hooks.
6
8
  Event = Data.define(:type, :payload)
7
9
 
8
10
  module Engines
@@ -10,10 +12,6 @@ module Silas
10
12
  # and reports what came back; the framework owns the loop, the ledger owns
11
13
  # tool execution.
12
14
  class Base
13
- # :framework — Silas's AgentLoopJob drives the loop (ruby_llm).
14
- # :engine — the engine drives its own loop (agent_sdk, future).
15
- def self.loop_ownership = :framework
16
-
17
15
  # context: { turn:, index:, system:, messages:, tools:, model:, limits: }
18
16
  # Yields Silas::Event objects as they stream; returns a Result.
19
17
  def execute_step(context, &on_event)
@@ -8,8 +8,6 @@ module Silas
8
8
  class RubyLLM < Base
9
9
  INTERCEPTED = "__silas_intercepted__".freeze
10
10
 
11
- def self.loop_ownership = :framework
12
-
13
11
  def execute_step(context, &on_event)
14
12
  chat = build_chat(context, &on_event)
15
13
 
@@ -17,7 +15,17 @@ module Silas
17
15
  turn_id: context[:turn]&.id,
18
16
  index: context[:index],
19
17
  model: context[:model]) do
20
- chat.complete
18
+ if on_event
19
+ # Streamed: RubyLLM's accumulator returns a Message identical in
20
+ # shape to the sync path, so to_result needs no branch. Chunks with
21
+ # tool-call fragments carry nil/empty content — only text streams.
22
+ chat.complete do |chunk|
23
+ text = chunk.content
24
+ on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
25
+ end
26
+ else
27
+ chat.complete
28
+ end
21
29
  end
22
30
 
23
31
  to_result(chat, response)
@@ -40,7 +48,10 @@ module Silas
40
48
  replay_history(chat, context[:messages])
41
49
 
42
50
  if on_event
43
- chat.on_new_message { on_event.call(Event.new(type: :message_start, payload: {})) }
51
+ # before_message replaces the deprecated on_new_message (gone in
52
+ # RubyLLM 2.0). Under streaming it fires BEFORE the HTTP request —
53
+ # deliberate; don't "fix" the earlier timing.
54
+ chat.before_message { on_event.call(Event.new(type: :message_start, payload: {})) }
44
55
  end
45
56
  chat
46
57
  end
data/lib/silas/errors.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  module Silas
2
2
  class Error < StandardError; end
3
3
 
4
- # The :agent_sdk + OAuth footgun (PLAN.md boot guard, non-negotiable): an
5
- # ANTHROPIC_API_KEY in the environment would silently override subscription
6
- # OAuth and drain credits.
4
+ # A fatal misconfiguration detected at boot — e.g. a removed engine still
5
+ # configured, or a missing API key for the configured engine. Fail loud at
6
+ # configure time, never on the first turn.
7
7
  class BootGuardError < Error; end
8
8
 
9
9
  # A continuation checkpoint occurred inside a ledger transaction. Checkpoints