silas 0.1.4 → 0.1.5

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: be0574c8a9c71366220b9be69d9f3d679a6c0a469d907bec1fb2a7d53436f6b3
4
- data.tar.gz: 5ac2adcf62f854ec3a4b8206c0d9a19bd33bb3b631615e36bffed425757126ca
3
+ metadata.gz: 7aa3f984d996b0eee69689f4009e3d43bd46d63caaf2144f0a4082b40d0ae918
4
+ data.tar.gz: 8c9b38ac7dae0a0ea510375046447442087a3a1536431199a8bf54677fca80b3
5
5
  SHA512:
6
- metadata.gz: 3a0be6b63aec608abe5cf2c72f0533c79b7150f8d389344a36e68c90122055fb1f304a4a251ade503467c875c21d99ddd9d136fa37f4d4734d1d5bda9c158bd6
7
- data.tar.gz: 1340782f9cb00620e4e1ed5e9c5b9dc9fc8d96a9310d3d9e67e55004363352e4d17e98f62d1cb6054738b2fed1052c0c7e92f576f5afe587a33770db5294cc48
6
+ metadata.gz: f3136bfb1276a84217f76733e1c718715b8dbed5794fe766f72383cf58560c50950c6cdb12932d89108c6c6de73bd16b1c2c1b915841bb1829b47c102d03f0f5
7
+ data.tar.gz: 5af58996877a0bb1bf6d64fbf2ef30c141462c8d00a7f37ebbaaca4dbf3518cd28c4ead9557d4ee2d5a23144748632f053e38b908238b39c64577ce9f0d355fd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ - **Turn cancellation.** `turn.cancel!` — a parked or queued turn settles to
6
+ `canceled` immediately (pending approvals expire, so a late `approve!` can
7
+ never zombie-resume it); a running turn is flagged and honored at the next
8
+ step boundary, keeping the in-flight step's paid work. Engine-owned
9
+ (`:agent_sdk`) turns cancel only before the subprocess starts (v1). New
10
+ migration adds `silas_turns.cancel_requested_at`.
11
+ - **Resumable budget parks.** A turn that hits `max_cost` / `max_input_tokens` /
12
+ `timeout` now PARKS at zero compute (state intact) instead of failing
13
+ terminally. A human resumes it with `turn.raise_budget!(max_cost: 1.50)` —
14
+ the top-up is recorded as a per-turn override and a fresh job replays
15
+ completed steps from rows (no model re-calls, no re-effects), continuing
16
+ where it left off. `bin/rails silas:chat` prompts for the top-up inline.
17
+ New migration adds `silas_turns.budget_overrides` (run
18
+ `bin/rails silas:install:migrations db:migrate` on upgrade). Notes: the
19
+ timeout clock includes time spent parked — size a timeout top-up from
20
+ elapsed wall-clock; budget parks have no TTL yet (visible in the inbox as
21
+ waiting); an inbox top-up card is planned.
22
+
3
23
  ## 0.1.4
4
24
 
5
25
  - **Fresh-app quickstart actually works.** A from-scratch install previously
@@ -40,6 +40,16 @@ module Silas
40
40
 
41
41
  index = 0
42
42
  loop do
43
+ # Cancellation is honored at step boundaries only — the same safe point
44
+ # as budgets. (Mutable-state read between steps: benign, like the
45
+ # budget wall-clock check — a cancel landing later on resume is still
46
+ # a correct cancel.)
47
+ if turn.reload.cancel_requested_at
48
+ turn.expire_pending_approvals!("turn canceled")
49
+ turn.finish!(:canceled, reason: "canceled")
50
+ return
51
+ end
52
+
43
53
  step :"step_#{index}", isolated: isolate? do
44
54
  Ledger.assert_no_checkpoint!
45
55
  StepRunner.call(turn, index)
@@ -50,9 +60,12 @@ module Silas
50
60
  return if row.parked?
51
61
  break if row.terminal?
52
62
 
53
- # Budget caps (cost/tokens/time) — checked between steps, never inside one.
63
+ # Budget caps (cost/tokens/time) — checked between steps, never inside
64
+ # one. A breach PARKS the turn (state intact, zero compute) rather than
65
+ # failing it: a human tops up with turn.raise_budget! and the fresh job
66
+ # replays completed steps from rows, resuming where it left off.
54
67
  if (reason = Budget.exceeded_reason(turn))
55
- turn.finish!(:failed, reason: reason)
68
+ turn.update!(status: "waiting", failure_reason: reason)
56
69
  return
57
70
  end
58
71
 
@@ -79,6 +92,13 @@ module Silas
79
92
  Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
80
93
  end
81
94
 
