silas 0.3.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 187a0730a1a84463f6207060d446c6960946513c0884d708fc0d16c011493a26
4
- data.tar.gz: ed9a09b617c7dde80deaf6fe2c460e254cc96487b43eac52f56c6aa7dedecbaa
3
+ metadata.gz: a8ece90334c6059bd396179bfae54b62ebd8abc483d72309da718dbb8e147845
4
+ data.tar.gz: '014359b0b35a36e7b34b796fbb9194f99c49bf39a1901b4b82cb7b94a10c0ea0'
5
5
  SHA512:
6
- metadata.gz: a38278a7020a9282b1ff9aaac7b18da60bd017a001bcc421c6e316d0033b1a4b468cd86c0d4bcdcd869940f7d9c4b953318e84dd626087bef4fee1ee8ea8b87c
7
- data.tar.gz: 6f42e1a81bf935ebdbb39c678e46662ab85b9f45bd8293c0e83f4467e7db922e2102087ab62ea7eb8e8b1b3b1d323eba1a5772e997028b3e5838882bda9cfeb3
6
+ metadata.gz: 25e3610da0c9a62b6e9f2a0f956e01c989c62eec40bfdee6703b1bd9206821bb6b4a7988726671b57cd53949ae518239a76d76511cc260b3279db9a36b4f38c4
7
+ data.tar.gz: f891bf52671a8cd2c7fdc9041b036194939995dc4de7dee7bdd8f09cea986593bc526681d161d41add81b16cfe9257d75df7599e09a7fb85d5af52a9b117828d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - **The `timeout` budget no longer counts time spent parked for approval.**
6
+ The wall clock restarts when an approval resumes a turn, because any
7
+ approval slower than `limits.timeout` previously made the approved resume
8
+ *instantly* re-park on "timeout" — pathological for a gate whose whole point
9
+ is waiting for a person (found live in the playground: a 3-minute approval
10
+ against a 120s timeout). Timeout now bounds **active** stretches — hung
11
+ providers, runaway loops; crash-rescue resumes keep the original clock (the
12
+ turn was genuinely live), and cost/token budgets stay cumulative because
13
+ they measure real spend.
14
+ - **Broadcast-rendered trace partials work in host apps.** The inbox's live
15
+ trace renders through Turbo's broadcast jobs — i.e. the HOST's default
16
+ renderer — where the engine-scoped `TraceHelper` and bare engine route
17
+ helpers didn't exist, so **every broadcast render raised and the live trace
18
+ silently never streamed in real host apps** (the gem's specs stubbed the
19
+ dispatch seam and never rendered). The helper is now registered host-wide,
20
+ partials build routes context-free via `silas_engine_path` (engine route
21
+ set + discovered mount point), and four host-renderer regression specs pin
22
+ the real path. `relative_time` is renamed `silas_relative_time` (it is now
23
+ host-visible, and the bare name is exactly what a host app would define).
24
+ Also new: `examples/playground` gets a customer-facing chat page that
25
+ renders and live-streams the engine's own trace partials with zero custom
26
+ streaming code, plus a scripted keyless demo mode (`bin/setup && bin/dev`
27
+ with no API key).
28
+
29
+ ## 0.3.1
30
+
31
+ - **Fixed: only the first scenario in a `silas:eval` run was really tested.**
32
+ `Silas.configure` never invalidated the memoized resolved engine, so a
33
+ second `configure` in the same process kept serving the first one — every
34
+ eval scenario after the first silently ran the *first* scenario's script
35
+ while still reporting pass/fail as though it hadn't. Reconfiguring now
36
+ re-resolves both the engine and the sandbox. The gem's own specs missed this
37
+ because the spec helper resets config between examples; a real multi-scenario
38
+ eval suite — the production path — does not. Found by building
39
+ `examples/playground`.
40
+ - **Automatic approvals are now recorded.** A graded gate that clears a call
41
+ (a lambda under its threshold, an `:once` rule already satisfied) sets
42
+ `approval_state: "approved"` with **no** `approved_by` — so the audit trail
43
+ distinguishes "gate ran and passed automatically" (which the inbox now
44
+ renders as *auto-approved by policy*) from "no gate at all", which stays
45
+ `nil`. Previously both were indistinguishable on a money-moving tool.
46
+ - The generated `ruby_llm.rb` initializer opts into `use_new_acts_as`, silencing
47
+ RubyLLM's legacy-API deprecation warning on every boot — Silas never uses
48
+ `acts_as_*`, so the warning was pure noise. Existing initializers are still
49
+ never touched.
50
+
3
51
  ## 0.3.0
4
52
 
5
53
  - **`bin/rails silas:doctor`** — every known first-run failure mode as one
@@ -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
@@ -84,7 +84,13 @@ module Silas
84
84
  return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
85
85
  return if turn.tool_invocations.where(approval_state: "required").exists?
86
86
 
