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
|
@@ -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
|
data/lib/silas/engine.rb
ADDED
|
@@ -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
|
data/lib/silas/errors.rb
ADDED
|
@@ -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
|