95
+ # Cancellation for engine-owned turns is honored only BEFORE the
96
+ # subprocess starts — a running claude -p is not aborted mid-flight (v1).
97
+ if turn.reload.cancel_requested_at
98
+ turn.finish!(:canceled, reason: "canceled")
99
+ return
100
+ end
101
+
82
102
  outcome = nil
83
103
  step :run, isolated: isolate? do
84
104
  Ledger.assert_no_checkpoint!
@@ -69,6 +69,7 @@ module Silas
69
69
  end
70
70
 
71
71
  def resume_turn!
72
+ return if turn.reload.canceled? # a canceled turn never zombie-resumes
72
73
  return if turn.tool_invocations.where(approval_state: "required").exists?
73
74
 
74
75
  turn.update!(status: "queued")
@@ -24,6 +24,57 @@ module Silas
24
24
  update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
25
25
  end
26
26
 
27
+ def canceled? = status == "canceled"
28
+
29
+ # Cancel a turn. A PARKED or QUEUED turn (no live execution) settles to
30
+ # canceled immediately, expiring its pending approvals so a later approve!
31
+ # can't zombie-resume it. A RUNNING turn is flagged; the loop honors the
32
+ # flag at the next step boundary — the in-flight model call completes and
33
+ # its step commits (aborting mid-step would forfeit paid work and create
34
+ # an in-doubt tool window for nothing).
35
+ def cancel!(reason: "canceled")
36
+ raise Error, "turn #{id} is already terminal (#{status})" unless active?
37
+
38
+ if running?
39
+ update!(cancel_requested_at: Time.current)
40
+ :cancel_requested
41
+ else
42
+ expire_pending_approvals!(reason)
43
+ finish!(:canceled, reason: reason)
44
+ :canceled
45
+ end
46
+ end
47
+
48
+ def expire_pending_approvals!(reason)
49
+ tool_invocations.where(approval_state: "required").find_each do |inv|
50
+ inv.update!(approval_state: "expired", status: "failed",
51
+ result: { "denied" => reason })
52
+ end
53
+ end
54
+
55
+ # Parked by a budget cap (failure_reason doubles as the park reason while
56
+ # the turn is waiting; it is cleared on resume).
57
+ def budget_parked?
58
+ waiting? && Budget::REASONS.include?(failure_reason)
59
+ end
60
+
61
+ # Human top-up for a budget-parked turn: record the raised limit(s) and
62
+ # resume with a fresh job — completed steps replay from rows, no model
63
+ # re-calls, no re-effects (the same resume path approvals use).
64
+ # turn.raise_budget!(max_cost: 1.50) # dollars
65
+ # turn.raise_budget!(max_input_tokens: 200_000, timeout: 3600)
66
+ def raise_budget!(max_cost: nil, max_input_tokens: nil, timeout: nil)
67
+ raise Error, "turn #{id} is not budget-parked (#{status}/#{failure_reason})" unless budget_parked?
68
+
69
+ raises = { "max_cost" => max_cost, "max_input_tokens" => max_input_tokens,
70
+ "timeout" => timeout }.compact
71
+ raise ArgumentError, "pass at least one limit to raise" if raises.empty?
72
+
73
+ update!(budget_overrides: (budget_overrides || {}).merge(raises),
74
+ failure_reason: nil, status: "queued")
75
+ AgentLoopJob.perform_later(id)
76
+ end
77
+
27
78
  # The agent's answer for this turn: the last completed step's text blocks.
28
79
  def answer_text
29
80
  step = steps.where(status: "completed").order(:index).last
@@ -0,0 +1,7 @@
1
+ class AddBudgetOverridesToSilasTurns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Per-turn budget raises (max_cost / max_input_tokens / timeout), set by a
4
+ # human topping up a budget-parked turn. Overrides agent.yml/config limits.
5
+ add_column :silas_turns, :budget_overrides, :json, null: false, default: {}
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddCancelRequestedToSilasTurns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Set by Turn#cancel! on a running turn; the loop honors it at the next
4
+ # step boundary (the same safe point budgets are checked at).
5
+ add_column :silas_turns, :cancel_requested_at, :datetime
6
+ end
7
+ end
data/lib/silas/budget.rb CHANGED
@@ -1,15 +1,21 @@
1
1
  module Silas
2
2
  # Per-turn budget caps beyond max_steps: cumulative input tokens, cost, and
3
3
  # wall-clock. Checked between steps in the framework-owned loop (never inside a
4
- # continuation step), so a breach fails the turn cleanly with a limit reason.
4
+ # continuation step). A breach PARKS the turn at zero compute (like an
5
+ # approval); a human resumes it with Turn#raise_budget!, which records a
6
+ # per-turn override consulted here ahead of the agent's limits.
5
7
  #
6
8
  # Token/cost checks are deterministic (persisted step data). The timeout check
