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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +17 -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/generators/silas/install/install_generator.rb +21 -9
- data/lib/generators/silas/install/templates/example_schedule.md +1 -1
- data/lib/generators/silas/install/templates/initializer.rb +4 -1
- data/lib/generators/silas/install/templates/ruby_llm.rb +9 -0
- data/lib/silas/budget.rb +18 -6
- data/lib/silas/chat.rb +161 -0
- data/lib/silas/configuration.rb +7 -3
- data/lib/silas/engines/ruby_llm.rb +8 -1
- data/lib/silas/schedule.rb +3 -1
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +1 -0
- data/lib/tasks/silas_chat.rake +16 -0
- metadata +12 -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,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
|
|
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
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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.
|
|
69
|
-
3.
|
|
70
|
-
4.
|
|
71
|
-
5.
|
|
72
|
-
6.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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)
|
|
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
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
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -64,7 +64,9 @@ module Silas
|
|
|
64
64
|
def initialize
|
|
65
65
|
@engine = :ruby_llm
|
|
66
66
|
@auth = :api_key
|
|
67
|
-
|
|
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
|
-
#
|
|
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-
|
|
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 =
|
|
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/schedule.rb
CHANGED
|
@@ -38,7 +38,9 @@ module Silas
|
|
|
38
38
|
|
|
39
39
|
def self.from_markdown(name, path)
|
|
40
40
|
content = File.read(path)
|
|
41
|
-
|
|
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
data/lib/silas.rb
CHANGED
|
@@ -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.
|
|
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
|