silas 0.1.3 → 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: 35e622a6b7c17416ebe71717f1d072bd07c8a075f901c5b477177aa9bfccfa29
4
- data.tar.gz: 59a87acd719063f275926852e255507d3c6159c16892907f70c8a77180dd61f8
3
+ metadata.gz: 7aa3f984d996b0eee69689f4009e3d43bd46d63caaf2144f0a4082b40d0ae918
4
+ data.tar.gz: 8c9b38ac7dae0a0ea510375046447442087a3a1536431199a8bf54677fca80b3
5
5
  SHA512:
6
- metadata.gz: 608b8bc79c926eadf04b74360e62832413580986c8ef5c6899b5acdc9371695db555799d9dd26d76b3aea77d2deafee13f32236cf8564c3cc6ad47692c58be80
7
- data.tar.gz: f992423e27b802ae2efb6e7cf1c366f584ff703bd2210b9388b68abb9730616f2d16ded6b0da6e4b0a62c4c9fefd5becc8e8a85c6a17763d47dfd72bfef6d9ab
6
+ metadata.gz: f3136bfb1276a84217f76733e1c718715b8dbed5794fe766f72383cf58560c50950c6cdb12932d89108c6c6de73bd16b1c2c1b915841bb1829b47c102d03f0f5
7
+ data.tar.gz: 5af58996877a0bb1bf6d64fbf2ef30c141462c8d00a7f37ebbaaca4dbf3518cd28c4ead9557d4ee2d5a23144748632f053e38b908238b39c64577ce9f0d355fd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
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
+
23
+ ## 0.1.4
24
+
25
+ - **Fresh-app quickstart actually works.** A from-scratch install previously
26
+ failed at its own post-install steps: the generated default model
27
+ ("claude-sonnet-5") wasn't in ruby_llm 1.16's bundled registry, and no
28
+ provider-key initializer was generated, so the first turn raised. The
29
+ generator now emits `config/initializers/ruby_llm.rb` (maps
30
+ `ANTHROPIC_API_KEY`), defaults to the registry-known `claude-opus-4-8`, and a
31
+ missing model raises an actionable error suggesting
32
+ `RubyLLM.models.refresh!`.
33
+ - **`silas:schedules` no longer crashes on the generator's own template.** The
34
+ example schedule's ERB comment rendered a leading blank line the frontmatter
35
+ parser rejected; template fixed and the parser now tolerates leading
36
+ whitespace.
37
+ - **`bin/ci` is never clobbered.** Rails 8.1 ships a real bin/ci; the installer
38
+ now leaves an existing one untouched and tells you to add
39
+ `bin/rails silas:eval` to it.
40
+ - **Correct Opus 4.8 pricing.** Default `model_prices` had Opus 4.8 at $15/$75
41
+ per MTok; it is $5/$25. Added Sonnet 4.6 and the `claude-haiku-4-5` alias.
42
+ - **ruby_llm dependency bounded `< 2`** (2.0 removes APIs the engine relies on).
43
+ - **`bin/rails silas:chat` — a terminal REPL for your agent.** The dev-loop a
44
+ hosted platform gives you from its CLI, except there is no platform: it runs
45
+ inside your app, tools hit your real dev database, and parked approvals prompt
46
+ inline (`approve? [y]es / [d]ecline / [s]kip`) calling the same
47
+ `approve!`/`decline!` as the inbox and Slack. `SESSION=id` resumes a session;
48
+ a new message while a turn is parked steers you to the pending approvals. The
49
+ task forces the synchronous `:inline` adapter for its own process (a REPL
50
+ wants each turn settled before the next prompt; production still runs Solid
51
+ Queue).
52
+
3
53
  ## 0.1.3
4
54
 
5
55
  - **Ledger: parallel graded gates.** When the model emits several tool calls in
data/README.md CHANGED
@@ -53,6 +53,23 @@ session.pending_approvals.first.approve!(by: "daniel")
53
53
  session.continue(input: "Now email the customer.")
