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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +37 -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/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/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 +68 -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/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +9 -9
- metadata +5 -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
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/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
|
data/lib/silas/version.rb
CHANGED
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/
|
|
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
|
|
101
|
-
#
|
|
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
|
|
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.
|
|
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
|
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
|
|
@@ -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
|