silas 0.1.7 → 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 +93 -0
- data/README.md +37 -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/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/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 +68 -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/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +9 -9
- metadata +5 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
|
@@ -53,23 +53,41 @@ module Silas
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
RESCUER_ENTRY = <<~YAML.freeze
|
|
57
|
+
# Silas: retries jobs failed by dead-worker reaping/pruning, sweeps
|
|
58
|
+
# expired approvals, and fails turns stranded by dead loop jobs. Part
|
|
59
|
+
# of the durability contract — do not remove.
|
|
60
|
+
silas_dead_job_rescuer:
|
|
61
|
+
class: Silas::DeadJobRescuerJob
|
|
62
|
+
queue: default
|
|
63
|
+
schedule: every 30 seconds
|
|
64
|
+
YAML
|
|
65
|
+
|
|
66
|
+
# Idempotent and environment-aware: the entry is injected directly under
|
|
67
|
+
# EVERY deployable top-level env key (production, staging, …) — never a
|
|
68
|
+
# blind append into whatever block happens to end the file, and never a
|
|
69
|
+
# duplicate key on a re-run. A staging worker needs the rescuer exactly
|
|
70
|
+
# as much as production does.
|
|
56
71
|
def add_rescuer_recurring_task
|
|
57
72
|
recurring = "config/recurring.yml"
|
|
58
|
-
|
|
73
|
+
path = File.expand_path(recurring, destination_root)
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
queue: default
|
|
65
|
-
schedule: every 30 seconds
|
|
66
|
-
YAML
|
|
75
|
+
unless File.exist?(path)
|
|
76
|
+
create_file recurring, "production:\n#{RESCUER_ENTRY.indent(2)}"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
67
79
|
|
|
68
|
-
if File.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
if File.read(path).include?("silas_dead_job_rescuer")
|
|
81
|
+
say_status :skip, "#{recurring} already contains silas_dead_job_rescuer", :yellow
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
injected = false
|
|
86
|
+
gsub_file recurring, /^(?!development:|test:)([a-zA-Z_]+:)[ \t]*$/ do |header|
|
|
87
|
+
injected = true
|
|
88
|
+
"#{header}\n#{RESCUER_ENTRY.indent(2)}"
|
|
72
89
|
end
|
|
90
|
+
append_to_file recurring, "\nproduction:\n#{RESCUER_ENTRY.indent(2)}" unless injected
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
def install_migrations
|
|
@@ -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.
|
|
@@ -37,9 +35,31 @@ module Silas
|
|
|
37
35
|
# default to credentials.dig(:silas, :slack, ...); nil disables Slack.
|
|
38
36
|
attr_accessor :channel_resolver
|
|
39
37
|
attr_writer :slack_signing_secret, :slack_bot_token
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
# Bind host for the in-process MCP server (Mcp::Server — the "mount your
|
|
39
|
+
# tools as MCP" seam).
|
|
40
|
+
attr_accessor :mcp_server_host
|
|
41
|
+
|
|
42
|
+
# 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
|
|
43
63
|
# Inbox (mountable UI at /silas/inbox).
|
|
44
64
|
# inbox_auth — deny-by-default lambda; the host renders/head-404s to
|
|
45
65
|
# DENY and passes by NOT rendering (resilience pattern).
|
|
@@ -70,7 +90,6 @@ module Silas
|
|
|
70
90
|
|
|
71
91
|
def initialize
|
|
72
92
|
@engine = :ruby_llm
|
|
73
|
-
@auth = :api_key
|
|
74
93
|
# Must be resolvable by the installed ruby_llm's model registry — newer
|
|
75
94
|
# Claude models may need `RubyLLM.models.refresh!` before they resolve.
|
|
76
95
|
@default_model = "claude-opus-4-8"
|
|
@@ -91,11 +110,7 @@ module Silas
|
|
|
91
110
|
@channel_resolver = nil
|
|
92
111
|
@slack_signing_secret = nil
|
|
93
112
|
@slack_bot_token = nil
|
|
94
|
-
@
|
|
95
|
-
@agent_sdk_model = nil # falls back to the agent model; must be a CLI-accepted id
|
|
96
|
-
@agent_sdk_mcp_host = "127.0.0.1"
|
|
97
|
-
@agent_sdk_mcp_timeout_ms = 15_000
|
|
98
|
-
@agent_sdk_cli_version_range = ">= 2.1.150, < 3"
|
|
113
|
+
@mcp_server_host = "127.0.0.1"
|
|
99
114
|
@eval_dir = "test/agent_evals"
|
|
100
115
|
@eval_grader = nil
|
|
101
116
|
@sandbox = :none
|
|
@@ -129,24 +144,45 @@ module Silas
|
|
|
129
144
|
self
|
|
130
145
|
end
|
|
131
146
|
|
|
132
|
-
#
|
|
133
|
-
# while an API key is present — the key would silently win and bill credits.
|
|
134
|
-
# The inverse also holds: :agent_sdk runs --bare (API-key auth only), so
|
|
135
|
-
# api_key mode needs a key present.
|
|
147
|
+
# Fail-loud misconfiguration checks, run from Silas.configure and at boot.
|
|
136
148
|
def boot_guard!
|
|
137
|
-
if engine == :agent_sdk
|
|
149
|
+
if engine == :agent_sdk
|
|
138
150
|
raise BootGuardError,
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
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."
|
|
142
154
|
end
|
|
143
155
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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? }
|
|
147
172
|
end
|
|
173
|
+
return if configured
|
|
148
174
|
|
|
149
|
-
|
|
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
|
|
150
186
|
end
|
|
151
187
|
|
|
152
188
|
# Silas's durability and exactly-once guarantees rest on the queue adapter:
|
|
@@ -158,6 +194,10 @@ module Silas
|
|
|
158
194
|
# mints new tool_call ids the ledger cannot dedup). Solid Queue — or any
|
|
159
195
|
# durable, serializing, DB-backed adapter — is required to run agents; the
|
|
160
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).
|
|
161
201
|
def warn_unsafe_queue_adapter!
|
|
162
202
|
return unless defined?(::ActiveJob::Base)
|
|
163
203
|
|
|
@@ -169,7 +209,11 @@ module Silas
|
|
|
169
209
|
"the original job, which double-executes agent steps and breaks " \
|
|
170
210
|
"exactly-once tool effects. Use :solid_queue (production) or " \
|
|
171
211
|
":inline (scripts/demos). See Silas DEPLOY.md."
|
|
212
|
+
raise BootGuardError, message if defined?(::Rails) && ::Rails.env.production?
|
|
213
|
+
|
|
172
214
|
(defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
|
|
215
|
+
rescue BootGuardError
|
|
216
|
+
raise
|
|
173
217
|
rescue StandardError
|
|
174
218
|
# A diagnostic must never break boot.
|
|
175
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
|
|
@@ -4,8 +4,6 @@ module Silas
|
|
|
4
4
|
# eval script the MODEL's decisions while the REAL Ledger runs the REAL tools —
|
|
5
5
|
# so assertions see a genuine transcript.
|
|
6
6
|
class ScriptedEngine < Silas::Engines::Base
|
|
7
|
-
def self.loop_ownership = :framework
|
|
8
|
-
|
|
9
7
|
attr_reader :calls
|
|
10
8
|
|
|
11
9
|
def initialize(steps)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# Streams accumulated step text into the live trace as it arrives.
|
|
4
|
+
#
|
|
5
|
+
# Synchronous on purpose: broadcast_update_to, not _later — the row
|
|
6
|
+
# broadcasts ride ActiveJob because they are rare; a delta batch every
|
|
7
|
+
# ~100ms per running turn would swamp the queue. The push happens on the
|
|
8
|
+
# worker thread mid-model-call, so it is wrapped: a cable/render failure
|
|
9
|
+
# can NEVER re-raise into the durable loop.
|
|
10
|
+
#
|
|
11
|
+
# Deltas are decoration. The authoritative after_commit row render replaces
|
|
12
|
+
# the whole step partial (dom_id target) and supersedes anything streamed
|
|
13
|
+
# into the inner text container.
|
|
14
|
+
module DeltaBroadcaster
|
|
15
|
+
EVENT = "silas.delta".freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def subscribe!
|
|
19
|
+
@subscription ||= ActiveSupport::Notifications.subscribe(EVENT) do |*args|
|
|
20
|
+
broadcast(args.last)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def broadcast(payload)
|
|
25
|
+
return unless Silas::Inbox.streaming?
|
|
26
|
+
|
|
27
|
+
Turbo::StreamsChannel.broadcast_update_to(
|
|
28
|
+
Silas::Inbox.stream_name(payload[:session_id]),
|
|
29
|
+
target: "silas-step-#{payload[:step_id]}-text",
|
|
30
|
+
html: ERB::Util.html_escape(payload[:text]) # plain text; the row render owns formatting
|
|
31
|
+
)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Rails.logger&.warn("[silas.inbox] delta broadcast failed: #{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/silas/ledger.rb
CHANGED
|
@@ -20,10 +20,16 @@ module Silas
|
|
|
20
20
|
GUARD_KEY = :silas_ledger_transaction
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
|
-
# True while a ledger transaction is open
|
|
24
|
-
# checkpoint inside would raise Interrupt and roll back
|
|
25
|
-
# progress (spike finding #5) — AgentLoopJob asserts
|
|
26
|
-
|
|
23
|
+
# True while a ledger transaction is open in this execution context. A
|
|
24
|
+
# continuation checkpoint inside would raise Interrupt and roll back
|
|
25
|
+
# committed-looking progress (spike finding #5) — AgentLoopJob asserts
|
|
26
|
+
# against this. Stored in IsolatedExecutionState (not Thread.current[],
|
|
27
|
+
# which is fiber-local) so the guard follows the app's configured
|
|
28
|
+
# isolation level, exactly like Silas.current_scope — under the default
|
|
29
|
+
# :thread isolation it survives into internally-created fibers
|
|
30
|
+
# (enumerators, streaming bodies) where a fiber-local flag would
|
|
31
|
+
# silently vanish.
|
|
32
|
+
def in_transaction? = ActiveSupport::IsolatedExecutionState[GUARD_KEY] == true
|
|
27
33
|
|
|
28
34
|
def assert_no_checkpoint!
|
|
29
35
|
return unless in_transaction?
|
|
@@ -50,9 +56,9 @@ module Silas
|
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
# Drive a SINGLE freshly-created invocation to a terminal state — the
|
|
53
|
-
#
|
|
54
|
-
# exactly the same exactly-once/effect-mode machinery as
|
|
55
|
-
# :done or :parked; the invocation carries its .result.
|
|
59
|
+
# hosted MCP endpoint (Mcp::Handler) creates one invocation per tools/call
|
|
60
|
+
# and needs exactly the same exactly-once/effect-mode machinery as
|
|
61
|
+
# settle!. Returns :done or :parked; the invocation carries its .result.
|
|
56
62
|
def execute_invocation!(invocation, resolver:)
|
|
57
63
|
settle_invocation!(invocation, resolver)
|
|
58
64
|
end
|
|
@@ -157,12 +163,18 @@ module Silas
|
|
|
157
163
|
end
|
|
158
164
|
end
|
|
159
165
|
|
|
166
|
+
# :once is scoped to tool name AND arguments. Name-only matching was a
|
|
167
|
+
# footgun: approving a £5 refund would silently auto-approve a £5,000
|
|
168
|
+
# refund later in the same session. Identical repeat calls still skip
|
|
169
|
+
# re-approval; anything else re-parks. Graded gates (thresholds, ranges)
|
|
170
|
+
# belong in an approval lambda, not :once. (Hash#== is order-independent,
|
|
171
|
+
# so jsonb key order can't produce false negatives.)
|
|
160
172
|
def previously_approved?(invocation)
|
|
161
173
|
ToolInvocation.joins(:turn)
|
|
162
174
|
.where(silas_turns: { session_id: invocation.turn.session_id },
|
|
163
175
|
tool_name: invocation.tool_name, approval_state: "approved")
|
|
164
176
|
.where.not(id: invocation.id)
|
|
165
|
-
.
|
|
177
|
+
.any? { |prior| prior.arguments == invocation.arguments }
|
|
166
178
|
end
|
|
167
179
|
|
|
168
180
|
# Compare-and-swap claim: only one racing execution wins.
|
|
@@ -173,11 +185,15 @@ module Silas
|
|
|
173
185
|
claimed
|
|
174
186
|
end
|
|
175
187
|
|
|
188
|
+
# Save/restore, not set/clear: a nested guarded_transaction must not
|
|
189
|
+
# clobber the outer guard on exit (the old `ensure ... = false` opened a
|
|
190
|
+
# checkpoint-guard hole for the remainder of the outer transaction).
|
|
176
191
|
def guarded_transaction
|
|
177
|
-
|
|
192
|
+
previous = ActiveSupport::IsolatedExecutionState[GUARD_KEY]
|
|
193
|
+
ActiveSupport::IsolatedExecutionState[GUARD_KEY] = true
|
|
178
194
|
ApplicationRecord.transaction { yield }
|
|
179
195
|
ensure
|
|
180
|
-
|
|
196
|
+
ActiveSupport::IsolatedExecutionState[GUARD_KEY] = previous
|
|
181
197
|
end
|
|
182
198
|
|
|
183
199
|
def wrap_result(result)
|
data/lib/silas/mcp/handler.rb
CHANGED
|
@@ -4,9 +4,9 @@ require "securerandom"
|
|
|
4
4
|
module Silas
|
|
5
5
|
module Mcp
|
|
6
6
|
# JSON-RPC handler for the hosted MCP endpoint. tools/call runs the tool
|
|
7
|
-
# THROUGH the Ledger, so
|
|
8
|
-
# effect-mode semantics as
|
|
9
|
-
# authenticated by a
|
|
7
|
+
# THROUGH the Ledger, so a remote MCP caller gets the same exactly-once and
|
|
8
|
+
# effect-mode semantics as the in-process loop. Closes over one turn + its
|
|
9
|
+
# anchor step; authenticated by a bearer token in the URL query.
|
|
10
10
|
class Handler
|
|
11
11
|
TOOL_PREFIX = "mcp__silas__".freeze
|
|
12
12
|
|
|
@@ -50,8 +50,9 @@ module Silas
|
|
|
50
50
|
invocation.reload
|
|
51
51
|
|
|
52
52
|
if outcome == :parked
|
|
53
|
-
#
|
|
54
|
-
|
|
53
|
+
# The hosted endpoint excludes approval-gated tools; if one slips
|
|
54
|
+
# through, fail loud rather than park a caller that can't wait.
|
|
55
|
+
{ "isError" => true, "content" => [ text_content("approval-gated tools are not supported over the hosted MCP endpoint") ] }
|
|
55
56
|
else
|
|
56
57
|
{ "content" => [ text_content(JSON.generate(invocation.result)) ] }
|
|
57
58
|
end
|