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 +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +53 -1
- data/app/controllers/silas/inbox/sessions_controller.rb +2 -0
- data/app/jobs/silas/agent_loop_job.rb +37 -3
- data/app/models/silas/tool_invocation.rb +1 -0
- data/app/models/silas/turn.rb +51 -0
- data/app/views/layouts/silas/inbox.html.erb +4 -0
- data/app/views/silas/inbox/sessions/index.html.erb +10 -0
- data/db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb +7 -0
- data/db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb +7 -0
- data/lib/generators/silas/install/install_generator.rb +9 -2
- data/lib/silas/budget.rb +18 -6
- data/lib/silas/chat.rb +36 -6
- 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 +4 -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,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**
|
|
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
|
|
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.
|
|
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!
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -24,6 +24,57 @@ module Silas
|
|
|
24
24
|
update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def canceled? = status == "canceled"
|
|
28
|
+
|
|
29
|
+
# Cancel a turn. A PARKED or QUEUED turn (no live execution) settles to
|
|
30
|
+
# canceled immediately, expiring its pending approvals so a later approve!
|
|
31
|
+
# can't zombie-resume it. A RUNNING turn is flagged; the loop honors the
|
|
32
|
+
# flag at the next step boundary — the in-flight model call completes and
|
|
33
|
+
# its step commits (aborting mid-step would forfeit paid work and create
|
|
34
|
+
# an in-doubt tool window for nothing).
|
|
35
|
+
def cancel!(reason: "canceled")
|
|
36
|
+
raise Error, "turn #{id} is already terminal (#{status})" unless active?
|
|
37
|
+
|
|
38
|
+
if running?
|
|
39
|
+
update!(cancel_requested_at: Time.current)
|
|
40
|
+
:cancel_requested
|
|
41
|
+
else
|
|
42
|
+
expire_pending_approvals!(reason)
|
|
43
|
+
finish!(:canceled, reason: reason)
|
|
44
|
+
:canceled
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def expire_pending_approvals!(reason)
|
|
49
|
+
tool_invocations.where(approval_state: "required").find_each do |inv|
|
|
50
|
+
inv.update!(approval_state: "expired", status: "failed",
|
|
51
|
+
result: { "denied" => reason })
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parked by a budget cap (failure_reason doubles as the park reason while
|
|
56
|
+
# the turn is waiting; it is cleared on resume).
|
|
57
|
+
def budget_parked?
|
|
58
|
+
waiting? && Budget::REASONS.include?(failure_reason)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Human top-up for a budget-parked turn: record the raised limit(s) and
|
|
62
|
+
# resume with a fresh job — completed steps replay from rows, no model
|
|
63
|
+
# re-calls, no re-effects (the same resume path approvals use).
|
|
64
|
+
# turn.raise_budget!(max_cost: 1.50) # dollars
|
|
65
|
+
# turn.raise_budget!(max_input_tokens: 200_000, timeout: 3600)
|
|
66
|
+
def raise_budget!(max_cost: nil, max_input_tokens: nil, timeout: nil)
|
|
67
|
+
raise Error, "turn #{id} is not budget-parked (#{status}/#{failure_reason})" unless budget_parked?
|
|
68
|
+
|
|
69
|
+
raises = { "max_cost" => max_cost, "max_input_tokens" => max_input_tokens,
|
|
70
|
+
"timeout" => timeout }.compact
|
|
71
|
+
raise ArgumentError, "pass at least one limit to raise" if raises.empty?
|
|
72
|
+
|
|
73
|
+
update!(budget_overrides: (budget_overrides || {}).merge(raises),
|
|
74
|
+
failure_reason: nil, status: "queued")
|
|
75
|
+
AgentLoopJob.perform_later(id)
|
|
76
|
+
end
|
|
77
|
+
|
|
27
78
|
# The agent's answer for this turn: the last completed step's text blocks.
|
|
28
79
|
def answer_text
|
|
29
80
|
step = steps.where(status: "completed").order(:index).last
|
|
@@ -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
|
-
|
|
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)
|
|
4
|
+
# continuation step). A breach PARKS the turn at zero compute (like an
|
|
5
|
+
# approval); a human resumes it with Turn#raise_budget!, which records a
|
|
6
|
+
# per-turn override consulted here ahead of the agent's limits.
|
|
5
7
|
#
|
|
6
8
|
# Token/cost checks are deterministic (persisted step data). The timeout check
|
|
7
9
|
# reads the wall clock — benign non-determinism: a cap firing later on resume
|
|
8
|
-
# is correct (the turn genuinely ran too long across the crash).
|
|
10
|
+
# is correct (the turn genuinely ran too long across the crash). Note the
|
|
11
|
+
# timeout clock includes time spent parked, so a timeout top-up should be
|
|
12
|
+
# sized from Time.current - started_at, not from the original limit.
|
|
9
13
|
module Budget
|
|
14
|
+
REASONS = %w[max_input_tokens max_cost timeout].freeze
|
|
15
|
+
|
|
10
16
|
module_function
|
|
11
17
|
|
|
12
|
-
# Returns a
|
|
18
|
+
# Returns a limit-reason string if a cap is exceeded, else nil.
|
|
13
19
|
def exceeded_reason(turn, agent: Silas.agent)
|
|
14
20
|
return "max_input_tokens" if over_tokens?(turn, agent)
|
|
15
21
|
return "max_cost" if over_cost?(turn, agent)
|
|
@@ -18,14 +24,20 @@ module Silas
|
|
|
18
24
|
nil
|
|
19
25
|
end
|
|
20
26
|
|
|
27
|
+
# A human top-up (turn.budget_overrides) beats the agent's configured limit.
|
|
28
|
+
def limit_for(turn, agent, key)
|
|
29
|
+
override = turn.budget_overrides&.dig(key.to_s)
|
|
30
|
+
override.nil? ? agent.public_send(key) : override
|
|
31
|
+
end
|
|
32
|
+
|
|
21
33
|
def over_tokens?(turn, agent)
|
|
22
|
-
limit = agent
|
|
34
|
+
limit = limit_for(turn, agent, :max_input_tokens) or return false
|
|
23
35
|
|
|
24
36
|
Silas::Step.where(turn_id: turn.id).sum(:input_tokens) > limit
|
|
25
37
|
end
|
|
26
38
|
|
|
27
39
|
def over_cost?(turn, agent)
|
|
28
|
-
limit = agent
|
|
40
|
+
limit = limit_for(turn, agent, :max_cost) or return false
|
|
29
41
|
|
|
30
42
|
spent = Silas::Inbox::Cost.for_turn(turn)
|
|
31
43
|
# Only enforce on priced tokens; unpriced models can't be cost-capped.
|
|
@@ -33,7 +45,7 @@ module Silas
|
|
|
33
45
|
end
|
|
34
46
|
|
|
35
47
|
def over_time?(turn, agent)
|
|
36
|
-
limit = agent
|
|
48
|
+
limit = limit_for(turn, agent, :timeout) or return false
|
|
37
49
|
return false unless turn.started_at
|
|
38
50
|
|
|
39
51
|
(Time.current - turn.started_at) > limit
|
data/lib/silas/chat.rb
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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" : ""
|
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
|
|
@@ -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
|