silas 0.1.6 → 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: e82a0a643fc11af7860df4d2633f6bf22b5f07626eecc2a572b6304924b20a9c
4
- data.tar.gz: 1464c694a8c8813c0d700d151a482d1c72c901e3f6690464fcf30fc0e3185e01
3
+ metadata.gz: ad5ce26e72a73ea83df134c187f8c8dedcfcc35a65f20493f3095abfbfaa7ed8
4
+ data.tar.gz: ab1989850b2b8c881da660824dcad3f71159cfffcd8648901ec2a06385d8c294
5
5
  SHA512:
6
- metadata.gz: 91d82c9f1fa784eb28cdffa635d8d04b97582d6b7201170bfc1052fcb036c43b89eab47462c6c931ca409e51595254f38cb56e336e74674bb30e3de69206e89e
7
- data.tar.gz: c90cb14c3fd89d6bf8b605b0ec3a68e4d25255b6e82ea48a5d5f190cb6b59316182cc9228353aa71d4564c94c5871b022fea6be0035986fbf04e98c3696e3cf4
6
+ metadata.gz: fb5da2cdb854533bf68a761582063e8c5b22c2e40fc02cab05240d4ea199554da317bb0509398c81923a312c7b43773834d354ffb6391be549a6254329e3b995
7
+ data.tar.gz: 57e53d9a3c5b438b941cd599ba805c2c210e303d6362599d757b58b907ea10b9401cbf918cd909d1c53590d8fee4cc9e0fef8a8fb5ccb422c26db5e0abaaf43a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
3
28
  ## 0.1.6
4
29
 
5
30
  - **Named agents — the staff pattern.** An app can now employ several
data/README.md CHANGED
@@ -160,6 +160,24 @@ default agent, unchanged. (Subagents stay a root-agent delegation feature;
160
160
  scope switching is execution-isolated, so concurrent jobs running different
161
161
  agents never cross wires.)
162
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
+
163
181
  ## Triggers
164
182
 
165
183
  An agent is reached by more than a method call:
@@ -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"
@@ -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
@@ -29,6 +29,10 @@ module Silas
29
29
 
30
30
  # Named top-level agents (app/agents/<name>/): lambda -> { name => AgentScope }.
31
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
32
36
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
33
37
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
34
38
  attr_accessor :channel_resolver
@@ -95,6 +99,9 @@ module Silas
95
99
  @eval_dir = "test/agent_evals"
96
100
  @eval_grader = nil
97
101
  @sandbox = :none
102
+ @memory = true
103
+ @memory_approval = :always
104
+ @memory_injection_limit = 8
98
105
  @sandbox_image = nil
99
106
  @sandbox_network = "none"
100
107
  @sandbox_memory = "512m"
@@ -17,7 +17,7 @@ module Silas
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
 
@@ -31,6 +31,18 @@ module Silas
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) }
@@ -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
@@ -73,6 +73,11 @@ module Silas
73
73
  b["load_skill"] = Silas::Tools::LoadSkill if skills.any?
74
74
  b["delegate"] = Silas::Tools::Delegate if subagent_dirs.any?
75
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?
76
81
  b
77
82
  end
78
83
 
@@ -125,7 +130,7 @@ module Silas
125
130
  end
126
131
 
127
132
  [ name, build_agent_scope(Pathname(dir), name, const_base: "Agents::#{name.camelize}",
128
- run_code: Silas.sandbox_enabled?) ]
133
+ run_code: Silas.sandbox_enabled?, named: true) ]
129
134
  end
130
135
  end
131
136
 
@@ -167,7 +172,7 @@ module Silas
167
172
  # identity under const_base, skills, the load_skill builtin when skills
168
173
  # exist, run_code when asked, and the scope's own digest (the same
169
174
  # NondeterminismError guard root turns get).
170
- def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false)
175
+ def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false, named: false)
171
176
  tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
172
177
  tname = File.basename(file, ".rb")
173
178
  klass = "#{const_base}::Tools::#{tname.camelize}".constantize
@@ -180,6 +185,11 @@ module Silas
180
185
  builtins = {}
181
186
  builtins["load_skill"] = Silas::Tools::LoadSkill if skills.any?
182
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
183
193
  resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
184
194
  definitions = (tools.values + builtins.values).map(&:schema)
185
195
  digest = Digest::SHA256.hexdigest(JSON.generate(tools: definitions, skills: skills.map { |s| [ s.name, s.description ] }))
@@ -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.6"
2
+ VERSION = "0.1.7"
3
3
  end
data/lib/silas.rb CHANGED
@@ -28,6 +28,9 @@ require "silas/budget"
28
28
  require "silas/registry"
29
29
  require "silas/agent"
30
30
  require "silas/tools/load_skill"
31
+ require "silas/tools/remember"
32
+ require "silas/tools/recall"
33
+ require "silas/tools/handoff"
31
34
  require "silas/engines/base"
32
35
  require "silas/agent_sdk/version_guard"
33
36
  require "silas/agent_sdk/stream_parser"
@@ -152,6 +155,14 @@ module Silas
152
155
 
153
156
  # Named-agent roster: { "name" => AgentScope }.
154
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
155
166
  def named_agent?(name) = named_agent_scopes.key?(name.to_s)
156
167
 
157
168
  def named_agent_scope!(name)
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.6
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
@@ -210,7 +212,10 @@ files:
210
212
  - lib/silas/subprocess_runner.rb
211
213
  - lib/silas/tool.rb
212
214
  - lib/silas/tools/delegate.rb
215
+ - lib/silas/tools/handoff.rb
213
216
  - lib/silas/tools/load_skill.rb
217
+ - lib/silas/tools/recall.rb
218
+ - lib/silas/tools/remember.rb
214
219
  - lib/silas/tools/run_code.rb
215
220
  - lib/silas/version.rb
216
221
  - lib/tasks/silas_chat.rake