silas 0.3.1 → 0.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +5 -5
  4. data/app/helpers/silas/inbox/trace_helper.rb +11 -1
  5. data/app/jobs/silas/agent_loop_job.rb +2 -0
  6. data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
  7. data/app/models/silas/tool_invocation.rb +15 -1
  8. data/app/models/silas/turn.rb +7 -1
  9. data/app/views/silas/channel_mailer/approval.text.erb +2 -2
  10. data/app/views/silas/channels/approvals/show.html.erb +1 -1
  11. data/app/views/silas/inbox/invocations/_approval_card.html.erb +5 -2
  12. data/app/views/silas/inbox/sessions/index.html.erb +1 -1
  13. data/app/views/silas/inbox/sessions/show.html.erb +1 -1
  14. data/app/views/silas/inbox/turns/_header.html.erb +5 -2
  15. data/config/brakeman.ignore +11 -0
  16. data/lib/generators/silas/install/templates/initializer.rb +1 -1
  17. data/lib/silas/{engines → adapters}/base.rb +12 -2
  18. data/lib/silas/{engines → adapters}/ruby_llm.rb +2 -2
  19. data/lib/silas/budget.rb +5 -3
  20. data/lib/silas/chat.rb +2 -2
  21. data/lib/silas/configuration.rb +24 -9
  22. data/lib/silas/delta_buffer.rb +3 -3
  23. data/lib/silas/deprecator.rb +16 -0
  24. data/lib/silas/engine.rb +26 -2
  25. data/lib/silas/eval/driver.rb +1 -1
  26. data/lib/silas/eval/dsl.rb +1 -1
  27. data/lib/silas/eval/scripted_engine.rb +3 -3
  28. data/lib/silas/inbox/delta_broadcaster.rb +1 -1
  29. data/lib/silas/inbox.rb +15 -0
  30. data/lib/silas/instrumentation.rb +55 -0
  31. data/lib/silas/ledger.rb +14 -0
  32. data/lib/silas/log_subscriber.rb +78 -0
  33. data/lib/silas/slack.rb +1 -1
  34. data/lib/silas/step_runner.rb +5 -2
  35. data/lib/silas/version.rb +1 -1
  36. data/lib/silas.rb +25 -14
  37. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36bc01dd259d8950ae57b75a3eb94f91065aed62d2b149d705959695bc8f323f
4
- data.tar.gz: f2aa37ea2d83373d2252b594aaef87894f1da03adda08edfa746baa6e76c2dea
3
+ metadata.gz: 9abd9603ae899da9a789ded30f092f11f1881ddc9abd2f0edf2638421f469abb
4
+ data.tar.gz: 16a684a62b54346ee51d4e7b9f6cb92ec19c2d95e7027667b432044d9a1e7f71
5
5
  SHA512:
