silas 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/README.md +81 -14
  4. data/app/controllers/concerns/silas/api/serialization.rb +63 -0
  5. data/app/controllers/silas/api/base_controller.rb +24 -0
  6. data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
  7. data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
  8. data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
  9. data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
  11. data/app/controllers/silas/inbox/turns_controller.rb +51 -0
  12. data/app/helpers/silas/inbox/trace_helper.rb +5 -0
  13. data/app/jobs/silas/agent_loop_job.rb +46 -43
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  15. data/app/models/silas/session.rb +6 -0
  16. data/app/models/silas/tool_invocation.rb +13 -1
  17. data/app/models/silas/turn.rb +10 -0
  18. data/app/views/layouts/silas/inbox.html.erb +20 -0
  19. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  20. data/app/views/silas/inbox/sessions/index.html.erb +31 -5
  21. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  22. data/app/views/silas/inbox/steps/_step.html.erb +9 -1
  23. data/app/views/silas/inbox/turns/_header.html.erb +19 -1
  24. data/config/routes.rb +28 -1
  25. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  26. data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
  27. data/lib/generators/silas/install/install_generator.rb +34 -14
  28. data/lib/generators/silas/install/templates/agent.yml +10 -1
  29. data/lib/generators/silas/install/templates/bin_ci +2 -2
  30. data/lib/generators/silas/install/templates/initializer.rb +30 -5
  31. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  32. data/lib/silas/agent.rb +4 -0
  33. data/lib/silas/chat.rb +47 -13
  34. data/lib/silas/configuration.rb +77 -37
  35. data/lib/silas/delta_buffer.rb +50 -0
  36. data/lib/silas/doctor.rb +155 -0
  37. data/lib/silas/engine.rb +6 -0
  38. data/lib/silas/engines/base.rb +6 -8
  39. data/lib/silas/engines/ruby_llm.rb +28 -6
  40. data/lib/silas/errors.rb +3 -3
  41. data/lib/silas/eval/assertions.rb +18 -0
  42. data/lib/silas/eval/scripted_engine.rb +0 -2
  43. data/lib/silas/eval/transcript.rb +1 -0
  44. data/lib/silas/inbox/cost.rb +37 -13
  45. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  46. data/lib/silas/ledger.rb +26 -10
  47. data/lib/silas/mcp/handler.rb +6 -5
  48. data/lib/silas/mcp/server.rb +9 -9
  49. data/lib/silas/message_builder.rb +9 -2
  50. data/lib/silas/named_agent.rb +1 -0
  51. data/lib/silas/registry.rb +18 -8
  52. data/lib/silas/step_runner.rb +33 -6
  53. data/lib/silas/tool.rb +5 -0
  54. data/lib/silas/version.rb +1 -1
  55. data/lib/silas.rb +10 -9
  56. data/lib/tasks/silas_doctor.rake +17 -0
  57. metadata +14 -6
  58. data/lib/silas/agent_sdk/cli.rb +0 -59
  59. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  60. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  61. data/lib/silas/engines/agent_sdk.rb +0 -75
  62. data/lib/silas/subprocess_runner.rb +0 -41
@@ -1,8 +1,10 @@
1
1
  module Silas
2
- # Streamed event from an engine (or the framework) during a step.
3
- # Types: :text_delta, :tool_call, :thinking, :usage and :approval_request,
4
- # which only :engine-owned loops (agent_sdk) emit; in :framework-owned loops
5
- # approvals are a Ledger concern, never an engine event.
2
+ # Streamed event from an engine during a step. The type vocabulary is an open
3
+ # set consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
4
+ # :message_start once per model call (before_message)
5
+ # :text_delta — { text: } chunks as the response streams
6
+ # StepRunner coalesces :text_delta into "silas.delta" notifications (see
7
+ # DeltaBuffer); everything else is available to custom engines/hooks.
6
8
  Event = Data.define(:type, :payload)
7
9
 
8
10
  module Engines
@@ -10,10 +12,6 @@ module Silas
10
12
  # and reports what came back; the framework owns the loop, the ledger owns
11
13
  # tool execution.
12
14
  class Base
13
- # :framework — Silas's AgentLoopJob drives the loop (ruby_llm).
14
- # :engine — the engine drives its own loop (agent_sdk, future).
15
- def self.loop_ownership = :framework
16
-
17
15
  # context: { turn:, index:, system:, messages:, tools:, model:, limits: }
18
16
  # Yields Silas::Event objects as they stream; returns a Result.
19
17
  def execute_step(context, &on_event)
@@ -8,8 +8,6 @@ module Silas
8
8
  class RubyLLM < Base
9
9
  INTERCEPTED = "__silas_intercepted__".freeze
