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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +55 -14
- data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
- data/app/controllers/silas/inbox/turns_controller.rb +32 -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/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +1 -0
- data/app/views/layouts/silas/inbox.html.erb +14 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +20 -2
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +6 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/config/routes.rb +6 -1
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/lib/generators/silas/install/install_generator.rb +33 -14
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +29 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/chat.rb +45 -13
- data/lib/silas/configuration.rb +75 -24
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +15 -4
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/instructions.rb +13 -1
- 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/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +12 -2
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/tools/handoff.rb +68 -0
- data/lib/silas/tools/recall.rb +18 -0
- data/lib/silas/tools/remember.rb +32 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +20 -9
- metadata +10 -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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<% content_for :header_extra do %>
|
|
2
|
-
<% if @pending_total.positive?
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
95
|
-
|
|
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
|
|
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-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
|
-
|
|
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,11 @@ 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
|
+
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 " \
|
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.
|
|
@@ -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
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
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
|
|
149
|
+
if engine == :agent_sdk
|
|
131
150
|
raise BootGuardError,
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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?
|
data/lib/silas/engines/base.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
module Silas
|
|
2
|
-
# Streamed event from an engine
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|