6
- metadata.gz: 43ba5370306adecd0c53f818d655a367748e5b3162559abca5e8eb2a5687410c8d145f8f8b545f98ca265961344378e1c0a474826b46853357f2bce4192aa12b
7
- data.tar.gz: 3cf07dea2adbe6eb33ec3bf65401c96127c1a066fbb104ea2e945d42f5526e8272aebbf9561a5777d1c6278674b3bc386036d1aa2b01bcb37d99366822ac431b
6
+ metadata.gz: 9518d703a421ed47d1c59e9cb74cdbd46686b8a783a1f5aea8f90f5911e0e27f5ccc5fc86da22c8ebe870a3d811b7de28e5abfc2ff837ab7dbc47722d13e3dcc
7
+ data.tar.gz: 954efe8fac65a18a0d936bafda946bcca96e8dda35201d06637d8f6ee5a8ca425ecd50ab322d2f137e7c0d03b2e97321db7d5cedfc5ac7d0a4b016ddf3184b63
data/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ The architecture-and-hardening release: one shipped feature that had never
6
+ worked, the naming locked down before 1.0 freezes it, and the durable loop
7
+ finally observable.
8
+
9
+ ### Fixed
10
+
11
+ - **The email approval channel had never worked.** Both the approval email
12
+ template and the confirmation page called `approval_url`/`approval_path`,
13
+ but the route is declared inside `namespace :channels`, so the real helpers
14
+ are `channels_approval_url`/`_path`. Rendering raised — meaning
15
+ **`ChannelMailer#approval` blew up and the "your agent needs approval" email
16
+ was never delivered**, and the confirmation page 500'd. If you relied on
17
+ email approvals, you were silently never notified that a money-moving call
18
+ was parked. Found by writing the first specs for these surfaces.
19
+
20
+ ### Changed (breaking, pre-1.0)
21
+
22
+ - **The inference seam is now `Adapters::`, not `Engines::`** — and
23
+ `config.adapter`, not `config.engine`. "Engine" meant two unrelated things
24
+ in one namespace: the Rails engine at `Silas::Engine`, and the pluggable
25
+ inference backend. Every comparable seam disambiguates — ActiveJob has
26
+ `QueueAdapters::`, ActiveStorage `Service::`, RubyLLM `Provider`. Done now
27
+ because 1.0 freezes the public API and host apps subclass this seam.
28
+ **Nothing breaks today**: `Silas::Engines::Base`, `config.engine`, and
29
+ `Silas.resolved_engine` all still resolve, warn through the new deprecator,
30
+ and are removed in 2.0.
31
+ - **Notification names follow the Rails convention** `<event>.silas` (like
32
+ `sql.active_record`). The two pre-existing events were backwards:
33
+ `silas.step` → `step.silas`, `silas.delta` → `delta.silas`. Update any
34
+ subscriber; `subscribe(/\.silas\z/)` now catches everything.
35
+
36
+ ### Added
37
+
38
+ - **Instrumentation for the durable loop.** It emitted almost nothing before:
39
+ a turn could start, park for a human, be rescued after a `kill -9`, breach a
40
+ budget and finish without a single line. Ten events now — `turn`, `step`,
41
+ `tool`, `park`, `resume`, `approval`, `budget`, `rescue`, `nondeterminism`,
42
+ `delta` — with documented payloads that always carry `turn_id`/`session_id`,
43
+ so a subscriber never has to join. **`tool.silas`** times the tool's own
44
+ execution and reports how it settled (the most useful span in the system);
45
+ **`resume.silas`** carries `parked_for` — how long the human actually took.
46
+ `Silas::LogSubscriber` (modelled on Solid Queue's) turns them into log lines
47
+ at operator-filterable levels: parks and rescues INFO, budget WARN, failed
48
+ turns and nondeterminism ERROR, per-token chatter DEBUG — and stays silent
49
+ when the rescuer did nothing.
50
+ - **`Silas.deprecator`** — an `ActiveSupport::Deprecation` registered in
51
+ `app.deprecators[:silas]`, so hosts silence or raise on Silas deprecations
52
+ exactly as they do Rails'. Every message names the replacement *and* the
53
+ removal version.
54
+ - **Coverage for the four money-path surfaces that had none** (36 specs):
55
+ `Channels::SlackController` (unsigned / wrong-secret / stale-timestamp
56
+ requests refused end to end; retries and bot messages ignored; buttons
57
+ settle through the same `approve!`/`decline!`),
58
+ `Channels::ApprovalsController` (tampered, garbage, expired and
59
+ wrong-purpose tokens refused; **GET never mutates**, so a link preview or
60
+ scanner cannot approve a refund; a replayed link on a settled invocation
61
+ 422s), `AgentMailbox` (References → In-Reply-To → Message-ID threading, so
62
+ replies continue rather than restart), and `ChannelMailer` (renders, shows
63
+ the arguments, embeds two distinct absolute links whose tokens verify back).
64
+ - **Quality tooling, enforced in CI**: `rubocop-rails-omakase` (Rails' own
65
+ style baseline, zero offenses), SimpleCov with a **90% line-coverage floor
66
+ that fails the build** (actual: 92.5%), Brakeman and bundler-audit (clean —
67
+ the single deliberate CSRF suppression is documented with its reasoning in
68
+ `config/brakeman.ignore`), and a `rake zeitwerk:check` job that eager-loads
69
+ every constant to catch naming violations lazy tests never see.
70
+ - **Dependency contract specs.** Silas reaches into Solid Queue and RubyLLM
71
+ internals, where a rename breaks *recovery* silently. Ten specs pin them:
72
+ the dead-process error classes the rescuer allowlists,
73
+ `FailedExecution#retry`, the Solid Queue >= 1.2 continuations floor,
74
+ RubyLLM's `with_schema`/`before_message`/`Tool::Halt`/model registry and the
75
+ error classes `retry_on` names, and `resume_errors_after_advancing` staying
76
+ false. Plus an allowed-to-fail CI canary against ruby_llm edge, for early
77
+ warning on the 2.0 horizon.
78
+ - **`docs/conventions.md`** — the naming and structure rules (why the seam is
79
+ `Adapters::`; the deliberate `class << self` vs `module_function` split) and
80
+ the audited posture: nothing mass-assigned, no `raw`/`html_safe` anywhere,
81
+ `Time.current` throughout, indexes on query paths rather than every foreign
82
+ key. Written down so nobody "fixes" something load-bearing.
83
+
84
+ No migration. 336 specs green on SQLite and Postgres.
85
+
86
+ ## 0.3.2
87
+
88
+ - **The `timeout` budget no longer counts time spent parked for approval.**
89
+ The wall clock restarts when an approval resumes a turn, because any
90
+ approval slower than `limits.timeout` previously made the approved resume
91
+ *instantly* re-park on "timeout" — pathological for a gate whose whole point
92
+ is waiting for a person (found live in the playground: a 3-minute approval
93
+ against a 120s timeout). Timeout now bounds **active** stretches — hung
94
+ providers, runaway loops; crash-rescue resumes keep the original clock (the
95
+ turn was genuinely live), and cost/token budgets stay cumulative because
96
+ they measure real spend.
97
+ - **Broadcast-rendered trace partials work in host apps.** The inbox's live
98
+ trace renders through Turbo's broadcast jobs — i.e. the HOST's default
99
+ renderer — where the engine-scoped `TraceHelper` and bare engine route
100
+ helpers didn't exist, so **every broadcast render raised and the live trace
101
+ silently never streamed in real host apps** (the gem's specs stubbed the
102
+ dispatch seam and never rendered). The helper is now registered host-wide,
103
+ partials build routes context-free via `silas_engine_path` (engine route
104
+ set + discovered mount point), and four host-renderer regression specs pin
105
+ the real path. `relative_time` is renamed `silas_relative_time` (it is now
106
+ host-visible, and the bare name is exactly what a host app would define).
107
+ Also new: `examples/playground` gets a customer-facing chat page that
108
+ renders and live-streams the engine's own trace partials with zero custom
109
+ streaming code, plus a scripted keyless demo mode (`bin/setup && bin/dev`
110
+ with no API key).
111
+
3
112
  ## 0.3.1
4
113
 
5
114
  - **Fixed: only the first scenario in a `silas:eval` run was really tested.**
data/README.md CHANGED
@@ -104,15 +104,15 @@ hundreds of times per release (results in `chaos_host/results/`):
104
104
  deploy that changes tools/skills mid-turn fails the turn loudly
105
105
  (`NondeterminismError`) instead of resuming into a different agent.
106
106
 
107
- ## Engine
107
+ ## Adapter
108
108
 
109
- Inference is one pluggable seam (`config.engine`): `:ruby_llm` — API-key auth
109
+ Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
110
110
  via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
111
111
  and the production path. Compose resilience via `config.around_model_call`, or
112
112
  swap in any object responding to `#execute_step` (the eval harness and the
113
113
  chaos tests do exactly that).
114
114
 
115
- > The experimental `:agent_sdk` engine (a `claude -p` subprocess) was removed
115
+ > The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
116
116
  > in 0.2: its subscription-auth rationale was structurally unreachable, and it
117
117
  > carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
118
118
  > server survives and returns as a first-class *mount your tools as MCP*
@@ -200,13 +200,13 @@ An agent is reached by more than a method call:
200
200
 
201
201
  ## Streaming
202
202
 
203
- Turns stream. The `:ruby_llm` engine emits text deltas as the model responds:
203
+ Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
204
204
  `bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
205
205
  renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
206
206
  the durable rows — never persisted, never fed back to the model, and a
207
207
  replayed step renders from its row with no deltas at all, so streaming adds
208
208
  zero risk to the durability contract. Custom sinks subscribe to the
209
- `"silas.delta"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
209
+ `"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
210
210
  text: }`, where `text` is the accumulated string so far — filter by ids;
211
211
  notifications are process-global).
212
212
 
@@ -37,11 +37,21 @@ module Silas
37
37
  end
38
38
  end
39
39
 
40
- def relative_time(time)
40
+ # Prefixed: this helper is registered host-wide (broadcast renders need
41
+ # it), and "relative_time" is exactly the name a host app would define.
42
+ def silas_relative_time(time)
41
43
  return "" unless time
42
44
 
43
45
  "#{time_ago_in_words(time)} ago"
44
46
  end
47
+
48
+ # Engine paths that resolve in EVERY render context. The mounted proxy
49
+ # (`silas.`) leans on the rendering scope's url_options, which Turbo's
50
+ # bare broadcast renderer doesn't have — so broadcast-rendered partials
51
+ # build paths from the engine's own route set + the discovered mount.
52
+ def silas_engine_path(helper, *args)
53
+ Silas::Engine.routes.url_helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
54
+ end
45
55
  end
46
56
  end
47
57
  end
@@ -112,6 +112,8 @@ module Silas
112
112
  # replays completed steps from rows, resuming where it left off.