10
10
 
11
- def self.loop_ownership = :framework
12
-
13
11
  def execute_step(context, &on_event)
14
12
  chat = build_chat(context, &on_event)
15
13
 
@@ -17,7 +15,17 @@ module Silas
17
15
  turn_id: context[:turn]&.id,
18
16
  index: context[:index],
19
17
  model: context[:model]) do
20
- chat.complete
18
+ if on_event
19
+ # Streamed: RubyLLM's accumulator returns a Message identical in
20
+ # shape to the sync path, so to_result needs no branch. Chunks with
21
+ # tool-call fragments carry nil/empty content — only text streams.
22
+ chat.complete do |chunk|
23
+ text = chunk.content
24
+ on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
25
+ end
26
+ else
27
+ chat.complete
28
+ end
21
29
  end
22
30
 
23
31
  to_result(chat, response)
@@ -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
- chat.on_new_message { on_event.call(Event.new(type: :message_start, payload: {})) }
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
- text = assistant.content.to_s
110
- blocks << { "type" => "text", "text" => text } if text.present?
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
- # The :agent_sdk + OAuth footgun (PLAN.md boot guard, non-negotiable): an
5
- # ANTHROPIC_API_KEY in the environment would silently override subscription
6
- # OAuth and drain credits.
4
+ # A fatal misconfiguration detected at boot — e.g. a removed engine still
5
+ # configured, or a missing API key for the configured engine. Fail loud at
6
+ # configure time, never on the first turn.
7
7
  class BootGuardError < Error; end
8
8
 
9
9
  # A continuation checkpoint occurred inside a ledger transaction. Checkpoints
@@ -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
@@ -1,45 +1,47 @@
1
1
  module Silas
2
2
  module Inbox
3
- # cost_microcents is declared on silas_turns but never populated (only
4
- # silas_steps carry real token counts). So the inbox derives cost at read
5
- # time from step tokens x a host-supplied price map and honestly reports
6
- # `unpriced` when a model isn't in the map rather than a lying £0.00.
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 (price = Silas.config.model_prices[model])
42
- microcents += (in_tok * price[:in] + out_tok * price[:out]) / 1000
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 on this thread. A continuation
24
- # checkpoint inside would raise Interrupt and roll back committed-looking
25
- # progress (spike finding #5) — AgentLoopJob asserts against this.
26
- def in_transaction? = Thread.current[GUARD_KEY] == true
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
- # :agent_sdk MCP endpoint creates one invocation per tools/call and needs
54
- # exactly the same exactly-once/effect-mode machinery as settle!. Returns
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
- .exists?
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
- Thread.current[GUARD_KEY] = true
192
+ previous = ActiveSupport::IsolatedExecutionState[GUARD_KEY]
193
+ ActiveSupport::IsolatedExecutionState[GUARD_KEY] = true
178
194
  ApplicationRecord.transaction { yield }
179
195
  ensure
180
- Thread.current[GUARD_KEY] = false
196
+ ActiveSupport::IsolatedExecutionState[GUARD_KEY] = previous
181
197
  end
182
198
 
183
199
  def wrap_result(result)
@@ -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 the :agent_sdk path gets the same exactly-once and
8
- # effect-mode semantics as :ruby_llm. Closes over one turn + its anchor step;
9
- # authenticated by a per-turn bearer token in the URL query.
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
- # v1 excludes approval-gated tools; if one slips through, fail loud.
54
- { "isError" => true, "content" => [ text_content("approval-gated tools are not supported by :agent_sdk") ] }
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
@@ -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 for the lifetime of one
6
- # claude -p subprocess. Deliberately NOT Puma/Rack: the transport claude's
7
- # MCP client needs (verified in the spike) is plain request/response JSON —
8
- # one request per connection, application/json, no SSE — so a raw threaded
9
- # TCPServer is fewer moving parts than embedding an app server in a worker.
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.agent_sdk_mcp_host)
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).select { |b| b["type"] == "text" }
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 || {} }
@@ -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
@@ -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), or skill
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
- Digest::SHA256.hexdigest(JSON.generate({
107
- tools: definitions,
108
- skills: skills.map { |s| [ s.name, s.description ] }
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
- digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
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: agent || Silas::Agent.load(dir: dir),
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
@@ -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
- if (hook = Silas.config.around_model_call)
64
- hook.call(context) { engine.execute_step(context) }
65
- else
66
- engine.execute_step(context)
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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.7"
2
+ VERSION = "0.3.0"
3
3
  end
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/agent_sdk"
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 a symbol (:ruby_llm,
101
- # :agent_sdk) or any object responding to #execute_step (specs, custom).
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 then Engines::AgentSdk.new
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