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,164 @@
1
+ module Silas
2
+ class Configuration
3
+ # Inference engine seam: :ruby_llm (Phase 1) or :agent_sdk (stub).
4
+ attr_accessor :engine
5
+ # Auth mode for :agent_sdk — :api_key or :oauth (subscription).
6
+ attr_accessor :auth
7
+ # Default model when agent.yml doesn't specify one.
8
+ attr_accessor :default_model
9
+ # Active Job queue for agent turns.
10
+ attr_accessor :queue_name
11
+ # Optional hook wrapping every model call — e.g. ruby_llm-resilience:
12
+ # config.around_model_call = ->(ctx, &call) { RubyLLM::Resilience.chain(:anthropic) { call.() } }
13
+ attr_accessor :around_model_call
14
+ # How long a parked approval (or in-doubt invocation) waits before expiring.
15
+ # eve parks forever; Silas expires (parked-forever ghosts are a bug, not a feature).
16
+ attr_accessor :approval_ttl
17
+ # Hard cap on model calls per turn (agent.yml can lower it per-agent).
18
+ attr_accessor :max_steps
19
+ # Continuation isolation for loop steps. true in production (persistence
20
+ # per step — the durability contract); specs may disable for inline runs.
21
+ attr_accessor :isolate_steps
22
+ # Injectable seams until/beyond the Registry: name -> tool object, the tool
23
+ # schema list for the model, and the definitions digest for the
24
+ # nondeterminism guard. The Registry wires real defaults at boot.
25
+ attr_accessor :tool_resolver, :tool_definitions, :definitions_digest, :skills, :schedules
26
+ # Subagents: the roster (name+description) and per-name scope builders, plus
27
+ # the active-agent overrides swapped in during a nested run.
28
+ attr_accessor :subagent_index, :subagent_scopes, :agent_override, :instructions_dir
29
+ # Channels: name -> Channel subclass (wired by the Registry). Slack creds
30
+ # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
31
+ attr_accessor :channel_resolver
32
+ attr_writer :slack_signing_secret, :slack_bot_token
33
+ # :agent_sdk engine knobs.
34
+ attr_accessor :agent_sdk_claude_bin, :agent_sdk_model, :agent_sdk_mcp_host,
35
+ :agent_sdk_mcp_timeout_ms, :agent_sdk_cli_version_range
36
+ # Inbox (mountable UI at /silas/inbox).
37
+ # inbox_auth — deny-by-default lambda; the host renders/head-404s to
38
+ # DENY and passes by NOT rendering (resilience pattern).
39
+ # inbox_public_read — reads render for anyone; approve/decline still gated.
40
+ # inbox_actor — controller -> identity string (approved_by/decline by:).
41
+ # model_prices — model id -> {in:, out:} cost-units per 1k tokens,
42
+ # where 1_000_000 units = $1 (so a $3/M-token input rate
43
+ # is 3000 units/1k). Cost.format divides by 1e6.
44
+ attr_accessor :inbox_auth, :inbox_public_read, :inbox_actor, :model_prices
45
+ # Force-disable live broadcasting even when turbo-rails is present (nil = auto).
46
+ attr_accessor :inbox_streaming
47
+ # Evals: where *_eval.rb scenarios live, and an optional custom LLM grader.
48
+ attr_accessor :eval_dir, :eval_grader
49
+ # Connections: a test seam for injecting a fake MCP client ->(connection){ client }.
50
+ attr_accessor :mcp_client_factory
51
+ # Sandbox: :none (default, code exec off) | :docker | any object responding
52
+ # to #run. Docker knobs are inert unless :docker.
53
+ attr_accessor :sandbox, :sandbox_image, :sandbox_network, :sandbox_memory,
54
+ :sandbox_cpus, :sandbox_pids, :sandbox_workdir, :sandbox_docker_bin, :sandbox_timeout
55
+
56
+ def slack_signing_secret
57
+ @slack_signing_secret || Rails.application.credentials.dig(:silas, :slack, :signing_secret)
58
+ end
59
+
60
+ def slack_bot_token
61
+ @slack_bot_token || Rails.application.credentials.dig(:silas, :slack, :bot_token)
62
+ end
63
+
64
+ def initialize
65
+ @engine = :ruby_llm
66
+ @auth = :api_key
67
+ @default_model = "claude-sonnet-5"
68
+ @queue_name = :default
69
+ @around_model_call = nil
70
+ @approval_ttl = 7.days
71
+ @max_steps = 25
72
+ @isolate_steps = true
73
+ @tool_resolver = nil
74
+ @tool_definitions = nil
75
+ @definitions_digest = nil
76
+ @skills = nil
77
+ @schedules = nil
78
+ @subagent_index = nil
79
+ @subagent_scopes = nil
80
+ @agent_override = nil
81
+ @instructions_dir = nil
82
+ @channel_resolver = nil
83
+ @slack_signing_secret = nil
84
+ @slack_bot_token = nil
85
+ @agent_sdk_claude_bin = "claude"
86
+ @agent_sdk_model = nil # falls back to the agent model; must be a CLI-accepted id
87
+ @agent_sdk_mcp_host = "127.0.0.1"
88
+ @agent_sdk_mcp_timeout_ms = 15_000
89
+ @agent_sdk_cli_version_range = ">= 2.1.150, < 3"
90
+ @eval_dir = "test/agent_evals"
91
+ @eval_grader = nil
92
+ @sandbox = :none
93
+ @sandbox_image = nil
94
+ @sandbox_network = "none"
95
+ @sandbox_memory = "512m"
96
+ @sandbox_cpus = "1"
97
+ @sandbox_pids = 256
98
+ @sandbox_workdir = "/workspace"
99
+ @sandbox_docker_bin = "docker"
100
+ @sandbox_timeout = 30
101
+ @inbox_auth = ->(controller) { controller.head :not_found } # deny by default
102
+ @inbox_public_read = false
103
+ @inbox_actor = ->(controller) { controller.try(:current_user)&.try(:email) || "inbox" }
104
+ # Current Claude rates as cost-units per 1k tokens (1e6 units = $1):
105
+ # Sonnet $3/$15, Opus $15/$75, Haiku $1/$5 per million tokens.
106
+ @model_prices = {
107
+ "claude-sonnet-5" => { in: 3000, out: 15_000 },
108
+ "claude-opus-4-8" => { in: 15_000, out: 75_000 },
109
+ "claude-haiku-4-5-20251001" => { in: 1000, out: 5000 }
110
+ }
111
+ end
112
+
113
+ def validate!
114
+ boot_guard!
115
+ self
116
+ end
117
+
118
+ # PLAN.md §1, non-negotiable: raise if subscription OAuth is configured
119
+ # while an API key is present — the key would silently win and bill credits.
120
+ # The inverse also holds: :agent_sdk runs --bare (API-key auth only), so
121
+ # api_key mode needs a key present.
122
+ def boot_guard!
123
+ if engine == :agent_sdk && auth == :oauth && ENV["ANTHROPIC_API_KEY"].present?
124
+ raise BootGuardError,
125
+ "engine :agent_sdk with auth: :oauth is configured, but ANTHROPIC_API_KEY exists " \
126
+ "in the environment. The key would silently override subscription OAuth and drain " \
127
+ "API credits. Unset ANTHROPIC_API_KEY or switch to auth: :api_key."
128
+ end
129
+
130
+ if engine == :agent_sdk && auth == :api_key && ENV["ANTHROPIC_API_KEY"].blank?
131
+ raise BootGuardError,
132
+ "engine :agent_sdk uses --bare (API-key auth only) but ANTHROPIC_API_KEY is not set."
133
+ end
134
+
135
+ warn_unsafe_queue_adapter!
136
+ end
137
+
138
+ # Silas's durability and exactly-once guarantees rest on the queue adapter:
139
+ # a turn's jobs must run one-at-a-time and continuation retries must run in a
140
+ # FRESH execution after the prior one committed. The in-process Async adapter
141
+ # (ActiveJob's dev default) breaks both — it runs a re-enqueued continuation
142
+ # on a thread pool concurrently with the original, which double-executes
143
+ # steps and violates the single-active-turn invariant (a re-run model call
144
+ # mints new tool_call ids the ledger cannot dedup). Solid Queue — or any
145
+ # durable, serializing, DB-backed adapter — is required to run agents; the
146
+ # synchronous :inline adapter is safe for scripts and demos (no durability).
147
+ def warn_unsafe_queue_adapter!
148
+ return unless defined?(::ActiveJob::Base)
149
+
150
+ name = ::ActiveJob::Base.queue_adapter.class.name.to_s
151
+ return unless name.include?("AsyncAdapter")
152
+
153
+ message = "[Silas] ActiveJob is using the in-process Async queue adapter. " \
154
+ "This runs continuation retries on a thread pool concurrently with " \
155
+ "the original job, which double-executes agent steps and breaks " \
156
+ "exactly-once tool effects. Use :solid_queue (production) or " \
157
+ ":inline (scripts/demos). See Silas DEPLOY.md."
158
+ (defined?(::Rails) && ::Rails.logger ? ::Rails.logger.warn(message) : nil) || Kernel.warn(message)
159
+ rescue StandardError
160
+ # A diagnostic must never break boot.
161
+ nil
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,77 @@
1
+ require "yaml"
2
+
3
+ module Silas
4
+ # A typed MCP integration declared by app/agent/connections/<name>.yml. Filename
5
+ # is identity. Holds the credential PATH, never the secret (resolved from Rails
6
+ # credentials at call time). Data-only (eve's shape).
7
+ class Connection
8
+ TRANSPORTS = %w[http].freeze
9
+ APPROVALS = %i[never once always].freeze
10
+ EFFECTS = %i[at_most_once idempotent].freeze # not transactional: a remote call can't share our DB txn
11
+
12
+ attr_reader :name, :transport, :url, :approval, :effect
13
+
14
+ def self.parse(path)
15
+ attrs = YAML.safe_load(File.read(path)) || {}
16
+ attrs.empty? ? nil : new(File.basename(path, ".yml"), attrs)
17
+ end
18
+
19
+ def initialize(name, attrs)
20
+ @name = name
21
+ @transport = attrs.fetch("transport", "http")
22
+ @url = attrs["url"]
23
+ @auth = attrs["auth"] || {}
24
+ @approval = (attrs["approval"] || "never").to_sym
25
+ @effect = (attrs["effect"] || "at_most_once").to_sym
26
+ validate!
27
+ end
28
+
29
+ def client = Mcp::Client.new(url: url, headers: auth_headers)
30
+
31
+ def auth_headers
32
+ case @auth["type"]
33
+ when "bearer" then { "Authorization" => "Bearer #{secret!}" }
34
+ when "header" then { @auth.fetch("header") => secret! }
35
+ else {}
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def secret!
42
+ path = @auth.fetch("credential")
43
+ val = Rails.application.credentials.dig(*path.split(".").map(&:to_sym))
44
+ raise Error, "connection #{name}: missing credential #{path.inspect}" if val.blank?
45
+
46
+ val
47
+ end
48
+
49
+ def validate!
50
+ raise Error, "connection #{name}: url required" if url.blank?
51
+ raise Error, "connection #{name}: transport #{transport.inspect} unsupported (v1: http)" unless TRANSPORTS.include?(transport)
52
+ raise Error, "connection #{name}: approval #{approval.inspect} invalid" unless APPROVALS.include?(approval)
53
+ raise Error, "connection #{name}: effect #{effect.inspect} invalid" unless EFFECTS.include?(effect)
54
+ end
55
+
56
+ # One remote tool, resolved. Quacks like a resolved Silas::Tool for the
57
+ # Ledger (approval_policy / effect_mode / session= / call) without being a
58
+ # Silas::Tool subclass — its schema is the remote inputSchema.
59
+ class RemoteTool
60
+ attr_accessor :session
61
+
62
+ def initialize(connection:, remote_name:, client: nil)
63
+ @connection = connection
64
+ @remote_name = remote_name
65
+ @client = client || connection.client
66
+ end
67
+
68
+ def approval_policy = @connection.approval
69
+ def effect_mode = @connection.effect
70
+
71
+ def call(**args)
72
+ @client.call_tool(@remote_name, args.deep_stringify_keys) ||
73
+ { "isError" => true, "content" => [ { "type" => "text", "text" => "no result" } ] }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,52 @@
1
+ module Silas
2
+ # Discovery + boot-warm for app/agent/connections/*.yml. Lists each remote MCP
3
+ # server's tools ONCE at boot (cached), surfaces them namespaced as
4
+ # "<connection>__<tool>" (model-visible -> in the digest), and resolves those
5
+ # names to fresh RemoteTool instances for the Ledger.
6
+ class Connections
7
+ def initialize(root:, client_factory: nil)
8
+ @root = Pathname(root)
9
+ @client_factory = client_factory
10
+ @definitions = []
11
+ @by_name = {}
12
+ end
13
+
14
+ def descriptors
15
+ @descriptors ||= Dir[@root.join("app/agent/connections/*.yml")].sort.filter_map { |f| Connection.parse(f) }
16
+ end
17
+
18
+ # Boot-time tools/list, cached. Fail-loud (like the Registry's boot tool
19
+ # validation): a misconfigured/unreachable connection raises HERE, never
20
+ # inside a turn or a mid-turn digest check.
21
+ def warm!
22
+ return self if @warmed
23
+
24
+ descriptors.each do |conn|
25
+ client = @client_factory&.call(conn) || conn.client
26
+ client.list_tools.each do |remote|
27
+ ns = "#{conn.name}__#{remote['name']}"
28
+ @definitions << {
29
+ "name" => ns, "description" => remote["description"].to_s,
30
+ "input_schema" => remote["inputSchema"] || { "type" => "object", "properties" => {} }
31
+ }
32
+ @by_name[ns] = [ conn, remote["name"] ]
33
+ end
34
+ rescue StandardError => e
35
+ raise Error, "connection #{conn.name}: tools/list failed at boot — #{e.class}: #{e.message}"
36
+ end
37
+ @warmed = true
38
+ self
39
+ end
40
+
41
+ def definitions = @definitions.dup
42
+
43
+ def resolve(name)
44
+ pair = @by_name[name]
45
+ return nil unless pair
46
+
47
+ conn, remote = pair
48
+ client = @client_factory&.call(conn) || conn.client
49
+ Connection::RemoteTool.new(connection: conn, remote_name: remote, client: client)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,36 @@
1
+ module Silas
2
+ # The Rails engine (not to be confused with inference adapters under
3
+ # Silas::Engines::*). Full engine: Silas is Rails-native by thesis.
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Silas
6
+
7
+ # Make app/agent/ a first-class app directory: files there autoload under
8
+ # the bare Agent namespace, so app/agent/tools/issue_refund.rb defines
9
+ # Agent::Tools::IssueRefund. Tool identity remains the filename.
10
+ initializer "silas.agent_directory" do |app|
11
+ agent_dir = app.root.join("app/agent")
12
+ next unless agent_dir.exist?
13
+
14
+ unless defined?(::Agent)
15
+ Object.const_set(:Agent, Module.new)
16
+ end
17
+ app.autoloaders.main.push_dir(agent_dir, namespace: ::Agent)
18
+
19
+ # Markdown/YAML (instructions.md, agent.yml, skills/*.md) are not Ruby;
20
+ # keep Zeitwerk away from them. tools/, schedules/ (.rb handlers), and
21
+ # channels/ all autoload under the Agent namespace via push_dir above.
22
+ app.autoloaders.main.ignore(agent_dir.join("skills"))
23
+ end
24
+
25
+ initializer "silas.boot_guard", after: :load_config_initializers do
26
+ Silas.config.boot_guard!
27
+ end
28
+
29
+ # Registry rebuilds on every code reload in development, once in production.
30
+ initializer "silas.registry" do |app|
31
+ next unless app.root.join("app/agent").exist?
32
+
33
+ app.config.to_prepare { Silas::Registry.install!(root: Rails.root) }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,30 @@
1
+ module Silas
2
+ # Streamed event from an engine (or the framework) during a step.
3
+ # Types: :text_delta, :tool_call, :thinking, :usage — and :approval_request,
4
+ # which only :engine-owned loops (agent_sdk) emit; in :framework-owned loops
5
+ # approvals are a Ledger concern, never an engine event.
6
+ Event = Data.define(:type, :payload)
7
+
8
+ module Engines
9
+ # The inference seam. An engine executes exactly ONE model call for a step
10
+ # and reports what came back; the framework owns the loop, the ledger owns
11
+ # tool execution.
12
+ class Base
13
+ # :framework — Silas's AgentLoopJob drives the loop (ruby_llm).
14
+ # :engine — the engine drives its own loop (agent_sdk, future).
15
+ def self.loop_ownership = :framework
16
+
17
+ # context: { turn:, index:, system:, messages:, tools:, model:, limits: }
18
+ # Yields Silas::Event objects as they stream; returns a Result.
19
+ def execute_step(context, &on_event)
20
+ raise NotImplementedError, "#{self.class} must implement #execute_step"
21
+ end
22
+ end
23
+
24
+ Result = Data.define(:blocks, :tool_calls, :stop_reason, :usage) do
25
+ def terminal? = tool_calls.empty?
26
+ end
27
+
28
+ ToolCall = Data.define(:id, :name, :arguments)
29
+ end
30
+ end
@@ -0,0 +1,136 @@
1
+ module Silas
2
+ module Engines
3
+ # The :ruby_llm adapter: ONE model call per step, streamed, with tool
4
+ # interception. Silas's Ledger owns tool execution, so tools are registered
5
+ # as halt-proxies — RubyLLM sees the schemas, but the moment the model
6
+ # requests a tool the proxy halts the chat loop and the calls are handed
7
+ # back to the framework untouched.
8
+ class RubyLLM < Base
9
+ INTERCEPTED = "__silas_intercepted__".freeze
10
+
11
+ def self.loop_ownership = :framework
12
+
13
+ def execute_step(context, &on_event)
14
+ chat = build_chat(context, &on_event)
15
+
16
+ response = ActiveSupport::Notifications.instrument("silas.step",
17
+ turn_id: context[:turn]&.id,
18
+ index: context[:index],
19
+ model: context[:model]) do
20
+ chat.complete
21
+ end
22
+
23
+ to_result(chat, response)
24
+ end
25
+
26
+ private
27
+
28
+ def build_chat(context, &on_event)
29
+ chat = ::RubyLLM.chat(model: context[:model])
30
+ chat.with_instructions(context[:system]) if context[:system].present?
31
+ context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
32
+
33
+ replay_history(chat, context[:messages])
34
+
35
+ if on_event
36
+ chat.on_new_message { on_event.call(Event.new(type: :message_start, payload: {})) }
37
+ end
38
+ chat
39
+ end
40
+
41
+ # Rebuild the provider conversation from Silas's canonical rows. The last
42
+ # user message is delivered via ask-equivalent add_message; RubyLLM sends
43
+ # the whole array on complete.
44
+ def replay_history(chat, messages)
45
+ i = 0
46
+ while i < messages.length
47
+ msg = messages[i]
48
+ case msg[:role]
49
+ when "user"
50
+ chat.add_message(role: :user, content: msg[:content])
51
+ i += 1
52
+ when "assistant"
53
+ chat.add_message(
54
+ role: :assistant,
55
+ content: text_from(msg[:content]),
56
+ tool_calls: tool_calls_from(msg[:content])
57
+ )
58
+ i += 1
59
+ when "tool"
60
+ # Anthropic requires every tool_result for one assistant turn to sit
61
+ # in a single user message. The model can emit parallel tool_use
62
+ # blocks, so batch all consecutive tool results into one Raw message
63
+ # (a one-element batch is the ordinary single-tool-call case).
64
+ first_id = msg[:tool_call_id]
65
+ blocks = []
66
+ while i < messages.length && messages[i][:role] == "tool"
67
+ t = messages[i]
68
+ blocks << {
69
+ type: "tool_result",
70
+ tool_use_id: t[:tool_call_id],
71
+ content: JSON.generate(t[:content])
72
+ }
73
+ i += 1
74
+ end
75
+ chat.add_message(role: :tool, tool_call_id: first_id,
76
+ content: ::RubyLLM::Content::Raw.new(blocks))
77
+ else
78
+ i += 1
79
+ end
80
+ end
81
+ end
82
+
83
+ def text_from(blocks)
84
+ Array(blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
85
+ end
86
+
87
+ def tool_calls_from(blocks)
88
+ calls = Array(blocks).select { |b| b["type"] == "tool_call" }
89
+ return nil if calls.empty?
90
+
91
+ calls.to_h do |b|
92
+ [ b["id"], ::RubyLLM::ToolCall.new(id: b["id"], name: b["name"], arguments: b["arguments"]) ]
93
+ end
94
+ end
95
+
96
+ # The halt-proxies stop the loop after the assistant's tool_use message
97
+ # was recorded on the chat; pull the LAST assistant message for the step.
98
+ def to_result(chat, response)
99
+ assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
100
+
101
+ blocks = []
102
+ text = assistant.content.to_s
103
+ blocks << { "type" => "text", "text" => text } if text.present?
104
+
105
+ tool_calls = (assistant.tool_calls || {}).values.map do |tc|
106
+ blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
107
+ "arguments" => tc.arguments || {} }
108
+ ToolCall.new(id: tc.id, name: tc.name, arguments: (tc.arguments || {}).stringify_keys)
109
+ end
110
+
111
+ Result.new(
112
+ blocks: blocks,
113
+ tool_calls: tool_calls,
114
+ stop_reason: tool_calls.any? ? "tool_use" : "end_turn",
115
+ usage: { input_tokens: assistant.tokens&.input, output_tokens: assistant.tokens&.output }
116
+ )
117
+ end
118
+
119
+ # Presents an Silas tool schema to RubyLLM; halts instead of executing.
120
+ class HaltProxy < ::RubyLLM::Tool
121
+ def initialize(definition)
122
+ super()
123
+ @definition = definition
124
+ end
125
+
126
+ def name = @definition["name"]
127
+ def description = @definition["description"]
128
+ def params_schema = @definition["input_schema"]
129
+
130
+ def execute(**)
131
+ ::RubyLLM::Tool::Halt.new(INTERCEPTED)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,26 @@
1
+ module Silas
2
+ class Error < StandardError; end
3
+
4
+ # The :agent_sdk + OAuth footgun (PLAN.md boot guard, non-negotiable): an
5
+ # ANTHROPIC_API_KEY in the environment would silently override subscription
6
+ # OAuth and drain credits.
7
+ class BootGuardError < Error; end
8
+
9
+ # A continuation checkpoint occurred inside a ledger transaction. Checkpoints
10
+ # raise Interrupt, which would roll back the ledger row + side effects while
11
+ # the continuation believes progress was made (spike finding #5).
12
+ class CheckpointInLedgerError < Error; end
13
+
14
+ # The live tool/skill definitions diverged from the turn's snapshot digest —
15
+ # e.g. a deploy changed the agent mid-turn. Fail loudly, never resume into a
16
+ # different agent.
17
+ class NondeterminismError < Error; end
18
+
19
+ # A second turn was started while one is active for the session. The partial
20
+ # unique index is the backstop; this is the friendly front door.
21
+ class TurnInProgressError < Error; end
22
+
23
+ # Sandbox misconfiguration or exec failure (fail-loud, like BootGuardError).
24
+ class SandboxError < Error; end
25
+ class SandboxDisabledError < SandboxError; end
26
+ end