113
113
  if (reason = Budget.exceeded_reason(turn))
114
114
  turn.update!(status: "waiting", failure_reason: reason)
115
+ Silas.instrument(:budget, reason: reason, turn_id: turn.id)
116
+ Silas.instrument(:park, reason: "budget", turn_id: turn.id, detail: reason)
115
117
  return
116
118
  end
117
119
 
@@ -15,7 +15,13 @@ module Silas
15
15
  queue_as { Silas.config.queue_name }
16
16
 
17
17
  def perform
18
+ Silas.instrument(:rescue) { |payload| sweep(payload) }
19
+ end
20
+
21
+ def sweep(payload)
18
22
  ToolInvocation.expire_stale!
23
+ payload[:rescued] = 0
24
+ payload[:stranded] = 0
19
25
  return 0 unless defined?(SolidQueue)
20
26
 
21
27
  rescued = 0
@@ -24,9 +30,10 @@ module Silas
24
30
  failed.retry
25
31
  rescued += 1
26
32
  elsif failed.job&.class_name == "Silas::AgentLoopJob"
27
- fail_stranded_turn(failed)
33
+ payload[:stranded] += 1 if fail_stranded_turn(failed)
28
34
  end
29
35
  end
36
+ payload[:rescued] = rescued
30
37
  rescued
31
38
  end
32
39
 
@@ -40,13 +47,14 @@ module Silas
40
47
  # loudly with its approvals expired.
41
48
  def fail_stranded_turn(failed)
42
49
  turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
43
- return unless turn&.active?
50
+ return false unless turn&.active?
44
51
 
45
52
  exception = failed.error&.dig("exception_class")
46
53
  turn.expire_pending_approvals!("turn failed: #{exception}")
47
54
  turn.finish!(:failed, reason: "job_failed")
48
55
  Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
49
56
  "#{exception} — #{failed.error&.dig('message')}")
57
+ true
50
58
  end
51
59
  end
52
60
  end
@@ -37,6 +37,8 @@ module Silas
37
37
  assert_parked!
38
38
  assert_turn_resumable!
39
39
  update!(status: "pending", approval_state: "approved", approved_by: by)
40
+ Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
41
+ invocation_id: id, turn_id: turn_id)
40
42
  resume_turn!
41
43
  end
42
44
 
@@ -49,6 +51,8 @@ module Silas
49
51
  assert_turn_resumable!
50
52
  update!(status: "failed", approval_state: "declined", approved_by: by,
51
53
  decline_reason: reason, result: { "denied" => reason })
54
+ Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
55
+ invocation_id: id, turn_id: turn_id)
52
56
  resume_turn!
53
57
  end
54
58
 
@@ -58,6 +62,8 @@ module Silas
58
62
  where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
59
63
  inv.update!(approval_state: "expired", status: "failed",
60
64
  result: { "denied" => "approval expired" })
65
+ Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
66
+ invocation_id: inv.id, turn_id: inv.turn_id)
61
67
  inv.turn.finish!(:failed, reason: "approval_expired")
62
68
  end
63
69
  end
@@ -84,7 +90,15 @@ module Silas
84
90
  return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
85
91
  return if turn.tool_invocations.where(approval_state: "required").exists?
86
92
 
87
- turn.update!(status: "queued")
93
+ # Restart the wall clock: `limits.timeout` bounds ACTIVE stretches (hung
94
+ # providers, runaway loops), not human deliberation. Without this, any
95
+ # approval that took longer than the timeout made the approved resume
96
+ # instantly re-park on "timeout" — pathological for a gate whose whole
97
+ # point is waiting for a person. Cost/token budgets stay cumulative;
98
+ # they measure real spend.
99
+ parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
100
+ turn.update!(status: "queued", started_at: Time.current)
101
+ Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
88
102
  AgentLoopJob.perform_later(turn.id)
89
103
  end
90
104
  end
@@ -21,8 +21,14 @@ module Silas
21
21
  def active? = ACTIVE_STATUSES.include?(status)
22
22
  def parked? = status == "waiting" || status == "in_doubt"
23
23
 
24
+ # Duration on this event spans the WHOLE turn, parked time included — it
25
+ # answers "how long did the customer wait", not "how much compute".
24
26
  def finish!(new_status, reason: nil)
25
- update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
27
+ Silas.instrument(:turn, status: new_status.to_s, reason: reason, turn_id: id,
28
+ session_id: session_id, agent: session.agent_name,
29
+ steps: steps.count) do
30
+ update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
31
+ end
26
32
  end
27
33
 
28
34
  def canceled? = status == "canceled"
@@ -3,7 +3,7 @@ Your agent needs approval to run: <%= @invocation.tool_name %>
3
3
  Arguments:
4
4
  <%= JSON.pretty_generate(@invocation.arguments) %>
5
5
 
6
- Approve: <%= silas.approval_url(token: @approve_token) %>
7
- Decline: <%= silas.approval_url(token: @decline_token) %>
6
+ Approve: <%= silas.channels_approval_url(token: @approve_token) %>
7
+ Decline: <%= silas.channels_approval_url(token: @decline_token) %>
8
8
 
9
9
  These links expire; do not forward them.
@@ -1,7 +1,7 @@
1
1
  <% if @invocation&.awaiting_approval? %>
2
2
  <h1>Approval needed: <%= @invocation.tool_name %></h1>
3
3
  <pre><%= JSON.pretty_generate(@invocation.arguments) %></pre>
4
- <%= form_with url: silas.approval_path(token: params[:token]), method: :post do %>
4
+ <%= form_with url: silas.channels_approval_path(token: params[:token]), method: :post do %>
5
5
  <button type="submit">Confirm <%= @action %></button>
6
6
  <% end %>
7
7
  <% else %>
@@ -1,10 +1,13 @@
1
1
  <div class="approval">
2
2
  <h3>Approval needed — <%= invocation.tool_name %></h3>
3
3
  <pre><%= pretty_args(invocation.arguments) %></pre>
4
- <%= form_with url: approve_inbox_invocation_path(invocation), method: :post, class: "inline" do %>
4
+ <%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
5
+ through the HOST's renderer, where engine route helpers don't exist and
6
+ the mounted proxy has no routing scope to lean on. %>
7
+ <%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
5
8
  <button class="btn approve">Approve</button>
6
9
  <% end %>
7
- <%= form_with url: decline_inbox_invocation_path(invocation), method: :post, class: "decline-form" do %>
10
+ <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
8
11
  <textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
9
12
  <button class="btn decline">Decline</button>
10
13
  <% end %>
@@ -45,7 +45,7 @@
45
45
  <div class="muted">
46
46
  <%= pluralize(session.turns.size, "turn") %> ·
47
47
  <%= session.channel.presence || "direct" %> ·
48
- <%= relative_time(session.updated_at) %>
48
+ <%= silas_relative_time(session.updated_at) %>
49
49
  </div>