54
54
  ```
55
55
 
56
+ Or talk to it from the terminal — the REPL runs *inside your app*, so tools hit
57
+ your real dev database, and parked approvals prompt inline (the same
58
+ `approve!`/`decline!` as the inbox and Slack):
59
+
60
+ ```
61
+ $ bin/rails silas:chat
62
+ you> Refund order 42, £12.50
63
+ ✓ lookup_order(order_id: 42)
64
+ ⏸ issue_refund(order_id: 42, amount: 1250) — awaiting approval
65
+
66
+ approval needed — issue_refund(order_id: 42, amount: 1250)
67
+ approve? [y]es / [d]ecline / [s]kip> y
68
+ agent> Done — £12.50 refunded on order 42.
69
+ ```
70
+
71
+ `SESSION=id` resumes an existing session.
72
+
56
73
  ## The durability contract (what's actually guaranteed)
57
74
 
58
75
  Verified by `chaos_host/bin/chaos` — the harness that kill -9s a live agent
@@ -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
@@ -7,6 +7,9 @@ module Silas
7
7
 
8
8
  def create_initializer
9
9
  template "initializer.rb", "config/initializers/silas.rb"
10
+ # ruby_llm reads no provider keys from ENV on its own; without this the
11
+ # first model call raises ConfigurationError.
12
+ template "ruby_llm.rb", "config/initializers/ruby_llm.rb"
10
13
  end
11
14
 
12
15
  def create_agent_directory
@@ -28,11 +31,19 @@ module Silas
28
31
  route 'mount Silas::Engine => "/silas"'
29
32
  end
30
33
 
31
- # Evals as a deploy gate: an example scenario + a bin/ci wrapper.
34
+ # Evals as a deploy gate: an example scenario + a bin/ci wrapper. Rails
35
+ # 8.1 apps ship their own bin/ci (rubocop, brakeman, tests) — never
36
+ # clobber it; tell the user to add the eval gate to theirs instead.
32
37
  def create_eval_gate
33
38
  template "example_eval.rb", "test/agent_evals/example_eval.rb"
34
- copy_file "bin_ci", "bin/ci"
35
- chmod "bin/ci", 0o755
39
+
40
+ if File.exist?(File.expand_path("bin/ci", destination_root))
41
+ say "bin/ci already exists — left untouched. Add `bin/rails silas:eval` " \
42
+ "to it to gate deploys on agent evals.", :yellow
43
+ else
44
+ copy_file "bin_ci", "bin/ci"
45
+ chmod "bin/ci", 0o755
46
+ end
36
47
  end
37
48
 
38
49
  def add_rescuer_recurring_task
@@ -65,14 +76,15 @@ module Silas
65
76
 
66
77
  Silas installed. Next:
67
78
  1. bin/rails db:migrate
68
- 2. Edit app/agent/instructions.md (your agent's persona)
69
- 3. Write tools in app/agent/tools/ (keyword signature = schema)
70
- 4. Silas.agent.start(input: "hello")
71
- 5. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
72
- 6. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
79
+ 2. export ANTHROPIC_API_KEY=sk-ant-... (config/initializers/ruby_llm.rb reads it)
80
+ 3. Edit app/agent/instructions.md (your agent's persona)
81
+ 4. Write tools in app/agent/tools/ (keyword signature = schema)
82
+ 5. Talk to it: bin/rails silas:chat (or Silas.agent.start(input: "hello"))
83
+ 6. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
84
+ 7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
73
85
  for Slack; route inbound mail to Silas::AgentMailbox for email.
74
86
  Delete app/agent/channels/{slack,email}.rb to disable.
75
- 7. Inbox: live at /silas/inbox once you set config.inbox_auth
87
+ 8. Inbox: live at /silas/inbox once you set config.inbox_auth
76
88
  (deny-by-default until you do).
77
89
  MSG
78
90
  end
@@ -1,5 +1,5 @@
1
- <%# Task-mode schedule: this markdown body becomes the turn input on each tick. %>
2
1
  ---
2
+ # Task-mode schedule: this markdown body becomes the turn input on each tick.
3
3
  cron: "0 9 * * *"
4
4
  # queue: default
5
5
  ---
@@ -3,7 +3,10 @@ Silas.configure do |config|
3
3
  # :agent_sdk (a `claude -p` subprocess; API-key auth only). See silas/README.
4
4
  config.engine = :ruby_llm
5
5
 
6
- config.default_model = "claude-sonnet-5"
6
+ # Any model your installed ruby_llm's registry resolves (newer Claude models
7
+ # may need `RubyLLM.models.refresh!` first). Alternatives: "claude-sonnet-5"
8
+ # (balanced), "claude-haiku-4-5" (fastest/cheapest).
9
+ config.default_model = "claude-opus-4-8"
7
10
 
8
11
  # Parked approvals expire after this long (the turn fails as approval_expired).
9
12
  # config.approval_ttl = 7.days
@@ -0,0 +1,9 @@
1
+ # Point RubyLLM at your model provider. RubyLLM does NOT read provider API keys
2
+ # from the environment automatically — this initializer maps them in.
3
+ #
4
+ # export ANTHROPIC_API_KEY=sk-ant-...
5
+ #
6
+ # Any provider RubyLLM supports works the same way (openai_api_key, etc.).
7
+ RubyLLM.configure do |c|
8
+ c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
9
+ end if ENV["ANTHROPIC_API_KEY"].present?
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 ADDED
@@ -0,0 +1,161 @@
1
+ module Silas
2
+ # Terminal REPL for talking to the app's agent: `bin/rails silas:chat`.
3
+ #
4
+ # The dev-loop equivalent of a hosted platform's CLI — except there is no
5
+ # platform: this runs inside your app, so tools hit your real dev database
6
+ # and the transcript is rows in your own Postgres. Parked approvals prompt
7
+ # inline and call the exact same approve!/decline! as the inbox and Slack.
8
+ #
9
+ # IO is injected so the loop is testable; the rake task wires $stdin/$stdout
10
+ # and forces the synchronous :inline adapter (a REPL wants each turn settled
11
+ # before the next prompt, and the dev-default Async adapter is unsafe).
12
+ class Chat
13
+ GLYPH = { "completed" => "✓", "failed" => "✗", "pending" => "⏸",
14
+ "started" => "…", "in_doubt" => "?" }.freeze
15
+
16
+ def initialize(io_in: $stdin, io_out: $stdout, actor: ENV["USER"] || "cli", session: nil)
17
+ @in = io_in
18
+ @out = io_out
19
+ @actor = actor
20
+ @session = session
21
+ end
22
+
23
+ def run
24
+ banner
25
+ if @session && pending_for(@session).exists? # resuming a session that parked last time
26
+ settle_parked
27
+ print_outcome(@session.turns.reload.last)
28
+ end
29
+
30
+ loop do
31
+ @out.print "\nyou> "
32
+ line = @in.gets
33
+ break if line.nil?
34
+
35
+ line = line.strip
36
+ next if line.empty?
37
+ break if %w[exit quit].include?(line.downcase)
38
+
39
+ submit(line)
40
+ end
41
+ @out.puts "bye — session #{@session.id}" if @session
42
+ end
43
+
44
+ private
45
+
46
+ def banner
47
+ description = Silas.agent.description.presence || "your agent"
48
+ @out.puts "Silas chat — #{description} (#{Silas.agent.model})."
49
+ @out.puts "Approvals prompt inline. 'exit' or Ctrl-D to quit."
50
+ @out.puts "Resuming session #{@session.id} (#{@session.turns.count} turns)." if @session
51
+ end
52
+
53
+ def submit(text)
54
+ turn =
55
+ if @session.nil?
56
+ @session = Silas.agent.start(input: text)
57
+ @session.turns.first
58
+ else
59
+ @session.continue(input: text)
60
+ end
61
+ # start/continue enqueue the loop job; the :inline adapter has already run
62
+ # it synchronously by the time they return.
63
+ report(turn)
64
+ rescue TurnInProgressError
65
+ @out.puts "! a turn is still parked awaiting approval — settle it first:"
66
+ settle_parked
67
+ print_outcome(@session.turns.reload.last)
68
+ end
69
+
70
+ def report(turn)
71
+ turn.reload
72
+ print_trace(turn)
73
+
74
+ if turn.parked?
75
+ settle_parked
76
+ settle_budget_park(turn.reload)
77
+ turn.reload
78
+ end
79
+
80
+ print_outcome(turn)
81
+ end
82
+
83
+ def print_outcome(turn)
84
+ turn.reload
85
+ case turn.status
86
+ when "completed"
87
+ @out.puts "agent> #{turn.answer_text}"
88
+ when "waiting", "in_doubt"
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
96
+ when "failed"
97
+ @out.puts "(turn failed: #{turn.failure_reason})"
98
+ end
99
+ end
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
+
118
+ def print_trace(turn)
119
+ ToolInvocation.where(turn_id: turn.id).order(:id).each do |inv|
120
+ gate = inv.approval_state == "required" ? " — awaiting approval" : ""
121
+ @out.puts " #{GLYPH.fetch(inv.status, '·')} #{inv.tool_name}(#{compact_args(inv.arguments)})#{gate}"
122
+ end
123
+ end
124
+
125
+ # Prompt for every parked approval in the session. Approving resumes the
126
+ # turn synchronously (inline adapter), so new invocations may appear —
127
+ # loop until nothing is pending or the user skips.
128
+ def settle_parked
129
+ loop do
130
+ invocation = pending_for(@session).order(:id).first
131
+ break unless invocation
132
+
133
+ @out.puts "\napproval needed — #{invocation.tool_name}(#{compact_args(invocation.arguments)})"
134
+ @out.print "approve? [y]es / [d]ecline / [s]kip> "
135
+ answer = @in.gets&.strip&.downcase
136
+
137
+ case answer
138
+ when "y", "yes"
139
+ invocation.approve!(by: @actor)
140
+ @out.puts
141
+ print_trace(@session.turns.reload.last)
142
+ when "d", "decline"
143
+ @out.print "reason> "
144
+ reason = @in.gets&.strip
145
+ invocation.decline!(reason: reason.presence || "declined from CLI", by: @actor)
146
+ else
147
+ @out.puts "(left parked — approve later here or in /silas/inbox)"
148
+ break
149
+ end
150
+ end
151
+ end
152
+
153
+ def pending_for(session)
154
+ session.pending_approvals.where(status: %w[pending in_doubt])
155
+ end
156
+
157
+ def compact_args(arguments)
158
+ (arguments || {}).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
159
+ end
160
+ end
161
+ end
@@ -64,7 +64,9 @@ module Silas
64
64
  def initialize
65
65
  @engine = :ruby_llm
66
66
  @auth = :api_key
67
- @default_model = "claude-sonnet-5"
67
+ # Must be resolvable by the installed ruby_llm's model registry — newer
68
+ # Claude models may need `RubyLLM.models.refresh!` before they resolve.
69
+ @default_model = "claude-opus-4-8"
68
70
  @queue_name = :default
69
71
  @around_model_call = nil
70
72
  @approval_ttl = 7.days
@@ -102,10 +104,12 @@ module Silas
102
104
  @inbox_public_read = false
103
105
  @inbox_actor = ->(controller) { controller.try(:current_user)&.try(:email) || "inbox" }
104
106
  # Current Claude rates as cost-units per 1k tokens (1e6 units = $1):
105
- # Sonnet $3/$15, Opus $15/$75, Haiku $1/$5 per million tokens.
107
+ # Opus 4.8 $5/$25, Sonnet $3/$15, Haiku $1/$5 per million tokens.
106
108
  @model_prices = {
109
+ "claude-opus-4-8" => { in: 5000, out: 25_000 },
107
110
  "claude-sonnet-5" => { in: 3000, out: 15_000 },
108
- "claude-opus-4-8" => { in: 15_000, out: 75_000 },
111
+ "claude-sonnet-4-6" => { in: 3000, out: 15_000 },
112
+ "claude-haiku-4-5" => { in: 1000, out: 5000 },
109
113
  "claude-haiku-4-5-20251001" => { in: 1000, out: 5000 }
110
114
  }
111
115
  end
@@ -26,7 +26,14 @@ module Silas
26
26
  private
27
27
 
28
28
  def build_chat(context, &on_event)
29
- chat = ::RubyLLM.chat(model: context[:model])
29
+ chat = begin
30
+ ::RubyLLM.chat(model: context[:model])
31
+ rescue ::RubyLLM::ModelNotFoundError
32
+ raise Silas::Error,
33
+ "Model #{context[:model].inspect} is not in ruby_llm's model registry. " \
34
+ "Newer models may need a registry refresh (`RubyLLM.models.refresh!`), " \
35
+ "or pick a registry-known model in config.default_model / agent.yml."
36
+ end
30
37
  chat.with_instructions(context[:system]) if context[:system].present?
31
38
  context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
32
39
 
@@ -38,7 +38,9 @@ module Silas
38
38
 
39
39
  def self.from_markdown(name, path)
40
40
  content = File.read(path)
41
- if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
41
+ # Tolerate leading whitespace/blank lines (e.g. an ERB comment in a
42
+ # generator template renders to an empty first line).
43
+ if content.sub(/\A\s+/, "") =~ /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
42
44
  frontmatter = YAML.safe_load(Regexp.last_match(1)) || {}
43
45
  body = Regexp.last_match(2)
44
46
  else
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.5"
3
3
  end
data/lib/silas.rb CHANGED
@@ -41,6 +41,7 @@ require "silas/instructions"
41
41
  require "silas/step_runner"
42
42
  require "silas/subprocess_runner"
43
43
  require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
44
+ require "silas/chat"
44
45
 
45
46
  module Silas
46
47
  class << self
@@ -0,0 +1,16 @@
1
+ namespace :silas do
2
+ desc "Chat with your agent in the terminal (REPL). SESSION=id resumes one; approvals prompt inline."
3
+ task chat: :environment do
4
+ # A REPL wants each turn settled synchronously in this one process before
5
+ # the next prompt — and Rails' dev-default Async adapter is unsafe for Silas
6
+ # (it double-executes continuation retries; Silas warns at boot). Force the
7
+ # synchronous :inline adapter for this process only; production still runs
8
+ # Solid Queue.
9
+ ActiveJob::Base.queue_adapter = :inline
10
+ Silas.config.isolate_steps = false
11
+ Silas::Registry.install!
12
+
13
+ session = ENV["SESSION"].present? ? Silas::Session.find(ENV["SESSION"]) : nil
14
+ Silas::Chat.new(session: session).run
15
+ end
16
+ 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.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -30,6 +30,9 @@ dependencies:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '1.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
@@ -37,6 +40,9 @@ dependencies:
37
40
  - - ">="
38
41
  - !ruby/object:Gem::Version
39
42
  version: '1.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2'
40
46
  - !ruby/object:Gem::Dependency
41
47
  name: rspec-rails
42
48
  requirement: !ruby/object:Gem::Requirement
@@ -142,6 +148,8 @@ files:
142
148
  - db/migrate/20260715000001_add_channel_outbound_markers.rb
143
149
  - db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb
144
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
145
153
  - lib/generators/silas/install/install_generator.rb
146
154
  - lib/generators/silas/install/templates/agent.yml
147
155
  - lib/generators/silas/install/templates/bin_ci
@@ -153,6 +161,7 @@ files:
153
161
  - lib/generators/silas/install/templates/example_tool.rb
154
162
  - lib/generators/silas/install/templates/initializer.rb
155
163
  - lib/generators/silas/install/templates/instructions.md
164
+ - lib/generators/silas/install/templates/ruby_llm.rb
156
165
  - lib/silas.rb
157
166
  - lib/silas/agent.rb
158
167
  - lib/silas/agent_scope.rb
@@ -161,6 +170,7 @@ files:
161
170
  - lib/silas/agent_sdk/version_guard.rb
162
171
  - lib/silas/budget.rb
163
172
  - lib/silas/channel.rb
173
+ - lib/silas/chat.rb
164
174
  - lib/silas/configuration.rb
165
175
  - lib/silas/connection.rb
166
176
  - lib/silas/connections.rb
@@ -202,6 +212,7 @@ files:
202
212
  - lib/silas/tools/load_skill.rb
203
213
  - lib/silas/tools/run_code.rb
204
214
  - lib/silas/version.rb
215
+ - lib/tasks/silas_chat.rake
205
216
  - lib/tasks/silas_eval.rake
206
217
  - lib/tasks/silas_schedules.rake
207
218
  homepage: https://github.com/danielstpaul/silas