silas 0.1.2 → 0.1.4

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: e6f592c5f4b254794f9a2a9cd4efc87d70b898f685f50ffff75c7af41415a7d8
4
- data.tar.gz: 43871af8708f50e68803bc727975201d693ba4337ddf056f54062fb8e6070208
3
+ metadata.gz: be0574c8a9c71366220b9be69d9f3d679a6c0a469d907bec1fb2a7d53436f6b3
4
+ data.tar.gz: 5ac2adcf62f854ec3a4b8206c0d9a19bd33bb3b631615e36bffed425757126ca
5
5
  SHA512:
6
- metadata.gz: f1b8239d3d5eaaf1de6d199ac1a3fd53562cc7c1e8d8b6f67124974810fbe811692aa2d466a4d595660bca13cbc98c98ae0b500275bc54e216e2a2a4b8810fb5
7
- data.tar.gz: d1a1132bb5b027b19e338e40b57ac9a418f8bdde7fefe11f1d20a196cf6f7ecd7999fd0f7209f45c1e21ef7445a313d65f88abd2dabe56c00fc5dafaf79e9279
6
+ metadata.gz: 3a0be6b63aec608abe5cf2c72f0533c79b7150f8d389344a36e68c90122055fb1f304a4a251ade503467c875c21d99ddd9d136fa37f4d4734d1d5bda9c158bd6
7
+ data.tar.gz: 1340782f9cb00620e4e1ed5e9c5b9dc9fc8d96a9310d3d9e67e55004363352e4d17e98f62d1cb6054738b2fed1052c0c7e92f576f5afe587a33770db5294cc48
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4
4
+
5
+ - **Fresh-app quickstart actually works.** A from-scratch install previously
6
+ failed at its own post-install steps: the generated default model
7
+ ("claude-sonnet-5") wasn't in ruby_llm 1.16's bundled registry, and no
8
+ provider-key initializer was generated, so the first turn raised. The
9
+ generator now emits `config/initializers/ruby_llm.rb` (maps
10
+ `ANTHROPIC_API_KEY`), defaults to the registry-known `claude-opus-4-8`, and a
11
+ missing model raises an actionable error suggesting
12
+ `RubyLLM.models.refresh!`.
13
+ - **`silas:schedules` no longer crashes on the generator's own template.** The
14
+ example schedule's ERB comment rendered a leading blank line the frontmatter
15
+ parser rejected; template fixed and the parser now tolerates leading
16
+ whitespace.
17
+ - **`bin/ci` is never clobbered.** Rails 8.1 ships a real bin/ci; the installer
18
+ now leaves an existing one untouched and tells you to add
19
+ `bin/rails silas:eval` to it.
20
+ - **Correct Opus 4.8 pricing.** Default `model_prices` had Opus 4.8 at $15/$75
21
+ per MTok; it is $5/$25. Added Sonnet 4.6 and the `claude-haiku-4-5` alias.
22
+ - **ruby_llm dependency bounded `< 2`** (2.0 removes APIs the engine relies on).
23
+ - **`bin/rails silas:chat` — a terminal REPL for your agent.** The dev-loop a
24
+ hosted platform gives you from its CLI, except there is no platform: it runs
25
+ inside your app, tools hit your real dev database, and parked approvals prompt
26
+ inline (`approve? [y]es / [d]ecline / [s]kip`) calling the same
27
+ `approve!`/`decline!` as the inbox and Slack. `SESSION=id` resumes a session;
28
+ a new message while a turn is parked steers you to the pending approvals. The
29
+ task forces the synchronous `:inline` adapter for its own process (a REPL
30
+ wants each turn settled before the next prompt; production still runs Solid
31
+ Queue).
32
+
33
+ ## 0.1.3
34
+
35
+ - **Ledger: parallel graded gates.** When the model emits several tool calls in
36
+ one step, `settle!` now settles every invocation instead of stopping at the
37
+ first one that needs approval. An ungated call (e.g. a low-risk write) runs
38
+ immediately even when a gated sibling (e.g. a money move) parks for a human —
39
+ it is no longer stranded behind the approval. This changes only *timing*, not
40
+ safety: an unsettled sibling already executed on resume regardless of the
41
+ human's approve/decline, so stopping early only delayed independent work.
42
+ Regression test in `spec/silas/parallel_tool_calls_spec.rb`.
43
+
3
44
  ## 0.1.2
4
45
 