50
50
  <% end %>
51
51
  <% end %>
@@ -11,7 +11,7 @@
11
11
  <span class="name"><%= @session.agent_name %></span>
12
12
  <span class="muted">#<%= @session.id %></span>
13
13
  </div>
14
- <div class="muted"><%= @session.channel.presence || "direct" %> · started <%= relative_time(@session.created_at) %></div>
14
+ <div class="muted"><%= @session.channel.presence || "direct" %> · started <%= silas_relative_time(@session.created_at) %></div>
15
15
  <div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
16
16
  </div>
17
17
 
@@ -2,7 +2,10 @@
2
2
  <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
3
  <%= status_pill(turn.status) %>
4
4
  <% if turn.active? && !turn.cancel_requested_at %>
5
- <%= form_with url: cancel_inbox_turn_path(turn), method: :post, class: "inline" do %>
5
+ <%# silas_engine_path this partial is broadcast-rendered through the
6
+ host renderer on every turn status change; bare engine helpers (and
7
+ the scope-dependent mounted proxy) 500 there. %>
8
+ <%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
6
9
  <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
7
10
  <% end %>
8
11
  <% elsif turn.cancel_requested_at && turn.active? %>
@@ -14,7 +17,7 @@
14
17
  <h3>Budget reached — <%= turn.failure_reason %></h3>
15
18
  <div class="muted">Parked at zero compute. Raise the limit and the turn resumes
16
19
  by replaying completed work from rows — no re-calls, no re-effects.</div>
17
- <%= form_with url: raise_budget_inbox_turn_path(turn), method: :post, class: "topup-form" do %>
20
+ <%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
18
21
  <input name="value" type="number" step="any" min="0" required class="topup-input"
19
22
  placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
20
23
  <button class="btn approve">Raise &amp; resume</button>
@@ -0,0 +1,11 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "Cross-Site Request Forgery",
5
+ "warning_code": 7,
6
+ "fingerprint": "64dd0725204c2cd5d6b2dba2fac6c278d20cebd07aaed38493f229193f44ed79",
7
+ "file": "app/controllers/silas/channels/base_controller.rb",
8
+ "note": "Deliberate: Silas::Channels::BaseController serves inbound WEBHOOKS, which no browser session originates, so a CSRF token cannot exist. Each route authenticates the request itself instead: Slack routes verify an HMAC-SHA256 request signature with a 300s replay window (Silas::Slack.verify_signature, covered by spec/silas/channels/slack_controller_spec.rb), and the email approve/decline routes require a purpose-scoped, expiring MessageVerifier token whose possession IS the credential — CSRF would add nothing an attacker holding the token could not already do (covered by spec/silas/channels/approvals_controller_spec.rb, including tampered/expired/wrong-purpose/replayed tokens and the GET-never-mutates property). The inbox controllers keep protect_from_forgery; the JSON API is ActionController::API and is token-authenticated."
9
+ }
10
+ ]
11
+ }
@@ -1,7 +1,7 @@
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
- config.engine = :ruby_llm
4
+ config.adapter = :ruby_llm
5
5
 
6
6
  # Any model your installed ruby_llm's registry resolves (newer models may
7
7
  # need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
@@ -3,11 +3,11 @@ module Silas
3
3
  # set — consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
4
4
  # :message_start — once per model call (before_message)
5
5
  # :text_delta — { text: } chunks as the response streams
6
- # StepRunner coalesces :text_delta into "silas.delta" notifications (see
6
+ # StepRunner coalesces :text_delta into "delta.silas" notifications (see
7
7
  # DeltaBuffer); everything else is available to custom engines/hooks.
8
8
  Event = Data.define(:type, :payload)
9
9
 
10
- module Engines
10
+ module Adapters
11
11
  # The inference seam. An engine executes exactly ONE model call for a step
12
12
  # and reports what came back; the framework owns the loop, the ledger owns
13
13
  # tool execution.
@@ -25,4 +25,14 @@ module Silas
25
25
 
26
26
  ToolCall = Data.define(:id, :name, :arguments)
27
27
  end
28
+
29
+ # Renamed Engines:: -> Adapters:: in 0.4, removed in 2.0. Host apps subclass
30
+ # Adapters::Base for custom inference backends, so the old constant keeps
31
+ # resolving (with a warning) rather than blowing up on upgrade.
32
+ module Engines
33
+ def self.const_missing(name)
34
+ Silas.deprecator.warn("Silas::Engines::#{name} is deprecated; use Silas::Adapters::#{name}")
35
+ Silas::Adapters.const_get(name)
36
+ end
37
+ end
28
38
  end
@@ -1,5 +1,5 @@
1
1
  module Silas
2
- module Engines
2
+ module Adapters
3
3
  # The :ruby_llm adapter: ONE model call per step, streamed, with tool
4
4
  # interception. Silas's Ledger owns tool execution, so tools are registered
5
5
  # as halt-proxies — RubyLLM sees the schemas, but the moment the model
@@ -11,7 +11,7 @@ module Silas
11
11
  def execute_step(context, &on_event)
12
12
  chat = build_chat(context, &on_event)
13
13
 
