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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7aa3f984d996b0eee69689f4009e3d43bd46d63caaf2144f0a4082b40d0ae918
4
- data.tar.gz: 8c9b38ac7dae0a0ea510375046447442087a3a1536431199a8bf54677fca80b3
3
+ metadata.gz: ad5ce26e72a73ea83df134c187f8c8dedcfcc35a65f20493f3095abfbfaa7ed8
4
+ data.tar.gz: ab1989850b2b8c881da660824dcad3f71159cfffcd8648901ec2a06385d8c294
5
5
  SHA512:
6
- metadata.gz: f3136bfb1276a84217f76733e1c718715b8dbed5794fe766f72383cf58560c50950c6cdb12932d89108c6c6de73bd16b1c2c1b915841bb1829b47c102d03f0f5
7
- data.tar.gz: 5af58996877a0bb1bf6d64fbf2ef30c141462c8d00a7f37ebbaaca4dbf3518cd28c4ead9557d4ee2d5a23144748632f053e38b908238b39c64577ce9f0d355fd
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** (the sandbox is an interim Docker seam, not a microVM). The full pitch
14
+ yourself** by default for untrusted or model-generated code, drop in the
15
+ companion gem [hermetic](https://github.com/danielstpaul/hermetic) (gVisor /
16
+ Firecracker / hosted sandboxes behind one call, see below). The full pitch
15
17
  and the honest caveats: [Why Silas](docs/why-silas.md) ·
16
18
  [Silas vs eve](docs/vs-eve.md).
17
19
 
@@ -108,6 +110,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
@@ -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
- def continue(input:)
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
- template "ruby_llm.rb", "config/initializers/ruby_llm.rb"
11
+ # first model call raises ConfigurationError. Brownfield apps often
12
+ # already configure ruby_llm themselves — never touch an existing one
13
+ # (not even a conflict prompt: an accidental Y clobbers production
14
+ # provider config).
15
+ if File.exist?(File.join(destination_root, "config/initializers/ruby_llm.rb"))
16
+ say_status :skip, "config/initializers/ruby_llm.rb exists — left untouched", :yellow
17
+ else
18
+ template "ruby_llm.rb", "config/initializers/ruby_llm.rb"
19
+ end
13
20
  end
14
21
 
15
22
  def create_agent_directory
data/lib/silas/chat.rb CHANGED
@@ -43,9 +43,15 @@ module Silas
43
43
 
44
44
  private
45
45
 
46
+ # AGENT=clerk resumes/starts against a named agent (app/agents/clerk/).
47
+ def agent_handle
48
+ @agent_handle ||= ENV["AGENT"].present? ? Silas.agent(ENV["AGENT"]) : Silas.agent
49
+ end
50
+
46
51
  def banner
47
- description = Silas.agent.description.presence || "your agent"
48
- @out.puts "Silas chat #{description} (#{Silas.agent.model})."
52
+ description = agent_handle.description.presence || "your agent"
53
+ label = ENV["AGENT"].present? ? "#{ENV['AGENT']} " : ""
54
+ @out.puts "Silas chat — #{label}#{description} (#{agent_handle.model})."
49
55
  @out.puts "Approvals prompt inline. 'exit' or Ctrl-D to quit."
50
56
  @out.puts "Resuming session #{@session.id} (#{@session.turns.count} turns)." if @session
51
57
  end
@@ -53,7 +59,7 @@ module Silas
53
59
  def submit(text)
54
60
  turn =
55
61
  if @session.nil?
56
- @session = Silas.agent.start(input: text)
62
+ @session = agent_handle.start(input: text)
57
63
  @session.turns.first
58
64
  else
59
65
  @session.continue(input: text)
@@ -26,6 +26,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
- next unless agent_dir.exist?
12
+ if agent_dir.exist?
13
+ unless defined?(::Agent)
14
+ Object.const_set(:Agent, Module.new)
15
+ end
16
+ app.autoloaders.main.push_dir(agent_dir, namespace: ::Agent)
13
17
 
14
- unless defined?(::Agent)
15
- Object.const_set(:Agent, Module.new)
18
+ # Markdown/YAML (instructions.md, agent.yml, skills/*.md) are not Ruby;
19
+ # keep Zeitwerk away from them. tools/, schedules/ (.rb handlers), and
20
+ # channels/ all autoload under the Agent namespace via push_dir above.
21
+ app.autoloaders.main.ignore(agent_dir.join("skills"))
16
22
  end
17
- app.autoloaders.main.push_dir(agent_dir, namespace: ::Agent)
18
23
 
19
- # Markdown/YAML (instructions.md, agent.yml, skills/*.md) are not Ruby;
20
- # keep Zeitwerk away from them. tools/, schedules/ (.rb handlers), and
21
- # channels/ all autoload under the Agent namespace via push_dir above.
22
- app.autoloaders.main.ignore(agent_dir.join("skills"))
24
+ # Named agents: app/agents/<name>/tools/x.rb autoloads as
25
+ # Agents::<Name>::Tools::X the staff pattern.
26
+ agents_dir = app.root.join("app/agents")
27
+ if agents_dir.exist?
28
+ unless defined?(::Agents)
29
+ Object.const_set(:Agents, Module.new)
30
+ end
31
+ app.autoloaders.main.push_dir(agents_dir, namespace: ::Agents)
32
+ Dir[agents_dir.join("*/skills")].each { |d| app.autoloaders.main.ignore(d) }
33
+ end
23
34
  end
24
35
 
25
36
  initializer "silas.boot_guard", after: :load_config_initializers do
@@ -28,7 +39,7 @@ module Silas
28
39
 
29
40
  # Registry rebuilds on every code reload in development, once in production.
