silas 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be0574c8a9c71366220b9be69d9f3d679a6c0a469d907bec1fb2a7d53436f6b3
4
- data.tar.gz: 5ac2adcf62f854ec3a4b8206c0d9a19bd33bb3b631615e36bffed425757126ca
3
+ metadata.gz: e82a0a643fc11af7860df4d2633f6bf22b5f07626eecc2a572b6304924b20a9c
4
+ data.tar.gz: 1464c694a8c8813c0d700d151a482d1c72c901e3f6690464fcf30fc0e3185e01
5
5
  SHA512:
6
- metadata.gz: 3a0be6b63aec608abe5cf2c72f0533c79b7150f8d389344a36e68c90122055fb1f304a4a251ade503467c875c21d99ddd9d136fa37f4d4734d1d5bda9c158bd6
7
- data.tar.gz: 1340782f9cb00620e4e1ed5e9c5b9dc9fc8d96a9310d3d9e67e55004363352e4d17e98f62d1cb6054738b2fed1052c0c7e92f576f5afe587a33770db5294cc48
6
+ metadata.gz: 91d82c9f1fa784eb28cdffa635d8d04b97582d6b7201170bfc1052fcb036c43b89eab47462c6c931ca409e51595254f38cb56e336e74674bb30e3de69206e89e
7
+ data.tar.gz: c90cb14c3fd89d6bf8b605b0ec3a68e4d25255b6e82ea48a5d5f190cb6b59316182cc9228353aa71d4564c94c5871b022fea6be0035986fbf04e98c3696e3cf4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ - **Named agents — the staff pattern.** An app can now employ several
6
+ top-level agents: `app/agents/<name>/` (instructions.md, agent.yml, tools/,
7
+ skills/), autoloaded under `Agents::<Name>`, started with
8
+ `Silas.agent(:clerk).start(input: ...)`. Sessions are stamped with the
9
+ agent's name and **every turn — including crash resumes — runs under that
10
+ agent's own scope** (tools, skills, instructions, definitions digest), so a
11
+ rescued staff member can never wake up holding another agent's tools. The
12
+ inbox gains per-agent filter chips; `silas:chat` gains `AGENT=name`. The
13
+ root `app/agent` is unchanged and remains the default.
14
+ - **Scope switching is now execution-isolated (concurrency fix).**
15
+ `with_agent_scope` previously mutated global config — two Solid Queue
16
+ threads running different agents (or a delegation racing a parallel job)
17
+ could see each other's tools. Scopes now live in
18
+ `ActiveSupport::IsolatedExecutionState` (per-thread *and* per-fiber —
19
+ Falcon-safe), nestable, with the readers (`Silas.agent`, `tool_resolver`,
20
+ `tool_definitions`, `skills`, `definitions_digest`, `instructions_dir`)
21
+ consulting the active scope first. This also fixes a latent bug where a
22
+ crashed *subagent* turn resumed by the rescuer would run under the ROOT
23
+ agent's scope.
24
+
25
+ - **Approval lambdas get indifferent-access input.** Arguments are stored as
26
+ jsonb (string keys); a lambda writing `input[:amount]` got a silent nil —
27
+ fail-closed for gates written `nil > 50 ? park : approve`, but a silent
28
+ always-approve for the inverse. `input` is now
29
+ `ActiveSupport::HashWithIndifferentAccess`.
30
+ - **Brownfield-safe installer.** `silas:install` now leaves an existing
31
+ `config/initializers/ruby_llm.rb` completely untouched (no conflict prompt —
32
+ an accidental Y clobbered production provider config). First generator specs.
33
+ - **hermetic integration.** `config.sandbox = Hermetic.gvisor(image: ...)` is
34
+ now a documented, spec-covered path (the companion
35
+ [hermetic](https://github.com/danielstpaul/hermetic) gem: gVisor, Firecracker,
36
+ hosted E2B, or hardened Docker behind one `run` call, with `trust`/`off_host?`
37
+ as first-class axes). `sandbox_enabled?` now honors the configured backend's
38
+ own `enabled?` (a `Hermetic.null` won't advertise `run_code`), and configuring
39
+ a hermetic backend auto-arms its ledger guard — a sandbox exec inside a ledger
40
+ transaction fails loud. No new runtime dependency: Silas only duck-types
41
+ against the seam.
42
+
43
+ ## 0.1.5
44
+
45
+ - **Turn cancellation.** `turn.cancel!` — a parked or queued turn settles to
46
+ `canceled` immediately (pending approvals expire, so a late `approve!` can
47
+ never zombie-resume it); a running turn is flagged and honored at the next
48
+ step boundary, keeping the in-flight step's paid work. Engine-owned
49
+ (`:agent_sdk`) turns cancel only before the subprocess starts (v1). New
50
+ migration adds `silas_turns.cancel_requested_at`.
51
+ - **Resumable budget parks.** A turn that hits `max_cost` / `max_input_tokens` /
52
+ `timeout` now PARKS at zero compute (state intact) instead of failing
53
+ terminally. A human resumes it with `turn.raise_budget!(max_cost: 1.50)` —
54
+ the top-up is recorded as a per-turn override and a fresh job replays
55
+ completed steps from rows (no model re-calls, no re-effects), continuing
56
+ where it left off. `bin/rails silas:chat` prompts for the top-up inline.
57
+ New migration adds `silas_turns.budget_overrides` (run
58
+ `bin/rails silas:install:migrations db:migrate` on upgrade). Notes: the
59
+ timeout clock includes time spent parked — size a timeout top-up from
60
+ elapsed wall-clock; budget parks have no TTL yet (visible in the inbox as
61
+ waiting); an inbox top-up card is planned.
62
+
3
63
  ## 0.1.4
4
64
 
5
65
  - **Fresh-app quickstart actually works.** A from-scratch install previously
data/README.md CHANGED
@@ -11,7 +11,9 @@ the durable stack is already booted inside your app. The only new surface is the
11
11
  Honestly early and honestly narrow: **v0.1, one maintainer, zero external
12
12
  users**, durability proven by an in-repo `kill -9` chaos harness (100/100, zero
13
13
  duplicate effects, byte-identical replay), and scoped to **trusted code you write
14
- yourself** (the sandbox is an interim Docker seam, not a microVM). The full pitch
14
+ yourself** by default for untrusted or model-generated code, drop in the
15
+ companion gem [hermetic](https://github.com/danielstpaul/hermetic) (gVisor /
16
+ Firecracker / hosted sandboxes behind one call, see below). The full pitch
15
17
  and the honest caveats: [Why Silas](docs/why-silas.md) ·
16
18
  [Silas vs eve](docs/vs-eve.md).
17
19
 
@@ -108,6 +110,56 @@ Inference is one pluggable seam (`config.engine`):
108
110
  in api_key mode). v1 is honestly weaker than `:ruby_llm`: exactly-once *within*
109
111
  a run, `approval :never` tools only, and fail-closed on a mid-subprocess kill.
110
112
 
113
+ ## Sandbox: run untrusted code with hermetic
114
+
115
+ The sandbox is a second pluggable seam (`config.sandbox`). Built-in adapters are
116
+ `:none` (default — code execution off) and `:docker` (hardened container,
117
+ honest-but-interim). For real isolation, the companion gem
118
+ [**hermetic**](https://github.com/danielstpaul/hermetic) drops straight in:
119
+
120
+ ```ruby
121
+ # Gemfile: gem "hermetic" (zero runtime deps)
122
+ Silas.configure do |c|
123
+ c.sandbox = Hermetic.gvisor(image: "python:3.12-slim") # or .docker /
124
+ # .firecracker(kernel:, rootfs:) / .hosted(:e2b, api_key:) # pick your strength
125
+ end
126
+ ```
127
+
128
+ That's the whole integration. When a sandbox is configured and enabled, the
129
+ `run_code` tool is advertised to the model automatically (`at_most_once!` — an
130
+ exec is an external effect). Two properties carry through the seam:
131
+
132
+ - **The trust axis is visible**: every hermetic backend exposes `trust`
133
+ (`:vendor`/`:remote`/`:vm`/`:host`) and `off_host?`, so you can refuse to run
134
+ untrusted code on the box that holds your `RAILS_MASTER_KEY` — pair any local
135
+ backend with `executor:` to push execution to a dedicated sandbox host.
136
+ - **The ledger guard is auto-armed**: configuring a hermetic backend loads its
137
+ Silas shim, so a sandbox exec attempted inside a ledger transaction fails loud
138
+ (sandbox-backed tools must be `at_most_once!`, never `transactional!`).
139
+
140
+ ## Named agents: the staff pattern
141
+
142
+ One app can employ several agents, each with its own room:
143
+
144
+ ```
145
+ app/agents/
146
+ reader/ # Silas.agent(:reader).start(input: "...")
147
+ instructions.md
148
+ agent.yml # model, limits — same keys as the root agent
149
+ tools/
150
+ skills/
151
+ clerk/
152
+ ...
153
+ ```
154
+
155
+ Sessions are stamped with the agent's name; every turn — including crash
156
+ resumes — runs under that agent's own tools, skills, instructions, and
157
+ definitions digest. The inbox filters by agent; `bin/rails silas:chat
158
+ AGENT=clerk` chats with one staff member. The root `app/agent/` remains the
159
+ default agent, unchanged. (Subagents stay a root-agent delegation feature;
160
+ scope switching is execution-isolated, so concurrent jobs running different
161
+ agents never cross wires.)
162
+
111
163
  ## Triggers
112
164
 
113
165
  An agent is reached by more than a method call:
@@ -3,6 +3,8 @@ module Silas
3
3
  class SessionsController < BaseController
4
4
  def index
5
5
  @sessions = Silas::Session.order(created_at: :desc).limit(100)
6
+ @sessions = @sessions.where(agent_name: params[:agent]) if params[:agent].present?
7
+ @agent_names = Silas::Session.distinct.pluck(:agent_name).sort
6
8
  @pending_total = Silas::ToolInvocation.where(approval_state: "required").count
7
9
  end
8
10
 
@@ -20,6 +20,21 @@ module Silas
20
20
  turn = Turn.find(turn_id)
21
21
  return if turn.completed? || %w[failed canceled].include?(turn.status)
22
22
 
23
+ # Named-agent / subagent sessions run EVERY turn under their own scope
24
+ # (tools, skills, instructions, digest) — including resumes: a rescued
25
+ # turn re-enters here and re-establishes the same scope, so a crashed
26
+ # staff member never wakes up holding the root agent's tools.
27
+ scope = Silas.scope_for_session(turn.session)
28
+ if scope
29
+ Silas.with_agent_scope(scope) { drive(turn) }
30
+ else
31
+ drive(turn)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def drive(turn)
23
38
  if Silas.resolved_engine.class.loop_ownership == :engine
24
39
  perform_engine_owned(turn)
25
40
  else
@@ -27,7 +42,6 @@ module Silas
27
42
  end
28
43
  end
29
44
 
30
- private
31
45
 
32
46
  # :ruby_llm — the framework drives the loop, one model call per step, tools
33
47
  # executed through the Ledger. The determinism constraints live here.
@@ -40,6 +54,16 @@ module Silas
40
54
 
41
55
  index = 0
42
56
  loop do
57
+ # Cancellation is honored at step boundaries only — the same safe point
58
+ # as budgets. (Mutable-state read between steps: benign, like the
59
+ # budget wall-clock check — a cancel landing later on resume is still
60
+ # a correct cancel.)
61
+ if turn.reload.cancel_requested_at
62
+ turn.expire_pending_approvals!("turn canceled")
63
+ turn.finish!(:canceled, reason: "canceled")
64
+ return
65
+ end
66
+
43
67
  step :"step_#{index}", isolated: isolate? do
44
68
  Ledger.assert_no_checkpoint!
45
69
  StepRunner.call(turn, index)
@@ -50,9 +74,12 @@ module Silas
50
74
  return if row.parked?
51
75
  break if row.terminal?
52
76
 
53
- # Budget caps (cost/tokens/time) — checked between steps, never inside one.
77
+ # Budget caps (cost/tokens/time) — checked between steps, never inside
78
+ # one. A breach PARKS the turn (state intact, zero compute) rather than
79
+ # failing it: a human tops up with turn.raise_budget! and the fresh job
80
+ # replays completed steps from rows, resuming where it left off.
54
81
  if (reason = Budget.exceeded_reason(turn))
55
- turn.finish!(:failed, reason: reason)
82
+ turn.update!(status: "waiting", failure_reason: reason)
56
83
  return
57
84
  end
58
85
 
@@ -79,6 +106,13 @@ module Silas
79
106
  Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
80
107
  end
81
108
 
109
+ # Cancellation for engine-owned turns is honored only BEFORE the
110
+ # subprocess starts — a running claude -p is not aborted mid-flight (v1).
111
+ if turn.reload.cancel_requested_at
112
+ turn.finish!(:canceled, reason: "canceled")
113
+ return
114
+ end
115
+
82
116
  outcome = nil
83
117
  step :run, isolated: isolate? do
84
118
  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
@@ -75,6 +75,10 @@
75
75
  .cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
76
76
  .flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
77
77
  .empty { text-align: center; color: var(--muted); padding: 48px 0; }
78
+ .agent-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
79
+ .agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid #d9dce1;
80
+ border-radius: 999px; text-decoration: none; color: inherit; }
81
+ .agent-filter .chip-on { background: #16181d; color: #fff; border-color: #16181d; }
78
82
  </style>
79
83
  </head>
80
84
  <body>
@@ -2,6 +2,16 @@
2
2
  <% if @pending_total.positive? %><span class="badge"><%= @pending_total %> awaiting approval</span><% end %>
3
3
  <% end %>
4
4
 
5
+ <% if @agent_names && @agent_names.size > 1 %>
6
+ <div class="agent-filter">
7
+ <%= link_to "all", inbox_sessions_path, class: params[:agent].blank? ? "chip chip-on" : "chip" %>
8
+ <% @agent_names.each do |name| %>
9
+ <%= link_to name, inbox_sessions_path(agent: name),
10
+ class: params[:agent] == name ? "chip chip-on" : "chip" %>
11
+ <% end %>
12
+ </div>
13
+ <% end %>
14
+
5
15
  <% if @sessions.empty? %>
6
16
  <div class="empty">No agent sessions yet.</div>
7
17
  <% else %>
@@ -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
@@ -8,8 +8,15 @@ module Silas
8
8
  def create_initializer
9
9
  template "initializer.rb", "config/initializers/silas.rb"
10
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"
11
+ # first model call raises ConfigurationError. Brownfield apps often
12
+ # already configure ruby_llm themselves — never touch an existing one
13
+ # (not even a conflict prompt: an accidental Y clobbers production
14
+ # provider config).
15
+ if File.exist?(File.join(destination_root, "config/initializers/ruby_llm.rb"))
16
+ say_status :skip, "config/initializers/ruby_llm.rb exists — left untouched", :yellow
17
+ else
18
+ template "ruby_llm.rb", "config/initializers/ruby_llm.rb"
19
+ end
13
20
  end
14
21
 
15
22
  def create_agent_directory
data/lib/silas/budget.rb CHANGED
@@ -1,15 +1,21 @@
1
1
  module Silas
2
2
  # Per-turn budget caps beyond max_steps: cumulative input tokens, cost, and
3
3
  # wall-clock. Checked between steps in the framework-owned loop (never inside a
4
- # continuation step), so a breach fails the turn cleanly with a limit reason.
4
+ # continuation step). A breach PARKS the turn at zero compute (like an
5
+ # approval); a human resumes it with Turn#raise_budget!, which records a
6
+ # per-turn override consulted here ahead of the agent's limits.
5
7
  #
6
8
  # Token/cost checks are deterministic (persisted step data). The timeout check
7
9
  # reads the wall clock — benign non-determinism: a cap firing later on resume
8
- # is correct (the turn genuinely ran too long across the crash).
10
+ # is correct (the turn genuinely ran too long across the crash). Note the
11
+ # timeout clock includes time spent parked, so a timeout top-up should be
12
+ # sized from Time.current - started_at, not from the original limit.
9
13
  module Budget
14
+ REASONS = %w[max_input_tokens max_cost timeout].freeze
15
+
10
16
  module_function
11
17
 
12
- # Returns a failure reason string if a cap is exceeded, else nil.
18
+ # Returns a limit-reason string if a cap is exceeded, else nil.
13
19
  def exceeded_reason(turn, agent: Silas.agent)
14
20
  return "max_input_tokens" if over_tokens?(turn, agent)
15
21
  return "max_cost" if over_cost?(turn, agent)
@@ -18,14 +24,20 @@ module Silas
18
24
  nil
19
25
  end
20
26
 
27
+ # A human top-up (turn.budget_overrides) beats the agent's configured limit.
28
+ def limit_for(turn, agent, key)
29
+ override = turn.budget_overrides&.dig(key.to_s)
30
+ override.nil? ? agent.public_send(key) : override
31
+ end
32
+
21
33
  def over_tokens?(turn, agent)
22
- limit = agent.max_input_tokens or return false
34
+ limit = limit_for(turn, agent, :max_input_tokens) or return false
23
35
 
24
36
  Silas::Step.where(turn_id: turn.id).sum(:input_tokens) > limit
25
37
  end
26
38
 
27
39
  def over_cost?(turn, agent)
28
- limit = agent.max_cost or return false
40
+ limit = limit_for(turn, agent, :max_cost) or return false
29
41
 
30
42
  spent = Silas::Inbox::Cost.for_turn(turn)
31
43
  # Only enforce on priced tokens; unpriced models can't be cost-capped.
@@ -33,7 +45,7 @@ module Silas
33
45
  end
34
46
 
35
47
  def over_time?(turn, agent)
36
- limit = agent.timeout or return false
48
+ limit = limit_for(turn, agent, :timeout) or return false
37
49
  return false unless turn.started_at
38
50
 
39
51
  (Time.current - turn.started_at) > limit
data/lib/silas/chat.rb CHANGED
@@ -43,9 +43,15 @@ module Silas
43
43
 
44
44
  private
45
45
 
46
+ # AGENT=clerk resumes/starts against a named agent (app/agents/clerk/).
47
+ def agent_handle
48
+ @agent_handle ||= ENV["AGENT"].present? ? Silas.agent(ENV["AGENT"]) : Silas.agent
49
+ end
50
+
46
51
  def banner
47
- description = Silas.agent.description.presence || "your agent"
48
- @out.puts "Silas chat #{description} (#{Silas.agent.model})."
52
+ description = agent_handle.description.presence || "your agent"
53
+ label = ENV["AGENT"].present? ? "#{ENV['AGENT']} " : ""
54
+ @out.puts "Silas chat — #{label}#{description} (#{agent_handle.model})."
49
55
  @out.puts "Approvals prompt inline. 'exit' or Ctrl-D to quit."
50
56
  @out.puts "Resuming session #{@session.id} (#{@session.turns.count} turns)." if @session
51
57
  end
@@ -53,7 +59,7 @@ module Silas
53
59
  def submit(text)
54
60
  turn =
55
61
  if @session.nil?
56
- @session = Silas.agent.start(input: text)
62
+ @session = agent_handle.start(input: text)
57
63
  @session.turns.first
58
64
  else
59
65
  @session.continue(input: text)
@@ -73,6 +79,7 @@ module Silas
73
79
 
74
80
  if turn.parked?
75
81
  settle_parked
82
+ settle_budget_park(turn.reload)
76
83
  turn.reload
77
84
  end
78
85
 
@@ -80,17 +87,40 @@ module Silas
80
87
  end
81
88
 
82
89
  def print_outcome(turn)
83
- case turn.reload.status
90
+ turn.reload
91
+ case turn.status
84
92
  when "completed"
85
93
  @out.puts "agent> #{turn.answer_text}"
86
94
  when "waiting", "in_doubt"
87
- @out.puts "(parked — #{pending_for(turn.session).count} approval(s) still pending; " \
88
- "they also render in /silas/inbox)"
95
+ if turn.budget_parked?
96
+ @out.puts "(parked: #{turn.failure_reason} budget reached — resume with " \
97
+ "turn.raise_budget! or from a fresh silas:chat)"
98
+ else
99
+ @out.puts "(parked — #{pending_for(turn.session).count} approval(s) still pending; " \
100
+ "they also render in /silas/inbox)"
101
+ end
89
102
  when "failed"
90
103
  @out.puts "(turn failed: #{turn.failure_reason})"
91
104
  end
92
105
  end
93
106
 
107
+ # A budget-parked turn prompts for a top-up right in the terminal. The
108
+ # resume runs synchronously on the :inline adapter, so new work (and
109
+ # possibly another park) follows immediately.
110
+ def settle_budget_park(turn)
111
+ return unless turn.budget_parked?
112
+
113
+ reason = turn.failure_reason
114
+ @out.puts "\nbudget reached — #{reason}"
115
+ @out.print "raise #{reason} to (blank to leave parked)> "
116
+ value = @in.gets&.strip
117
+ return if value.blank?
118
+
119
+ numeric = reason == "max_cost" ? value.to_f : value.to_i
120
+ turn.raise_budget!(**{ reason.to_sym => numeric })
121
+ print_trace(turn.reload)
122
+ end
123
+
94
124
  def print_trace(turn)
95
125
  ToolInvocation.where(turn_id: turn.id).order(:id).each do |inv|
96
126
  gate = inv.approval_state == "required" ? " — awaiting approval" : ""
@@ -26,6 +26,9 @@ module Silas
26
26
  # Subagents: the roster (name+description) and per-name scope builders, plus
27
27
  # the active-agent overrides swapped in during a nested run.
28
28
  attr_accessor :subagent_index, :subagent_scopes, :agent_override, :instructions_dir
29
+
30
+ # Named top-level agents (app/agents/<name>/): lambda -> { name => AgentScope }.
31
+ attr_accessor :named_agent_scopes
29
32
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
30
33
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
31
34
  attr_accessor :channel_resolver
data/lib/silas/engine.rb CHANGED
@@ -9,17 +9,28 @@ module Silas
9
9
  # Agent::Tools::IssueRefund. Tool identity remains the filename.
10
10
  initializer "silas.agent_directory" do |app|
11
11
  agent_dir = app.root.join("app/agent")
12
- next unless agent_dir.exist?
12
+ if agent_dir.exist?
13
+ unless defined?(::Agent)
14
+ Object.const_set(:Agent, Module.new)
15
+ end
16
+ app.autoloaders.main.push_dir(agent_dir, namespace: ::Agent)
13
17
 
14
- unless defined?(::Agent)
15
- Object.const_set(:Agent, Module.new)
18
+ # Markdown/YAML (instructions.md, agent.yml, skills/*.md) are not Ruby;
19
+ # keep Zeitwerk away from them. tools/, schedules/ (.rb handlers), and
20
+ # channels/ all autoload under the Agent namespace via push_dir above.
21
+ app.autoloaders.main.ignore(agent_dir.join("skills"))
16
22
  end
17
- app.autoloaders.main.push_dir(agent_dir, namespace: ::Agent)
18
23
 
19
- # Markdown/YAML (instructions.md, agent.yml, skills/*.md) are not Ruby;
20
- # keep Zeitwerk away from them. tools/, schedules/ (.rb handlers), and
21
- # channels/ all autoload under the Agent namespace via push_dir above.
22
- app.autoloaders.main.ignore(agent_dir.join("skills"))
24
+ # Named agents: app/agents/<name>/tools/x.rb autoloads as
25
+ # Agents::<Name>::Tools::X the staff pattern.
26
+ agents_dir = app.root.join("app/agents")
27
+ if agents_dir.exist?
28
+ unless defined?(::Agents)
29
+ Object.const_set(:Agents, Module.new)
30
+ end
31
+ app.autoloaders.main.push_dir(agents_dir, namespace: ::Agents)
32
+ Dir[agents_dir.join("*/skills")].each { |d| app.autoloaders.main.ignore(d) }
33
+ end
23
34
  end
24
35
 
25
36
  initializer "silas.boot_guard", after: :load_config_initializers do
@@ -28,7 +39,7 @@ module Silas
28
39
 
29
40
  # Registry rebuilds on every code reload in development, once in production.
30
41
  initializer "silas.registry" do |app|
31
- next unless app.root.join("app/agent").exist?
42
+ next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
32
43
 
33
44
  app.config.to_prepare { Silas::Registry.install!(root: Rails.root) }
34
45
  end
@@ -12,7 +12,7 @@ module Silas
12
12
 
13
13
  turn.update!(
14
14
  instructions_snapshot: render(turn),
15
- definitions_digest: Silas.config.definitions_digest&.call.to_s
15
+ definitions_digest: Silas.definitions_digest.to_s
16
16
  )
17
17
  end
18
18
 
@@ -24,7 +24,7 @@ module Silas
24
24
  def base_instructions(turn)
25
25
  # config.instructions_dir points at the active agent's directory (root or a
26
26
  # subagent, swapped during a nested run).
27
- dir = Silas.config.instructions_dir || Rails.root.join("app/agent")
27
+ dir = Silas.instructions_dir || Rails.root.join("app/agent")
28
28
  path = Pathname(dir).join("instructions.md")
29
29
  return default_instructions unless path.exist?
30
30
 
data/lib/silas/ledger.rb CHANGED
@@ -148,7 +148,10 @@ module Silas
148
148
  when :once
149
149
  previously_approved?(invocation) ? :approved : :user_approval
150
150
  when Proc
151
- policy.call(session: invocation.turn.session, input: invocation.arguments)
151
+ # Indifferent access: arguments are stored as jsonb (string keys),
152
+ # but a lambda writing input[:amount] must not get a silent nil.
153
+ policy.call(session: invocation.turn.session,
154
+ input: invocation.arguments.with_indifferent_access)
152
155
  else
153
156
  :not_applicable
154
157
  end
@@ -0,0 +1,32 @@
1
+ module Silas
2
+ # Handle for a named top-level agent (app/agents/<name>/). Same surface as
3
+ # the root Agent — #start plus the definition readers — so call sites don't
4
+ # care which kind they hold. The only difference: sessions it starts are
5
+ # stamped with the agent's name, and the loop swaps in the agent's scope
6
+ # (tools, skills, instructions, digest) for every turn of those sessions.
7
+ class NamedAgent
8
+ attr_reader :scope
9
+
10
+ def initialize(scope)
11
+ @scope = scope
12
+ end
13
+
14
+ def name = scope.name
15
+
16
+ def start(input:, metadata: {}, channel: nil, continuation_token: nil)
17
+ session = Session.create!(agent_name: name, metadata: metadata,
18
+ channel: channel, continuation_token: continuation_token)
19
+ session.continue(input: input)
20
+ session
21
+ end
22
+
23
+ # Definition readers delegate to the scope's parsed agent.yml.
24
+ def model = scope.agent.model
25
+ def description = scope.agent.description
26
+ def limits = scope.agent.limits
27
+ def max_steps = scope.agent.max_steps
28
+ def max_input_tokens = scope.agent.max_input_tokens
29
+ def max_cost = scope.agent.max_cost
30
+ def timeout = scope.agent.timeout
31
+ end
32
+ end
@@ -16,6 +16,7 @@ module Silas
16
16
  Silas.config.channel_resolver = ->(name) { registry.channels[name] }
17
17
  Silas.config.subagent_index = -> { registry.subagent_index }
18
18
  Silas.config.subagent_scopes = -> { registry.subagent_scopes }
19
+ Silas.config.named_agent_scopes = -> { registry.named_agent_scopes }
19
20
  Silas.config.agent_override = nil
20
21
  Silas.config.instructions_dir = nil
21
22
  Silas.reset_agent_memo!
@@ -103,6 +104,31 @@ module Silas
103
104
  }))
