silas 0.1.5 → 0.1.7
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 +65 -0
- data/README.md +71 -1
- data/app/controllers/silas/inbox/sessions_controller.rb +2 -0
- data/app/jobs/silas/agent_loop_job.rb +15 -1
- data/app/models/silas/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/app/views/layouts/silas/inbox.html.erb +4 -0
- data/app/views/silas/inbox/sessions/index.html.erb +10 -0
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/lib/generators/silas/install/install_generator.rb +9 -2
- data/lib/silas/chat.rb +9 -3
- data/lib/silas/configuration.rb +10 -0
- data/lib/silas/engine.rb +20 -9
- data/lib/silas/instructions.rb +15 -3
- data/lib/silas/ledger.rb +4 -1
- data/lib/silas/named_agent.rb +32 -0
- data/lib/silas/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +49 -3
- data/lib/silas/step_runner.rb +2 -2
- data/lib/silas/tools/handoff.rb +68 -0
- data/lib/silas/tools/recall.rb +18 -0
- data/lib/silas/tools/remember.rb +32 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +95 -24
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad5ce26e72a73ea83df134c187f8c8dedcfcc35a65f20493f3095abfbfaa7ed8
|
|
4
|
+
data.tar.gz: ab1989850b2b8c881da660824dcad3f71159cfffcd8648901ec2a06385d8c294
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb5da2cdb854533bf68a761582063e8c5b22c2e40fc02cab05240d4ea199554da317bb0509398c81923a312c7b43773834d354ffb6391be549a6254329e3b995
|
|
7
|
+
data.tar.gz: 57e53d9a3c5b438b941cd599ba805c2c210e303d6362599d757b58b907ea10b9401cbf918cd909d1c53590d8fee4cc9e0fef8a8fb5ccb422c26db5e0abaaf43a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
- **Memory — graph-shaped, not a graph database.** New `silas_memories` table:
|
|
6
|
+
entity-attributed facts (`subject · attribute · content`) with provenance
|
|
7
|
+
(session/turn) and **supersession** — a new fact about the same
|
|
8
|
+
subject+attribute retires the old one. Two built-ins: `remember`
|
|
9
|
+
(`transactional!`, **approval-gated by default** — the memory card parks in
|
|
10
|
+
your inbox; `config.memory_approval = :never` opts out) and `recall`
|
|
11
|
+
(on-demand subject lookup). Recent memories inject into the instructions
|
|
12
|
+
snapshot (bounded by `config.memory_injection_limit`). Scopes: private
|
|
13
|
+
per-agent or `shared: true` app-wide. Domain memory stays where it belongs —
|
|
14
|
+
your own tables; this is for the fuzzy residue with no natural home. Edges
|
|
15
|
+
are a deliberate not-yet. Upgrade-safe: tools only advertise when the
|
|
16
|
+
migration has run.
|
|
17
|
+
- **Handoffs — staff composition without agent chatter.** New `handoff`
|
|
18
|
+
built-in (advertised when `app/agents/` exists): file a self-contained brief
|
|
19
|
+
that starts another named agent's linked session (`parent_session_id`),
|
|
20
|
+
async by default, `await: true` for run-now-and-return-answer.
|
|
21
|
+
`at_most_once!` through the ledger; refuses self-handoffs, unknown targets,
|
|
22
|
+
cycles, and chains deeper than 3. Free-form agent-to-agent conversation
|
|
23
|
+
remains deliberately unblessed.
|
|
24
|
+
- `Session#continue(enqueue: false)` for callers that drive the turn
|
|
25
|
+
themselves. New migration: run `bin/rails silas:install:migrations
|
|
26
|
+
db:migrate` on upgrade.
|
|
27
|
+
|
|
28
|
+
## 0.1.6
|
|
29
|
+
|
|
30
|
+
- **Named agents — the staff pattern.** An app can now employ several
|
|
31
|
+
top-level agents: `app/agents/<name>/` (instructions.md, agent.yml, tools/,
|
|
32
|
+
skills/), autoloaded under `Agents::<Name>`, started with
|
|
33
|
+
`Silas.agent(:clerk).start(input: ...)`. Sessions are stamped with the
|
|
34
|
+
agent's name and **every turn — including crash resumes — runs under that
|
|
35
|
+
agent's own scope** (tools, skills, instructions, definitions digest), so a
|
|
36
|
+
rescued staff member can never wake up holding another agent's tools. The
|
|
37
|
+
inbox gains per-agent filter chips; `silas:chat` gains `AGENT=name`. The
|
|
38
|
+
root `app/agent` is unchanged and remains the default.
|
|
39
|
+
- **Scope switching is now execution-isolated (concurrency fix).**
|
|
40
|
+
`with_agent_scope` previously mutated global config — two Solid Queue
|
|
41
|
+
threads running different agents (or a delegation racing a parallel job)
|
|
42
|
+
could see each other's tools. Scopes now live in
|
|
43
|
+
`ActiveSupport::IsolatedExecutionState` (per-thread *and* per-fiber —
|
|
44
|
+
Falcon-safe), nestable, with the readers (`Silas.agent`, `tool_resolver`,
|
|
45
|
+
`tool_definitions`, `skills`, `definitions_digest`, `instructions_dir`)
|
|
46
|
+
consulting the active scope first. This also fixes a latent bug where a
|
|
47
|
+
crashed *subagent* turn resumed by the rescuer would run under the ROOT
|
|
48
|
+
agent's scope.
|
|
49
|
+
|
|
50
|
+
- **Approval lambdas get indifferent-access input.** Arguments are stored as
|
|
51
|
+
jsonb (string keys); a lambda writing `input[:amount]` got a silent nil —
|
|
52
|
+
fail-closed for gates written `nil > 50 ? park : approve`, but a silent
|
|
53
|
+
always-approve for the inverse. `input` is now
|
|
54
|
+
`ActiveSupport::HashWithIndifferentAccess`.
|
|
55
|
+
- **Brownfield-safe installer.** `silas:install` now leaves an existing
|
|
56
|
+
`config/initializers/ruby_llm.rb` completely untouched (no conflict prompt —
|
|
57
|
+
an accidental Y clobbered production provider config). First generator specs.
|
|
58
|
+
- **hermetic integration.** `config.sandbox = Hermetic.gvisor(image: ...)` is
|
|
59
|
+
now a documented, spec-covered path (the companion
|
|
60
|
+
[hermetic](https://github.com/danielstpaul/hermetic) gem: gVisor, Firecracker,
|
|
61
|
+
hosted E2B, or hardened Docker behind one `run` call, with `trust`/`off_host?`
|
|
62
|
+
as first-class axes). `sandbox_enabled?` now honors the configured backend's
|
|
63
|
+
own `enabled?` (a `Hermetic.null` won't advertise `run_code`), and configuring
|
|
64
|
+
a hermetic backend auto-arms its ledger guard — a sandbox exec inside a ledger
|
|
65
|
+
transaction fails loud. No new runtime dependency: Silas only duck-types
|
|
66
|
+
against the seam.
|
|
67
|
+
|
|
3
68
|
## 0.1.5
|
|
4
69
|
|
|
5
70
|
- **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,74 @@ 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
|
+
|
|
163
|
+
## Memory & handoffs
|
|
164
|
+
|
|
165
|
+
Silas memory is **graph-shaped, not a graph database**: facts as
|
|
166
|
+
`subject · attribute · content` triples with provenance and supersession
|
|
167
|
+
("author:jane · report_format: prefers CSV" — a new value retires the old).
|
|
168
|
+
The `remember` tool is **approval-gated by default** — the memory card parks
|
|
169
|
+
in your inbox before anything persists; `recall` digs deeper than the few
|
|
170
|
+
recent memories injected into each turn. Private per agent, or `shared: true`
|
|
171
|
+
for the whole staff. Your *domain* data does not belong here — it belongs in
|
|
172
|
+
your own tables, which your tools already read; memory is for the fuzzy
|
|
173
|
+
residue with no natural home.
|
|
174
|
+
|
|
175
|
+
Staff compose through **handoffs, not conversations**: `handoff` files a
|
|
176
|
+
self-contained brief that starts a linked session for another named agent
|
|
177
|
+
(async, or `await: true` for an answer), exactly-once-guarded, cycle-checked.
|
|
178
|
+
Two models chatting freely is a cost and audit hazard — deliberately
|
|
179
|
+
unblessed.
|
|
180
|
+
|
|
111
181
|
## Triggers
|
|
112
182
|
|
|
113
183
|
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.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One remembered fact. Triple-ish (subject · attribute · content) with
|
|
3
|
+
# provenance (which turn wrote it) and supersession: a new fact about the
|
|
4
|
+
# same (agent, scope, subject, attribute) retires the old one — temporal
|
|
5
|
+
# versioning without a temporal store. Edges between memories are a
|
|
6
|
+
# deliberate not-yet: add them when a real agent needs multi-hop.
|
|
7
|
+
class Memory < ApplicationRecord
|
|
8
|
+
self.table_name = "silas_memories"
|
|
9
|
+
|
|
10
|
+
SCOPES = %w[agent app].freeze
|
|
11
|
+
validates :scope, inclusion: { in: SCOPES }
|
|
12
|
+
validates :agent_name, :subject, :content, presence: true
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(status: "active") }
|
|
15
|
+
|
|
16
|
+
# Write-through with supersession. attribute nil = free-form note about the
|
|
17
|
+
# subject (accumulates); attribute present = the triple's slot (supersedes).
|
|
18
|
+
def self.remember!(agent_name:, subject:, content:, attribute: nil, scope: "agent", turn: nil)
|
|
19
|
+
transaction do
|
|
20
|
+
record = create!(agent_name:, scope:, subject: subject.to_s.strip.downcase,
|
|
21
|
+
attribute_name: attribute.presence&.strip&.downcase, content:,
|
|
22
|
+
session_id: turn&.session_id, turn_id: turn&.id)
|
|
23
|
+
if record.attribute_name
|
|
24
|
+
active.where(agent_name:, scope:, subject: record.subject, attribute_name: record.attribute_name)
|
|
25
|
+
.where.not(id: record.id)
|
|
26
|
+
.update_all(status: "superseded", superseded_by_id: record.id, updated_at: Time.current)
|
|
27
|
+
end
|
|
28
|
+
record
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What an agent can see: its own memories + app-shared ones. Subject-matched
|
|
33
|
+
# first (when subjects given), then most recent.
|
|
34
|
+
def self.recall(agent_name:, subjects: [], limit: 10)
|
|
35
|
+
visible = active.where("agent_name = :a OR scope = 'app'", a: agent_name)
|
|
36
|
+
if subjects.any?
|
|
37
|
+
keys = subjects.map { |s| s.to_s.strip.downcase }
|
|
38
|
+
matched = visible.where(subject: keys).order(created_at: :desc).limit(limit).to_a
|
|
39
|
+
rest = visible.where.not(subject: keys).order(created_at: :desc).limit(limit - matched.size)
|
|
40
|
+
matched + rest
|
|
41
|
+
else
|
|
42
|
+
visible.order(created_at: :desc).limit(limit).to_a
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_line
|
|
47
|
+
head = attribute_name ? "#{subject} · #{attribute_name}: " : "#{subject}: "
|
|
48
|
+
head + content
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/app/models/silas/session.rb
CHANGED
|
@@ -19,15 +19,17 @@ module Silas
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Enqueue the next turn. One active turn per session — the partial unique
|
|
22
|
-
# index is the backstop; this is the friendly front door.
|
|
23
|
-
|
|
22
|
+
# index is the backstop; this is the friendly front door. enqueue: false
|
|
23
|
+
# creates the turn without scheduling it (callers that drive it themselves,
|
|
24
|
+
# e.g. an awaited handoff running the loop inline).
|
|
25
|
+
def continue(input:, enqueue: true)
|
|
24
26
|
if active_turn
|
|
25
27
|
raise TurnInProgressError, "session #{id} already has an active turn (##{active_turn.index})"
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
next_index = (turns.maximum(:index) || -1) + 1
|
|
29
31
|
turn = turns.create!(index: next_index, input: input)
|
|
30
|
-
AgentLoopJob.perform_later(turn.id)
|
|
32
|
+
AgentLoopJob.perform_later(turn.id) if enqueue
|
|
31
33
|
turn
|
|
32
34
|
rescue ActiveRecord::RecordNotUnique
|
|
33
35
|
raise TurnInProgressError, "session #{id} already has an active turn"
|
|
@@ -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,22 @@
|
|
|
1
|
+
class CreateSilasMemories < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# Agent memory: entity-attributed facts with provenance and supersession
|
|
4
|
+
# (graph-SHAPED — triples, no edges until the need is proven). Domain
|
|
5
|
+
# memory belongs in YOUR tables; this is only for the fuzzy residue that
|
|
6
|
+
# has no natural home ("author X prefers CSV", "reports arrive on the 15th").
|
|
7
|
+
create_table :silas_memories do |t|
|
|
8
|
+
t.string :agent_name, null: false # whose memory ("agent" = root)
|
|
9
|
+
t.string :scope, null: false, default: "agent" # agent (private) | app (shared)
|
|
10
|
+
t.string :subject, null: false # entity ref, e.g. "author:jane"
|
|
11
|
+
t.string :attribute_name # optional: triple form
|
|
12
|
+
t.text :content, null: false # the fact, plain prose
|
|
13
|
+
t.string :status, null: false, default: "active" # active | superseded
|
|
14
|
+
t.bigint :superseded_by_id
|
|
15
|
+
t.bigint :session_id # provenance
|
|
16
|
+
t.bigint :turn_id
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
add_index :silas_memories, [ :agent_name, :scope, :status ]
|
|
20
|
+
add_index :silas_memories, [ :subject, :status ]
|
|
21
|
+
end
|
|
22
|
+
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/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,13 @@ 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
|
|
32
|
+
|
|
33
|
+
# Memory (silas_memories): memory=false disables entirely; memory_approval
|
|
34
|
+
# :always parks every remember for a human (default), :never auto-approves.
|
|
35
|
+
attr_accessor :memory, :memory_approval, :memory_injection_limit
|
|
29
36
|
# Channels: name -> Channel subclass (wired by the Registry). Slack creds
|
|
30
37
|
# default to credentials.dig(:silas, :slack, ...); nil disables Slack.
|
|
31
38
|
attr_accessor :channel_resolver
|
|
@@ -92,6 +99,9 @@ module Silas
|
|
|
92
99
|
@eval_dir = "test/agent_evals"
|
|
93
100
|
@eval_grader = nil
|
|
94
101
|
@sandbox = :none
|
|
102
|
+
@memory = true
|
|
103
|
+
@memory_approval = :always
|
|
104
|
+
@memory_injection_limit = 8
|
|
95
105
|
@sandbox_image = nil
|
|
96
106
|
@sandbox_network = "none"
|
|
97
107
|
@sandbox_memory = "512m"
|
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,25 +12,37 @@ 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
|
|
|
19
19
|
def render(turn)
|
|
20
|
-
[ base_instructions(turn), skill_index_block(turn.session), loaded_skills_block(turn.session) ]
|
|
20
|
+
[ base_instructions(turn), memory_block(turn.session), skill_index_block(turn.session), loaded_skills_block(turn.session) ]
|
|
21
21
|
.compact.join("\n\n")
|
|
22
22
|
end
|
|
23
23
|
|
|
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
|
|
|
31
31
|
ERB.new(path.read).result_with_hash(session: turn.session, agent_name: turn.session.agent_name)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Recent memories surface into the snapshot (bounded; recall digs deeper).
|
|
35
|
+
def memory_block(session)
|
|
36
|
+
return nil unless Silas.memory_enabled?
|
|
37
|
+
|
|
38
|
+
memories = Silas::Memory.recall(agent_name: session.agent_name,
|
|
39
|
+
limit: Silas.config.memory_injection_limit)
|
|
40
|
+
return nil if memories.empty?
|
|
41
|
+
|
|
42
|
+
"## Memory (most recent — use the recall tool for more)\n\n" +
|
|
43
|
+
memories.map { |m| "- #{m.to_line}" }.join("\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
# Advertise skill descriptions (eve's routing hint); bodies load on demand.
|
|
35
47
|
def skill_index_block(session)
|
|
36
48
|
advertised = Silas.skills.reject { |s| session.loaded_skills.include?(s.name) }
|
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/nested_runner.rb
CHANGED
|
@@ -10,8 +10,8 @@ module Silas
|
|
|
10
10
|
module NestedRunner
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def run(session, input:)
|
|
14
|
-
scope
|
|
13
|
+
def run(session, input:, scope: nil)
|
|
14
|
+
scope ||= Silas.subagent_scope(session.agent_name)
|
|
15
15
|
turn = session.turns.create!(index: 0, input: input)
|
|
16
16
|
|
|
17
17
|
Silas.with_agent_scope(scope) do
|
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!
|
|
@@ -72,6 +73,11 @@ module Silas
|
|
|
72
73
|
b["load_skill"] = Silas::Tools::LoadSkill if skills.any?
|
|
73
74
|
b["delegate"] = Silas::Tools::Delegate if subagent_dirs.any?
|
|
74
75
|
b["run_code"] = Silas::Tools::RunCode if Silas.sandbox_enabled?
|
|
76
|
+
if Silas.memory_enabled?
|
|
77
|
+
b["remember"] = Silas::Tools::Remember
|
|
78
|
+
b["recall"] = Silas::Tools::Recall
|
|
79
|
+
end
|
|
80
|
+
b["handoff"] = Silas::Tools::Handoff if named_agent_dirs.any?
|
|
75
81
|
b
|
|
76
82
|
end
|
|
77
83
|
|
|
@@ -103,6 +109,31 @@ module Silas
|
|
|
103
109
|
}))
|
|
104
110
|
end
|
|
105
111
|
|
|
112
|
+
# --- named agents (app/agents/<name>/ — the staff pattern) ---------------
|
|
113
|
+
|
|
114
|
+
RESERVED_AGENT_NAMES = %w[agent shared].freeze
|
|
115
|
+
|
|
116
|
+
def named_agent_dirs
|
|
117
|
+
@named_agent_dirs ||= Dir[@root.join("app/agents/*")].select { |p| File.directory?(p) }.sort
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# { "clerk" => AgentScope, ... }. Each named agent is a full top-level
|
|
121
|
+
# agent: its own instructions.md, agent.yml, tools/, skills/ — autoloaded
|
|
122
|
+
# under Agents::<Camelized>. Sessions stamped with the name run every turn
|
|
123
|
+
# under this scope (loop-enforced, resume-safe, thread-isolated).
|
|
124
|
+
def named_agent_scopes
|
|
125
|
+
@named_agent_scopes ||= named_agent_dirs.to_h do |dir|
|
|
126
|
+
name = File.basename(dir)
|
|
127
|
+
if RESERVED_AGENT_NAMES.include?(name)
|
|
128
|
+
raise Error, "app/agents/#{name} collides with a reserved name — " \
|
|
129
|
+
"'agent' is the root app/agent; rename the directory"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
[ name, build_agent_scope(Pathname(dir), name, const_base: "Agents::#{name.camelize}",
|
|
133
|
+
run_code: Silas.sandbox_enabled?, named: true) ]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
106
137
|
# --- subagents -----------------------------------------------------------
|
|
107
138
|
|
|
108
139
|
def subagent_dirs
|
|
@@ -133,7 +164,15 @@ module Silas
|
|
|
133
164
|
end
|
|
134
165
|
|
|
135
166
|
def build_subagent_scope(dir, name)
|
|
136
|
-
const_base
|
|
167
|
+
build_agent_scope(dir, name, const_base: "Agent::Subagents::#{name.camelize}",
|
|
168
|
+
agent: subagent_agent(dir, name))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Shared scope builder for subagents and named agents: tools by filename
|
|
172
|
+
# identity under const_base, skills, the load_skill builtin when skills
|
|
173
|
+
# exist, run_code when asked, and the scope's own digest (the same
|
|
174
|
+
# NondeterminismError guard root turns get).
|
|
175
|
+
def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false, named: false)
|
|
137
176
|
tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
|
|
138
177
|
tname = File.basename(file, ".rb")
|
|
139
178
|
klass = "#{const_base}::Tools::#{tname.camelize}".constantize
|
|
@@ -143,12 +182,19 @@ module Silas
|
|
|
143
182
|
[ tname, klass ]
|
|
144
183
|
end
|
|
145
184
|
skills = Dir[dir.join("skills/*.md")].sort.map { |f| Skill.parse(f) }
|
|
146
|
-
builtins =
|
|
185
|
+
builtins = {}
|
|
186
|
+
builtins["load_skill"] = Silas::Tools::LoadSkill if skills.any?
|
|
187
|
+
builtins["run_code"] = Silas::Tools::RunCode if run_code
|
|
188
|
+
if named && Silas.memory_enabled?
|
|
189
|
+
builtins["remember"] = Silas::Tools::Remember
|
|
190
|
+
builtins["recall"] = Silas::Tools::Recall
|
|
191
|
+
end
|
|
192
|
+
builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
|
|
147
193
|
resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
|
|
148
194
|
definitions = (tools.values + builtins.values).map(&:schema)
|
|
149
195
|
digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
|
|
150
196
|
|
|
151
|
-
Silas::AgentScope.new(name: name, dir: dir, agent:
|
|
197
|
+
Silas::AgentScope.new(name: name, dir: dir, agent: agent || Silas::Agent.load(dir: dir),
|
|
152
198
|
resolver: resolver, definitions: definitions, digest: digest, skills: skills)
|
|
153
199
|
end
|
|
154
200
|
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")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Tools
|
|
3
|
+
# Staff-to-staff composition WITHOUT free-form agent chatter: file a brief
|
|
4
|
+
# that starts (or awaits) another named agent's session, linked to this one.
|
|
5
|
+
# at_most_once! — the handoff is one external effect; a crash parks it
|
|
6
|
+
# in-doubt instead of double-starting the colleague.
|
|
7
|
+
class Handoff < Tool
|
|
8
|
+
class << self
|
|
9
|
+
# Roster from DIRECTORY NAMES only — reading scopes here would recurse
|
|
10
|
+
# (description -> digest -> scope build -> description). Names alone
|
|
11
|
+
# keep the digest roster-sensitive without the cycle.
|
|
12
|
+
def description
|
|
13
|
+
names = Dir[Rails.root.join("app/agents/*")].select { |p| File.directory?(p) }
|
|
14
|
+
.map { |p| File.basename(p) }.sort
|
|
15
|
+
"Hand a task to another staff agent as a self-contained brief (they share none of " \
|
|
16
|
+
"your context). Async by default; await: true waits for their answer. " \
|
|
17
|
+
"Staff: #{names.join(', ')}."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
param :agent, :string, desc: "Which staff agent takes this."
|
|
22
|
+
param :brief, :string, desc: "Fully self-contained task brief."
|
|
23
|
+
param :await, :boolean, desc: "true = run now and return their answer; default fire-and-forget."
|
|
24
|
+
|
|
25
|
+
at_most_once!
|
|
26
|
+
|
|
27
|
+
MAX_CHAIN = 3
|
|
28
|
+
|
|
29
|
+
def call(agent:, brief:, await: nil)
|
|
30
|
+
target = agent.to_s
|
|
31
|
+
return { "error" => "unknown agent #{target.inspect}" } unless Silas.named_agent?(target)
|
|
32
|
+
return { "error" => "an agent cannot hand off to itself" } if target == session.agent_name
|
|
33
|
+
|
|
34
|
+
chain = ancestry(session)
|
|
35
|
+
if chain.include?(target)
|
|
36
|
+
return { "error" => "handoff cycle: #{[ *chain.reverse, session.agent_name, target ].join(' -> ')}" }
|
|
37
|
+
end
|
|
38
|
+
return { "error" => "handoff chain too deep (max #{MAX_CHAIN})" } if chain.size >= MAX_CHAIN
|
|
39
|
+
|
|
40
|
+
nested = Session.create!(agent_name: target, parent_session_id: session.id,
|
|
41
|
+
metadata: { "handoff_from" => session.agent_name })
|
|
42
|
+
if await
|
|
43
|
+
# Inline drive via NestedRunner (NOT AgentLoopJob.perform_now: a
|
|
44
|
+
# Continuable job's isolated steps re-enqueue and return early under
|
|
45
|
+
# production isolate_steps — the await would see a half-run turn).
|
|
46
|
+
turn = NestedRunner.run(nested, input: brief, scope: Silas.named_agent_scope!(target))
|
|
47
|
+
{ "session_id" => nested.id, "status" => turn.status, "answer" => turn.answer_text.to_s }
|
|
48
|
+
else
|
|
49
|
+
turn = nested.continue(input: brief, enqueue: false)
|
|
50
|
+
AgentLoopJob.perform_later(turn.id)
|
|
51
|
+
{ "session_id" => nested.id, "status" => "queued" }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def ancestry(session)
|
|
58
|
+
names = []
|
|
59
|
+
cursor = session
|
|
60
|
+
while cursor.parent_session_id && names.size <= MAX_CHAIN
|
|
61
|
+
cursor = Session.find_by(id: cursor.parent_session_id) or break
|
|
62
|
+
names << cursor.agent_name
|
|
63
|
+
end
|
|
64
|
+
names
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Tools
|
|
3
|
+
# Read-side of memory: on-demand subject lookup (the injected snapshot
|
|
4
|
+
# carries only the most recent few — this digs deeper).
|
|
5
|
+
class Recall < Tool
|
|
6
|
+
description "Look up saved memories about a subject (yours + app-shared). Use before " \
|
|
7
|
+
"asking a human something the staff may already know."
|
|
8
|
+
param :subject, :string, desc: "Entity ref to look up, e.g. 'author:jane'."
|
|
9
|
+
idempotent!
|
|
10
|
+
|
|
11
|
+
def call(subject:)
|
|
12
|
+
memories = Memory.recall(agent_name: session.agent_name, subjects: [ subject ], limit: 20)
|
|
13
|
+
.select { |m| m.subject == subject.to_s.strip.downcase }
|
|
14
|
+
{ "subject" => subject, "memories" => memories.map(&:to_line) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Tools
|
|
3
|
+
# Built-in, advertised when memory is enabled and the table exists. Writes
|
|
4
|
+
# are APPROVAL-GATED by default (config.memory_approval = :always) — a
|
|
5
|
+
# memory card parks in the inbox before anything persists, eve's
|
|
6
|
+
# user-approved-memory pattern done Rails-style. transactional! — the
|
|
7
|
+
# memory row and the ledger row commit together, exactly once.
|
|
8
|
+
class Remember < Tool
|
|
9
|
+
description "Save a durable memory for future sessions. Use for stable preferences and " \
|
|
10
|
+
"facts worth keeping (\"author:jane · report_format: prefers CSV\"), never for " \
|
|
11
|
+
"transient task state. Same subject+attribute supersedes the old value."
|
|
12
|
+
param :subject, :string, desc: "Entity this is about, e.g. 'author:jane' or 'retailer:kdp'."
|
|
13
|
+
param :content, :string, desc: "The fact, one plain sentence."
|
|
14
|
+
param :attribute, :string, desc: "Optional slot name (enables supersession), e.g. 'report_format'."
|
|
15
|
+
param :shared, :boolean, desc: "true = visible to ALL agents (app scope); default private to this agent."
|
|
16
|
+
|
|
17
|
+
transactional!
|
|
18
|
+
approval ->(session:, input:) do
|
|
19
|
+
Silas.config.memory_approval == :never ? :approved : :user_approval
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(subject:, content:, attribute: nil, shared: nil)
|
|
23
|
+
memory = Memory.remember!(
|
|
24
|
+
agent_name: session.agent_name, subject:, content:, attribute:,
|
|
25
|
+
scope: shared ? "app" : "agent",
|
|
26
|
+
turn: session.turns.order(:index).last
|
|
27
|
+
)
|
|
28
|
+
{ "remembered" => memory.to_line, "scope" => memory.scope }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
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"
|
|
@@ -27,6 +28,9 @@ require "silas/budget"
|
|
|
27
28
|
require "silas/registry"
|
|
28
29
|
require "silas/agent"
|
|
29
30
|
require "silas/tools/load_skill"
|
|
31
|
+
require "silas/tools/remember"
|
|
32
|
+
require "silas/tools/recall"
|
|
33
|
+
require "silas/tools/handoff"
|
|
30
34
|
require "silas/engines/base"
|
|
31
35
|
require "silas/agent_sdk/version_guard"
|
|
32
36
|
require "silas/agent_sdk/stream_parser"
|
|
@@ -62,7 +66,13 @@ module Silas
|
|
|
62
66
|
@agent = nil
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
# A configured-but-disabled backend (e.g. Hermetic.null) must not register
|
|
70
|
+
# run_code, so the object's own enabled? is the final word.
|
|
71
|
+
def sandbox_enabled?
|
|
72
|
+
return false if [ :none, nil ].include?(config.sandbox)
|
|
73
|
+
|
|
74
|
+
resolved_sandbox.enabled?
|
|
75
|
+
end
|
|
66
76
|
|
|
67
77
|
def resolved_sandbox
|
|
68
78
|
@resolved_sandbox ||=
|
|
@@ -74,7 +84,14 @@ module Silas
|
|
|
74
84
|
pids: config.sandbox_pids, workdir: config.sandbox_workdir,
|
|
75
85
|
docker_bin: config.sandbox_docker_bin)
|
|
76
86
|
when Symbol then raise Error, "unknown sandbox #{config.sandbox.inspect}"
|
|
77
|
-
else
|
|
87
|
+
else
|
|
88
|
+
# A hermetic backend drops straight in (its Result is a superset of
|
|
89
|
+
# ours). Auto-arm its ledger guard so a sandbox exec inside a ledger
|
|
90
|
+
# transaction fails loud — same posture as our own Docker adapter.
|
|
91
|
+
if defined?(Hermetic::Backends::Base) && config.sandbox.is_a?(Hermetic::Backends::Base)
|
|
92
|
+
require "hermetic/silas"
|
|
93
|
+
end
|
|
94
|
+
config.sandbox
|
|
78
95
|
end
|
|
79
96
|
end
|
|
80
97
|
|
|
@@ -92,26 +109,68 @@ module Silas
|
|
|
92
109
|
end
|
|
93
110
|
end
|
|
94
111
|
|
|
112
|
+
# ---- scope-aware readers -------------------------------------------------
|
|
113
|
+
# Every reader consults the active AgentScope first (named agent or
|
|
114
|
+
# subagent), falling back to the boot-time config the Registry installed.
|
|
115
|
+
# The scope lives in IsolatedExecutionState — per-thread AND per-fiber
|
|
116
|
+
# (Falcon-safe), so concurrent jobs running different agents in one
|
|
117
|
+
# process can never see each other's tools.
|
|
118
|
+
|
|
95
119
|
def tool_resolver
|
|
96
|
-
|
|
120
|
+
current_scope&.resolver ||
|
|
121
|
+
config.tool_resolver or raise Error, "no tool resolver configured (Registry boots one; specs must inject)"
|
|
97
122
|
end
|
|
98
123
|
|
|
99
124
|
def tool_definitions
|
|
100
|
-
config.tool_definitions&.call || []
|
|
125
|
+
current_scope&.definitions || config.tool_definitions&.call || []
|
|
101
126
|
end
|
|
102
127
|
|
|
103
128
|
def skills
|
|
104
|
-
config.skills&.call || []
|
|
129
|
+
current_scope&.skills || config.skills&.call || []
|
|
105
130
|
end
|
|
106
131
|
|
|
107
132
|
def schedules
|
|
108
133
|
config.schedules&.call || []
|
|
109
134
|
end
|
|
110
135
|
|
|
111
|
-
# The
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
# The live definitions digest as a String (nil when none configured).
|
|
137
|
+
def definitions_digest
|
|
138
|
+
current_scope&.digest || config.definitions_digest&.call&.to_s.presence
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def instructions_dir
|
|
142
|
+
current_scope&.dir || config.instructions_dir
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The active agent definition — or, given a name, a handle for a NAMED
|
|
146
|
+
# agent (app/agents/<name>/) whose sessions run under that agent's scope:
|
|
147
|
+
#
|
|
148
|
+
# Silas.agent.start(input: "...") # the root app/agent
|
|
149
|
+
# Silas.agent(:clerk).start(input: "...") # a named staff member
|
|
150
|
+
def agent(name = nil)
|
|
151
|
+
return NamedAgent.new(named_agent_scope!(name)) if name
|
|
152
|
+
|
|
153
|
+
current_scope&.agent || config.agent_override || (@agent ||= Agent.load)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Named-agent roster: { "name" => AgentScope }.
|
|
157
|
+
def named_agent_scopes = config.named_agent_scopes&.call || {}
|
|
158
|
+
|
|
159
|
+
# Memory is on when configured AND the table exists (upgrade-safe: an app
|
|
160
|
+
# that hasn't run the 0.1.7 migration simply doesn't advertise the tools).
|
|
161
|
+
def memory_enabled?
|
|
162
|
+
config.memory && Memory.table_exists?
|
|
163
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
def named_agent?(name) = named_agent_scopes.key?(name.to_s)
|
|
167
|
+
|
|
168
|
+
def named_agent_scope!(name)
|
|
169
|
+
named_agent_scopes.fetch(name.to_s) do
|
|
170
|
+
known = named_agent_scopes.keys
|
|
171
|
+
raise Error, "unknown agent #{name.inspect}" \
|
|
172
|
+
"#{known.any? ? " (known: #{known.join(', ')})" : " — no app/agents/ directories found"}"
|
|
173
|
+
end
|
|
115
174
|
end
|
|
116
175
|
|
|
117
176
|
# Subagent roster: [[name, description], ...] (model-visible, so it's in the
|
|
@@ -120,24 +179,36 @@ module Silas
|
|
|
120
179
|
def subagent?(name) = subagent_index.any? { |n, _| n == name.to_s }
|
|
121
180
|
def subagent_scope(name) = config.subagent_scopes&.call&.fetch(name.to_s)
|
|
122
181
|
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
182
|
+
# The scope a session's turns must run under: nil for the root agent,
|
|
183
|
+
# otherwise the named-agent or subagent scope matching session.agent_name.
|
|
184
|
+
# Fails loud on an unknown name — a session pointing at a deleted agent
|
|
185
|
+
# directory must never silently run with the root agent's tools.
|
|
186
|
+
def scope_for_session(session)
|
|
187
|
+
name = session.agent_name.to_s
|
|
188
|
+
return nil if name.empty? || name == "agent"
|
|
189
|
+
|
|
190
|
+
named_agent_scopes[name] || config.subagent_scopes&.call&.[](name) or
|
|
191
|
+
raise Error, "session #{session.id} belongs to agent #{name.inspect}, " \
|
|
192
|
+
"but no app/agents/#{name} or app/agent/subagents/#{name} exists"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ---- scope switching -----------------------------------------------------
|
|
196
|
+
|
|
197
|
+
SCOPE_KEY = :silas_agent_scope
|
|
198
|
+
|
|
199
|
+
def current_scope
|
|
200
|
+
ActiveSupport::IsolatedExecutionState[SCOPE_KEY]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Run a block under an AgentScope. Nestable (delegation inside a named
|
|
204
|
+
# agent restores the outer scope on exit) and isolated per execution
|
|
205
|
+
# context — no global config is mutated, so concurrent jobs are safe.
|
|
126
206
|
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
|
|
207
|
+
previous = ActiveSupport::IsolatedExecutionState[SCOPE_KEY]
|
|
208
|
+
ActiveSupport::IsolatedExecutionState[SCOPE_KEY] = scope
|
|
138
209
|
yield
|
|
139
210
|
ensure
|
|
140
|
-
|
|
211
|
+
ActiveSupport::IsolatedExecutionState[SCOPE_KEY] = previous
|
|
141
212
|
end
|
|
142
213
|
end
|
|
143
214
|
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.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -126,6 +126,7 @@ files:
|
|
|
126
126
|
- app/mailers/silas/channel_mailer.rb
|
|
127
127
|
- app/models/concerns/silas/inbox/broadcastable.rb
|
|
128
128
|
- app/models/silas/application_record.rb
|
|
129
|
+
- app/models/silas/memory.rb
|
|
129
130
|
- app/models/silas/session.rb
|
|
130
131
|
- app/models/silas/step.rb
|
|
131
132
|
- app/models/silas/tool_invocation.rb
|
|
@@ -150,6 +151,7 @@ files:
|
|
|
150
151
|
- db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb
|
|
151
152
|
- db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb
|
|
152
153
|
- db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb
|
|
154
|
+
- db/migrate/20260721000001_create_silas_memories.rb
|
|
153
155
|
- lib/generators/silas/install/install_generator.rb
|
|
154
156
|
- lib/generators/silas/install/templates/agent.yml
|
|
155
157
|
- lib/generators/silas/install/templates/bin_ci
|
|
@@ -196,6 +198,7 @@ files:
|
|
|
196
198
|
- lib/silas/mcp/handler.rb
|
|
197
199
|
- lib/silas/mcp/server.rb
|
|
198
200
|
- lib/silas/message_builder.rb
|
|
201
|
+
- lib/silas/named_agent.rb
|
|
199
202
|
- lib/silas/nested_runner.rb
|
|
200
203
|
- lib/silas/registry.rb
|
|
201
204
|
- lib/silas/sandbox.rb
|
|
@@ -209,7 +212,10 @@ files:
|
|
|
209
212
|
- lib/silas/subprocess_runner.rb
|
|
210
213
|
- lib/silas/tool.rb
|
|
211
214
|
- lib/silas/tools/delegate.rb
|
|
215
|
+
- lib/silas/tools/handoff.rb
|
|
212
216
|
- lib/silas/tools/load_skill.rb
|
|
217
|
+
- lib/silas/tools/recall.rb
|
|
218
|
+
- lib/silas/tools/remember.rb
|
|
213
219
|
- lib/silas/tools/run_code.rb
|
|
214
220
|
- lib/silas/version.rb
|
|
215
221
|
- lib/tasks/silas_chat.rake
|