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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +18 -0
- data/app/models/silas/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/lib/silas/configuration.rb +7 -0
- data/lib/silas/instructions.rb +13 -1
- data/lib/silas/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +12 -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 +11 -0
- metadata +6 -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,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
|
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"
|
|
@@ -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
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -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"
|
data/lib/silas/instructions.rb
CHANGED
|
@@ -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) }
|
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
|
@@ -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
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.
|
|
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
|