silas 0.1.6 → 0.2.0
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 +118 -0
- data/README.md +55 -14
- data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
- data/app/controllers/silas/inbox/turns_controller.rb +32 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +1 -0
- data/app/views/layouts/silas/inbox.html.erb +14 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +20 -2
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +6 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/config/routes.rb +6 -1
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/lib/generators/silas/install/install_generator.rb +33 -14
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +29 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/chat.rb +45 -13
- data/lib/silas/configuration.rb +75 -24
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +15 -4
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/instructions.rb +13 -1
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +12 -2
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- 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 +20 -9
- metadata +10 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
|
@@ -4,8 +4,6 @@ module Silas
|
|
|
4
4
|
# eval script the MODEL's decisions while the REAL Ledger runs the REAL tools —
|
|
5
5
|
# so assertions see a genuine transcript.
|
|
6
6
|
class ScriptedEngine < Silas::Engines::Base
|
|
7
|
-
def self.loop_ownership = :framework
|
|
8
|
-
|
|
9
7
|
attr_reader :calls
|
|
10
8
|
|
|
11
9
|
def initialize(steps)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# Streams accumulated step text into the live trace as it arrives.
|
|
4
|
+
#
|
|
5
|
+
# Synchronous on purpose: broadcast_update_to, not _later — the row
|
|
6
|
+
# broadcasts ride ActiveJob because they are rare; a delta batch every
|
|
7
|
+
# ~100ms per running turn would swamp the queue. The push happens on the
|
|
8
|
+
# worker thread mid-model-call, so it is wrapped: a cable/render failure
|
|
9
|
+
# can NEVER re-raise into the durable loop.
|
|
10
|
+
#
|
|
11
|
+
# Deltas are decoration. The authoritative after_commit row render replaces
|
|
12
|
+
# the whole step partial (dom_id target) and supersedes anything streamed
|
|
13
|
+
# into the inner text container.
|
|
14
|
+
module DeltaBroadcaster
|
|
15
|
+
EVENT = "silas.delta".freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def subscribe!
|
|
19
|
+
@subscription ||= ActiveSupport::Notifications.subscribe(EVENT) do |*args|
|
|
20
|
+
broadcast(args.last)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def broadcast(payload)
|
|
25
|
+
return unless Silas::Inbox.streaming?
|
|
26
|
+
|
|
27
|
+
Turbo::StreamsChannel.broadcast_update_to(
|
|
28
|
+
Silas::Inbox.stream_name(payload[:session_id]),
|
|
29
|
+
target: "silas-step-#{payload[:step_id]}-text",
|
|
30
|
+
html: ERB::Util.html_escape(payload[:text]) # plain text; the row render owns formatting
|
|
31
|
+
)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Rails.logger&.warn("[silas.inbox] delta broadcast failed: #{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
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/ledger.rb
CHANGED
|
@@ -20,10 +20,16 @@ module Silas
|
|
|
20
20
|
GUARD_KEY = :silas_ledger_transaction
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
|
-
# True while a ledger transaction is open
|
|
24
|
-
# checkpoint inside would raise Interrupt and roll back
|
|
25
|
-
# progress (spike finding #5) — AgentLoopJob asserts
|
|
26
|
-
|
|
23
|
+
# True while a ledger transaction is open in this execution context. A
|
|
24
|
+
# continuation checkpoint inside would raise Interrupt and roll back
|
|
25
|
+
# committed-looking progress (spike finding #5) — AgentLoopJob asserts
|
|
26
|
+
# against this. Stored in IsolatedExecutionState (not Thread.current[],
|
|
27
|
+
# which is fiber-local) so the guard follows the app's configured
|
|
28
|
+
# isolation level, exactly like Silas.current_scope — under the default
|
|
29
|
+
# :thread isolation it survives into internally-created fibers
|
|
30
|
+
# (enumerators, streaming bodies) where a fiber-local flag would
|
|
31
|
+
# silently vanish.
|
|
32
|
+
def in_transaction? = ActiveSupport::IsolatedExecutionState[GUARD_KEY] == true
|
|
27
33
|
|
|
28
34
|
def assert_no_checkpoint!
|
|
29
35
|
return unless in_transaction?
|
|
@@ -50,9 +56,9 @@ module Silas
|
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
# Drive a SINGLE freshly-created invocation to a terminal state — the
|
|
53
|
-
#
|
|
54
|
-
# exactly the same exactly-once/effect-mode machinery as
|
|
55
|
-
# :done or :parked; the invocation carries its .result.
|
|
59
|
+
# hosted MCP endpoint (Mcp::Handler) creates one invocation per tools/call
|
|
60
|
+
# and needs exactly the same exactly-once/effect-mode machinery as
|
|
61
|
+
# settle!. Returns :done or :parked; the invocation carries its .result.
|
|
56
62
|
def execute_invocation!(invocation, resolver:)
|
|
57
63
|
settle_invocation!(invocation, resolver)
|
|
58
64
|
end
|
|
@@ -157,12 +163,18 @@ module Silas
|
|
|
157
163
|
end
|
|
158
164
|
end
|
|
159
165
|
|
|
166
|
+
# :once is scoped to tool name AND arguments. Name-only matching was a
|
|
167
|
+
# footgun: approving a £5 refund would silently auto-approve a £5,000
|
|
168
|
+
# refund later in the same session. Identical repeat calls still skip
|
|
169
|
+
# re-approval; anything else re-parks. Graded gates (thresholds, ranges)
|
|
170
|
+
# belong in an approval lambda, not :once. (Hash#== is order-independent,
|
|
171
|
+
# so jsonb key order can't produce false negatives.)
|
|
160
172
|
def previously_approved?(invocation)
|
|
161
173
|
ToolInvocation.joins(:turn)
|
|
162
174
|
.where(silas_turns: { session_id: invocation.turn.session_id },
|
|
163
175
|
tool_name: invocation.tool_name, approval_state: "approved")
|
|
164
176
|
.where.not(id: invocation.id)
|
|
165
|
-
.
|
|
177
|
+
.any? { |prior| prior.arguments == invocation.arguments }
|
|
166
178
|
end
|
|
167
179
|
|
|
168
180
|
# Compare-and-swap claim: only one racing execution wins.
|
|
@@ -173,11 +185,15 @@ module Silas
|
|
|
173
185
|
claimed
|
|
174
186
|
end
|
|
175
187
|
|
|
188
|
+
# Save/restore, not set/clear: a nested guarded_transaction must not
|
|
189
|
+
# clobber the outer guard on exit (the old `ensure ... = false` opened a
|
|
190
|
+
# checkpoint-guard hole for the remainder of the outer transaction).
|
|
176
191
|
def guarded_transaction
|
|
177
|
-
|
|
192
|
+
previous = ActiveSupport::IsolatedExecutionState[GUARD_KEY]
|
|
193
|
+
ActiveSupport::IsolatedExecutionState[GUARD_KEY] = true
|
|
178
194
|
ApplicationRecord.transaction { yield }
|
|
179
195
|
ensure
|
|
180
|
-
|
|
196
|
+
ActiveSupport::IsolatedExecutionState[GUARD_KEY] = previous
|
|
181
197
|
end
|
|
182
198
|
|
|
183
199
|
def wrap_result(result)
|
data/lib/silas/mcp/handler.rb
CHANGED
|
@@ -4,9 +4,9 @@ require "securerandom"
|
|
|
4
4
|
module Silas
|
|
5
5
|
module Mcp
|
|
6
6
|
# JSON-RPC handler for the hosted MCP endpoint. tools/call runs the tool
|
|
7
|
-
# THROUGH the Ledger, so
|
|
8
|
-
# effect-mode semantics as
|
|
9
|
-
# authenticated by a
|
|
7
|
+
# THROUGH the Ledger, so a remote MCP caller gets the same exactly-once and
|
|
8
|
+
# effect-mode semantics as the in-process loop. Closes over one turn + its
|
|
9
|
+
# anchor step; authenticated by a bearer token in the URL query.
|
|
10
10
|
class Handler
|
|
11
11
|
TOOL_PREFIX = "mcp__silas__".freeze
|
|
12
12
|
|
|
@@ -50,8 +50,9 @@ module Silas
|
|
|
50
50
|
invocation.reload
|
|
51
51
|
|
|
52
52
|
if outcome == :parked
|
|
53
|
-
#
|
|
54
|
-
|
|
53
|
+
# The hosted endpoint excludes approval-gated tools; if one slips
|
|
54
|
+
# through, fail loud rather than park a caller that can't wait.
|
|
55
|
+
{ "isError" => true, "content" => [ text_content("approval-gated tools are not supported over the hosted MCP endpoint") ] }
|
|
55
56
|
else
|
|
56
57
|
{ "content" => [ text_content(JSON.generate(invocation.result)) ] }
|
|
57
58
|
end
|
data/lib/silas/mcp/server.rb
CHANGED
|
@@ -2,20 +2,20 @@ require "socket"
|
|
|
2
2
|
|
|
3
3
|
module Silas
|
|
4
4
|
module Mcp
|
|
5
|
-
# A minimal HTTP/1.1 server hosting the MCP Handler
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
5
|
+
# A minimal HTTP/1.1 server hosting the MCP Handler in-process. Deliberately
|
|
6
|
+
# NOT Puma/Rack: the transport MCP clients need (verified in the spike) is
|
|
7
|
+
# plain request/response JSON — one request per connection,
|
|
8
|
+
# application/json, no SSE — so a raw threaded TCPServer is fewer moving
|
|
9
|
+
# parts than embedding an app server in a worker.
|
|
10
10
|
#
|
|
11
11
|
# In-process: the Handler has direct access to the Ledger/models, so no
|
|
12
|
-
# cross-service call is needed and it works on any worker box.
|
|
12
|
+
# cross-service call is needed and it works on any worker box. This is the
|
|
13
|
+
# seam for the "mount your agent's tools as an MCP server" feature.
|
|
13
14
|
class Server
|
|
14
15
|
attr_reader :port
|
|
15
16
|
|
|
16
|
-
def self.start(turn:, step:, tools:, resolver:, host: Silas.config.
|
|
17
|
-
token = SecureRandom.hex(16)
|
|
18
|
-
turn.update_columns(mcp_token: token)
|
|
17
|
+
def self.start(turn:, step:, tools:, resolver:, host: Silas.config.mcp_server_host)
|
|
18
|
+
token = SecureRandom.hex(16) # minted and compared in memory only
|
|
19
19
|
handler = Handler.new(turn: turn, step: step, tools: tools, resolver: resolver, token: token)
|
|
20
20
|
new(handler: handler, turn: turn, token: token, host: host).tap(&:boot)
|
|
21
21
|
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
|
@@ -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 ] }))
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Silas
|
|
|
11
11
|
step = Step.find_or_create_by!(turn: turn, index: index)
|
|
12
12
|
|
|
13
13
|
unless step.completed?
|
|
14
|
-
result = execute_model_call(turn, index)
|
|
14
|
+
result = execute_model_call(turn, index, step)
|
|
15
15
|
|
|
16
16
|
# One transaction: the step's response, its terminal verdict, and the
|
|
17
17
|
# pending ledger rows commit together — or none of them do.
|
|
@@ -48,7 +48,7 @@ module Silas
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def execute_model_call(turn, index)
|
|
51
|
+
def execute_model_call(turn, index, step)
|
|
52
52
|
assert_definitions_unchanged!(turn)
|
|
53
53
|
engine = Silas.resolved_engine
|
|
54
54
|
context = {
|
|
@@ -60,10 +60,25 @@ module Silas
|
|
|
60
60
|
model: turn_model(turn),
|
|
61
61
|
limits: { max_steps: Silas.agent.max_steps }
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
|
|
64
|
+
# Live deltas: the engine yields Events, the buffer coalesces them into
|
|
65
|
+
# "silas.delta" notifications. A replayed step never reaches this method
|
|
66
|
+
# (the completed? guard above), so replay emits nothing. The emitter is
|
|
67
|
+
# created HERE and closed over by the inner block, so around_model_call
|
|
68
|
+
# hooks keep their existing one-argument contract and can't swallow it.
|
|
69
|
+
buffer = DeltaBuffer.new(turn: turn, step: step)
|
|
70
|
+
emitter = ->(event) { buffer.append(event.payload[:text].to_s) if event.type == :text_delta }
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
if (hook = Silas.config.around_model_call)
|
|
74
|
+
hook.call(context) { engine.execute_step(context, &emitter) }
|
|
75
|
+
else
|
|
76
|
+
engine.execute_step(context, &emitter)
|
|
77
|
+
end
|
|
78
|
+
ensure
|
|
79
|
+
# Tail flush BEFORE the step row commits — the authoritative
|
|
80
|
+
# after_commit render must never race a straggling delta batch.
|
|
81
|
+
buffer.finish
|
|
67
82
|
end
|
|
68
83
|
end
|
|
69
84
|
|
data/lib/silas/tool.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Silas
|
|
|
7
7
|
# approval :always # :never | :once | :always | lambda
|
|
8
8
|
# transactional! # or at_most_once! (default) / idempotent!
|
|
9
9
|
#
|
|
10
|
+
# :once approves ONE (tool, arguments) pair per session — an identical repeat
|
|
11
|
+
# call skips re-approval; different arguments park again. For graded gates
|
|
12
|
+
# (e.g. auto-approve under a threshold) use a lambda:
|
|
13
|
+
# approval ->(session:, input:) { input[:amount] > 5000 ? :user_approval : :approved }
|
|
14
|
+
#
|
|
10
15
|
# def call(order_id:, amount:, note: nil)
|
|
11
16
|
# ...
|
|
12
17
|
# end
|
|
@@ -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
|
@@ -24,23 +24,23 @@ require "silas/connection"
|
|
|
24
24
|
require "silas/connections"
|
|
25
25
|
require "silas/inbox"
|
|
26
26
|
require "silas/inbox/cost"
|
|
27
|
+
require "silas/inbox/delta_broadcaster"
|
|
28
|
+
require "silas/delta_buffer"
|
|
27
29
|
require "silas/budget"
|
|
28
30
|
require "silas/registry"
|
|
29
31
|
require "silas/agent"
|
|
30
32
|
require "silas/tools/load_skill"
|
|
31
|
-
require "silas/
|
|
32
|
-
require "silas/
|
|
33
|
-
require "silas/
|
|
34
|
-
require "silas/agent_sdk/cli"
|
|
33
|
+
require "silas/tools/remember"
|
|
34
|
+
require "silas/tools/recall"
|
|
35
|
+
require "silas/tools/handoff"
|
|
35
36
|
require "silas/mcp/handler"
|
|
36
37
|
require "silas/mcp/server"
|
|
37
|
-
require "silas/engines/
|
|
38
|
+
require "silas/engines/base"
|
|
38
39
|
require "ruby_llm"
|
|
39
40
|
require "silas/engines/ruby_llm"
|
|
40
41
|
require "silas/message_builder"
|
|
41
42
|
require "silas/instructions"
|
|
42
43
|
require "silas/step_runner"
|
|
43
|
-
require "silas/subprocess_runner"
|
|
44
44
|
require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
|
|
45
45
|
require "silas/chat"
|
|
46
46
|
|
|
@@ -94,13 +94,16 @@ module Silas
|
|
|
94
94
|
|
|
95
95
|
def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
|
|
96
96
|
|
|
97
|
-
# The inference adapter instance. config.engine may be
|
|
98
|
-
#
|
|
97
|
+
# The inference adapter instance. config.engine may be :ruby_llm or any
|
|
98
|
+
# object responding to #execute_step (specs, custom).
|
|
99
99
|
def resolved_engine
|
|
100
100
|
@resolved_engine ||=
|
|
101
101
|
case config.engine
|
|
102
102
|
when :ruby_llm then Engines::RubyLLM.new
|
|
103
|
-
when :agent_sdk
|
|
103
|
+
when :agent_sdk
|
|
104
|
+
raise Error, "the :agent_sdk engine was removed in Silas 0.2 — the claude -p " \
|
|
105
|
+
"subprocess integration is gone (its subscription-auth rationale was " \
|
|
106
|
+
"unreachable). Use engine :ruby_llm, the production path."
|
|
104
107
|
when Symbol then raise Error, "unknown engine #{config.engine.inspect}"
|
|
105
108
|
else config.engine
|
|
106
109
|
end
|
|
@@ -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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -117,6 +117,7 @@ files:
|
|
|
117
117
|
- app/controllers/silas/inbox/base_controller.rb
|
|
118
118
|
- app/controllers/silas/inbox/invocations_controller.rb
|
|
119
119
|
- app/controllers/silas/inbox/sessions_controller.rb
|
|
120
|
+
- app/controllers/silas/inbox/turns_controller.rb
|
|
120
121
|
- app/helpers/silas/inbox/trace_helper.rb
|
|
121
122
|
- app/jobs/silas/agent_loop_job.rb
|
|
122
123
|
- app/jobs/silas/channel_delivery_job.rb
|
|
@@ -126,6 +127,7 @@ files:
|
|
|
126
127
|
- app/mailers/silas/channel_mailer.rb
|
|
127
128
|
- app/models/concerns/silas/inbox/broadcastable.rb
|
|
128
129
|
- app/models/silas/application_record.rb
|
|
130
|
+
- app/models/silas/memory.rb
|
|
129
131
|
- app/models/silas/session.rb
|
|
130
132
|
- app/models/silas/step.rb
|
|
131
133
|
- app/models/silas/tool_invocation.rb
|
|
@@ -150,6 +152,8 @@ files:
|
|
|
150
152
|
- db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb
|
|
151
153
|
- db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb
|
|
152
154
|
- db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb
|
|
155
|
+
- db/migrate/20260721000001_create_silas_memories.rb
|
|
156
|
+
- db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb
|
|
153
157
|
- lib/generators/silas/install/install_generator.rb
|
|
154
158
|
- lib/generators/silas/install/templates/agent.yml
|
|
155
159
|
- lib/generators/silas/install/templates/bin_ci
|
|
@@ -165,17 +169,14 @@ files:
|
|
|
165
169
|
- lib/silas.rb
|
|
166
170
|
- lib/silas/agent.rb
|
|
167
171
|
- lib/silas/agent_scope.rb
|
|
168
|
-
- lib/silas/agent_sdk/cli.rb
|
|
169
|
-
- lib/silas/agent_sdk/stream_parser.rb
|
|
170
|
-
- lib/silas/agent_sdk/version_guard.rb
|
|
171
172
|
- lib/silas/budget.rb
|
|
172
173
|
- lib/silas/channel.rb
|
|
173
174
|
- lib/silas/chat.rb
|
|
174
175
|
- lib/silas/configuration.rb
|
|
175
176
|
- lib/silas/connection.rb
|
|
176
177
|
- lib/silas/connections.rb
|
|
178
|
+
- lib/silas/delta_buffer.rb
|
|
177
179
|
- lib/silas/engine.rb
|
|
178
|
-
- lib/silas/engines/agent_sdk.rb
|
|
179
180
|
- lib/silas/engines/base.rb
|
|
180
181
|
- lib/silas/engines/ruby_llm.rb
|
|
181
182
|
- lib/silas/errors.rb
|
|
@@ -190,6 +191,7 @@ files:
|
|
|
190
191
|
- lib/silas/eval/transcript.rb
|
|
191
192
|
- lib/silas/inbox.rb
|
|
192
193
|
- lib/silas/inbox/cost.rb
|
|
194
|
+
- lib/silas/inbox/delta_broadcaster.rb
|
|
193
195
|
- lib/silas/instructions.rb
|
|
194
196
|
- lib/silas/ledger.rb
|
|
195
197
|
- lib/silas/mcp/client.rb
|
|
@@ -207,10 +209,12 @@ files:
|
|
|
207
209
|
- lib/silas/skill.rb
|
|
208
210
|
- lib/silas/slack.rb
|
|
209
211
|
- lib/silas/step_runner.rb
|
|
210
|
-
- lib/silas/subprocess_runner.rb
|
|
211
212
|
- lib/silas/tool.rb
|
|
212
213
|
- lib/silas/tools/delegate.rb
|
|
214
|
+
- lib/silas/tools/handoff.rb
|
|
213
215
|
- lib/silas/tools/load_skill.rb
|
|
216
|
+
- lib/silas/tools/recall.rb
|
|
217
|
+
- lib/silas/tools/remember.rb
|
|
214
218
|
- lib/silas/tools/run_code.rb
|
|
215
219
|
- lib/silas/version.rb
|
|
216
220
|
- lib/tasks/silas_chat.rake
|
data/lib/silas/agent_sdk/cli.rb
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
|
|
3
|
-
module Silas
|
|
4
|
-
module AgentSdk
|
|
5
|
-
# Builds the verified claude -p argv and manages the subprocess. --bare is
|
|
6
|
-
# the API-key-only auth guard (never subscription OAuth); alwaysLoad + a
|
|
7
|
-
# non-zero MCP_TIMEOUT are BOTH mandatory or the single-shot -p turn races
|
|
8
|
-
# ahead of the MCP handshake and never sees the tools (spike trap #1).
|
|
9
|
-
class Cli
|
|
10
|
-
def initialize(bin:, prompt:, system:, model:, mcp_url:, allowed:, resume_session_id: nil)
|
|
11
|
-
@bin = bin
|
|
12
|
-
@prompt = prompt
|
|
13
|
-
@system = system
|
|
14
|
-
@model = model
|
|
15
|
-
@mcp_url = mcp_url
|
|
16
|
-
@allowed = allowed
|
|
17
|
-
@resume_session_id = resume_session_id
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Spawns claude, yields each stdout line, returns the exit status integer.
|
|
21
|
-
def stream
|
|
22
|
-
@io = IO.popen(env, argv, err: %i[child out], pgroup: true)
|
|
23
|
-
@pid = @io.pid
|
|
24
|
-
@io.each_line { |line| yield line }
|
|
25
|
-
@io.close
|
|
26
|
-
$?.exitstatus
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def terminate
|
|
30
|
-
return unless @pid
|
|
31
|
-
|
|
32
|
-
Process.kill("-TERM", @pid)
|
|
33
|
-
Process.kill("-KILL", @pid)
|
|
34
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
35
|
-
# already gone
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def argv
|
|
39
|
-
args = [ @bin, "-p", @prompt,
|
|
40
|
-
"--output-format", "stream-json", "--verbose", "--bare",
|
|
41
|
-
"--mcp-config", mcp_config_json,
|
|
42
|
-
"--allowedTools", @allowed.join(","),
|
|
43
|
-
"--model", @model ]
|
|
44
|
-
args += [ "--append-system-prompt", @system ] if @system.present?
|
|
45
|
-
args += [ "--resume", @resume_session_id ] if @resume_session_id.present?
|
|
46
|
-
args
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def mcp_config_json
|
|
50
|
-
JSON.generate("mcpServers" => { "silas" => { "type" => "http", "url" => @mcp_url, "alwaysLoad" => true } })
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def env
|
|
54
|
-
{ "ANTHROPIC_API_KEY" => ENV["ANTHROPIC_API_KEY"],
|
|
55
|
-
"MCP_TIMEOUT" => Silas.config.agent_sdk_mcp_timeout_ms.to_s }
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|