104
105
  end
105
106
 
107
+ # --- named agents (app/agents/<name>/ — the staff pattern) ---------------
108
+
109
+ RESERVED_AGENT_NAMES = %w[agent shared].freeze
110
+
111
+ def named_agent_dirs
112
+ @named_agent_dirs ||= Dir[@root.join("app/agents/*")].select { |p| File.directory?(p) }.sort
113
+ end
114
+
115
+ # { "clerk" => AgentScope, ... }. Each named agent is a full top-level
116
+ # agent: its own instructions.md, agent.yml, tools/, skills/ — autoloaded
117
+ # under Agents::<Camelized>. Sessions stamped with the name run every turn
118
+ # under this scope (loop-enforced, resume-safe, thread-isolated).
119
+ def named_agent_scopes
120
+ @named_agent_scopes ||= named_agent_dirs.to_h do |dir|
121
+ name = File.basename(dir)
122
+ if RESERVED_AGENT_NAMES.include?(name)
123
+ raise Error, "app/agents/#{name} collides with a reserved name — " \
124
+ "'agent' is the root app/agent; rename the directory"
125
+ end
126
+
127
+ [ name, build_agent_scope(Pathname(dir), name, const_base: "Agents::#{name.camelize}",
128
+ run_code: Silas.sandbox_enabled?) ]
129
+ end
130
+ end
131
+
106
132
  # --- subagents -----------------------------------------------------------