7
9
  # reads the wall clock — benign non-determinism: a cap firing later on resume
8
- # is correct (the turn genuinely ran too long across the crash).
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.
9
13
  module Budget
14
+ REASONS = %w[max_input_tokens max_cost timeout].freeze
15
+
10
16
  module_function
11
17
 
12
- # Returns a failure reason string if a cap is exceeded, else nil.
18
+ # Returns a limit-reason string if a cap is exceeded, else nil.
13
19
  def exceeded_reason(turn, agent: Silas.agent)
14
20
  return "max_input_tokens" if over_tokens?(turn, agent)
15
21
  return "max_cost" if over_cost?(turn, agent)
@@ -18,14 +24,20 @@ module Silas
18
24
  nil
19
25
  end
20
26
 
27
+ # A human top-up (turn.budget_overrides) beats the agent's configured limit.
28
+ def limit_for(turn, agent, key)
29
+ override = turn.budget_overrides&.dig(key.to_s)
30
+ override.nil? ? agent.public_send(key) : override
31
+ end
32
+
21
33
  def over_tokens?(turn, agent)
22
- limit = agent.max_input_tokens or return false
34
+ limit = limit_for(turn, agent, :max_input_tokens) or return false
23
35
 
24
36
  Silas::Step.where(turn_id: turn.id).sum(:input_tokens) > limit
25
37
  end
26
38
 
27
39
  def over_cost?(turn, agent)
28
- limit = agent.max_cost or return false
40
+ limit = limit_for(turn, agent, :max_cost) or return false
29
41
 
30
42
  spent = Silas::Inbox::Cost.for_turn(turn)
31
43
  # Only enforce on priced tokens; unpriced models can't be cost-capped.
@@ -33,7 +45,7 @@ module Silas
33
45
  end
34
46
 
35
47
  def over_time?(turn, agent)
36
- limit = agent.timeout or return false
48
+ limit = limit_for(turn, agent, :timeout) or return false
37
49
  return false unless turn.started_at
38
50
 
39
51
  (Time.current - turn.started_at) > limit
data/lib/silas/chat.rb CHANGED
@@ -73,6 +73,7 @@ module Silas
73
73
 
74
74
  if turn.parked?
75
75
  settle_parked
76
+ settle_budget_park(turn.reload)
76
77
  turn.reload
77
78
  end
78
79
 
@@ -80,17 +81,40 @@ module Silas
80
81
  end
81
82
 
82
83
  def print_outcome(turn)
83
- case turn.reload.status
84
+ turn.reload
85
+ case turn.status
84
86
  when "completed"
85
87
  @out.puts "agent> #{turn.answer_text}"
86
88
  when "waiting", "in_doubt"
87
- @out.puts "(parked — #{pending_for(turn.session).count} approval(s) still pending; " \
88
- "they also render in /silas/inbox)"
89
+ if turn.budget_parked?
90
+ @out.puts "(parked: #{turn.failure_reason} budget reached — resume with " \
91
+ "turn.raise_budget! or from a fresh silas:chat)"
92
+ else
93
+ @out.puts "(parked — #{pending_for(turn.session).count} approval(s) still pending; " \
94
+ "they also render in /silas/inbox)"
95
+ end
89
96
  when "failed"
90
97
  @out.puts "(turn failed: #{turn.failure_reason})"
91
98
  end
92
99
  end
93
100
 
101
+ # A budget-parked turn prompts for a top-up right in the terminal. The
102
+ # resume runs synchronously on the :inline adapter, so new work (and
103
+ # possibly another park) follows immediately.
104
+ def settle_budget_park(turn)
105
+ return unless turn.budget_parked?
106
+
107
+ reason = turn.failure_reason
108
+ @out.puts "\nbudget reached — #{reason}"
109
+ @out.print "raise #{reason} to (blank to leave parked)> "
110
+ value = @in.gets&.strip
111
+ return if value.blank?
112
+
113
+ numeric = reason == "max_cost" ? value.to_f : value.to_i
114
+ turn.raise_budget!(**{ reason.to_sym => numeric })
115
+ print_trace(turn.reload)
116
+ end
117
+
94
118
  def print_trace(turn)
95
119
  ToolInvocation.where(turn_id: turn.id).order(:id).each do |inv|
96
120
  gate = inv.approval_state == "required" ? " — awaiting approval" : ""
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.5"
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.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -148,6 +148,8 @@ files:
148
148
  - db/migrate/20260715000001_add_channel_outbound_markers.rb
149
149
  - db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb
150
150
  - db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb
151
+ - db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb
152
+ - db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb
151
153
  - lib/generators/silas/install/install_generator.rb
152
154
  - lib/generators/silas/install/templates/agent.yml
153
155
  - lib/generators/silas/install/templates/bin_ci