87
- turn.update!(status: "queued")
87
+ # Restart the wall clock: `limits.timeout` bounds ACTIVE stretches (hung
88
+ # providers, runaway loops), not human deliberation. Without this, any
89
+ # approval that took longer than the timeout made the approved resume
90
+ # instantly re-park on "timeout" — pathological for a gate whose whole
91
+ # point is waiting for a person. Cost/token budgets stay cumulative;
92
+ # they measure real spend.
93
+ turn.update!(status: "queued", started_at: Time.current)
88
94
  AgentLoopJob.perform_later(turn.id)
89
95
  end
90
96
  end
@@ -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 %>
@@ -3,8 +3,8 @@
3
3
  <code><%= invocation.tool_name %></code>
4
4
  <%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
5
5
  <%# The audit line — who held the lever, and why it moved. %>
6
- <% if invocation.approval_state == "approved" && invocation.approved_by.present? %>
7
- <span class="muted">approved by <%= invocation.approved_by %></span>
6
+ <% if invocation.approval_state == "approved" %>
7
+ <span class="muted"><%= invocation.approved_by.present? ? "approved by #{invocation.approved_by}" : "auto-approved by policy" %></span>
8
8
  <% elsif invocation.approval_state == "declined" %>
9
9
  <span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
10
10
  <% elsif invocation.approval_state == "expired" %>
@@ -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>
@@ -5,6 +5,12 @@
5
5
  #
6
6
  # Any provider RubyLLM supports works the same way (openai_api_key, etc.).
7
7
  RubyLLM.configure do |c|
8
+ # Silas never uses RubyLLM's acts_as_* ActiveRecord mixins (it owns its own
9
+ # durable schema), so opt into the new API to silence the legacy deprecation
10
+ # warning on every boot. Remove this line if THIS app uses acts_as_chat and
11
+ # hasn't migrated yet — see https://rubyllm.com/upgrading-to-1-7/
12
+ c.use_new_acts_as = true if c.respond_to?(:use_new_acts_as=)
13
+
8
14
  c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
9
15
  # The per-request timeout (seconds; RubyLLM default 300). Under streaming
10
16
  # this is an idle-between-chunks timeout — the hang protection for a stuck
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/engine.rb CHANGED
@@ -43,6 +43,17 @@ module Silas
43
43
  Silas::Inbox::DeltaBroadcaster.subscribe!
44
44
  end
45
45
 
46
+ # The trace partials render in TWO contexts: the inbox controllers (which
47
+ # declare this helper) and Turbo's broadcast jobs, which render through the
48
+ # HOST's default renderer — where an engine-scoped helper doesn't exist and
49
+ # every broadcast died silently inside Turbo's job. Register it host-wide
50
+ # so broadcast renders — and host apps embedding the trace partials in
51
+ # their own pages — resolve it. (Routes inside those partials go through
52
+ # TraceHelper#silas_engine_path, which needs no routing scope at all.)
53
+ initializer "silas.trace_helper" do
54
+ ActiveSupport.on_load(:action_controller_base) { helper Silas::Inbox::TraceHelper }
55
+ end
56
+
46
57
  # Registry rebuilds on every code reload in development, once in production.
47
58
  initializer "silas.registry" do |app|
48
59
  next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
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
data/lib/silas/ledger.rb CHANGED
@@ -92,8 +92,15 @@ module Silas
92
92
  when Hash # {denied: "reason"} — eve's shape
93
93
  invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
94
94
  return :done
95
+ when :approved
96
+ # A gate ran and CLEARED it (a lambda under its threshold, or an
97
+ # :once rule already satisfied). Record that, so the audit trail
98
+ # can tell "gate evaluated, passed automatically" apart from "no
99
+ # gate at all" (which stays nil). approved_by is left nil — that
100
+ # absence is what marks it automatic rather than human.
101
+ invocation.update!(approval_state: "approved")
95
102
  end
96
- # :not_applicable / :approved / :once-satisfied fall through
103
+ # :not_applicable falls through ungated
97
104
  end
98
105
 
99
106
  execute!(invocation, tool)
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.2"
3
3
  end
data/lib/silas.rb CHANGED
@@ -51,9 +51,17 @@ module Silas
51
51
  @config ||= Configuration.new
52
52
  end
53
53
 
54
+ # Reconfiguring re-resolves the engine and sandbox. Without this the
55
+ # memos below outlive the config that produced them: a second
56
+ # `Silas.configure { |c| c.engine = ... }` in one process kept serving the
57
+ # FIRST engine. That silently broke multi-scenario `silas:eval` runs —
58
+ # every scenario after the first ran the first one's script and still
59
+ # reported pass/fail as if it hadn't. Re-resolution is cheap (one `.new`).
54
60
  def configure
55
61
  yield config
56
62
  config.validate!
63
+ @resolved_engine = nil
64
+ @resolved_sandbox = nil
57
65
  config
58
66
  end
59
67
 
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.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -103,7 +103,7 @@ description: 'An agent framework where your Rails app is the chassis: app/agent/
103
103
  convention, durable runs on Active Job Continuations, exactly-once tool execution
104
104
  via a transactional ledger, approvals that park at zero compute.'
105
105
  email:
106
- - daniel@zerogravity.co.uk
106
+ - danielstpaul@hotmail.com
107
107
  executables: []
108
108
  extensions: []
109
109
  extra_rdoc_files: []