107
133
 
108
134
  def subagent_dirs
@@ -133,7 +159,15 @@ module Silas
133
159
  end
134
160
 
135
161
  def build_subagent_scope(dir, name)
136
- const_base = "Agent::Subagents::#{name.camelize}"
162
+ build_agent_scope(dir, name, const_base: "Agent::Subagents::#{name.camelize}",
163
+ agent: subagent_agent(dir, name))
164
+ end
165
+
166
+ # Shared scope builder for subagents and named agents: tools by filename
167
+ # identity under const_base, skills, the load_skill builtin when skills
168
+ # exist, run_code when asked, and the scope's own digest (the same
169
+ # NondeterminismError guard root turns get).
170
+ def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false)
137
171
  tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
138
172
  tname = File.basename(file, ".rb")
139
173
  klass = "#{const_base}::Tools::#{tname.camelize}".constantize
@@ -143,12 +177,14 @@ module Silas
143
177
  [ tname, klass ]
144
178
  end
145
179
  skills = Dir[dir.join("skills/*.md")].sort.map { |f| Skill.parse(f) }
146
- builtins = skills.any? ? { "load_skill" => Silas::Tools::LoadSkill } : {}
180
+ builtins = {}
181
+ builtins["load_skill"] = Silas::Tools::LoadSkill if skills.any?
182
+ builtins["run_code"] = Silas::Tools::RunCode if run_code
147
183
  resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
