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 +4 -4
- data/CHANGELOG.md +20 -0
- data/app/jobs/silas/agent_loop_job.rb +22 -2
- data/app/models/silas/tool_invocation.rb +1 -0
- data/app/models/silas/turn.rb +51 -0
- data/db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb +7 -0
- data/db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb +7 -0
- data/lib/silas/budget.rb +18 -6
- data/lib/silas/chat.rb +27 -3
- data/lib/silas/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7aa3f984d996b0eee69689f4009e3d43bd46d63caaf2144f0a4082b40d0ae918
|
|
4
|
+
data.tar.gz: 8c9b38ac7dae0a0ea510375046447442087a3a1536431199a8bf54677fca80b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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!
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
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
|
+
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
|