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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +135 -0
  5. data/app/controllers/silas/channels/approvals_controller.rb +34 -0
  6. data/app/controllers/silas/channels/base_controller.rb +22 -0
  7. data/app/controllers/silas/channels/slack_controller.rb +58 -0
  8. data/app/controllers/silas/inbox/base_controller.rb +37 -0
  9. data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
  11. data/app/helpers/silas/inbox/trace_helper.rb +42 -0
  12. data/app/jobs/silas/agent_loop_job.rb +95 -0
  13. data/app/jobs/silas/channel_delivery_job.rb +62 -0
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
  15. data/app/jobs/silas/schedule_job.rb +22 -0
  16. data/app/mailboxes/silas/agent_mailbox.rb +35 -0
  17. data/app/mailers/silas/channel_mailer.rb +17 -0
  18. data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
  19. data/app/models/silas/application_record.rb +5 -0
  20. data/app/models/silas/session.rb +36 -0
  21. data/app/models/silas/step.rb +33 -0
  22. data/app/models/silas/tool_invocation.rb +78 -0
  23. data/app/models/silas/turn.rb +58 -0
  24. data/app/views/layouts/silas/inbox.html.erb +91 -0
  25. data/app/views/silas/channel_mailer/approval.text.erb +9 -0
  26. data/app/views/silas/channels/approvals/done.html.erb +2 -0
  27. data/app/views/silas/channels/approvals/error.html.erb +2 -0
  28. data/app/views/silas/channels/approvals/show.html.erb +9 -0
  29. data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
  30. data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
  31. data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
  32. data/app/views/silas/inbox/sessions/index.html.erb +23 -0
  33. data/app/views/silas/inbox/sessions/show.html.erb +20 -0
  34. data/app/views/silas/inbox/steps/_step.html.erb +8 -0
  35. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  36. data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
  37. data/config/routes.rb +19 -0
  38. data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
  39. data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
  40. data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
  41. data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
  42. data/lib/generators/silas/install/install_generator.rb +81 -0
  43. data/lib/generators/silas/install/templates/agent.yml +9 -0
  44. data/lib/generators/silas/install/templates/bin_ci +7 -0
  45. data/lib/generators/silas/install/templates/channel_email.rb +18 -0
  46. data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
  47. data/lib/generators/silas/install/templates/example_eval.rb +22 -0
  48. data/lib/generators/silas/install/templates/example_schedule.md +11 -0
  49. data/lib/generators/silas/install/templates/example_skill.md +8 -0
  50. data/lib/generators/silas/install/templates/example_tool.rb +12 -0
  51. data/lib/generators/silas/install/templates/initializer.rb +16 -0
  52. data/lib/generators/silas/install/templates/instructions.md +4 -0
  53. data/lib/silas/agent.rb +33 -0
  54. data/lib/silas/agent_scope.rb +6 -0
  55. data/lib/silas/agent_sdk/cli.rb +59 -0
  56. data/lib/silas/agent_sdk/stream_parser.rb +86 -0
  57. data/lib/silas/agent_sdk/version_guard.rb +26 -0
  58. data/lib/silas/budget.rb +42 -0
  59. data/lib/silas/channel.rb +69 -0
  60. data/lib/silas/configuration.rb +164 -0
  61. data/lib/silas/connection.rb +77 -0
  62. data/lib/silas/connections.rb +52 -0
  63. data/lib/silas/engine.rb +36 -0
  64. data/lib/silas/engines/agent_sdk.rb +75 -0
  65. data/lib/silas/engines/base.rb +30 -0
  66. data/lib/silas/engines/ruby_llm.rb +136 -0
  67. data/lib/silas/errors.rb +26 -0
  68. data/lib/silas/eval/assertions.rb +107 -0
  69. data/lib/silas/eval/driver.rb +47 -0
  70. data/lib/silas/eval/dsl.rb +55 -0
  71. data/lib/silas/eval/grader.rb +37 -0
  72. data/lib/silas/eval/result.rb +28 -0
  73. data/lib/silas/eval/runner.rb +38 -0
  74. data/lib/silas/eval/scripted_engine.rb +39 -0
  75. data/lib/silas/eval/transcript.rb +22 -0
  76. data/lib/silas/eval.rb +35 -0
  77. data/lib/silas/inbox/cost.rb +58 -0
  78. data/lib/silas/inbox.rb +23 -0
  79. data/lib/silas/instructions.rb +55 -0
  80. data/lib/silas/ledger.rb +178 -0
  81. data/lib/silas/mcp/client.rb +86 -0
  82. data/lib/silas/mcp/handler.rb +89 -0
  83. data/lib/silas/mcp/server.rb +134 -0
  84. data/lib/silas/message_builder.rb +63 -0
  85. data/lib/silas/nested_runner.rb +41 -0
  86. data/lib/silas/registry.rb +155 -0
  87. data/lib/silas/sandbox/docker.rb +67 -0
  88. data/lib/silas/sandbox/null.rb +17 -0
  89. data/lib/silas/sandbox.rb +15 -0
  90. data/lib/silas/schedule/compiler.rb +52 -0
  91. data/lib/silas/schedule.rb +109 -0
  92. data/lib/silas/skill.rb +21 -0
  93. data/lib/silas/slack.rb +55 -0
  94. data/lib/silas/step_runner.rb +98 -0
  95. data/lib/silas/subprocess_runner.rb +41 -0
  96. data/lib/silas/tool.rb +93 -0
  97. data/lib/silas/tools/delegate.rb +42 -0
  98. data/lib/silas/tools/load_skill.rb +25 -0
  99. data/lib/silas/tools/run_code.rb +21 -0
  100. data/lib/silas/version.rb +3 -0
  101. data/lib/silas.rb +144 -0
  102. data/lib/tasks/silas_eval.rake +20 -0
  103. data/lib/tasks/silas_schedules.rake +33 -0
  104. metadata +232 -0
@@ -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