148
184
  definitions = (tools.values + builtins.values).map(&:schema)
149
185
  digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
150
186
 
151
- Silas::AgentScope.new(name: name, dir: dir, agent: subagent_agent(dir, name),
187
+ Silas::AgentScope.new(name: name, dir: dir, agent: agent || Silas::Agent.load(dir: dir),
152
188
  resolver: resolver, definitions: definitions, digest: digest, skills: skills)
153
189
  end
154
190
  end
@@ -70,9 +70,9 @@ module Silas
70
70
  # A deploy that changes tools/skills mid-turn must fail loudly, never
71
71
  # resume into a different agent than the one that started the turn.
72
72
  def assert_definitions_unchanged!(turn)
73
- return if turn.definitions_digest.blank? || Silas.config.definitions_digest.nil?
73
+ return if turn.definitions_digest.blank? || Silas.definitions_digest.nil?
74
74
 
75
- live = Silas.config.definitions_digest.call.to_s
75
+ live = Silas.definitions_digest.to_s
76
76
  return if live == turn.definitions_digest
77
77
 
78
78
  turn.finish!(:failed, reason: "definitions_changed")
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.6"
3
3
  end
data/lib/silas.rb CHANGED
@@ -12,6 +12,7 @@ require "silas/skill"
12
12
  require "silas/schedule"
13
13
  require "silas/schedule/compiler"
14
14
  require "silas/agent_scope"
15
+ require "silas/named_agent"
15
16
  require "silas/tools/delegate"
16
17
  require "silas/nested_runner"
17
18
  require "silas/sandbox"
@@ -62,7 +63,13 @@ module Silas
62
63
  @agent = nil
63
64
  end
64
65
 
65
- def sandbox_enabled? = ![ :none, nil ].include?(config.sandbox)
66
+ # A configured-but-disabled backend (e.g. Hermetic.null) must not register
67
+ # run_code, so the object's own enabled? is the final word.
68
+ def sandbox_enabled?
69
+ return false if [ :none, nil ].include?(config.sandbox)
70
+
71
+ resolved_sandbox.enabled?
72
+ end
66
73
 
67
74
  def resolved_sandbox
68
75
  @resolved_sandbox ||=
@@ -74,7 +81,14 @@ module Silas
74
81
  pids: config.sandbox_pids, workdir: config.sandbox_workdir,
75
82
  docker_bin: config.sandbox_docker_bin)
76
83
  when Symbol then raise Error, "unknown sandbox #{config.sandbox.inspect}"
77
- else config.sandbox
84
+ else
85
+ # A hermetic backend drops straight in (its Result is a superset of
86
+ # ours). Auto-arm its ledger guard so a sandbox exec inside a ledger
87
+ # transaction fails loud — same posture as our own Docker adapter.
88
+ if defined?(Hermetic::Backends::Base) && config.sandbox.is_a?(Hermetic::Backends::Base)
89
+ require "hermetic/silas"
90
+ end
91
+ config.sandbox
78
92
  end
79
93
  end
80
94
 
@@ -92,26 +106,60 @@ module Silas
92
106
  end
93
107
  end
94
108
 
109
+ # ---- scope-aware readers -------------------------------------------------
110
+ # Every reader consults the active AgentScope first (named agent or
111
+ # subagent), falling back to the boot-time config the Registry installed.
112
+ # The scope lives in IsolatedExecutionState — per-thread AND per-fiber
113
+ # (Falcon-safe), so concurrent jobs running different agents in one
114
+ # process can never see each other's tools.
115
+
95
116
  def tool_resolver
96
- config.tool_resolver or raise Error, "no tool resolver configured (Registry boots one; specs must inject)"
117
+ current_scope&.resolver ||
118
+ config.tool_resolver or raise Error, "no tool resolver configured (Registry boots one; specs must inject)"
97
119
  end
