silas 0.1.7 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +37 -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/tool_invocation.rb +13 -1
  9. data/app/models/silas/turn.rb +1 -0
  10. data/app/views/layouts/silas/inbox.html.erb +14 -0
  11. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  12. data/app/views/silas/inbox/sessions/index.html.erb +20 -2
  13. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  14. data/app/views/silas/inbox/steps/_step.html.erb +6 -1
  15. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  16. data/config/routes.rb +6 -1
  17. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  18. data/lib/generators/silas/install/install_generator.rb +33 -14
  19. data/lib/generators/silas/install/templates/bin_ci +2 -2
  20. data/lib/generators/silas/install/templates/initializer.rb +29 -5
  21. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  22. data/lib/silas/chat.rb +45 -13
  23. data/lib/silas/configuration.rb +68 -24
  24. data/lib/silas/delta_buffer.rb +50 -0
  25. data/lib/silas/engine.rb +6 -0
  26. data/lib/silas/engines/base.rb +6 -8
  27. data/lib/silas/engines/ruby_llm.rb +15 -4
  28. data/lib/silas/errors.rb +3 -3
  29. data/lib/silas/eval/scripted_engine.rb +0 -2
  30. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  31. data/lib/silas/ledger.rb +26 -10
  32. data/lib/silas/mcp/handler.rb +6 -5
  33. data/lib/silas/mcp/server.rb +9 -9
  34. data/lib/silas/step_runner.rb +21 -6
  35. data/lib/silas/tool.rb +5 -0
  36. data/lib/silas/version.rb +1 -1
  37. data/lib/silas.rb +9 -9
  38. metadata +5 -6
  39. data/lib/silas/agent_sdk/cli.rb +0 -59
  40. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  41. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  42. data/lib/silas/engines/agent_sdk.rb +0 -75
  43. data/lib/silas/subprocess_runner.rb +0 -41
@@ -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
@@ -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
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.1.7"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/silas.rb CHANGED
@@ -24,6 +24,8 @@ 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"
@@ -31,19 +33,14 @@ require "silas/tools/load_skill"
31
33
  require "silas/tools/remember"
32
34
  require "silas/tools/recall"
33
35
  require "silas/tools/handoff"
34
- require "silas/engines/base"
35
- require "silas/agent_sdk/version_guard"
36
- require "silas/agent_sdk/stream_parser"
37
- require "silas/agent_sdk/cli"
38
36
  require "silas/mcp/handler"
39
37
  require "silas/mcp/server"
40
- require "silas/engines/agent_sdk"
38
+ require "silas/engines/base"
41
39
  require "ruby_llm"
42
40
  require "silas/engines/ruby_llm"
43
41
  require "silas/message_builder"
44
42
  require "silas/instructions"
45
43
  require "silas/step_runner"
46
- require "silas/subprocess_runner"
47
44
  require "silas/eval" # after engines (ScriptedEngine < Engines::Base)
48
45
  require "silas/chat"
49
46
 
@@ -97,13 +94,16 @@ module Silas
97
94
 
98
95
  def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
99
96
 
100
- # The inference adapter instance. config.engine may be a symbol (:ruby_llm,
101
- # :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).
102
99
  def resolved_engine
103
100
  @resolved_engine ||=
104
101
  case config.engine
105
102
  when :ruby_llm then Engines::RubyLLM.new
106
- 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."
107
107
  when Symbol then raise Error, "unknown engine #{config.engine.inspect}"
108
108
  else config.engine
109
109
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: silas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
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
@@ -152,6 +153,7 @@ files:
152
153
  - db/migrate/20260716000001_add_budget_overrides_to_silas_turns.rb
153
154
  - db/migrate/20260716000002_add_cancel_requested_to_silas_turns.rb
154
155
  - db/migrate/20260721000001_create_silas_memories.rb
156
+ - db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb
155
157
  - lib/generators/silas/install/install_generator.rb
156
158
  - lib/generators/silas/install/templates/agent.yml
157
159
  - lib/generators/silas/install/templates/bin_ci
@@ -167,17 +169,14 @@ files:
167
169
  - lib/silas.rb
168
170
  - lib/silas/agent.rb
169
171
  - lib/silas/agent_scope.rb
170
- - lib/silas/agent_sdk/cli.rb
171
- - lib/silas/agent_sdk/stream_parser.rb
172
- - lib/silas/agent_sdk/version_guard.rb
173
172
  - lib/silas/budget.rb
174
173
  - lib/silas/channel.rb
175
174
  - lib/silas/chat.rb
176
175
  - lib/silas/configuration.rb
177
176
  - lib/silas/connection.rb
178
177
  - lib/silas/connections.rb
178
+ - lib/silas/delta_buffer.rb
179
179
  - lib/silas/engine.rb
180
- - lib/silas/engines/agent_sdk.rb
181
180
  - lib/silas/engines/base.rb
182
181
  - lib/silas/engines/ruby_llm.rb
183
182
  - lib/silas/errors.rb
@@ -192,6 +191,7 @@ files:
192
191
  - lib/silas/eval/transcript.rb