30
41
  initializer "silas.registry" do |app|
31
- next unless app.root.join("app/agent").exist?
42
+ next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
32
43
 
33
44
  app.config.to_prepare { Silas::Registry.install!(root: Rails.root) }
34
45
  end
@@ -12,25 +12,37 @@ module Silas
12
12
 
13
13
  turn.update!(
14
14
  instructions_snapshot: render(turn),
15
- definitions_digest: Silas.config.definitions_digest&.call.to_s
15
+ definitions_digest: Silas.definitions_digest.to_s
16
16
  )
17
17
  end
18
18
 
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.config.instructions_dir || Rails.root.join("app/agent")
27
+ dir = Silas.instructions_dir || Rails.root.join("app/agent")
28
28
  path = Pathname(dir).join("instructions.md")
29
29
  return default_instructions unless path.exist?
30
30
 
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
- policy.call(session: invocation.turn.session, input: invocation.arguments)
151
+ # Indifferent access: arguments are stored as jsonb (string keys),
152
+ # but a lambda writing input[:amount] must not get a silent nil.
153
+ policy.call(session: invocation.turn.session,
154
+ input: invocation.arguments.with_indifferent_access)
152
155
  else
153
156
  :not_applicable
154
157
  end
@@ -0,0 +1,32 @@
1
+ module Silas
2
+ # Handle for a named top-level agent (app/agents/<name>/). Same surface as
3
+ # the root Agent — #start plus the definition readers — so call sites don't
4
+ # care which kind they hold. The only difference: sessions it starts are
5
+ # stamped with the agent's name, and the loop swaps in the agent's scope
6
+ # (tools, skills, instructions, digest) for every turn of those sessions.
7
+ class NamedAgent
8
+ attr_reader :scope
9
+
10
+ def initialize(scope)
11
+ @scope = scope
12
+ end
13
+
14
+ def name = scope.name
15
+
16
+ def start(input:, metadata: {}, channel: nil, continuation_token: nil)
17
+ session = Session.create!(agent_name: name, metadata: metadata,
18
+ channel: channel, continuation_token: continuation_token)
19
+ session.continue(input: input)
20
+ session
21
+ end
22
+
23
+ # Definition readers delegate to the scope's parsed agent.yml.
24
+ def model = scope.agent.model
25
+ def description = scope.agent.description
26
+ def limits = scope.agent.limits
27
+ def max_steps = scope.agent.max_steps
28
+ def max_input_tokens = scope.agent.max_input_tokens
29
+ def max_cost = scope.agent.max_cost
30
+ def timeout = scope.agent.timeout
31
+ end
32
+ end
@@ -10,8 +10,8 @@ module Silas
10
10
  module NestedRunner
11
11
  module_function
12
12
 
13
- def run(session, input:)
14
- scope = Silas.subagent_scope(session.agent_name)
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
@@ -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 = "Agent::Subagents::#{name.camelize}"
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 = skills.any? ? { "load_skill" => Silas::Tools::LoadSkill } : {}
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: subagent_agent(dir, name),
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
@@ -70,9 +70,9 @@ module Silas
70
70
  # A deploy that changes tools/skills mid-turn must fail loudly, never
71
71
  # resume into a different agent than the one that started the turn.
72
72
  def assert_definitions_unchanged!(turn)
73
- return if turn.definitions_digest.blank? || Silas.config.definitions_digest.nil?
73
+ return if turn.definitions_digest.blank? || Silas.definitions_digest.nil?
74
74
 
75
- live = Silas.config.definitions_digest.call.to_s
75
+ live = Silas.definitions_digest.to_s
76
76
  return if live == turn.definitions_digest
77
77
 
78
78
  turn.finish!(:failed, reason: "definitions_changed")
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.7"
3
3
  end
data/lib/silas.rb CHANGED
@@ -12,6 +12,7 @@ require "silas/skill"
12
12
  require "silas/schedule"
13
13
  require "silas/schedule/compiler"
14
14
  require "silas/agent_scope"
15
+ require "silas/named_agent"
15
16
  require "silas/tools/delegate"
16
17
  require "silas/nested_runner"
17
18
  require "silas/sandbox"
@@ -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
- def sandbox_enabled? = ![ :none, nil ].include?(config.sandbox)
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 config.sandbox
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
- config.tool_resolver or raise Error, "no tool resolver configured (Registry boots one; specs must inject)"
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 active agent definition. config.agent_override is set during a nested
112
- # subagent run; otherwise it's the root app/agent.
113
- def agent
114
- config.agent_override || (@agent ||= Agent.load)
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
- # Run a block with a subagent's scope swapped in as the active globals,
124
- # restoring afterward. Synchronous/depth-1 safe because delegation runs
125
- # inline on one thread while the parent loop is paused in the delegate tool.
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
- saved = {
128
- tool_resolver: config.tool_resolver, tool_definitions: config.tool_definitions,
129
- definitions_digest: config.definitions_digest, skills: config.skills,
130
- agent_override: config.agent_override, instructions_dir: config.instructions_dir
131
- }
132
- config.tool_resolver = scope.resolver
133
- config.tool_definitions = -> { scope.definitions }
134
- config.definitions_digest = -> { scope.digest }
135
- config.skills = -> { scope.skills }
136
- config.agent_override = scope.agent
137
- config.instructions_dir = scope.dir
207
+ previous = ActiveSupport::IsolatedExecutionState[SCOPE_KEY]
208
+ ActiveSupport::IsolatedExecutionState[SCOPE_KEY] = scope
138
209
  yield
139
210
  ensure
140
- saved.each { |k, v| config.public_send("#{k}=", v) }
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.5
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