silas 0.3.1 → 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: 36bc01dd259d8950ae57b75a3eb94f91065aed62d2b149d705959695bc8f323f
4
- data.tar.gz: f2aa37ea2d83373d2252b594aaef87894f1da03adda08edfa746baa6e76c2dea
3
+ metadata.gz: a8ece90334c6059bd396179bfae54b62ebd8abc483d72309da718dbb8e147845
4
+ data.tar.gz: '014359b0b35a36e7b34b796fbb9194f99c49bf39a1901b4b82cb7b94a10c0ea0'
5
5
  SHA512:
6
- metadata.gz: 43ba5370306adecd0c53f818d655a367748e5b3162559abca5e8eb2a5687410c8d145f8f8b545f98ca265961344378e1c0a474826b46853357f2bce4192aa12b
7
- data.tar.gz: 3cf07dea2adbe6eb33ec3bf65401c96127c1a066fbb104ea2e945d42f5526e8272aebbf9561a5777d1c6278674b3bc386036d1aa2b01bcb37d99366822ac431b
6
+ metadata.gz: 25e3610da0c9a62b6e9f2a0f956e01c989c62eec40bfdee6703b1bd9206821bb6b4a7988726671b57cd53949ae518239a76d76511cc260b3279db9a36b4f38c4
7
+ data.tar.gz: f891bf52671a8cd2c7fdc9041b036194939995dc4de7dee7bdd8f09cea986593bc526681d161d41add81b16cfe9257d75df7599e09a7fb85d5af52a9b117828d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
3
29
  ## 0.3.1
4
30
 
5
31
  - **Fixed: only the first scenario in a `silas:eval` run was really tested.**
@@ -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 %>
@@ -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>
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/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.3.1"
2
+ VERSION = "0.3.2"
3
3
  end
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.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul