silas 0.1.5 → 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: 7aa3f984d996b0eee69689f4009e3d43bd46d63caaf2144f0a4082b40d0ae918
4
- data.tar.gz: 8c9b38ac7dae0a0ea510375046447442087a3a1536431199a8bf54677fca80b3
3
+ metadata.gz: e82a0a643fc11af7860df4d2633f6bf22b5f07626eecc2a572b6304924b20a9c
4
+ data.tar.gz: 1464c694a8c8813c0d700d151a482d1c72c901e3f6690464fcf30fc0e3185e01
5
5
  SHA512:
6
- metadata.gz: f3136bfb1276a84217f76733e1c718715b8dbed5794fe766f72383cf58560c50950c6cdb12932d89108c6c6de73bd16b1c2c1b915841bb1829b47c102d03f0f5
7
- data.tar.gz: 5af58996877a0bb1bf6d64fbf2ef30c141462c8d00a7f37ebbaaca4dbf3518cd28c4ead9557d4ee2d5a23144748632f053e38b908238b39c64577ce9f0d355fd
6
+ metadata.gz: 91d82c9f1fa784eb28cdffa635d8d04b97582d6b7201170bfc1052fcb036c43b89eab47462c6c931ca409e51595254f38cb56e336e74674bb30e3de69206e89e
7
+ data.tar.gz: c90cb14c3fd89d6bf8b605b0ec3a68e4d25255b6e82ea48a5d5f190cb6b59316182cc9228353aa71d4564c94c5871b022fea6be0035986fbf04e98c3696e3cf4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
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
+
3
43
  ## 0.1.5
4
44
 
5
45
  - **Turn cancellation.** `turn.cancel!` — a parked or queued turn settles to
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.
@@ -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 %>
@@ -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/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)
@@ -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.5"
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.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul
@@ -196,6 +196,7 @@ files:
196
196
  - lib/silas/mcp/handler.rb
197
197
  - lib/silas/mcp/server.rb
198
198
  - lib/silas/message_builder.rb
199
+ - lib/silas/named_agent.rb
199
200
  - lib/silas/nested_runner.rb
200
201
  - lib/silas/registry.rb
201
202
  - lib/silas/sandbox.rb