5
46
  - **Security (email channel scaffold): approvals no longer go to the session
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
@@ -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/chat.rb ADDED
@@ -0,0 +1,137 @@
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
+ turn.reload
77
+ end
78
+
79
+ print_outcome(turn)
80
+ end
81
+
82
+ def print_outcome(turn)
83
+ case turn.reload.status
84
+ when "completed"
85
+ @out.puts "agent> #{turn.answer_text}"
86
+ 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
+ when "failed"
90
+ @out.puts "(turn failed: #{turn.failure_reason})"
91
+ end
92
+ end
93
+
94
+ def print_trace(turn)
95
+ ToolInvocation.where(turn_id: turn.id).order(:id).each do |inv|
96
+ gate = inv.approval_state == "required" ? " — awaiting approval" : ""
97
+ @out.puts " #{GLYPH.fetch(inv.status, '·')} #{inv.tool_name}(#{compact_args(inv.arguments)})#{gate}"
98
+ end
99
+ end
100
+
101
+ # Prompt for every parked approval in the session. Approving resumes the
102
+ # turn synchronously (inline adapter), so new invocations may appear —
103
+ # loop until nothing is pending or the user skips.
104
+ def settle_parked
105
+ loop do
106
+ invocation = pending_for(@session).order(:id).first
107
+ break unless invocation
108
+
109
+ @out.puts "\napproval needed — #{invocation.tool_name}(#{compact_args(invocation.arguments)})"
110
+ @out.print "approve? [y]es / [d]ecline / [s]kip> "
111
+ answer = @in.gets&.strip&.downcase
112
+
113
+ case answer
114
+ when "y", "yes"
115
+ invocation.approve!(by: @actor)
116
+ @out.puts
117
+ print_trace(@session.turns.reload.last)
118
+ when "d", "decline"
119
+ @out.print "reason> "
120
+ reason = @in.gets&.strip
121
+ invocation.decline!(reason: reason.presence || "declined from CLI", by: @actor)
122
+ else
123
+ @out.puts "(left parked — approve later here or in /silas/inbox)"
124
+ break
125
+ end
126
+ end
127
+ end
128
+
129
+ def pending_for(session)
130
+ session.pending_approvals.where(status: %w[pending in_doubt])
131
+ end
132
+
133
+ def compact_args(arguments)
134
+ (arguments || {}).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
135
+ end
136
+ end
137
+ 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
 
data/lib/silas/ledger.rb CHANGED
@@ -34,12 +34,19 @@ module Silas
34
34
  "the continuation believes are committed."
35
35
  end
36
36
 
37
+ # Settle EVERY invocation in the step, then report whether the turn must
38
+ # park. When the model emits parallel tool calls, an ungated one must not
39
+ # be blocked by a gated sibling's approval — and it wouldn't be protected
40
+ # anyway: an unsettled sibling executes on resume regardless of whether the
41
+ # human approves or declines the gated call, so stopping at the first park
42
+ # only delays independent work. Execute what can proceed; park if any
43
+ # invocation is awaiting a human.
37
44
  def settle!(step, resolver:)
45
+ parked = false
38
46
  step.tool_invocations.order(:id).each do |invocation|
39
- outcome = settle_invocation!(invocation, resolver)
40
- return :parked if outcome == :parked
47
+ parked = true if settle_invocation!(invocation, resolver) == :parked
41
48
  end
42
- :completed
49
+ parked ? :parked : :completed
43
50
  end
44
51
 
45
52
  # Drive a SINGLE freshly-created invocation to a terminal state — the
@@ -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.2"
2
+ VERSION = "0.1.4"
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.2
4
+ version: 0.1.4
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
@@ -153,6 +159,7 @@ files:
153
159
  - lib/generators/silas/install/templates/example_tool.rb
154
160
  - lib/generators/silas/install/templates/initializer.rb
155
161
  - lib/generators/silas/install/templates/instructions.md
162
+ - lib/generators/silas/install/templates/ruby_llm.rb
156
163
  - lib/silas.rb
157
164
  - lib/silas/agent.rb
158
165
  - lib/silas/agent_scope.rb
@@ -161,6 +168,7 @@ files:
161
168
  - lib/silas/agent_sdk/version_guard.rb
162
169
  - lib/silas/budget.rb
163
170
  - lib/silas/channel.rb
171
+ - lib/silas/chat.rb
164
172
  - lib/silas/configuration.rb
165
173
  - lib/silas/connection.rb
166
174
  - lib/silas/connections.rb
@@ -202,6 +210,7 @@ files:
202
210
  - lib/silas/tools/load_skill.rb
203
211
  - lib/silas/tools/run_code.rb
204
212
  - lib/silas/version.rb
213
+ - lib/tasks/silas_chat.rake
205
214
  - lib/tasks/silas_eval.rake
206
215
  - lib/tasks/silas_schedules.rake
207
216
  homepage: https://github.com/danielstpaul/silas