silas 0.1.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/README.md +81 -14
- data/app/controllers/concerns/silas/api/serialization.rb +63 -0
- data/app/controllers/silas/api/base_controller.rb +24 -0
- data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
- data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
- data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
- data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
- data/app/controllers/silas/inbox/turns_controller.rb +51 -0
- data/app/helpers/silas/inbox/trace_helper.rb +5 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/session.rb +6 -0
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +10 -0
- data/app/views/layouts/silas/inbox.html.erb +20 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +31 -5
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +9 -1
- data/app/views/silas/inbox/turns/_header.html.erb +19 -1
- data/config/routes.rb +28 -1
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
- data/lib/generators/silas/install/install_generator.rb +34 -14
- data/lib/generators/silas/install/templates/agent.yml +10 -1
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +30 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/agent.rb +4 -0
- data/lib/silas/chat.rb +47 -13
- data/lib/silas/configuration.rb +77 -37
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/doctor.rb +155 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +28 -6
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/assertions.rb +18 -0
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/eval/transcript.rb +1 -0
- data/lib/silas/inbox/cost.rb +37 -13
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/message_builder.rb +9 -2
- data/lib/silas/named_agent.rb +1 -0
- data/lib/silas/registry.rb +18 -8
- data/lib/silas/step_runner.rb +33 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +10 -9
- data/lib/tasks/silas_doctor.rake +17 -0
- metadata +14 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
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)
|
|
@@ -35,12 +43,19 @@ module Silas
|
|
|
35
43
|
"or pick a registry-known model in config.default_model / agent.yml."
|
|
36
44
|
end
|
|
37
45
|
chat.with_instructions(context[:system]) if context[:system].present?
|
|
46
|
+
# agent.yml's final_answer schema: RubyLLM renders the provider's
|
|
47
|
+
# structured-output dialect and JSON-parses the response back to a
|
|
48
|
+
# Hash — which to_result persists as a "structured" block.
|
|
49
|
+
chat.with_schema(context[:final_answer]) if context[:final_answer].present?
|
|
38
50
|
context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
|
|
39
51
|
|
|
40
52
|
replay_history(chat, context[:messages])
|
|
41
53
|
|
|
42
54
|
if on_event
|
|
43
|
-
|
|
55
|
+
# before_message replaces the deprecated on_new_message (gone in
|
|
56
|
+
# RubyLLM 2.0). Under streaming it fires BEFORE the HTTP request —
|
|
57
|
+
# deliberate; don't "fix" the earlier timing.
|
|
58
|
+
chat.before_message { on_event.call(Event.new(type: :message_start, payload: {})) }
|
|
44
59
|
end
|
|
45
60
|
chat
|
|
46
61
|
end
|
|
@@ -106,8 +121,15 @@ module Silas
|
|
|
106
121
|
assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
|
|
107
122
|
|
|
108
123
|
blocks = []
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
content = assistant.content
|
|
125
|
+
if content.is_a?(Hash)
|
|
126
|
+
# with_schema active: RubyLLM parsed the response to a Hash. Persist
|
|
127
|
+
# it as its own block type — content.to_s here would write Ruby's
|
|
128
|
+
# Hash#inspect string into the transcript as "text".
|
|
129
|
+
blocks << { "type" => "structured", "data" => content }
|
|
130
|
+
elsif content.to_s.present?
|
|
131
|
+
blocks << { "type" => "text", "text" => content.to_s }
|
|
132
|
+
end
|
|
111
133
|
|
|
112
134
|
tool_calls = (assistant.tool_calls || {}).values.map do |tc|
|
|
113
135
|
blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
|
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
|
|
@@ -39,6 +39,24 @@ module Silas
|
|
|
39
39
|
check(matcher === @t.final_text, "final answer #{@t.final_text.inspect} does not match #{matcher.inspect}")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Structured (final_answer) assertions. With a Hash, expects the whole
|
|
43
|
+
# payload; with key/value, one field; with a block, a predicate over
|
|
44
|
+
# the payload. String keys — the payload is stored jsonb.
|
|
45
|
+
def assert_answer_data(expected = :__unset, key: nil, value: :__unset, &pred)
|
|
46
|
+
data = @t.answer_data
|
|
47
|
+
return check(false, "no structured answer (final_answer schema not set, or turn unfinished)") if data.nil?
|
|
48
|
+
|
|
49
|
+
if pred
|
|
50
|
+
check(pred.call(data), "answer_data predicate failed for #{data.inspect}")
|
|
51
|
+
elsif key
|
|
52
|
+
check(data[key.to_s] == value, "answer_data.#{key} expected #{value.inspect}, got #{data[key.to_s].inspect}")
|
|
53
|
+
elsif expected != :__unset
|
|
54
|
+
check(data == expected, "answer_data expected #{expected.inspect}, got #{data.inspect}")
|
|
55
|
+
else
|
|
56
|
+
check(true, nil) # presence alone
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
42
60
|
# No-hallucinated-price guard: every money amount in the final answer must
|
|
43
61
|
# trace to a number the agent actually saw (tool results or the user input),
|
|
44
62
|
# allowing pence<->pounds scaling.
|
|
@@ -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)
|
|
@@ -14,6 +14,7 @@ module Silas
|
|
|
14
14
|
def completed? = @turn.completed?
|
|
15
15
|
def parked? = @turn.parked?
|
|
16
16
|
def final_text = @turn.answer_text.to_s
|
|
17
|
+
def answer_data = @turn.answer_data
|
|
17
18
|
def invocations = @turn.tool_invocations.order(:id).to_a
|
|
18
19
|
def invocations_for(name) = invocations.select { |i| i.tool_name == name.to_s }
|
|
19
20
|
def results = invocations.map(&:result).compact
|
data/lib/silas/inbox/cost.rb
CHANGED
|
@@ -1,45 +1,47 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
module Inbox
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
3
|
+
# Cost is derived at read time from step tokens. Prices come from
|
|
4
|
+
# config.model_prices (the OVERRIDE map — custom deployments, fine-tunes,
|
|
5
|
+
# models newer than the installed registry) and fall back to RubyLLM's
|
|
6
|
+
# model registry, priced per (model, provider) — 85/1081 registry ids
|
|
7
|
+
# exist under multiple providers at different prices, which is why steps
|
|
8
|
+
# stamp the provider. Unknown stays `unpriced`, never a lying $0.00.
|
|
7
9
|
module Cost
|
|
8
10
|
module_function
|
|
9
11
|
|
|
10
12
|
def for_session(session)
|
|
11
13
|
rows = Silas::Step.joins(:turn)
|
|
12
14
|
.where(silas_turns: { session_id: session.id })
|
|
13
|
-
.group(:model)
|
|
14
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
15
|
+
.group(:model, :provider)
|
|
16
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
15
17
|
aggregate(rows)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def for_turn(turn)
|
|
19
21
|
rows = Silas::Step.where(turn_id: turn.id)
|
|
20
|
-
.group(:model)
|
|
21
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
22
|
+
.group(:model, :provider)
|
|
23
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
22
24
|
aggregate(rows)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def for_agent(agent_name)
|
|
26
28
|
rows = Silas::Step.joins(turn: :session)
|
|
27
29
|
.where(silas_sessions: { agent_name: agent_name })
|
|
28
|
-
.group(:model)
|
|
29
|
-
.pluck(:model, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
30
|
+
.group(:model, :provider)
|
|
31
|
+
.pluck(:model, :provider, Arel.sql("SUM(silas_steps.input_tokens)"), Arel.sql("SUM(silas_steps.output_tokens)"))
|
|
30
32
|
aggregate(rows)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def aggregate(rows)
|
|
34
36
|
input = output = microcents = 0
|
|
35
37
|
unpriced = false
|
|
36
|
-
rows.each do |model, in_tok, out_tok|
|
|
38
|
+
rows.each do |model, provider, in_tok, out_tok|
|
|
37
39
|
in_tok = in_tok.to_i
|
|
38
40
|
out_tok = out_tok.to_i
|
|
39
41
|
input += in_tok
|
|
40
42
|
output += out_tok
|
|
41
|
-
if (
|
|
42
|
-
microcents += (in_tok *
|
|
43
|
+
if (rate = rate_for(model, provider))
|
|
44
|
+
microcents += (in_tok * rate[:in] + out_tok * rate[:out]) / 1000
|
|
43
45
|
else
|
|
44
46
|
unpriced = true
|
|
45
47
|
end
|
|
@@ -47,6 +49,28 @@ module Silas
|
|
|
47
49
|
{ input_tokens: input, output_tokens: output, microcents: microcents, unpriced: unpriced }
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# {in:, out:} in cost-units per 1k tokens (1e6 units = $1), or nil when
|
|
53
|
+
# the model can't be priced. Override map first; then the registry —
|
|
54
|
+
# two-arg find when the step stamped a provider (the bare form
|
|
55
|
+
# tie-breaks by a hardcoded preference list and can price the wrong
|
|
56
|
+
# provider), registry $/MTok converted at x1000. A model with no price
|
|
57
|
+
# data returns nil: unknown is never zero.
|
|
58
|
+
def rate_for(model, provider)
|
|
59
|
+
if (price = Silas.config.model_prices[model])
|
|
60
|
+
return price
|
|
61
|
+
end
|
|
62
|
+
return nil if model.nil?
|
|
63
|
+
|
|
64
|
+
info = provider.present? ? ::RubyLLM.models.find(model, provider) : ::RubyLLM.models.find(model)
|
|
65
|
+
in_pm = info.input_price_per_million
|
|
66
|
+
out_pm = info.output_price_per_million
|
|
67
|
+
return nil unless in_pm && out_pm
|
|
68
|
+
|
|
69
|
+
{ in: (in_pm * 1000).round, out: (out_pm * 1000).round }
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
50
74
|
# microcents -> "$0.0123" (or nil when unpriced with no priced tokens)
|
|
51
75
|
def format(cents)
|
|
52
76
|
return nil if cents.nil?
|
|
@@ -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
|
data/lib/silas/mcp/server.rb
CHANGED
|
@@ -2,20 +2,20 @@ require "socket"
|
|
|
2
2
|
|
|
3
3
|
module Silas
|
|
4
4
|
module Mcp
|
|
5
|
-
# A minimal HTTP/1.1 server hosting the MCP Handler
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
5
|
+
# A minimal HTTP/1.1 server hosting the MCP Handler in-process. Deliberately
|
|
6
|
+
# NOT Puma/Rack: the transport MCP clients need (verified in the spike) is
|
|
7
|
+
# plain request/response JSON — one request per connection,
|
|
8
|
+
# application/json, no SSE — so a raw threaded TCPServer is fewer moving
|
|
9
|
+
# parts than embedding an app server in a worker.
|
|
10
10
|
#
|
|
11
11
|
# In-process: the Handler has direct access to the Ledger/models, so no
|
|
12
|
-
# cross-service call is needed and it works on any worker box.
|
|
12
|
+
# cross-service call is needed and it works on any worker box. This is the
|
|
13
|
+
# seam for the "mount your agent's tools as an MCP server" feature.
|
|
13
14
|
class Server
|
|
14
15
|
attr_reader :port
|
|
15
16
|
|
|
16
|
-
def self.start(turn:, step:, tools:, resolver:, host: Silas.config.
|
|
17
|
-
token = SecureRandom.hex(16)
|
|
18
|
-
turn.update_columns(mcp_token: token)
|
|
17
|
+
def self.start(turn:, step:, tools:, resolver:, host: Silas.config.mcp_server_host)
|
|
18
|
+
token = SecureRandom.hex(16) # minted and compared in memory only
|
|
19
19
|
handler = Handler.new(turn: turn, step: step, tools: tools, resolver: resolver, token: token)
|
|
20
20
|
new(handler: handler, turn: turn, token: token, host: host).tap(&:boot)
|
|
21
21
|
end
|
|
@@ -50,9 +50,16 @@ module Silas
|
|
|
50
50
|
|
|
51
51
|
# Text comes from the model's own blocks; tool_use blocks are rebuilt from
|
|
52
52
|
# the settled invocations so the assistant message and the tool results that
|
|
53
|
-
# follow are always a matched set (same ids, same count).
|
|
53
|
+
# follow are always a matched set (same ids, same count). A structured
|
|
54
|
+
# (final_answer) block replays as its JSON text — deterministic (same rows
|
|
55
|
+
# -> same string), and providers need message content, not our block type.
|
|
54
56
|
def assistant_blocks(step, settled)
|
|
55
|
-
text = Array(step.response_blocks).
|
|
57
|
+
text = Array(step.response_blocks).filter_map do |b|
|
|
58
|
+
case b["type"]
|
|
59
|
+
when "text" then b
|
|
60
|
+
when "structured" then { "type" => "text", "text" => JSON.generate(b["data"]) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
56
63
|
tools = settled.map do |inv|
|
|
57
64
|
{ "type" => "tool_call", "id" => inv.tool_call_id,
|
|
58
65
|
"name" => inv.tool_name, "arguments" => inv.arguments || {} }
|
data/lib/silas/named_agent.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Silas
|
|
|
23
23
|
# Definition readers delegate to the scope's parsed agent.yml.
|
|
24
24
|
def model = scope.agent.model
|
|
25
25
|
def description = scope.agent.description
|
|
26
|
+
def final_answer = scope.agent.final_answer
|
|
26
27
|
def limits = scope.agent.limits
|
|
27
28
|
def max_steps = scope.agent.max_steps
|
|
28
29
|
def max_input_tokens = scope.agent.max_input_tokens
|
data/lib/silas/registry.rb
CHANGED
|
@@ -100,13 +100,20 @@ module Silas
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
# Stable across boots for the same agent definition; changes when any tool
|
|
103
|
-
# schema (incl. the delegate roster + remote connection tools),
|
|
104
|
-
# description, changes.
|
|
103
|
+
# schema (incl. the delegate roster + remote connection tools), skill
|
|
104
|
+
# description, or final_answer schema changes.
|
|
105
|
+
#
|
|
106
|
+
# final_answer is appended ONLY when present: schema-less agents keep a
|
|
107
|
+
# byte-identical digest across upgrades, so turns parked over a deploy
|
|
108
|
+
# never fail NondeterminismError for a key they don't use.
|
|
105
109
|
def digest
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
|
|
111
|
+
payload[:final_answer] = root_agent.final_answer if root_agent.final_answer.present?
|
|
112
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def root_agent
|
|
116
|
+
@root_agent ||= Silas::Agent.load(root: @root)
|
|
110
117
|
end
|
|
111
118
|
|
|
112
119
|
# --- named agents (app/agents/<name>/ — the staff pattern) ---------------
|
|
@@ -192,9 +199,12 @@ module Silas
|
|
|
192
199
|
builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
|
|
193
200
|
resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
|
|
194
201
|
definitions = (tools.values + builtins.values).map(&:schema)
|
|
195
|
-
|
|
202
|
+
loaded_agent = agent || Silas::Agent.load(dir: dir)
|
|
203
|
+
payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
|
|
204
|
+
payload[:final_answer] = loaded_agent.final_answer if loaded_agent.final_answer.present?
|
|
205
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
196
206
|
|
|
197
|
-
Silas::AgentScope.new(name: name, dir: dir, agent:
|
|
207
|
+
Silas::AgentScope.new(name: name, dir: dir, agent: loaded_agent,
|
|
198
208
|
resolver: resolver, definitions: definitions, digest: digest, skills: skills)
|
|
199
209
|
end
|
|
200
210
|
end
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Silas
|
|
|
11
11
|
step = Step.find_or_create_by!(turn: turn, index: index)
|
|
12
12
|
|
|
13
13
|
unless step.completed?
|
|
14
|
-
result = execute_model_call(turn, index)
|
|
14
|
+
result = execute_model_call(turn, index, step)
|
|
15
15
|
|
|
16
16
|
# One transaction: the step's response, its terminal verdict, and the
|
|
17
17
|
# pending ledger rows commit together — or none of them do.
|
|
@@ -22,6 +22,7 @@ module Silas
|
|
|
22
22
|
stop_reason: result.stop_reason,
|
|
23
23
|
terminal: result.terminal?,
|
|
24
24
|
model: turn_model(turn),
|
|
25
|
+
provider: provider_for(turn_model(turn)),
|
|
25
26
|
input_tokens: result.usage&.dig(:input_tokens),
|
|
26
27
|
output_tokens: result.usage&.dig(:output_tokens)
|
|
27
28
|
)
|
|
@@ -48,7 +49,7 @@ module Silas
|
|
|
48
49
|
end
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
def execute_model_call(turn, index)
|
|
52
|
+
def execute_model_call(turn, index, step)
|
|
52
53
|
assert_definitions_unchanged!(turn)
|
|
53
54
|
engine = Silas.resolved_engine
|
|
54
55
|
context = {
|
|
@@ -58,12 +59,28 @@ module Silas
|
|
|
58
59
|
messages: MessageBuilder.call(turn, upto_index: index),
|
|
59
60
|
tools: Silas.tool_definitions,
|
|
60
61
|
model: turn_model(turn),
|
|
62
|
+
final_answer: Silas.agent.final_answer,
|
|
61
63
|
limits: { max_steps: Silas.agent.max_steps }
|
|
62
64
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
|
|
66
|
+
# Live deltas: the engine yields Events, the buffer coalesces them into
|
|
67
|
+
# "silas.delta" notifications. A replayed step never reaches this method
|
|
68
|
+
# (the completed? guard above), so replay emits nothing. The emitter is
|
|
69
|
+
# created HERE and closed over by the inner block, so around_model_call
|
|
70
|
+
# hooks keep their existing one-argument contract and can't swallow it.
|
|
71
|
+
buffer = DeltaBuffer.new(turn: turn, step: step)
|
|
72
|
+
emitter = ->(event) { buffer.append(event.payload[:text].to_s) if event.type == :text_delta }
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
if (hook = Silas.config.around_model_call)
|
|
76
|
+
hook.call(context) { engine.execute_step(context, &emitter) }
|
|
77
|
+
else
|
|
78
|
+
engine.execute_step(context, &emitter)
|
|
79
|
+
end
|
|
80
|
+
ensure
|
|
81
|
+
# Tail flush BEFORE the step row commits — the authoritative
|
|
82
|
+
# after_commit render must never race a straggling delta batch.
|
|
83
|
+
buffer.finish
|
|
67
84
|
end
|
|
68
85
|
end
|
|
69
86
|
|
|
@@ -94,5 +111,15 @@ module Silas
|
|
|
94
111
|
def turn_model(_turn)
|
|
95
112
|
Silas.agent.model
|
|
96
113
|
end
|
|
114
|
+
|
|
115
|
+
# The provider RubyLLM's own resolution picks for this model id, stamped
|
|
116
|
+
# on the row so cost lookups price against (model, provider) forever
|
|
117
|
+
# after — the registry's tie-break can change; the row shouldn't. nil for
|
|
118
|
+
# ids the installed registry doesn't know (fakes, custom engines).
|
|
119
|
+
def provider_for(model)
|
|
120
|
+
::RubyLLM.models.find(model).provider
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
97
124
|
end
|
|
98
125
|
end
|
data/lib/silas/tool.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Silas
|
|
|
7
7
|
# approval :always # :never | :once | :always | lambda
|
|
8
8
|
# transactional! # or at_most_once! (default) / idempotent!
|
|
9
9
|
#
|
|
10
|
+
# :once approves ONE (tool, arguments) pair per session — an identical repeat
|
|
11
|
+
# call skips re-approval; different arguments park again. For graded gates
|
|
12
|
+
# (e.g. auto-approve under a threshold) use a lambda:
|
|
13
|
+
# approval ->(session:, input:) { input[:amount] > 5000 ? :user_approval : :approved }
|
|
14
|
+
#
|
|
10
15
|
# def call(order_id:, amount:, note: nil)
|
|
11
16
|
# ...
|
|
12
17
|
# end
|
data/lib/silas/version.rb
CHANGED
data/lib/silas.rb
CHANGED
|
@@ -24,6 +24,8 @@ require "silas/connection"
|
|
|
24
24
|
require "silas/connections"
|
|
25
25
|
require "silas/inbox"
|
|
26
26
|
require "silas/inbox/cost"
|
|
27
|
+
require "silas/inbox/delta_broadcaster"
|
|
28
|
+
require "silas/delta_buffer"
|
|
27
29
|
require "silas/budget"
|
|
28
30
|
require "silas/registry"
|
|
29
31
|
require "silas/agent"
|
|
@@ -31,21 +33,17 @@ require "silas/tools/load_skill"
|
|
|
31
33
|
require "silas/tools/remember"
|
|
32
34
|
require "silas/tools/recall"
|
|
33
35
|
require "silas/tools/handoff"
|
|
34
|
-
require "silas/engines/base"
|
|
35
|
-
require "silas/agent_sdk/version_guard"
|
|
36
|
-
require "silas/agent_sdk/stream_parser"
|
|
37
|
-
require "silas/agent_sdk/cli"
|
|
38
36
|
require "silas/mcp/handler"
|
|
39
37
|
require "silas/mcp/server"
|
|
40
|
-
require "silas/engines/
|
|
38
|
+
require "silas/engines/base"
|
|
41
39
|
require "ruby_llm"
|
|
42
40
|
require "silas/engines/ruby_llm"
|
|
43
41
|
require "silas/message_builder"
|
|
44
42
|
require "silas/instructions"
|
|
45
43
|
require "silas/step_runner"
|
|
46
|
-
require "silas/subprocess_runner"
|
|
47
44
|
require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
|
|
48
45
|
require "silas/chat"
|
|
46
|
+
require "silas/doctor"
|
|
49
47
|
|
|
50
48
|
module Silas
|
|
51
49
|
class << self
|
|
@@ -97,13 +95,16 @@ module Silas
|
|
|
97
95
|
|
|
98
96
|
def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
|
|
99
97
|
|
|
100
|
-
# The inference adapter instance. config.engine may be
|
|
101
|
-
#
|
|
98
|
+
# The inference adapter instance. config.engine may be :ruby_llm or any
|
|
99
|
+
# object responding to #execute_step (specs, custom).
|
|
102
100
|
def resolved_engine
|
|
103
101
|
@resolved_engine ||=
|
|
104
102
|
case config.engine
|
|
105
103
|
when :ruby_llm then Engines::RubyLLM.new
|
|
106
|
-
when :agent_sdk
|
|
104
|
+
when :agent_sdk
|
|
105
|
+
raise Error, "the :agent_sdk engine was removed in Silas 0.2 — the claude -p " \
|
|
106
|
+
"subprocess integration is gone (its subscription-auth rationale was " \
|
|
107
|
+
"unreachable). Use engine :ruby_llm, the production path."
|
|
107
108
|
when Symbol then raise Error, "unknown engine #{config.engine.inspect}"
|
|
108
109
|
else config.engine
|
|
109
110
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
namespace :silas do
|
|
2
|
+
desc "Diagnose common Silas misconfigurations (provider key, queue adapter, model, migrations, tools, rescuer, cable, auth)"
|
|
3
|
+
task doctor: :environment do
|
|
4
|
+
glyph = { pass: "\e[32m✓\e[0m", warn: "\e[33m!\e[0m", fail: "\e[31m✗\e[0m" }
|
|
5
|
+
checks = Silas::Doctor.run
|
|
6
|
+
|
|
7
|
+
puts "silas:doctor — #{Silas::VERSION}"
|
|
8
|
+
checks.each do |check|
|
|
9
|
+
puts " #{glyph[check.status]} #{check.label}#{" — #{check.detail}" if check.detail.present?}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
failed = checks.count { |c| c.status == :fail }
|
|
13
|
+
warned = checks.count { |c| c.status == :warn }
|
|
14
|
+
puts "\n#{failed} failure(s), #{warned} warning(s)"
|
|
15
|
+
exit 1 if failed.positive?
|
|
16
|
+
end
|
|
17
|
+
end
|