193
192
  - lib/silas/inbox.rb
194
193
  - lib/silas/inbox/cost.rb
194
+ - lib/silas/inbox/delta_broadcaster.rb
195
195
  - lib/silas/instructions.rb
196
196
  - lib/silas/ledger.rb
197
197
  - lib/silas/mcp/client.rb
@@ -209,7 +209,6 @@ files:
209
209
  - lib/silas/skill.rb
210
210
  - lib/silas/slack.rb
211
211
  - lib/silas/step_runner.rb
212
- - lib/silas/subprocess_runner.rb
213
212
  - lib/silas/tool.rb
214
213
  - lib/silas/tools/delegate.rb
215
214
  - lib/silas/tools/handoff.rb
@@ -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
@@ -1,86 +0,0 @@
1
- require "json"
2
-
3
- module Silas
4
- module AgentSdk
5
- # Tolerant NDJSON parser for `claude -p --output-format stream-json`. The
6
- # event schema is under-documented and shifts between versions, so unknown
7
- # lines/types must never raise. Yields Silas::Event objects; accumulates the
8
- # terminal Result (final text, tool_use blocks, usage, session_id).
9
- class StreamParser
10
- attr_reader :session_id, :final_text, :usage, :stop_reason, :blocks, :tool_calls
11
-
12
- def initialize
13
- @blocks = []
14
- @tool_calls = []
15
- @final_text = nil
16
- @usage = nil
17
- @session_id = nil
18
- @stop_reason = nil
19
- end
20
-
21
- def ingest(line)
22
- line = line.to_s.strip
23
- return if line.empty?
24
-
25
- event = JSON.parse(line)
26
- @session_id ||= event["session_id"] if event["session_id"]
27
-
28
- case event["type"]
29
- when "system"
30
- @session_id ||= event["session_id"]
31
- yield Event.new(type: :"system.#{event['subtype']}", payload: symbolize(event))
32
- when "assistant"
33
- ingest_assistant(event) { |e| yield e }
34
- when "user"
35
- yield Event.new(type: :tool_result, payload: symbolize(event))
36
- when "result"
37
- @final_text = event["result"]
38
- @stop_reason = event["subtype"]
39
- @usage = extract_usage(event)
40
- # The result text usually repeats the last assistant text — only add a
41
- # block if it isn't already captured.
42
- if @final_text && @blocks.none? { |b| b["type"] == "text" && b["text"] == @final_text }
43
- @blocks << { "type" => "text", "text" => @final_text }
44
- end
45
- yield Event.new(type: :result, payload: symbolize(event))
46
- else
47
- yield Event.new(type: :unknown, payload: symbolize(event))
48
- end
49
- rescue JSON::ParserError
50
- # A non-JSON line (banner, warning) — ignore.
51
- end
52
-
53
- # tool_calls stays [] on the Result: in the engine-owned path Claude Code
54
- # already executed the tools (through our MCP endpoint), so the framework
55
- # loop must not try to run them again.
56
- def to_result
57
- Engines::Result.new(blocks: @blocks, tool_calls: [], stop_reason: @stop_reason || "end_turn", usage: @usage)
58
- end
59
-
60
- private
61
-
62
- def ingest_assistant(event)
63
- Array(event.dig("message", "content")).each do |block|
64
- case block["type"]
65
- when "text"
66
- @blocks << { "type" => "text", "text" => block["text"] }
67
- yield Event.new(type: :text, payload: { text: block["text"] })
68
- when "tool_use"
69
- @tool_calls << block["id"]
70
- @blocks << { "type" => "tool_call", "id" => block["id"], "name" => block["name"], "arguments" => block["input"] }
71
- yield Event.new(type: :tool_call, payload: symbolize(block))
72
- end
73
- end
74
- end
75
-
76
- def extract_usage(event)
77
- u = event["usage"] || {}
78
- { input_tokens: u["input_tokens"], output_tokens: u["output_tokens"], cost_usd: event["total_cost_usd"] }
79
- end
80
-
81
- def symbolize(hash)
82
- hash.transform_keys(&:to_sym)
83
- end
84
- end
85
- end
86
- end
@@ -1,26 +0,0 @@
1
- module Silas
2
- module AgentSdk
3
- # The adapter wraps an external CLI whose JSON stream can shift between
4
- # versions, so pin a tested range and fail loudly outside it.
5
- module VersionGuard
6
- module_function
7
-
8
- def assert!(bin, requirement: Silas.config.agent_sdk_cli_version_range)
9
- version = detect(bin)
10
- raise Silas::Error, "could not determine `#{bin}` version (is the Claude CLI installed?)" if version.nil?
11
-
12
- unless Gem::Requirement.new(requirement.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version))
13
- raise Silas::Error, "claude CLI #{version} is outside the tested range (#{requirement}); pin or upgrade"
14
- end
15
- version
16
- end
17
-
18
- def detect(bin)
19
- out = `#{bin} --version 2>/dev/null`
20
- out[/\d+\.\d+\.\d+/]
21
- rescue StandardError
22
- nil
23
- end
24
- end
25
- end
26
- end
@@ -1,75 +0,0 @@
1
- module Silas
2
- module Engines
3
- # The engine-owned adapter: one `claude -p` subprocess runs a whole agentic
4
- # turn, calling back into Silas tools over an in-worker HTTP MCP endpoint
5
- # whose tools/call goes THROUGH the Ledger (exactly-once within the run).
6
- # The mirror image of :ruby_llm — Claude Code owns the loop; Silas hosts the
7
- # tools and maps the NDJSON stream onto durable rows.
8
- #
9
- # v1 contract (honestly weaker than :ruby_llm): exactly-once WITHIN a run,
10
- # approval :never tools only, and fail-closed on mid-subprocess worker kill.
11
- class AgentSdk < Base
12
- def self.loop_ownership = :engine
13
-
14
- def execute_step(context, &on_event)
15
- turn = context[:turn]
16
- Silas::AgentSdk::VersionGuard.assert!(Silas.config.agent_sdk_claude_bin)
17
- assert_api_key!
18
-
19
- tools = allowed_tools(context[:tools])
20
- server = Mcp::Server.start(turn: turn, step: context[:step], tools: tools, resolver: Silas.tool_resolver)
21
- cli = nil
22
- begin
23
- server.await_ready!
24
- cli = build_cli(context, server, tools)
25
- parser = Silas::AgentSdk::StreamParser.new
26
- cli.stream do |line|
27
- parser.ingest(line) do |event|
28
- persist_session_id!(turn, parser)
29
- on_event&.call(event)
30
- ActiveSupport::Notifications.instrument("silas.agent_sdk.event", turn_id: turn.id, type: event.type)
31
- end
32
- end
33
- parser.to_result
34
- ensure
35
- cli&.terminate
36
- server.stop
37
- end
38
- end
39
-
40
- private
41
-
42
- def build_cli(context, server, tools)
43
- Silas::AgentSdk::Cli.new(
44
- bin: Silas.config.agent_sdk_claude_bin,
45
- prompt: context[:turn].input,
46
- system: context[:system],
47
- model: Silas.config.agent_sdk_model || context[:model],
48
- mcp_url: server.mcp_url,
49
- allowed: tools.map { |d| "mcp__silas__#{d['name']}" }
50
- )
51
- end
52
-
53
- # v1 excludes approval-gated tools entirely (both tools/list and
54
- # --allowedTools) — an engine-owned subprocess can't park cheaply.
55
- def allowed_tools(definitions)
56
- kept = definitions.select { |d| Silas.tool_resolver.call(d["name"]).approval_policy == :never }
57
- dropped = definitions.map { |d| d["name"] } - kept.map { |d| d["name"] }
58
- Rails.logger&.info("silas: :agent_sdk excludes approval-gated tools #{dropped.inspect}") if dropped.any?
59
- kept
60
- end
61
-
62
- def persist_session_id!(turn, parser)
63
- return unless turn.cli_session_id.nil? && parser.session_id.present?
64
-
65
- turn.update_columns(cli_session_id: parser.session_id)
66
- end
67
-
68
- def assert_api_key!
69
- return if ENV["ANTHROPIC_API_KEY"].present?
70
-
71
- raise Silas::BootGuardError, ":agent_sdk uses --bare (API-key auth only); set ANTHROPIC_API_KEY"
72
- end
73
- end
74
- end
75
- end
@@ -1,41 +0,0 @@
1
- module Silas
2
- # The engine-owned analog of StepRunner: it wraps one whole subprocess run in
3
- # a single anchor Step and persists the durable result. Replay-aware and
4
- # fail-closed — a resumed run whose subprocess got far enough to register a
5
- # CLI session but did not finish is FAILED rather than re-spawned, because an
6
- # engine-owned subprocess can't be replayed exactly-once (design risk #1).
7
- module SubprocessRunner
8
- module_function
9
-
10
- # Returns :terminal or :failed.
11
- def call(turn)
12
- step = Step.find_or_create_by!(turn: turn, index: 0)
13
- return :terminal if step.completed?
14
-
15
- if turn.cli_session_id.present?
16
- # A prior execution spawned a subprocess that never completed the step.
17
- turn.finish!(:failed, reason: "agent_sdk_interrupted")
18
- return :failed
19
- end
20
-
21
- result = Silas.resolved_engine.execute_step(engine_context(turn, step))
22
- step.update!(
23
- status: "completed", terminal: true,
24
- response_blocks: result.blocks, stop_reason: result.stop_reason,
25
- model: Silas.agent.model,
26
- input_tokens: result.usage&.dig(:input_tokens),
27
- output_tokens: result.usage&.dig(:output_tokens)
28
- )
29
- :terminal
30
- end
31
-
32
- def engine_context(turn, step)
33
- { turn: turn, step: step, index: 0,
34
- system: turn.instructions_snapshot,
35
- messages: MessageBuilder.call(turn, upto_index: nil),
36
- tools: Silas.tool_definitions,
37
- model: Silas.agent.model,
38
- limits: { max_steps: Silas.agent.max_steps } }
39
- end
40
- end
41
- end