silas 0.1.1
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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/app/controllers/silas/channels/approvals_controller.rb +34 -0
- data/app/controllers/silas/channels/base_controller.rb +22 -0
- data/app/controllers/silas/channels/slack_controller.rb +58 -0
- data/app/controllers/silas/inbox/base_controller.rb +37 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
- data/app/helpers/silas/inbox/trace_helper.rb +42 -0
- data/app/jobs/silas/agent_loop_job.rb +95 -0
- data/app/jobs/silas/channel_delivery_job.rb +62 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
- data/app/jobs/silas/schedule_job.rb +22 -0
- data/app/mailboxes/silas/agent_mailbox.rb +35 -0
- data/app/mailers/silas/channel_mailer.rb +17 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
- data/app/models/silas/application_record.rb +5 -0
- data/app/models/silas/session.rb +36 -0
- data/app/models/silas/step.rb +33 -0
- data/app/models/silas/tool_invocation.rb +78 -0
- data/app/models/silas/turn.rb +58 -0
- data/app/views/layouts/silas/inbox.html.erb +91 -0
- data/app/views/silas/channel_mailer/approval.text.erb +9 -0
- data/app/views/silas/channels/approvals/done.html.erb +2 -0
- data/app/views/silas/channels/approvals/error.html.erb +2 -0
- data/app/views/silas/channels/approvals/show.html.erb +9 -0
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
- data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
- data/app/views/silas/inbox/sessions/index.html.erb +23 -0
- data/app/views/silas/inbox/sessions/show.html.erb +20 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -0
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
- data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
- data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
- data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
- data/lib/generators/silas/install/install_generator.rb +81 -0
- data/lib/generators/silas/install/templates/agent.yml +9 -0
- data/lib/generators/silas/install/templates/bin_ci +7 -0
- data/lib/generators/silas/install/templates/channel_email.rb +18 -0
- data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
- data/lib/generators/silas/install/templates/example_eval.rb +22 -0
- data/lib/generators/silas/install/templates/example_schedule.md +11 -0
- data/lib/generators/silas/install/templates/example_skill.md +8 -0
- data/lib/generators/silas/install/templates/example_tool.rb +12 -0
- data/lib/generators/silas/install/templates/initializer.rb +16 -0
- data/lib/generators/silas/install/templates/instructions.md +4 -0
- data/lib/silas/agent.rb +33 -0
- data/lib/silas/agent_scope.rb +6 -0
- data/lib/silas/agent_sdk/cli.rb +59 -0
- data/lib/silas/agent_sdk/stream_parser.rb +86 -0
- data/lib/silas/agent_sdk/version_guard.rb +26 -0
- data/lib/silas/budget.rb +42 -0
- data/lib/silas/channel.rb +69 -0
- data/lib/silas/configuration.rb +164 -0
- data/lib/silas/connection.rb +77 -0
- data/lib/silas/connections.rb +52 -0
- data/lib/silas/engine.rb +36 -0
- data/lib/silas/engines/agent_sdk.rb +75 -0
- data/lib/silas/engines/base.rb +30 -0
- data/lib/silas/engines/ruby_llm.rb +136 -0
- data/lib/silas/errors.rb +26 -0
- data/lib/silas/eval/assertions.rb +107 -0
- data/lib/silas/eval/driver.rb +47 -0
- data/lib/silas/eval/dsl.rb +55 -0
- data/lib/silas/eval/grader.rb +37 -0
- data/lib/silas/eval/result.rb +28 -0
- data/lib/silas/eval/runner.rb +38 -0
- data/lib/silas/eval/scripted_engine.rb +39 -0
- data/lib/silas/eval/transcript.rb +22 -0
- data/lib/silas/eval.rb +35 -0
- data/lib/silas/inbox/cost.rb +58 -0
- data/lib/silas/inbox.rb +23 -0
- data/lib/silas/instructions.rb +55 -0
- data/lib/silas/ledger.rb +178 -0
- data/lib/silas/mcp/client.rb +86 -0
- data/lib/silas/mcp/handler.rb +89 -0
- data/lib/silas/mcp/server.rb +134 -0
- data/lib/silas/message_builder.rb +63 -0
- data/lib/silas/nested_runner.rb +41 -0
- data/lib/silas/registry.rb +155 -0
- data/lib/silas/sandbox/docker.rb +67 -0
- data/lib/silas/sandbox/null.rb +17 -0
- data/lib/silas/sandbox.rb +15 -0
- data/lib/silas/schedule/compiler.rb +52 -0
- data/lib/silas/schedule.rb +109 -0
- data/lib/silas/skill.rb +21 -0
- data/lib/silas/slack.rb +55 -0
- data/lib/silas/step_runner.rb +98 -0
- data/lib/silas/subprocess_runner.rb +41 -0
- data/lib/silas/tool.rb +93 -0
- data/lib/silas/tools/delegate.rb +42 -0
- data/lib/silas/tools/load_skill.rb +25 -0
- data/lib/silas/tools/run_code.rb +21 -0
- data/lib/silas/version.rb +3 -0
- data/lib/silas.rb +144 -0
- data/lib/tasks/silas_eval.rake +20 -0
- data/lib/tasks/silas_schedules.rake +33 -0
- metadata +232 -0
data/lib/silas/ledger.rb
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# The three-state tool executor — the exactly-once machinery the spike proved.
|
|
3
|
+
#
|
|
4
|
+
# settle!(step, resolver:) walks the step's invocations in creation order and
|
|
5
|
+
# drives each to a terminal state. Returns :completed when every invocation
|
|
6
|
+
# settled, or :parked when one is awaiting a human (approval or in-doubt).
|
|
7
|
+
#
|
|
8
|
+
# State machine per invocation:
|
|
9
|
+
# pending --approval fires--> pending + approval_state=required (PARK)
|
|
10
|
+
# pending --transactional--> completed (tool + row in ONE txn)
|
|
11
|
+
# pending --at_most_once/idempotent--> started --> completed
|
|
12
|
+
# started --found on replay, at_most_once--> in_doubt (PARK)
|
|
13
|
+
# started --found on replay, idempotent--> re-run --> completed
|
|
14
|
+
# completed --> no-op (replay feeds the persisted result forward)
|
|
15
|
+
#
|
|
16
|
+
# Concurrency: claims are compare-and-swap UPDATEs (status='pending' in the
|
|
17
|
+
# WHERE clause), so two executions racing the same invocation cannot both run
|
|
18
|
+
# the tool. The unique (step_id, tool_call_id) index backstops row creation.
|
|
19
|
+
module Ledger
|
|
20
|
+
GUARD_KEY = :silas_ledger_transaction
|
|
21
|
+
|
|
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
|
|
27
|
+
|
|
28
|
+
def assert_no_checkpoint!
|
|
29
|
+
return unless in_transaction?
|
|
30
|
+
|
|
31
|
+
raise CheckpointInLedgerError,
|
|
32
|
+
"A continuation checkpoint was attempted inside a ledger transaction. " \
|
|
33
|
+
"Checkpoints raise Interrupt, which would roll back tool side effects " \
|
|
34
|
+
"the continuation believes are committed."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def settle!(step, resolver:)
|
|
38
|
+
step.tool_invocations.order(:id).each do |invocation|
|
|
39
|
+
outcome = settle_invocation!(invocation, resolver)
|
|
40
|
+
return :parked if outcome == :parked
|
|
41
|
+
end
|
|
42
|
+
:completed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Drive a SINGLE freshly-created invocation to a terminal state — the
|
|
46
|
+
# :agent_sdk MCP endpoint creates one invocation per tools/call and needs
|
|
47
|
+
# exactly the same exactly-once/effect-mode machinery as settle!. Returns
|
|
48
|
+
# :done or :parked; the invocation carries its .result.
|
|
49
|
+
def execute_invocation!(invocation, resolver:)
|
|
50
|
+
settle_invocation!(invocation, resolver)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def settle_invocation!(invocation, resolver)
|
|
56
|
+
case invocation.status
|
|
57
|
+
when "completed", "failed" then :done
|
|
58
|
+
# approve! resets in_doubt -> pending+approved (re-execute); decline!
|
|
59
|
+
# resolves it to failed with an operator-supplied outcome. So a row
|
|
60
|
+
# still in_doubt here is by definition awaiting its human.
|
|
61
|
+
when "in_doubt" then :parked
|
|
62
|
+
when "started" then handle_in_doubt(invocation, resolver)
|
|
63
|
+
when "pending" then execute_pending(invocation, resolver)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def execute_pending(invocation, resolver)
|
|
68
|
+
return :parked if invocation.awaiting_approval?
|
|
69
|
+
|
|
70
|
+
tool = resolver.call(invocation.tool_name)
|
|
71
|
+
|
|
72
|
+
unless invocation.approval_state == "approved"
|
|
73
|
+
verdict = approval_verdict(tool, invocation)
|
|
74
|
+
case verdict
|
|
75
|
+
when :user_approval
|
|
76
|
+
invocation.update!(approval_state: "required",
|
|
77
|
+
approval_expires_at: Silas.config.approval_ttl.from_now)
|
|
78
|
+
return :parked
|
|
79
|
+
when Hash # {denied: "reason"} — eve's shape
|
|
80
|
+
invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
|
|
81
|
+
return :done
|
|
82
|
+
end
|
|
83
|
+
# :not_applicable / :approved / :once-satisfied fall through
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
execute!(invocation, tool)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def execute!(invocation, tool)
|
|
90
|
+
tool.session = invocation.turn.session if tool.respond_to?(:session=)
|
|
91
|
+
args = invocation.arguments.symbolize_keys
|
|
92
|
+
|
|
93
|
+
if invocation.effect_mode == "transactional"
|
|
94
|
+
# Tool side effects + ledger completion commit or roll back together:
|
|
95
|
+
# exactly-once for DB-backed tools (spike cell C).
|
|
96
|
+
guarded_transaction do
|
|
97
|
+
next :done unless claim!(invocation, from: "pending", to: "completed")
|
|
98
|
+
|
|
99
|
+
result = tool.call(**args)
|
|
100
|
+
invocation.update!(result: wrap_result(result))
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
# External effects: mark started (committed), call, mark completed.
|
|
104
|
+
# A crash between the two commits is the in-doubt window.
|
|
105
|
+
return :done unless claim!(invocation, from: "pending", to: "started")
|
|
106
|
+
|
|
107
|
+
result = tool.call(**args)
|
|
108
|
+
invocation.update!(status: "completed", result: wrap_result(result))
|
|
109
|
+
end
|
|
110
|
+
:done
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
raise if e.is_a?(Silas::Error)
|
|
113
|
+
|
|
114
|
+
invocation.update!(status: "failed", error: "#{e.class}: #{e.message}")
|
|
115
|
+
:done
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# started + replay = the tool may or may not have run (spike's in-doubt).
|
|
119
|
+
def handle_in_doubt(invocation, resolver)
|
|
120
|
+
if invocation.effect_mode == "idempotent"
|
|
121
|
+
tool = resolver.call(invocation.tool_name)
|
|
122
|
+
tool.session = invocation.turn.session if tool.respond_to?(:session=)
|
|
123
|
+
result = tool.call(**invocation.arguments.symbolize_keys)
|
|
124
|
+
invocation.update!(status: "completed", result: wrap_result(result))
|
|
125
|
+
:done
|
|
126
|
+
else
|
|
127
|
+
# at_most_once (default): park for a human verdict via the approval
|
|
128
|
+
# machinery — approve! = "it did not run, re-execute";
|
|
129
|
+
# decline! = "it ran / abandon", operator supplies the outcome.
|
|
130
|
+
invocation.update!(status: "in_doubt", approval_state: "required",
|
|
131
|
+
approval_expires_at: Silas.config.approval_ttl.from_now)
|
|
132
|
+
:parked
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def approval_verdict(tool, invocation)
|
|
137
|
+
policy = tool.respond_to?(:approval_policy) ? tool.approval_policy : :never
|
|
138
|
+
case policy
|
|
139
|
+
when :never then :not_applicable
|
|
140
|
+
when :always then :user_approval
|
|
141
|
+
when :once
|
|
142
|
+
previously_approved?(invocation) ? :approved : :user_approval
|
|
143
|
+
when Proc
|
|
144
|
+
policy.call(session: invocation.turn.session, input: invocation.arguments)
|
|
145
|
+
else
|
|
146
|
+
:not_applicable
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def previously_approved?(invocation)
|
|
151
|
+
ToolInvocation.joins(:turn)
|
|
152
|
+
.where(silas_turns: { session_id: invocation.turn.session_id },
|
|
153
|
+
tool_name: invocation.tool_name, approval_state: "approved")
|
|
154
|
+
.where.not(id: invocation.id)
|
|
155
|
+
.exists?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Compare-and-swap claim: only one racing execution wins.
|
|
159
|
+
def claim!(invocation, from:, to:)
|
|
160
|
+
claimed = ToolInvocation.where(id: invocation.id, status: from)
|
|
161
|
+
.update_all(status: to, updated_at: Time.current) == 1
|
|
162
|
+
invocation.reload if claimed
|
|
163
|
+
claimed
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def guarded_transaction
|
|
167
|
+
Thread.current[GUARD_KEY] = true
|
|
168
|
+
ApplicationRecord.transaction { yield }
|
|
169
|
+
ensure
|
|
170
|
+
Thread.current[GUARD_KEY] = false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def wrap_result(result)
|
|
174
|
+
result.is_a?(Hash) ? result : { "value" => result }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Silas
|
|
6
|
+
module Mcp
|
|
7
|
+
# JSON-RPC 2.0 over MCP Streamable HTTP — the CLIENT half of Handler/Server,
|
|
8
|
+
# used by connections to call remote MCP servers. Stateless per operation
|
|
9
|
+
# (fresh initialize each call) so it's replay-safe. Net::HTTP only.
|
|
10
|
+
class Client
|
|
11
|
+
PROTOCOL_VERSION = "2025-06-18".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(url:, headers: {}, open_timeout: 5, read_timeout: 30)
|
|
14
|
+
@uri = URI(url)
|
|
15
|
+
@headers = headers
|
|
16
|
+
@open_timeout = open_timeout
|
|
17
|
+
@read_timeout = read_timeout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def list_tools
|
|
21
|
+
with_session { |sid| rpc("tools/list", {}, session: sid).fetch("tools") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# An MCP application error comes back as a normal result with isError=true
|
|
25
|
+
# (the remote tool ran and errored) — returned so the model can react. Only
|
|
26
|
+
# JSON-RPC/transport failures raise.
|
|
27
|
+
def call_tool(name, arguments)
|
|
28
|
+
with_session { |sid| rpc("tools/call", { "name" => name, "arguments" => arguments }, session: sid) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def with_session
|
|
34
|
+
_, headers = post(rpc_body("initialize",
|
|
35
|
+
"protocolVersion" => PROTOCOL_VERSION, "capabilities" => {},
|
|
36
|
+
"clientInfo" => { "name" => "silas", "version" => Silas::VERSION }))
|
|
37
|
+
sid = headers["mcp-session-id"]
|
|
38
|
+
post(JSON.generate("jsonrpc" => "2.0", "method" => "notifications/initialized"), session: sid)
|
|
39
|
+
yield sid
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rpc(method, params, session:)
|
|
43
|
+
parsed, = post(rpc_body(method, params), session: session)
|
|
44
|
+
raise Error, "MCP #{method} error: #{parsed['error']}" if parsed && parsed["error"]
|
|
45
|
+
|
|
46
|
+
parsed && parsed["result"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rpc_body(method, params)
|
|
50
|
+
JSON.generate("jsonrpc" => "2.0", "id" => SecureRandom.uuid, "method" => method, "params" => params)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post(body, session: nil)
|
|
54
|
+
req = Net::HTTP::Post.new(@uri)
|
|
55
|
+
req["content-type"] = "application/json"
|
|
56
|
+
req["accept"] = "application/json, text/event-stream"
|
|
57
|
+
req["mcp-protocol-version"] = PROTOCOL_VERSION
|
|
58
|
+
req["mcp-session-id"] = session if session
|
|
59
|
+
@headers.each { |k, v| req[k] = v } # secret injection lives here
|
|
60
|
+
req.body = body
|
|
61
|
+
res = http.request(req)
|
|
62
|
+
raise Error, "MCP HTTP #{res.code}" unless res.code.start_with?("2")
|
|
63
|
+
|
|
64
|
+
[ parse_payload(res), res.each_header.to_h ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_payload(res)
|
|
68
|
+
return nil if res.body.to_s.empty?
|
|
69
|
+
|
|
70
|
+
if res["content-type"].to_s.include?("text/event-stream")
|
|
71
|
+
data = res.body.each_line.select { |l| l.start_with?("data:") }.last
|
|
72
|
+
return data && JSON.parse(data.delete_prefix("data:").strip)
|
|
73
|
+
end
|
|
74
|
+
JSON.parse(res.body)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def http
|
|
78
|
+
@http ||= Net::HTTP.new(@uri.host, @uri.port).tap do |h|
|
|
79
|
+
h.use_ssl = @uri.scheme == "https"
|
|
80
|
+
h.open_timeout = @open_timeout
|
|
81
|
+
h.read_timeout = @read_timeout
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Silas
|
|
5
|
+
module Mcp
|
|
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.
|
|
10
|
+
class Handler
|
|
11
|
+
TOOL_PREFIX = "mcp__silas__".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(turn:, step:, tools:, resolver:, token:)
|
|
14
|
+
@turn = turn
|
|
15
|
+
@step = step
|
|
16
|
+
@tools = tools
|
|
17
|
+
@resolver = resolver
|
|
18
|
+
@token = token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns [status, json_string_or_nil]. path/query already parsed by the server.
|
|
22
|
+
def call(path:, query_token:, body:)
|
|
23
|
+
return [ 404, error_body("not found") ] unless path == "/mcp/#{@turn.id}"
|
|
24
|
+
return [ 403, error_body("forbidden") ] unless ActiveSupport::SecurityUtils.secure_compare(query_token.to_s, @token)
|
|
25
|
+
|
|
26
|
+
msg = JSON.parse(body)
|
|
27
|
+
case msg["method"]
|
|
28
|
+
when "initialize" then ok(msg["id"], initialize_result(msg))
|
|
29
|
+
when "notifications/initialized" then [ 202, nil ]
|
|
30
|
+
when "tools/list" then ok(msg["id"], { "tools" => @tools.map { |d| mcp_tool(d) } })
|
|
31
|
+
when "tools/call" then ok(msg["id"], call_tool(msg))
|
|
32
|
+
else rpc_error(msg["id"], -32601, "method not found: #{msg['method']}")
|
|
33
|
+
end
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
[ 400, error_body("bad json") ]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def call_tool(msg)
|
|
41
|
+
name = msg.dig("params", "name").to_s.sub(/\A#{Regexp.escape(TOOL_PREFIX)}/, "")
|
|
42
|
+
args = msg.dig("params", "arguments") || {}
|
|
43
|
+
|
|
44
|
+
invocation = Silas::ToolInvocation.create!(
|
|
45
|
+
step: @step, turn: @turn,
|
|
46
|
+
tool_call_id: SecureRandom.uuid, # Claude's tool_use.id isn't on the JSON-RPC msg; synth is fine (never fed to a provider)
|
|
47
|
+
tool_name: name, arguments: args, effect_mode: effect_mode_for(name)
|
|
48
|
+
)
|
|
49
|
+
outcome = Silas::Ledger.execute_invocation!(invocation, resolver: @resolver)
|
|
50
|
+
invocation.reload
|
|
51
|
+
|
|
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") ] }
|
|
55
|
+
else
|
|
56
|
+
{ "content" => [ text_content(JSON.generate(invocation.result)) ] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def effect_mode_for(name)
|
|
61
|
+
tool = @resolver.call(name)
|
|
62
|
+
tool.respond_to?(:effect_mode) ? tool.effect_mode.to_s : "at_most_once"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mcp_tool(definition)
|
|
66
|
+
{ "name" => definition["name"], "description" => definition["description"],
|
|
67
|
+
"inputSchema" => definition["input_schema"] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize_result(msg)
|
|
71
|
+
{ "protocolVersion" => msg.dig("params", "protocolVersion") || "2025-06-18",
|
|
72
|
+
"capabilities" => { "tools" => {} },
|
|
73
|
+
"serverInfo" => { "name" => "silas", "version" => Silas::VERSION } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def text_content(text) = { "type" => "text", "text" => text }
|
|
77
|
+
|
|
78
|
+
def ok(id, result)
|
|
79
|
+
[ 200, JSON.generate("jsonrpc" => "2.0", "id" => id, "result" => result) ]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def rpc_error(id, code, message)
|
|
83
|
+
[ 200, JSON.generate("jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message }) ]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def error_body(message) = JSON.generate("error" => message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
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.
|
|
10
|
+
#
|
|
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.
|
|
13
|
+
class Server
|
|
14
|
+
attr_reader :port
|
|
15
|
+
|
|
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)
|
|
19
|
+
handler = Handler.new(turn: turn, step: step, tools: tools, resolver: resolver, token: token)
|
|
20
|
+
new(handler: handler, turn: turn, token: token, host: host).tap(&:boot)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(handler:, turn:, token:, host:)
|
|
24
|
+
@handler = handler
|
|
25
|
+
@turn = turn
|
|
26
|
+
@token = token
|
|
27
|
+
@host = host
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def boot
|
|
31
|
+
@socket = TCPServer.new(@host, 0)
|
|
32
|
+
@port = @socket.addr[1]
|
|
33
|
+
@thread = Thread.new { accept_loop }
|
|
34
|
+
@thread.report_on_exception = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mcp_url = "http://#{@host}:#{@port}/mcp/#{@turn.id}?t=#{@token}"
|
|
38
|
+
|
|
39
|
+
# Health-check before spawning claude: a booting server that refuses the
|
|
40
|
+
# first connection gets marked "pending" by the CLI with no retry (spike trap).
|
|
41
|
+
def await_ready!(timeout: 5.0)
|
|
42
|
+
deadline = clock + timeout
|
|
43
|
+
loop do
|
|
44
|
+
TCPSocket.new(@host, @port).close
|
|
45
|
+
return true
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
raise Silas::Error, "MCP server did not become ready" if clock > deadline
|
|
48
|
+
|
|
49
|
+
sleep 0.02
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stop
|
|
54
|
+
@stopping = true
|
|
55
|
+
@socket&.close
|
|
56
|
+
@thread&.kill
|
|
57
|
+
rescue IOError
|
|
58
|
+
# already closed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def accept_loop
|
|
64
|
+
loop do
|
|
65
|
+
client = @socket.accept
|
|
66
|
+
Thread.new(client) { |c| handle_connection(c) }
|
|
67
|
+
rescue IOError, Errno::EBADF
|
|
68
|
+
break # socket closed by #stop
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_connection(client)
|
|
73
|
+
request_line = client.gets
|
|
74
|
+
return unless request_line
|
|
75
|
+
|
|
76
|
+
method, target, = request_line.split(" ")
|
|
77
|
+
path, query = target.split("?", 2)
|
|
78
|
+
token = query_param(query, "t")
|
|
79
|
+
|
|
80
|
+
headers = read_headers(client)
|
|
81
|
+
length = headers["content-length"].to_i
|
|
82
|
+
body = length.positive? ? client.read(length) : ""
|
|
83
|
+
|
|
84
|
+
status, payload = dispatch(method, path, token, body)
|
|
85
|
+
write_response(client, status, payload)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
write_response(client, 500, nil) rescue nil
|
|
88
|
+
ensure
|
|
89
|
+
client.close rescue nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def dispatch(method, path, token, body)
|
|
93
|
+
return [ 405, nil ] unless method == "POST"
|
|
94
|
+
|
|
95
|
+
# ActiveRecord connection per handler thread; released after the call.
|
|
96
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
97
|
+
@handler.call(path: path, query_token: token, body: body)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def read_headers(client)
|
|
102
|
+
headers = {}
|
|
103
|
+
while (line = client.gets) && line != "\r\n"
|
|
104
|
+
key, value = line.split(":", 2)
|
|
105
|
+
headers[key.strip.downcase] = value.to_s.strip if value
|
|
106
|
+
end
|
|
107
|
+
headers
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def query_param(query, key)
|
|
111
|
+
return nil unless query
|
|
112
|
+
|
|
113
|
+
query.split("&").each do |pair|
|
|
114
|
+
k, v = pair.split("=", 2)
|
|
115
|
+
return v if k == key
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write_response(client, status, body)
|
|
121
|
+
reason = { 200 => "OK", 202 => "Accepted", 400 => "Bad Request", 403 => "Forbidden",
|
|
122
|
+
404 => "Not Found", 405 => "Method Not Allowed", 500 => "Internal Server Error" }[status] || "OK"
|
|
123
|
+
body = body.to_s
|
|
124
|
+
client.write("HTTP/1.1 #{status} #{reason}\r\n")
|
|
125
|
+
client.write("content-type: application/json\r\n")
|
|
126
|
+
client.write("content-length: #{body.bytesize}\r\n")
|
|
127
|
+
client.write("connection: close\r\n\r\n")
|
|
128
|
+
client.write(body)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Rebuilds the provider conversation deterministically from persisted rows —
|
|
3
|
+
# the rows ARE the transcript (no separate events table in v0.1). Replayed
|
|
4
|
+
# executions must produce byte-identical message arrays, so nothing here may
|
|
5
|
+
# read mutable state or the clock.
|
|
6
|
+
module MessageBuilder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Canonical provider-agnostic shape; adapters map it to their wire format.
|
|
10
|
+
# { role: "user", content: "..." }
|
|
11
|
+
# { role: "assistant", content: [ {type:, ...blocks} ] }
|
|
12
|
+
# { role: "tool", tool_call_id:, content: {...} }
|
|
13
|
+
# NOTE: always fresh queries, never cached associations — this runs inside
|
|
14
|
+
# a live loop where rows were created moments ago on the same objects, and
|
|
15
|
+
# a memoized empty `turn.steps` silently erases the model's own history.
|
|
16
|
+
def call(turn, upto_index:)
|
|
17
|
+
messages = []
|
|
18
|
+
|
|
19
|
+
Turn.where(session_id: turn.session_id).order(:index).each do |prior|
|
|
20
|
+
break if prior.index >= turn.index
|
|
21
|
+
|
|
22
|
+
messages << { role: "user", content: prior.input }
|
|
23
|
+
messages.concat(step_messages(prior, upto_index: nil))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
messages << { role: "user", content: turn.input }
|
|
27
|
+
messages.concat(step_messages(turn, upto_index: upto_index))
|
|
28
|
+
messages
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def step_messages(turn, upto_index:)
|
|
32
|
+
Step.where(turn_id: turn.id).order(:index).each_with_object([]) do |step, acc|
|
|
33
|
+
next unless step.completed?
|
|
34
|
+
next if upto_index && step.index >= upto_index
|
|
35
|
+
|
|
36
|
+
# The settled invocations are the ledger's source of truth for what the
|
|
37
|
+
# model actually invoked. Reconstruct the assistant's tool_use blocks
|
|
38
|
+
# from them (not from step.response_blocks, which can drift when the
|
|
39
|
+
# model emits parallel tool calls) so every tool_result replayed below
|
|
40
|
+
# has a matching tool_use by construction — the provider requires it.
|
|
41
|
+
settled = ToolInvocation.where(step_id: step.id).order(:id)
|
|
42
|
+
.select { |inv| inv.status == "completed" || inv.status == "failed" }
|
|
43
|
+
|
|
44
|
+
acc << { role: "assistant", content: assistant_blocks(step, settled) }
|
|
45
|
+
settled.each do |inv|
|
|
46
|
+
acc << { role: "tool", tool_call_id: inv.tool_call_id, content: inv.result }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Text comes from the model's own blocks; tool_use blocks are rebuilt from
|
|
52
|
+
# the settled invocations so the assistant message and the tool results that
|
|
53
|
+
# follow are always a matched set (same ids, same count).
|
|
54
|
+
def assistant_blocks(step, settled)
|
|
55
|
+
text = Array(step.response_blocks).select { |b| b["type"] == "text" }
|
|
56
|
+
tools = settled.map do |inv|
|
|
57
|
+
{ "type" => "tool_call", "id" => inv.tool_call_id,
|
|
58
|
+
"name" => inv.tool_name, "arguments" => inv.arguments || {} }
|
|
59
|
+
end
|
|
60
|
+
text + tools
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Drives a nested subagent Session's turn to terminal INLINE (no AgentLoopJob),
|
|
3
|
+
# inside the parent's delegate #call. Swaps the subagent's scope in as the
|
|
4
|
+
# active globals for the duration, then restores — so the existing loop
|
|
5
|
+
# machinery (StepRunner/Instructions/Budget/Ledger) runs unchanged against the
|
|
6
|
+
# subagent's tools/instructions. Synchronous, depth-1 (v1).
|
|
7
|
+
#
|
|
8
|
+
# Durability of the nested run = its persisted rows; its exactly-once BOUNDARY
|
|
9
|
+
# is the parent's at_most_once delegate invocation (a crash parks it in-doubt).
|
|
10
|
+
module NestedRunner
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def run(session, input:)
|
|
14
|
+
scope = Silas.subagent_scope(session.agent_name)
|
|
15
|
+
turn = session.turns.create!(index: 0, input: input)
|
|
16
|
+
|
|
17
|
+
Silas.with_agent_scope(scope) do
|
|
18
|
+
agent = scope.agent
|
|
19
|
+
turn.update!(status: "running", started_at: Time.current)
|
|
20
|
+
Instructions.snapshot!(turn) # renders the SUBAGENT's instructions + snapshots ITS digest
|
|
21
|
+
|
|
22
|
+
index = 0
|
|
23
|
+
loop do
|
|
24
|
+
case StepRunner.call(turn, index)
|
|
25
|
+
when :parked then return turn.finish!(:failed, reason: "subagent_parked") && turn.reload
|
|
26
|
+
when :terminal then return turn.finish!(:completed) && turn.reload
|
|
27
|
+
end
|
|
28
|
+
if (reason = Budget.exceeded_reason(turn, agent: agent))
|
|
29
|
+
return turn.finish!(:failed, reason: reason) && turn.reload
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
index += 1
|
|
33
|
+
if index >= agent.max_steps
|
|
34
|
+
turn.finish!(:failed, reason: "max_steps")
|
|
35
|
+
return turn.reload
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|