98
120
 
99
121
  def tool_definitions
100
- config.tool_definitions&.call || []
122
+ current_scope&.definitions || config.tool_definitions&.call || []
101
123
  end
102
124
 
103
125
  def skills
104
- config.skills&.call || []
126
+ current_scope&.skills || config.skills&.call || []
105
127
  end
106
128
 
107
129
  def schedules
108
130
  config.schedules&.call || []
109
131
  end
110
132
 
111
- # The active agent definition. config.agent_override is set during a nested
112
- # subagent run; otherwise it's the root app/agent.
113
- def agent
114
- config.agent_override || (@agent ||= Agent.load)
133
+ # The live definitions digest as a String (nil when none configured).
134
+ def definitions_digest
135
+ current_scope&.digest || config.definitions_digest&.call&.to_s.presence
136
+ end
137
+
138
+ def instructions_dir
139
+ current_scope&.dir || config.instructions_dir
140
+ end
141
+
142
+ # The active agent definition — or, given a name, a handle for a NAMED
143
+ # agent (app/agents/<name>/) whose sessions run under that agent's scope:
144
+ #
145
+ # Silas.agent.start(input: "...") # the root app/agent
146
+ # Silas.agent(:clerk).start(input: "...") # a named staff member
147
+ def agent(name = nil)
148
+ return NamedAgent.new(named_agent_scope!(name)) if name
149
+
150
+ current_scope&.agent || config.agent_override || (@agent ||= Agent.load)
151
+ end
152
+
153
+ # Named-agent roster: { "name" => AgentScope }.
154
+ def named_agent_scopes = config.named_agent_scopes&.call || {}
155
+ def named_agent?(name) = named_agent_scopes.key?(name.to_s)
156
+
157
+ def named_agent_scope!(name)
158
+ named_agent_scopes.fetch(name.to_s) do
159
+ known = named_agent_scopes.keys
160
+ raise Error, "unknown agent #{name.inspect}" \
161
+ "#{known.any? ? " (known: #{known.join(', ')})" : " — no app/agents/ directories found"}"
162
+ end
115
163
  end
116
164
 
117
165
  # Subagent roster: [[name, description], ...] (model-visible, so it's in the
@@ -120,24 +168,36 @@ module Silas
120
168
  def subagent?(name) = subagent_index.any? { |n, _| n == name.to_s }
121
169
  def subagent_scope(name) = config.subagent_scopes&.call&.fetch(name.to_s)
122
170
 
123
- # Run a block with a subagent's scope swapped in as the active globals,
124
- # restoring afterward. Synchronous/depth-1 safe because delegation runs
125
- # inline on one thread while the parent loop is paused in the delegate tool.
171
+ # The scope a session's turns must run under: nil for the root agent,
172
+ # otherwise the named-agent or subagent scope matching session.agent_name.
173
+ # Fails loud on an unknown name a session pointing at a deleted agent
174
+ # directory must never silently run with the root agent's tools.
175
+ def scope_for_session(session)
176
+ name = session.agent_name.to_s
177
+ return nil if name.empty? || name == "agent"
178
+
179
+ named_agent_scopes[name] || config.subagent_scopes&.call&.[](name) or
180
+ raise Error, "session #{session.id} belongs to agent #{name.inspect}, " \
181
+ "but no app/agents/#{name} or app/agent/subagents/#{name} exists"
182
+ end
183
+
184
+ # ---- scope switching -----------------------------------------------------
185
+
186
+ SCOPE_KEY = :silas_agent_scope
187
+
188
+ def current_scope
189
+ ActiveSupport::IsolatedExecutionState[SCOPE_KEY]
190
+ end
191
+
192
+ # Run a block under an AgentScope. Nestable (delegation inside a named
193
+ # agent restores the outer scope on exit) and isolated per execution
194
+ # context — no global config is mutated, so concurrent jobs are safe.
126
195
  def with_agent_scope(scope)
127
- saved = {
128
- tool_resolver: config.tool_resolver, tool_definitions: config.tool_definitions,
129
- definitions_digest: config.definitions_digest, skills: config.skills,
130
- agent_override: config.agent_override, instructions_dir: config.instructions_dir
131
- }
132
- config.tool_resolver = scope.resolver
133
- config.tool_definitions = -> { scope.definitions }
134
- config.definitions_digest = -> { scope.digest }
135
- config.skills = -> { scope.skills }
136
- config.agent_override = scope.agent
137
- config.instructions_dir = scope.dir
196
+ previous = ActiveSupport::IsolatedExecutionState[SCOPE_KEY]
197
+ ActiveSupport::IsolatedExecutionState[SCOPE_KEY] = scope
138
198
  yield
139
199
  ensure
140
- saved.each { |k, v| config.public_send("#{k}=", v) }
200
+ ActiveSupport::IsolatedExecutionState[SCOPE_KEY] = previous
141
201
  end
142
202
  end
143
203
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: silas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
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
@@ -194,6 +196,7 @@ files:
194
196
  - lib/silas/mcp/handler.rb
195
197
  - lib/silas/mcp/server.rb
196
198
  - lib/silas/message_builder.rb
199
+ - lib/silas/named_agent.rb
197
200
  - lib/silas/nested_runner.rb
198
201
  - lib/silas/registry.rb
199
202
  - lib/silas/sandbox.rb