14
- response = ActiveSupport::Notifications.instrument("silas.step",
14
+ response = Silas.instrument(:step,
15
15
  turn_id: context[:turn]&.id,
16
16
  index: context[:index],
17
17
  model: context[:model]) do
data/lib/silas/budget.rb CHANGED
@@ -7,9 +7,11 @@ module Silas
7
7
  #
8
8
  # Token/cost checks are deterministic (persisted step data). The timeout check
9
9
  # reads the wall clock — benign non-determinism: a cap firing later on resume
10
- # is correct (the turn genuinely ran too long across the crash). Note the
11
- # timeout clock includes time spent parked, so a timeout top-up should be
12
- # sized from Time.current - started_at, not from the original limit.
10
+ # is correct (the turn genuinely ran too long across the crash). The clock
11
+ # RESTARTS when an approval resumes the turn (resume_turn! resets
12
+ # started_at): timeout bounds active stretches, never the hours a human
13
+ # spends deciding. Crash-rescue resumes keep the original clock — the turn
14
+ # was live the whole time.
13
15
  module Budget
14
16
  REASONS = %w[max_input_tokens max_cost timeout].freeze
15
17
 
data/lib/silas/chat.rb CHANGED
@@ -59,11 +59,11 @@ module Silas
59
59
  end
60
60
 
61
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
62
+ # "delta.silas" notifications and prints tokens as they arrive. Filtered by
63
63
  # session id: notifications are process-global.
64
64
  def with_delta_stream
65
65
  @live = {}
66
- subscription = ActiveSupport::Notifications.subscribe("silas.delta") do |*args|
66
+ subscription = ActiveSupport::Notifications.subscribe("delta.silas") do |*args|
67
67
  payload = args.last
68
68
  print_delta(payload) if @session && payload[:session_id] == @session.id
69
69
  end
@@ -1,7 +1,9 @@
1
1
  module Silas
2
2
  class Configuration
3
- # Inference engine seam: :ruby_llm, or any object responding to #execute_step.
4
- attr_accessor :engine
3
+ # Inference adapter seam: :ruby_llm, or any object responding to
4
+ # #execute_step. (Named `engine` before 0.4 — see the deprecated alias
5
+ # below; "engine" already meant the Rails engine at Silas::Engine.)
6
+ attr_accessor :adapter
5
7
  # Default model when agent.yml doesn't specify one.
6
8
  attr_accessor :default_model
7
9
  # Active Job queue for agent turns.
@@ -39,8 +41,21 @@ module Silas
39
41
  # tools as MCP" seam).
40
42
  attr_accessor :mcp_server_host
41
43
 
44
+ # Renamed in 0.4, removed in 2.0. "engine" meant two unrelated things —
45
+ # the Rails engine (Silas::Engine) and the inference backend — the exact
46
+ # collision ActiveJob avoids by calling its seam QueueAdapters.
47
+ def engine
48
+ Silas.deprecator.warn("config.engine is deprecated; use config.adapter")
49
+ adapter
50
+ end
51
+
52
+ def engine=(value)
53
+ Silas.deprecator.warn("config.engine= is deprecated; use config.adapter=")
54
+ self.adapter = value
55
+ end
56
+
42
57
  # config.auth and the agent_sdk_* options were removed with the :agent_sdk
43
- # engine in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
58
+ # adapter in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
44
59
  # a leftover write now raises NoMethodError. Delete them from your
45
60
  # initializer.
46
61
  # JSON API (mounted under /silas/api/v1).
@@ -83,7 +98,7 @@ module Silas
83
98
  end
84
99
 
85
100
  def initialize
86
- @engine = :ruby_llm
101
+ @adapter = :ruby_llm
87
102
  # Must be resolvable by the installed ruby_llm's model registry — newer
88
103
  # Claude models may need `RubyLLM.models.refresh!` before they resolve.
89
104
  # (Sonnet 4.5 ships in every supported registry; never default a first
@@ -142,11 +157,11 @@ module Silas
142
157
 
143
158
  # Fail-loud misconfiguration checks, run from Silas.configure and at boot.
144
159
  def boot_guard!
145
- if engine == :agent_sdk
160
+ if adapter == :agent_sdk
146
161
  raise BootGuardError,
147
- "the :agent_sdk engine was removed in Silas 0.2 — the claude -p subprocess " \
162
+ "the :agent_sdk adapter was removed in Silas 0.2 — the claude -p subprocess " \
148
163
  "integration is gone (its subscription-auth rationale was unreachable). " \
149
- "Use engine :ruby_llm, the production path."
164
+ "Use adapter :ruby_llm, the production path."
150
165
  end
151
166
 
152
167
  check_provider_credentials!
@@ -159,7 +174,7 @@ module Silas
159
174
  # always a misconfiguration); warns in development so a fresh app can
160
175
  # still boot and browse the inbox before a key exists.
161
176
  def check_provider_credentials!
162
- return unless engine == :ruby_llm && defined?(::RubyLLM)
177
+ return unless adapter == :ruby_llm && defined?(::RubyLLM)
163
178
 
164
179
  providers = ::RubyLLM::Provider.providers.values
165
180
  configured = providers.any? do |provider|
@@ -168,7 +183,7 @@ module Silas
168
183
  end
169
184
  return if configured
170
185
 
171
- message = "[Silas] engine :ruby_llm has no configured provider — no API key is set on " \
186
+ message = "[Silas] adapter :ruby_llm has no configured provider — no API key is set on " \
172
187
  "RubyLLM.config. Set one in config/initializers/ruby_llm.rb, e.g. " \
173
188
  "RubyLLM.configure { |c| c.anthropic_api_key = ENV[\"ANTHROPIC_API_KEY\"] } " \
174
189
  "— the first agent turn will fail without it."
@@ -1,5 +1,5 @@
1
1
  module Silas
2
- # Coalesces model text deltas into ~10Hz "silas.delta" notifications carrying
2
+ # Coalesces model text deltas into ~10Hz "delta.silas" notifications carrying
3
3
  # the ACCUMULATED text so far — subscribers replace rather than append, which
4
4
  # is idempotent under a crash-restream (same step id, fresh stream overwrites
5
5
  # itself) and ordering-safe under Turbo. Deltas are decoration over the
@@ -38,8 +38,8 @@ module Silas
38
38
 
39
39
  @published = @text.length
40
40
  @last_publish = clock
41
- ActiveSupport::Notifications.instrument(
42
- "silas.delta",
41
+ Silas.instrument(
42
+ :delta,
43
43
  session_id: @turn.session_id, turn_id: @turn.id,
44
44
  step_id: @step.id, step_index: @step.index, text: @text.dup
45
45
  )
@@ -0,0 +1,16 @@
1
+ module Silas
2
+ # One deprecator for the whole gem, so hosts can control the noise the way
3
+ # they control Rails' own:
4
+ #
5
+ # Silas.deprecator.behavior = :raise # or :warn (default), :silence
6
+ #
7
+ # Rails registers it in application.deprecators (see Silas::Engine), which
8
+ # means `config.active_support.report_deprecations = false` silences Silas
9
+ # along with everything else, and a host can opt into raising in CI.
10
+ #
11
+ # Every deprecation message names BOTH the replacement and the version it
12
+ # disappears in — a warning you can't act on is just noise.
13
+ def self.deprecator
14
+ @deprecator ||= ActiveSupport::Deprecation.new("2.0", "Silas")
15
+ end
16
+ end
data/lib/silas/engine.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Silas
2
2
  # The Rails engine (not to be confused with inference adapters under
3
- # Silas::Engines::*). Full engine: Silas is Rails-native by thesis.
3
+ # Silas::Adapters::*). Full engine: Silas is Rails-native by thesis.
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Silas
6
6
 
@@ -33,16 +33,40 @@ module Silas
33
33
  end
34
34
  end
35
35
 
36
+ # Register with Rails so hosts control Silas's deprecations exactly as they
37
+ # control everyone else's: config.active_support.report_deprecations,
38
+ # or `config.silas.deprecator.behavior = :raise` in CI.
39
+ initializer "silas.deprecator" do |app|
40
+ app.deprecators[:silas] = Silas.deprecator if app.respond_to?(:deprecators)
41
+ end
42
+
36
43
  initializer "silas.boot_guard", after: :load_config_initializers do
37
44
  Silas.config.boot_guard!
38
45
  end
39
46
 
47
+ # Turn the loop's notifications into log lines. Attaching here (rather than
48
+ # at require time) means a host that never boots Rails pays nothing.
49
+ initializer "silas.log_subscriber" do
50
+ Silas::LogSubscriber.attach_to :silas if defined?(Silas::LogSubscriber)
51
+ end
52
+
40
53
  # 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.
54
+ # "delta.silas"; a no-op unless turbo-rails is present and streaming is on.
42
55
  initializer "silas.delta_broadcaster" do
43
56
  Silas::Inbox::DeltaBroadcaster.subscribe!
44
57
  end
45
58
 
59
+ # The trace partials render in TWO contexts: the inbox controllers (which
60
+ # declare this helper) and Turbo's broadcast jobs, which render through the
61
+ # HOST's default renderer — where an engine-scoped helper doesn't exist and
62
+ # every broadcast died silently inside Turbo's job. Register it host-wide
63
+ # so broadcast renders — and host apps embedding the trace partials in
64
+ # their own pages — resolve it. (Routes inside those partials go through
65
+ # TraceHelper#silas_engine_path, which needs no routing scope at all.)
66
+ initializer "silas.trace_helper" do
67
+ ActiveSupport.on_load(:action_controller_base) { helper Silas::Inbox::TraceHelper }
68
+ end
69
+
46
70
  # Registry rebuilds on every code reload in development, once in production.
47
71
  initializer "silas.registry" do |app|
48
72
  next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
@@ -34,7 +34,7 @@ module Silas
34
34
  engine = scenario.real? ? nil : ScriptedEngine.new(scenario.steps)
35
35
  base_resolver = Silas.config.tool_resolver
36
36
  Silas.configure do |c|
37
- c.engine = engine if engine
37
+ c.adapter = engine if engine
38
38
  c.isolate_steps = false
39
39
  c.max_steps = scenario.max_steps if scenario.max_steps
40
40
  if scenario.stubs.any?
@@ -22,7 +22,7 @@ module Silas
22
22
  # on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
23
23
  def on_step(index, text: nil, call: nil, calls: [])
24
24
  tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
25
- Silas::Engines::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
25
+ Silas::Adapters::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
26
26
  arguments: (c[:arguments] || {}).stringify_keys)
27
27
  end
28
28
  blocks = []
@@ -3,7 +3,7 @@ module Silas
3
3
  # The productized FakeEngine: a pure function of context[:index] that lets an
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
- class ScriptedEngine < Silas::Engines::Base
6
+ class ScriptedEngine < Silas::Adapters::Base
7
7
  attr_reader :calls
8
8
 
9
9
  def initialize(steps)
@@ -17,7 +17,7 @@ module Silas
17
17
  spec = @steps[i]
18
18
  return terminal("OK.") unless spec
19
19
 
20
- Silas::Engines::Result.new(
20
+ Silas::Adapters::Result.new(
21
21
  blocks: spec[:blocks],
22
22
  tool_calls: spec[:tool_calls],
23
23
  stop_reason: spec[:tool_calls].empty? ? "end_turn" : "tool_use",
@@ -28,7 +28,7 @@ module Silas
28
28
  private
29
29
 
30
30
  def terminal(text)
31
- Silas::Engines::Result.new(blocks: [ { "type" => "text", "text" => text } ],
31
+ Silas::Adapters::Result.new(blocks: [ { "type" => "text", "text" => text } ],
32
32
  tool_calls: [], stop_reason: "end_turn",
33
33
  usage: { input_tokens: 1, output_tokens: 1 })
34
34
  end
@@ -12,7 +12,7 @@ module Silas
12
12
  # the whole step partial (dom_id target) and supersedes anything streamed
13
13
  # into the inner text container.
14
14
  module DeltaBroadcaster
15
- EVENT = "silas.delta".freeze
15
+ EVENT = "delta.silas".freeze
16
16
 
17
17
  class << self
18
18
  def subscribe!
data/lib/silas/inbox.rb CHANGED
@@ -19,5 +19,20 @@ module Silas
19
19
  def stream_name(session_id)
20
20
  "silas:inbox:session:#{session_id}"
21
21
  end
22
+
23
+ # Where the host mounted the engine ("/silas" by the installer's
24
+ # convention), discovered from the app's route set. Used to build paths
25
+ # that work in EVERY render context — including Turbo's bare broadcast
26
+ # renderer, which has no routing scope for the mounted proxy to lean on.
27
+ def mount_path
28
+ @mount_path ||= begin
29
+ route = Rails.application.routes.routes.find do |r|
30
+ r.app.respond_to?(:app) && r.app.app == Silas::Engine
31
+ end
32
+ route&.path&.spec.to_s.sub(/\(.*\z/, "").presence || "/silas"
33
+ end
34
+ end
35
+
36
+ def reset_mount_path! = (@mount_path = nil) # specs remount
22
37
  end
23
38
  end
@@ -0,0 +1,55 @@
1
+ module Silas
2
+ # ActiveSupport::Notifications for the durable loop.
3
+ #
4
+ # Until now the loop was silent: a turn could start, park for a human, get
5
+ # rescued after a kill -9, breach a budget, and finish — without emitting a
6
+ # single line. This is the seam for logs, APM spans, and metrics.
7
+ #
8
+ # Names follow the Rails convention `<event>.silas` (like `sql.active_record`),
9
+ # so `ActiveSupport::Notifications.subscribe(/\.silas\z/)` gets everything.
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("tool.silas") do |event|
12
+ # StatsD.timing("agent.tool", event.duration, tags: ["tool:#{event.payload[:tool]}"])
13
+ # end
14
+ #
15
+ # ## Events and payloads
16
+ #
17
+ # Every payload carries `turn_id` and `session_id` where they exist, so any
18
+ # subscriber can correlate without joining.
19
+ #
20
+ # turn.silas status:, reason:, steps:, session_id:, turn_id:, agent:
21
+ # Duration = the whole turn INCLUDING parked time.
22
+ # step.silas index:, model:, turn_id: (one model call)
23
+ # tool.silas tool:, effect_mode:, status:, approval_state:,
24
+ # invocation_id:, turn_id:
25
+ # Duration = the tool's own execution. The single most
26
+ # useful span in the system.
27
+ # delta.silas session_id:, turn_id:, step_id:, step_index:, text:
28
+ # Streamed text so far (see DeltaBuffer). High frequency.
29
+ # park.silas reason: (approval | in_doubt | budget), turn_id:, detail:
30
+ # resume.silas turn_id:, parked_for: (seconds a human took)
31
+ # approval.silas action: (approved | declined | expired), tool:, by:,
32
+ # invocation_id:, turn_id:
33
+ # budget.silas reason: (max_cost | max_input_tokens | timeout), turn_id:
34
+ # rescue.silas rescued: (jobs retried), stranded: (turns failed)
35
+ # nondeterminism.silas turn_id:, was:, now: (digest changed mid-turn)
36
+ module Instrumentation
37
+ module_function
38
+
39
+ def instrument(event, **payload, &block)
40
+ ActiveSupport::Notifications.instrument("#{event}.silas", **payload, &block)
41
+ end
42
+ end
43
+
44
+ # Silas.instrument(:tool, tool: "issue_refund") { ... }
45
+ def self.instrument(event, **payload, &block)
46
+ Instrumentation.instrument(event, **payload, &block)
47
+ end
48
+
49
+ # The logger Silas writes through; hosts can point it elsewhere.
50
+ mattr_accessor :logger, default: nil
51
+
52
+ def self.logger
53
+ @@logger ||= (defined?(Rails) && Rails.logger) || ActiveSupport::Logger.new($stdout)
54
+ end
55
+ end
data/lib/silas/ledger.rb CHANGED
@@ -88,6 +88,8 @@ module Silas
88
88
  when :user_approval
89
89
  invocation.update!(approval_state: "required",
90
90
  approval_expires_at: Silas.config.approval_ttl.from_now)
91
+ Silas.instrument(:park, reason: "approval", turn_id: invocation.turn_id,
92
+ detail: invocation.tool_name)
91
93
  return :parked
92
94
  when Hash # {denied: "reason"} — eve's shape
93
95
  invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
@@ -107,6 +109,16 @@ module Silas
107
109
  end
108
110
 
109
111
  def execute!(invocation, tool)
112
+ Silas.instrument(:tool, tool: invocation.tool_name, effect_mode: invocation.effect_mode,
113
+ invocation_id: invocation.id, turn_id: invocation.turn_id) do |payload|
114
+ run_tool!(invocation, tool).tap do
115
+ payload[:status] = invocation.reload.status
116
+ payload[:approval_state] = invocation.approval_state
117
+ end
118
+ end
119
+ end
120
+
121
+ def run_tool!(invocation, tool)
110
122
  tool.session = invocation.turn.session if tool.respond_to?(:session=)
111
123
  args = invocation.arguments.symbolize_keys
112
124
 
@@ -149,6 +161,8 @@ module Silas
149
161
  # decline! = "it ran / abandon", operator supplies the outcome.
150
162
  invocation.update!(status: "in_doubt", approval_state: "required",
151
163
  approval_expires_at: Silas.config.approval_ttl.from_now)
164
+ Silas.instrument(:park, reason: "in_doubt", turn_id: invocation.turn_id,
165
+ detail: invocation.tool_name)
152
166
  :parked
153
167
  end
154
168
  end
@@ -0,0 +1,78 @@
1
+ require "active_support/log_subscriber"
2
+
3
+ # Turns the loop's notifications into log lines, at levels that match what an
4
+ # operator actually wants paged about: parks and rescues are INFO (a human is
5
+ # now in the loop, or a crash was recovered), budget breaches and
6
+ # nondeterminism are WARN, failed turns are ERROR, and the per-step/per-token
7
+ # chatter stays DEBUG.
8
+ #
9
+ # Attach is automatic (see Silas::Engine). To silence just Silas:
10
+ # Silas.logger = Logger.new(IO::NULL)
11
+ class Silas::LogSubscriber < ActiveSupport::LogSubscriber
12
+ def turn(event)
13
+ status = event.payload[:status]
14
+ line = formatted_event(event, action: "Turn #{status}",
15
+ **event.payload.slice(:turn_id, :session_id, :agent, :steps, :reason).compact)
16
+ status.to_s == "failed" ? error(line) : info(line)
17
+ end
18
+
19
+ def step(event)
20
+ debug formatted_event(event, action: "Model call",
21
+ **event.payload.slice(:turn_id, :index, :model).compact)
22
+ end
23
+
24
+ def tool(event)
25
+ line = formatted_event(event, action: "Tool #{event.payload[:tool]}",
26
+ **event.payload.slice(:effect_mode, :status, :approval_state, :turn_id).compact)
27
+ event.payload[:status].to_s == "failed" ? warn(line) : info(line)
28
+ end
29
+
30
+ def park(event)
31
+ info formatted_event(event, action: "Turn parked (#{event.payload[:reason]})",
32
+ **event.payload.slice(:turn_id, :detail).compact)
33
+ end
34
+
35
+ def resume(event)
36
+ parked_for = event.payload[:parked_for]
37
+ info formatted_event(event, action: "Turn resumed after #{parked_for&.round(1)}s parked",
38
+ **event.payload.slice(:turn_id).compact)
39
+ end
40
+
41
+ def approval(event)
42
+ info formatted_event(event, action: "Approval #{event.payload[:action]}",
43
+ **event.payload.slice(:tool, :by, :turn_id).compact)
44
+ end
45
+
46
+ def budget(event)
47
+ warn formatted_event(event, action: "Budget reached (#{event.payload[:reason]})",
48
+ **event.payload.slice(:turn_id).compact)
49
+ end
50
+
51
+ def nondeterminism(event)
52
+ error formatted_event(event, action: "Definitions changed mid-turn",
53
+ **event.payload.slice(:turn_id, :was, :now).compact)
54
+ end
55
+
56
+ def rescue(event)
57
+ payload = event.payload
58
+ return if payload[:rescued].to_i.zero? && payload[:stranded].to_i.zero?
59
+
60
+ info formatted_event(event, action: "Rescuer", **payload.slice(:rescued, :stranded))
61
+ end
62
+
63
+ # delta.silas is deliberately NOT logged — it fires many times per second
64
+ # per running turn. Subscribe to it directly if you want the firehose.
65
+
66
+ private
67
+ def formatted_event(event, action:, **attributes)
68
+ "Silas-#{Silas::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
69
+ end
70
+
71
+ def formatted_attributes(**attributes)
72
+ attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
73
+ end
74
+
75
+ def logger
76
+ Silas.logger
77
+ end
78
+ end
data/lib/silas/slack.rb CHANGED
@@ -43,7 +43,7 @@ module Silas
43
43
 
44
44
  # Slack signs each request (v0 scheme) — HMAC-SHA256 over "v0:ts:body" plus a
45
45
  # 5-minute replay window. Returns true only for a genuine, fresh request.
46
- def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.now.to_i)
46
+ def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.current.to_i)
47
47
  return false if signing_secret.blank? || signature.blank? || timestamp.blank?
48
48
  return false if (now - timestamp.to_i).abs > REPLAY_WINDOW
49
49
 
@@ -51,7 +51,7 @@ module Silas
51
51
 
52
52
  def execute_model_call(turn, index, step)
53
53
  assert_definitions_unchanged!(turn)
54
- engine = Silas.resolved_engine
54
+ engine = Silas.resolved_adapter
55
55
  context = {
56
56
  turn: turn,
57
57
  index: index,
@@ -64,7 +64,7 @@ module Silas
64
64
  }
65
65
 
66
66
  # Live deltas: the engine yields Events, the buffer coalesces them into
67
- # "silas.delta" notifications. A replayed step never reaches this method
67
+ # "delta.silas" notifications. A replayed step never reaches this method
68
68
  # (the completed? guard above), so replay emits nothing. The emitter is
69
69
  # created HERE and closed over by the inner block, so around_model_call
70
70
  # hooks keep their existing one-argument contract and can't swallow it.
@@ -93,6 +93,9 @@ module Silas
93
93
  return if live == turn.definitions_digest
94
94
 
95
95
  turn.finish!(:failed, reason: "definitions_changed")
96
+ Silas.instrument(:nondeterminism, turn_id: turn.id,
97
+ was: turn.definitions_digest.to_s[0, 12],
98
+ now: live[0, 12])
96
99
  raise NondeterminismError,
97
100
  "Tool/skill definitions changed mid-turn (digest #{turn.definitions_digest[0, 12]}… → " \
98
101
  "#{live[0, 12]}…). The turn was failed rather than resumed against a different agent."
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/silas.rb CHANGED
@@ -4,6 +4,8 @@ require "active_job/railtie" if defined?(::Rails::Railtie)
4
4
  require "active_record/railtie" if defined?(::Rails::Railtie)
5
5
 
6
6
  require "silas/version"
7
+ require "silas/deprecator"
8
+ require "silas/instrumentation"
7
9
  require "silas/errors"
8
10
  require "silas/configuration"
9
11
  require "silas/ledger"
@@ -35,15 +37,16 @@ require "silas/tools/recall"
35
37
  require "silas/tools/handoff"
36
38
  require "silas/mcp/handler"
37
39
  require "silas/mcp/server"
38
- require "silas/engines/base"
40
+ require "silas/adapters/base"
39
41
  require "ruby_llm"
40
- require "silas/engines/ruby_llm"
42
+ require "silas/adapters/ruby_llm"
41
43
  require "silas/message_builder"
42
44
  require "silas/instructions"
43
45
  require "silas/step_runner"
44
- require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
46
+ require "silas/eval" # after adapters (ScriptedEngine < Adapters::Base)
45
47
  require "silas/chat"
46
48
  require "silas/doctor"
49
+ require "silas/log_subscriber" if defined?(::ActiveSupport::LogSubscriber)
47
50
 
48
51
  module Silas
49
52
  class << self
@@ -60,14 +63,14 @@ module Silas
60
63
  def configure
61
64
  yield config
62
65
  config.validate!
63
- @resolved_engine = nil
66
+ @resolved_adapter = nil
64
67
  @resolved_sandbox = nil
65
68
  config
66
69
  end
67
70
 
68
71
  def reset_configuration! # for specs
69
72
  @config = nil
70
- @resolved_engine = nil
73
+ @resolved_adapter = nil
71
74
  @resolved_sandbox = nil
72
75
  @agent = nil
73
76
  end
@@ -103,21 +106,29 @@ module Silas
103
106
 
104
107
  def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
105
108
 
106
- # The inference adapter instance. config.engine may be :ruby_llm or any
109
+ # The inference adapter instance. config.adapter may be :ruby_llm or any
107
110
  # object responding to #execute_step (specs, custom).
108
- def resolved_engine
109
- @resolved_engine ||=
110
- case config.engine
111
- when :ruby_llm then Engines::RubyLLM.new
111
+ def resolved_adapter
112
+ @resolved_adapter ||=
113
+ case config.adapter
114
+ when :ruby_llm then Adapters::RubyLLM.new
112
115
  when :agent_sdk
113
- raise Error, "the :agent_sdk engine was removed in Silas 0.2 — the claude -p " \
116
+ raise Error, "the :agent_sdk adapter was removed in Silas 0.2 — the claude -p " \
114
117
  "subprocess integration is gone (its subscription-auth rationale was " \
115
- "unreachable). Use engine :ruby_llm, the production path."
116
- when Symbol then raise Error, "unknown engine #{config.engine.inspect}"
117
- else config.engine
118
+ "unreachable). Use adapter :ruby_llm, the production path."
119
+ when Symbol then raise Error, "unknown adapter #{config.adapter.inspect}"
120
+ else config.adapter
118
121
  end
119
122
  end
120
123
 
124
+ # Renamed in 0.4: "engine" meant two unrelated things (the Rails engine at
125
+ # Silas::Engine, and the inference backend), which is exactly the collision
126
+ # ActiveJob avoids with QueueAdapters. Removed in 2.0.
127
+ def resolved_engine
128
+ Silas.deprecator.warn("Silas.resolved_engine is deprecated; use Silas.resolved_adapter")
129
+ resolved_adapter
130
+ end
131
+
121
132
  # ---- scope-aware readers -------------------------------------------------
122
133
  # Every reader consults the active AgentScope first (named agent or
123
134
  # subagent), falling back to the boot-time config the Registry installed.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: silas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -151,6 +151,7 @@ files:
151
151
  - app/views/silas/inbox/steps/_step.html.erb
152
152
  - app/views/silas/inbox/turns/_header.html.erb
153
153
  - app/views/silas/inbox/turns/_turn.html.erb
154
+ - config/brakeman.ignore
154
155
  - config/routes.rb
155
156
  - db/migrate/20260714000001_create_silas_tables.rb
156
157
  - db/migrate/20260715000001_add_channel_outbound_markers.rb
@@ -174,6 +175,8 @@ files:
174
175
  - lib/generators/silas/install/templates/instructions.md
175
176
  - lib/generators/silas/install/templates/ruby_llm.rb
176
177
  - lib/silas.rb
178
+ - lib/silas/adapters/base.rb
179
+ - lib/silas/adapters/ruby_llm.rb
177
180
  - lib/silas/agent.rb
178
181
  - lib/silas/agent_scope.rb
179
182
  - lib/silas/budget.rb
@@ -183,10 +186,9 @@ files:
183
186
  - lib/silas/connection.rb
184
187
  - lib/silas/connections.rb
185
188
  - lib/silas/delta_buffer.rb
189
+ - lib/silas/deprecator.rb
186
190
  - lib/silas/doctor.rb
187
191
  - lib/silas/engine.rb
188
- - lib/silas/engines/base.rb
189
- - lib/silas/engines/ruby_llm.rb
190
192
  - lib/silas/errors.rb
191
193
  - lib/silas/eval.rb
192
194
  - lib/silas/eval/assertions.rb
@@ -201,7 +203,9 @@ files:
201
203
  - lib/silas/inbox/cost.rb
202
204
  - lib/silas/inbox/delta_broadcaster.rb
203
205
  - lib/silas/instructions.rb
206
+ - lib/silas/instrumentation.rb
204
207
  - lib/silas/ledger.rb
208
+ - lib/silas/log_subscriber.rb
205
209
  - lib/silas/mcp/client.rb
206
210
  - lib/silas/mcp/handler.rb
207
211
  - lib/silas/mcp/server.rb