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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +55 -14
  4. data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
  5. data/app/controllers/silas/inbox/turns_controller.rb +32 -0
  6. data/app/jobs/silas/agent_loop_job.rb +46 -43
  7. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  8. data/app/models/silas/memory.rb +51 -0
  9. data/app/models/silas/session.rb +5 -3
  10. data/app/models/silas/tool_invocation.rb +13 -1
  11. data/app/models/silas/turn.rb +1 -0
  12. data/app/views/layouts/silas/inbox.html.erb +14 -0
  13. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  14. data/app/views/silas/inbox/sessions/index.html.erb +20 -2
  15. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  16. data/app/views/silas/inbox/steps/_step.html.erb +6 -1
  17. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  18. data/config/routes.rb +6 -1
  19. data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
  20. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  21. data/lib/generators/silas/install/install_generator.rb +33 -14
  22. data/lib/generators/silas/install/templates/bin_ci +2 -2
  23. data/lib/generators/silas/install/templates/initializer.rb +29 -5
  24. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  25. data/lib/silas/chat.rb +45 -13
  26. data/lib/silas/configuration.rb +75 -24
  27. data/lib/silas/delta_buffer.rb +50 -0
  28. data/lib/silas/engine.rb +6 -0
  29. data/lib/silas/engines/base.rb +6 -8
  30. data/lib/silas/engines/ruby_llm.rb +15 -4
  31. data/lib/silas/errors.rb +3 -3
  32. data/lib/silas/eval/scripted_engine.rb +0 -2
  33. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  34. data/lib/silas/instructions.rb +13 -1
  35. data/lib/silas/ledger.rb +26 -10
  36. data/lib/silas/mcp/handler.rb +6 -5
  37. data/lib/silas/mcp/server.rb +9 -9
  38. data/lib/silas/nested_runner.rb +2 -2
  39. data/lib/silas/registry.rb +12 -2
  40. data/lib/silas/step_runner.rb +21 -6
  41. data/lib/silas/tool.rb +5 -0
  42. data/lib/silas/tools/handoff.rb +68 -0
  43. data/lib/silas/tools/recall.rb +18 -0
  44. data/lib/silas/tools/remember.rb +32 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas.rb +20 -9
  47. metadata +10 -6
  48. data/lib/silas/agent_sdk/cli.rb +0 -59
  49. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  50. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  51. data/lib/silas/engines/agent_sdk.rb +0 -75
  52. 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
@@ -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 on this thread. A continuation
24
- # checkpoint inside would raise Interrupt and roll back committed-looking
25
- # progress (spike finding #5) — AgentLoopJob asserts against this.
26
- def in_transaction? = Thread.current[GUARD_KEY] == true
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
- # :agent_sdk MCP endpoint creates one invocation per tools/call and needs
54
- # exactly the same exactly-once/effect-mode machinery as settle!. Returns
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
- .exists?
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
- Thread.current[GUARD_KEY] = true
192
+ previous = ActiveSupport::IsolatedExecutionState[GUARD_KEY]
193
+ ActiveSupport::IsolatedExecutionState[GUARD_KEY] = true
178
194
  ApplicationRecord.transaction { yield }
179
195
  ensure
180
- Thread.current[GUARD_KEY] = false
196
+ ActiveSupport::IsolatedExecutionState[GUARD_KEY] = previous
181
197
  end
182
198
 
183
199
  def wrap_result(result)
@@ -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 the :agent_sdk path gets the same exactly-once and
8
- # effect-mode semantics as :ruby_llm. Closes over one turn + its anchor step;
9
- # authenticated by a per-turn bearer token in the URL query.
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
- # v1 excludes approval-gated tools; if one slips through, fail loud.
54
- { "isError" => true, "content" => [ text_content("approval-gated tools are not supported by :agent_sdk") ] }
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
@@ -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 for the lifetime of one
6
- # claude -p subprocess. Deliberately NOT Puma/Rack: the transport claude's
7
- # MCP client needs (verified in the spike) is plain request/response JSON —
8
- # one request per connection, application/json, no SSE — so a raw threaded
9
- # TCPServer is fewer moving parts than embedding an app server in a worker.
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.agent_sdk_mcp_host)
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
@@ -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 ] }))
@@ -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
- if (hook = Silas.config.around_model_call)
64
- hook.call(context) { engine.execute_step(context) }
65
- else
66
- engine.execute_step(context)
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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.6"
2
+ VERSION = "0.2.0"
3
3
  end
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/engines/base"
32
- require "silas/agent_sdk/version_guard"
33
- require "silas/agent_sdk/stream_parser"
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/agent_sdk"
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 a symbol (:ruby_llm,
98
- # :agent_sdk) or any object responding to #execute_step (specs, custom).
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 then Engines::AgentSdk.new
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.1.6
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
@@ -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