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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +53 -1
- data/app/controllers/silas/inbox/sessions_controller.rb +2 -0
- data/app/jobs/silas/agent_loop_job.rb +15 -1
- data/app/views/layouts/silas/inbox.html.erb +4 -0
- data/app/views/silas/inbox/sessions/index.html.erb +10 -0
- data/lib/generators/silas/install/install_generator.rb +9 -2
- data/lib/silas/chat.rb +9 -3
- data/lib/silas/configuration.rb +3 -0
- data/lib/silas/engine.rb +20 -9
- data/lib/silas/instructions.rb +2 -2
- data/lib/silas/ledger.rb +4 -1
- data/lib/silas/named_agent.rb +32 -0
- data/lib/silas/registry.rb +39 -3
- data/lib/silas/step_runner.rb +2 -2
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +84 -24
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e82a0a643fc11af7860df4d2633f6bf22b5f07626eecc2a572b6304924b20a9c
|
|
4
|
+
data.tar.gz: 1464c694a8c8813c0d700d151a482d1c72c901e3f6690464fcf30fc0e3185e01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**
|
|
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
|
-
|
|
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 =
|
|
48
|
-
|
|
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 =
|
|
62
|
+
@session = agent_handle.start(input: text)
|
|
57
63
|
@session.turns.first
|
|
58
64
|
else
|
|
59
65
|
@session.continue(input: text)
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
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
|
data/lib/silas/instructions.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Silas
|
|
|
12
12
|
|
|
13
13
|
turn.update!(
|
|
14
14
|
instructions_snapshot: render(turn),
|
|
15
|
-
definitions_digest: Silas.
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/silas/registry.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -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.
|
|
73
|
+
return if turn.definitions_digest.blank? || Silas.definitions_digest.nil?
|
|
74
74
|
|
|
75
|
-
live = Silas.
|
|
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
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
+
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
|