silas 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/README.md +12 -6
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/jobs/silas/agent_loop_job.rb +2 -0
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +37 -3
- data/app/models/silas/turn.rb +7 -1
- data/app/views/silas/channel_mailer/approval.text.erb +2 -2
- data/app/views/silas/channels/approvals/show.html.erb +1 -1
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- data/config/brakeman.ignore +11 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +2 -1
- data/lib/generators/silas/install/templates/initializer.rb +1 -1
- data/lib/silas/{engines → adapters}/base.rb +12 -2
- data/lib/silas/adapters/ruby_llm.rb +221 -0
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/chat.rb +2 -2
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +40 -9
- data/lib/silas/delta_buffer.rb +3 -3
- data/lib/silas/deprecator.rb +16 -0
- data/lib/silas/engine.rb +15 -2
- data/lib/silas/eval/driver.rb +1 -1
- data/lib/silas/eval/dsl.rb +1 -1
- data/lib/silas/eval/scripted_engine.rb +3 -3
- data/lib/silas/inbox/delta_broadcaster.rb +1 -1
- data/lib/silas/instrumentation.rb +59 -0
- data/lib/silas/ledger.rb +14 -0
- data/lib/silas/log_subscriber.rb +83 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +10 -7
- data/lib/silas/step_runner.rb +10 -2
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +28 -14
- metadata +15 -3
- data/lib/silas/engines/ruby_llm.rb +0 -165
data/lib/silas/schedule.rb
CHANGED
|
@@ -10,33 +10,59 @@ module Silas
|
|
|
10
10
|
#
|
|
11
11
|
# Identity is the filesystem path under app/agent/schedules (subdirs included):
|
|
12
12
|
# schedules/billing/sweep.rb -> "billing/sweep" -> Agent::Schedules::Billing::Sweep.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Named agents own their schedules the same way they own tools and skills:
|
|
14
|
+
# app/agents/analyst/schedules/monday_kpis.md -> "agents/analyst/monday_kpis",
|
|
15
|
+
# and its ticks start THAT agent — a staff member's cron never wakes the root
|
|
16
|
+
# agent. Schedules are NOT model-visible capabilities, so they never enter
|
|
17
|
+
# the definitions digest — adding or removing one cannot diverge an
|
|
18
|
+
# in-flight turn.
|
|
15
19
|
class Schedule
|
|
16
20
|
KINDS = %i[task handler].freeze
|
|
17
21
|
|
|
18
|
-
attr_reader :name, :cron, :queue, :kind, :payload
|
|
22
|
+
attr_reader :name, :cron, :queue, :kind, :payload, :agent_name
|
|
19
23
|
|
|
20
|
-
def initialize(name:, cron:, queue:, kind:, payload:)
|
|
24
|
+
def initialize(name:, cron:, queue:, kind:, payload:, agent_name: nil)
|
|
21
25
|
@name = name
|
|
22
26
|
@cron = cron
|
|
23
27
|
@queue = queue
|
|
24
28
|
@kind = kind
|
|
25
29
|
@payload = payload
|
|
30
|
+
@agent_name = agent_name
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def self.parse(path, root:)
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
agents_dir = root.join("app/agents")
|
|
35
|
+
if path.to_s.start_with?("#{agents_dir}#{File::SEPARATOR}")
|
|
36
|
+
parse_named(path, agents_dir: agents_dir)
|
|
37
|
+
else
|
|
38
|
+
rel = path.relative_path_from(root.join("app/agent/schedules"))
|
|
39
|
+
name = rel.sub_ext("").to_s
|
|
40
|
+
case path.extname
|
|
41
|
+
when ".md" then from_markdown(name, path)
|
|
42
|
+
when ".rb"
|
|
43
|
+
const = "Agent::Schedules::" + name.split("/").map(&:camelize).join("::")
|
|
44
|
+
from_handler(name, const.constantize)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# app/agents/<agent>/schedules/<rel> — the "agents/" name prefix keeps
|
|
50
|
+
# names (and therefore recurring keys) collision-free against a root
|
|
51
|
+
# schedule that happens to share the path shape.
|
|
52
|
+
def self.parse_named(path, agents_dir:)
|
|
53
|
+
rel = path.relative_path_from(agents_dir) # analyst/schedules/monday_kpis.md
|
|
54
|
+
agent = rel.each_filename.first
|
|
55
|
+
sched = rel.relative_path_from(Pathname(agent).join("schedules")).sub_ext("").to_s
|
|
56
|
+
name = "agents/#{agent}/#{sched}"
|
|
31
57
|
case path.extname
|
|
32
|
-
when ".md" then from_markdown(name, path)
|
|
58
|
+
when ".md" then from_markdown(name, path, agent_name: agent)
|
|
33
59
|
when ".rb"
|
|
34
|
-
const = "
|
|
35
|
-
from_handler(name, const.constantize)
|
|
60
|
+
const = "Agents::#{agent.camelize}::Schedules::" + sched.split("/").map(&:camelize).join("::")
|
|
61
|
+
from_handler(name, const.constantize, agent_name: agent)
|
|
36
62
|
end
|
|
37
63
|
end
|
|
38
64
|
|
|
39
|
-
def self.from_markdown(name, path)
|
|
65
|
+
def self.from_markdown(name, path, agent_name: nil)
|
|
40
66
|
content = File.read(path)
|
|
41
67
|
# Tolerate leading whitespace/blank lines (e.g. an ERB comment in a
|
|
42
68
|
# generator template renders to an empty first line).
|
|
@@ -51,10 +77,10 @@ module Silas
|
|
|
51
77
|
raise Error, "schedule #{name}: missing `cron:` frontmatter" if cron.nil?
|
|
52
78
|
|
|
53
79
|
new(name: name, cron: cron.to_s, queue: (frontmatter["queue"] || Silas.config.queue_name).to_s,
|
|
54
|
-
kind: :task, payload: body.strip)
|
|
80
|
+
kind: :task, payload: body.strip, agent_name: agent_name)
|
|
55
81
|
end
|
|
56
82
|
|
|
57
|
-
def self.from_handler(name, klass)
|
|
83
|
+
def self.from_handler(name, klass, agent_name: nil)
|
|
58
84
|
unless klass.ancestors.include?(Schedule::Handler)
|
|
59
85
|
raise Error, "#{klass} (schedule #{name}) must inherit Silas::Schedule::Handler"
|
|
60
86
|
end
|
|
@@ -62,14 +88,17 @@ module Silas
|
|
|
62
88
|
raise Error, "schedule #{name}: handler must declare `cron` or `every`" if cron.nil?
|
|
63
89
|
|
|
64
90
|
new(name: name, cron: cron, queue: (klass.queue || Silas.config.queue_name).to_s,
|
|
65
|
-
kind: :handler, payload: klass)
|
|
91
|
+
kind: :handler, payload: klass, agent_name: agent_name)
|
|
66
92
|
end
|
|
67
93
|
|
|
68
|
-
# The only behavioural difference between the two forms.
|
|
94
|
+
# The only behavioural difference between the two forms. A named agent's
|
|
95
|
+
# task starts THAT agent (its tools, instructions, digest — the loop swaps
|
|
96
|
+
# the scope in for every turn of the session it creates).
|
|
69
97
|
def trigger!
|
|
70
98
|
case kind
|
|
71
99
|
when :task
|
|
72
|
-
Silas.agent
|
|
100
|
+
owner = agent_name ? Silas.agent(agent_name) : Silas.agent
|
|
101
|
+
owner.start(input: payload, metadata: { "trigger" => "schedule", "schedule" => name })
|
|
73
102
|
when :handler
|
|
74
103
|
payload.new(schedule: self).call
|
|
75
104
|
end
|
data/lib/silas/slack.rb
CHANGED
|
@@ -43,13 +43,16 @@ module Silas
|
|
|
43
43
|
|
|
44
44
|
# Slack signs each request (v0 scheme) — HMAC-SHA256 over "v0:ts:body" plus a
|
|
45
45
|
# 5-minute replay window. Returns true only for a genuine, fresh request.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
# Slack ALWAYS sends a timestamp, so a missing one is refused here rather
|
|
47
|
+
# than falling through to Webhook's "no timestamp, no window" default.
|
|
48
|
+
def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.current.to_i)
|
|
49
|
+
return false if timestamp.blank?
|
|
50
|
+
|
|
51
|
+
Silas::Webhook.verify_hmac(
|
|
52
|
+
secret: signing_secret, signature: signature,
|
|
53
|
+
payload: "v0:#{timestamp}:#{body}", timestamp: timestamp,
|
|
54
|
+
window: REPLAY_WINDOW, prefix: "v0=", now: now
|
|
55
|
+
)
|
|
53
56
|
end
|
|
54
57
|
end
|
|
55
58
|
end
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -51,7 +51,12 @@ module Silas
|
|
|
51
51
|
|
|
52
52
|
def execute_model_call(turn, index, step)
|
|
53
53
|
assert_definitions_unchanged!(turn)
|
|
54
|
-
|
|
54
|
+
# Compact BEFORE building messages, inside the isolated step: a crash
|
|
55
|
+
# anywhere in this step re-runs it, and the compaction claim is
|
|
56
|
+
# idempotent — a re-executed step sees the identical (compacted) history
|
|
57
|
+
# its first attempt saw. MessageBuilder reads the row this ensures.
|
|
58
|
+
Compactor.ensure!(turn)
|
|
59
|
+
engine = Silas.resolved_adapter
|
|
55
60
|
context = {
|
|
56
61
|
turn: turn,
|
|
57
62
|
index: index,
|
|
@@ -64,7 +69,7 @@ module Silas
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
# Live deltas: the engine yields Events, the buffer coalesces them into
|
|
67
|
-
# "silas
|
|
72
|
+
# "delta.silas" notifications. A replayed step never reaches this method
|
|
68
73
|
# (the completed? guard above), so replay emits nothing. The emitter is
|
|
69
74
|
# created HERE and closed over by the inner block, so around_model_call
|
|
70
75
|
# hooks keep their existing one-argument contract and can't swallow it.
|
|
@@ -93,6 +98,9 @@ module Silas
|
|
|
93
98
|
return if live == turn.definitions_digest
|
|
94
99
|
|
|
95
100
|
turn.finish!(:failed, reason: "definitions_changed")
|
|
101
|
+
Silas.instrument(:nondeterminism, turn_id: turn.id,
|
|
102
|
+
was: turn.definitions_digest.to_s[0, 12],
|
|
103
|
+
now: live[0, 12])
|
|
96
104
|
raise NondeterminismError,
|
|
97
105
|
"Tool/skill definitions changed mid-turn (digest #{turn.definitions_digest[0, 12]}… → " \
|
|
98
106
|
"#{live[0, 12]}…). The turn was failed rather than resumed against a different agent."
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Tools
|
|
3
|
+
# Parks the turn to ask the HUMAN something — information, not permission.
|
|
4
|
+
#
|
|
5
|
+
# It rides the approval machinery end to end (park at zero compute, TTL
|
|
6
|
+
# expiry, channel ping, the resume gate), differing only in the verdict: an
|
|
7
|
+
# operator ANSWERS, and the answer text becomes the tool result the model
|
|
8
|
+
# resumes with. Replay determinism is free — the answer is a persisted row
|
|
9
|
+
# like any other tool result.
|
|
10
|
+
class AskQuestion < Tool
|
|
11
|
+
description "Ask the human operator a question and pause until they answer. " \
|
|
12
|
+
"Use when you need information only a person has. The run parks at " \
|
|
13
|
+
"zero compute until the answer arrives; the answer is returned as this tool's result."
|
|
14
|
+
param :question, :string, desc: "The question, complete and self-contained — " \
|
|
15
|
+
"the operator sees nothing but this text."
|
|
16
|
+
approval :always # the park; ToolInvocation#answer!/decline! settle it
|
|
17
|
+
|
|
18
|
+
def call(question:)
|
|
19
|
+
# pending+approved would execute this, but ToolInvocation#approve!
|
|
20
|
+
# refuses questions (answer! is their verdict) — reaching here is a
|
|
21
|
+
# framework bug, never a user mistake.
|
|
22
|
+
raise Error, "ask_question is answered, not executed — ToolInvocation#answer! settles it"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/silas/version.rb
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
# Inbound webhook signature verification, shared by every channel.
|
|
5
|
+
#
|
|
6
|
+
# Vendors differ only in what they sign and how they label it: Slack signs
|
|
7
|
+
# "v0:#{timestamp}:#{body}" and prefixes "v0=", GitHub signs the raw body and
|
|
8
|
+
# prefixes "sha256=", Shopify signs the raw body and Base64-encodes it. The
|
|
9
|
+
# dangerous parts are identical everywhere — constant-time comparison, a
|
|
10
|
+
# replay window, and failing closed on a missing secret — so they live here
|
|
11
|
+
# and get tested once, while the caller supplies the vendor's shape.
|
|
12
|
+
module Webhook
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
REPLAY_WINDOW = 300 # seconds
|
|
16
|
+
|
|
17
|
+
# Returns true ONLY for a genuine, fresh request. Every failure path returns
|
|
18
|
+
# false rather than raising: a webhook endpoint answers 401, it does not 500.
|
|
19
|
+
#
|
|
20
|
+
# secret the shared signing secret. Blank => false (a channel with no
|
|
21
|
+
# secret configured must reject, never accept).
|
|
22
|
+
# signature the header value, exactly as sent.
|
|
23
|
+
# payload the bytes the vendor signed — usually request.raw_post,
|
|
24
|
+
# sometimes a basestring built from it. NEVER the parsed
|
|
25
|
+
# params: re-serializing changes the bytes and the HMAC.
|
|
26
|
+
# timestamp the vendor's request timestamp, if it sends one. Present =>
|
|
27
|
+
# anything outside +/- window is refused as a replay.
|
|
28
|
+
# prefix whatever the vendor puts before the digest ("v0=", "sha256=").
|
|
29
|
+
# digest "hex" (nearly everyone) or "base64" (Shopify, Twilio).
|
|
30
|
+
def verify_hmac(secret:, signature:, payload:, timestamp: nil, window: REPLAY_WINDOW,
|
|
31
|
+
prefix: "", algorithm: "SHA256", digest: :hex, now: Time.current.to_i)
|
|
32
|
+
return false if secret.blank? || signature.blank?
|
|
33
|
+
return false if timestamp.present? && window && (now - timestamp.to_i).abs > window
|
|
34
|
+
|
|
35
|
+
expected = prefix + hmac(algorithm, secret, payload, digest)
|
|
36
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def hmac(algorithm, secret, payload, digest)
|
|
40
|
+
case digest
|
|
41
|
+
when :hex then OpenSSL::HMAC.hexdigest(algorithm, secret, payload)
|
|
42
|
+
when :base64 then Base64.strict_encode64(OpenSSL::HMAC.digest(algorithm, secret, payload))
|
|
43
|
+
else raise ArgumentError, "digest must be :hex or :base64, got #{digest.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/silas.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "active_job/railtie" if defined?(::Rails::Railtie)
|
|
|
4
4
|
require "active_record/railtie" if defined?(::Rails::Railtie)
|
|
5
5
|
|
|
6
6
|
require "silas/version"
|
|
7
|
+
require "silas/deprecator"
|
|
8
|
+
require "silas/instrumentation"
|
|
7
9
|
require "silas/errors"
|
|
8
10
|
require "silas/configuration"
|
|
9
11
|
require "silas/ledger"
|
|
@@ -17,6 +19,7 @@ require "silas/tools/delegate"
|
|
|
17
19
|
require "silas/nested_runner"
|
|
18
20
|
require "silas/sandbox"
|
|
19
21
|
require "silas/tools/run_code"
|
|
22
|
+
require "silas/webhook"
|
|
20
23
|
require "silas/channel"
|
|
21
24
|
require "silas/slack"
|
|
22
25
|
require "silas/mcp/client"
|
|
@@ -27,23 +30,26 @@ require "silas/inbox/cost"
|
|
|
27
30
|
require "silas/inbox/delta_broadcaster"
|
|
28
31
|
require "silas/delta_buffer"
|
|
29
32
|
require "silas/budget"
|
|
33
|
+
require "silas/compactor"
|
|
30
34
|
require "silas/registry"
|
|
31
35
|
require "silas/agent"
|
|
36
|
+
require "silas/tools/ask_question"
|
|
32
37
|
require "silas/tools/load_skill"
|
|
33
38
|
require "silas/tools/remember"
|
|
34
39
|
require "silas/tools/recall"
|
|
35
40
|
require "silas/tools/handoff"
|
|
36
41
|
require "silas/mcp/handler"
|
|
37
42
|
require "silas/mcp/server"
|
|
38
|
-
require "silas/
|
|
43
|
+
require "silas/adapters/base"
|
|
39
44
|
require "ruby_llm"
|
|
40
|
-
require "silas/
|
|
45
|
+
require "silas/adapters/ruby_llm"
|
|
41
46
|
require "silas/message_builder"
|
|
42
47
|
require "silas/instructions"
|
|
43
48
|
require "silas/step_runner"
|
|
44
|
-
require "silas/eval" # after
|
|
49
|
+
require "silas/eval" # after adapters (ScriptedEngine < Adapters::Base)
|
|
45
50
|
require "silas/chat"
|
|
46
51
|
require "silas/doctor"
|
|
52
|
+
require "silas/log_subscriber" if defined?(::ActiveSupport::LogSubscriber)
|
|
47
53
|
|
|
48
54
|
module Silas
|
|
49
55
|
class << self
|
|
@@ -60,14 +66,14 @@ module Silas
|
|
|
60
66
|
def configure
|
|
61
67
|
yield config
|
|
62
68
|
config.validate!
|
|
63
|
-
@
|
|
69
|
+
@resolved_adapter = nil
|
|
64
70
|
@resolved_sandbox = nil
|
|
65
71
|
config
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def reset_configuration! # for specs
|
|
69
75
|
@config = nil
|
|
70
|
-
@
|
|
76
|
+
@resolved_adapter = nil
|
|
71
77
|
@resolved_sandbox = nil
|
|
72
78
|
@agent = nil
|
|
73
79
|
end
|
|
@@ -103,21 +109,29 @@ module Silas
|
|
|
103
109
|
|
|
104
110
|
def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
|
|
105
111
|
|
|
106
|
-
# The inference adapter instance. config.
|
|
112
|
+
# The inference adapter instance. config.adapter may be :ruby_llm or any
|
|
107
113
|
# object responding to #execute_step (specs, custom).
|
|
108
|
-
def
|
|
109
|
-
@
|
|
110
|
-
case config.
|
|
111
|
-
when :ruby_llm then
|
|
114
|
+
def resolved_adapter
|
|
115
|
+
@resolved_adapter ||=
|
|
116
|
+
case config.adapter
|
|
117
|
+
when :ruby_llm then Adapters::RubyLLM.new
|
|
112
118
|
when :agent_sdk
|
|
113
|
-
raise Error, "the :agent_sdk
|
|
119
|
+
raise Error, "the :agent_sdk adapter was removed in Silas 0.2 — the claude -p " \
|
|
114
120
|
"subprocess integration is gone (its subscription-auth rationale was " \
|
|
115
|
-
"unreachable). Use
|
|
116
|
-
when Symbol then raise Error, "unknown
|
|
117
|
-
else config.
|
|
121
|
+
"unreachable). Use adapter :ruby_llm, the production path."
|
|
122
|
+
when Symbol then raise Error, "unknown adapter #{config.adapter.inspect}"
|
|
123
|
+
else config.adapter
|
|
118
124
|
end
|
|
119
125
|
end
|
|
120
126
|
|
|
127
|
+
# Renamed in 0.4: "engine" meant two unrelated things (the Rails engine at
|
|
128
|
+
# Silas::Engine, and the inference backend), which is exactly the collision
|
|
129
|
+
# ActiveJob avoids with QueueAdapters. Removed in 2.0.
|
|
130
|
+
def resolved_engine
|
|
131
|
+
Silas.deprecator.warn("Silas.resolved_engine is deprecated; use Silas.resolved_adapter")
|
|
132
|
+
resolved_adapter
|
|
133
|
+
end
|
|
134
|
+
|
|
121
135
|
# ---- scope-aware readers -------------------------------------------------
|
|
122
136
|
# Every reader consults the active AgentScope first (named agent or
|
|
123
137
|
# subagent), falling back to the boot-time config the Registry installed.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: silas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- app/mailers/silas/channel_mailer.rb
|
|
134
134
|
- app/models/concerns/silas/inbox/broadcastable.rb
|
|
135
135
|
- app/models/silas/application_record.rb
|
|
136
|
+
- app/models/silas/compaction.rb
|
|
136
137
|
- app/models/silas/memory.rb
|
|
137
138
|
- app/models/silas/session.rb
|
|
138
139
|
- app/models/silas/step.rb
|
|
@@ -151,6 +152,7 @@ files:
|
|
|
151
152
|
- app/views/silas/inbox/steps/_step.html.erb
|
|
152
153
|
- app/views/silas/inbox/turns/_header.html.erb
|
|
153
154
|
- app/views/silas/inbox/turns/_turn.html.erb
|
|
155
|
+
- config/brakeman.ignore
|
|
154
156
|
- config/routes.rb
|
|
155
157
|
- db/migrate/20260714000001_create_silas_tables.rb
|
|
156
158
|
- db/migrate/20260715000001_add_channel_outbound_markers.rb
|
|
@@ -161,6 +163,10 @@ files:
|
|
|
161
163
|
- db/migrate/20260721000001_create_silas_memories.rb
|
|
162
164
|
- db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb
|
|
163
165
|
- db/migrate/20260725000001_add_provider_to_silas_steps.rb
|
|
166
|
+
- db/migrate/20260725000002_create_silas_compactions.rb
|
|
167
|
+
- lib/generators/silas/channel/channel_generator.rb
|
|
168
|
+
- lib/generators/silas/channel/templates/channel.rb.tt
|
|
169
|
+
- lib/generators/silas/channel/templates/controller.rb.tt
|
|
164
170
|
- lib/generators/silas/install/install_generator.rb
|
|
165
171
|
- lib/generators/silas/install/templates/agent.yml
|
|
166
172
|
- lib/generators/silas/install/templates/bin_ci
|
|
@@ -174,19 +180,21 @@ files:
|
|
|
174
180
|
- lib/generators/silas/install/templates/instructions.md
|
|
175
181
|
- lib/generators/silas/install/templates/ruby_llm.rb
|
|
176
182
|
- lib/silas.rb
|
|
183
|
+
- lib/silas/adapters/base.rb
|
|
184
|
+
- lib/silas/adapters/ruby_llm.rb
|
|
177
185
|
- lib/silas/agent.rb
|
|
178
186
|
- lib/silas/agent_scope.rb
|
|
179
187
|
- lib/silas/budget.rb
|
|
180
188
|
- lib/silas/channel.rb
|
|
181
189
|
- lib/silas/chat.rb
|
|
190
|
+
- lib/silas/compactor.rb
|
|
182
191
|
- lib/silas/configuration.rb
|
|
183
192
|
- lib/silas/connection.rb
|
|
184
193
|
- lib/silas/connections.rb
|
|
185
194
|
- lib/silas/delta_buffer.rb
|
|
195
|
+
- lib/silas/deprecator.rb
|
|
186
196
|
- lib/silas/doctor.rb
|
|
187
197
|
- lib/silas/engine.rb
|
|
188
|
-
- lib/silas/engines/base.rb
|
|
189
|
-
- lib/silas/engines/ruby_llm.rb
|
|
190
198
|
- lib/silas/errors.rb
|
|
191
199
|
- lib/silas/eval.rb
|
|
192
200
|
- lib/silas/eval/assertions.rb
|
|
@@ -201,7 +209,9 @@ files:
|
|
|
201
209
|
- lib/silas/inbox/cost.rb
|
|
202
210
|
- lib/silas/inbox/delta_broadcaster.rb
|
|
203
211
|
- lib/silas/instructions.rb
|
|
212
|
+
- lib/silas/instrumentation.rb
|
|
204
213
|
- lib/silas/ledger.rb
|
|
214
|
+
- lib/silas/log_subscriber.rb
|
|
205
215
|
- lib/silas/mcp/client.rb
|
|
206
216
|
- lib/silas/mcp/handler.rb
|
|
207
217
|
- lib/silas/mcp/server.rb
|
|
@@ -218,6 +228,7 @@ files:
|
|
|
218
228
|
- lib/silas/slack.rb
|
|
219
229
|
- lib/silas/step_runner.rb
|
|
220
230
|
- lib/silas/tool.rb
|
|
231
|
+
- lib/silas/tools/ask_question.rb
|
|
221
232
|
- lib/silas/tools/delegate.rb
|
|
222
233
|
- lib/silas/tools/handoff.rb
|
|
223
234
|
- lib/silas/tools/load_skill.rb
|
|
@@ -225,6 +236,7 @@ files:
|
|
|
225
236
|
- lib/silas/tools/remember.rb
|
|
226
237
|
- lib/silas/tools/run_code.rb
|
|
227
238
|
- lib/silas/version.rb
|
|
239
|
+
- lib/silas/webhook.rb
|
|
228
240
|
- lib/tasks/silas_chat.rake
|
|
229
241
|
- lib/tasks/silas_doctor.rake
|
|
230
242
|
- lib/tasks/silas_eval.rake
|
|
@@ -1,165 +0,0 @@
|
|
|
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 execute_step(context, &on_event)
|
|
12
|
-
chat = build_chat(context, &on_event)
|
|
13
|
-
|
|
14
|
-
response = ActiveSupport::Notifications.instrument("silas.step",
|
|
15
|
-
turn_id: context[:turn]&.id,
|
|
16
|
-
index: context[:index],
|
|
17
|
-
model: context[:model]) do
|
|
18
|
-
if on_event
|
|
19
|
-
# Streamed: RubyLLM's accumulator returns a Message identical in
|
|
20
|
-
# shape to the sync path, so to_result needs no branch. Chunks with
|
|
21
|
-
# tool-call fragments carry nil/empty content — only text streams.
|
|
22
|
-
chat.complete do |chunk|
|
|
23
|
-
text = chunk.content
|
|
24
|
-
on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
|
|
25
|
-
end
|
|
26
|
-
else
|
|
27
|
-
chat.complete
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
to_result(chat, response)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def build_chat(context, &on_event)
|
|
37
|
-
chat = begin
|
|
38
|
-
::RubyLLM.chat(model: context[:model])
|
|
39
|
-
rescue ::RubyLLM::ModelNotFoundError
|
|
40
|
-
raise Silas::Error,
|
|
41
|
-
"Model #{context[:model].inspect} is not in ruby_llm's model registry. " \
|
|
42
|
-
"Newer models may need a registry refresh (`RubyLLM.models.refresh!`), " \
|
|
43
|
-
"or pick a registry-known model in config.default_model / agent.yml."
|
|
44
|
-
end
|
|
45
|
-
chat.with_instructions(context[:system]) if context[:system].present?
|
|
46
|
-
# agent.yml's final_answer schema: RubyLLM renders the provider's
|
|
47
|
-
# structured-output dialect and JSON-parses the response back to a
|
|
48
|
-
# Hash — which to_result persists as a "structured" block.
|
|
49
|
-
chat.with_schema(context[:final_answer]) if context[:final_answer].present?
|
|
50
|
-
context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
|
|
51
|
-
|
|
52
|
-
replay_history(chat, context[:messages])
|
|
53
|
-
|
|
54
|
-
if on_event
|
|
55
|
-
# before_message replaces the deprecated on_new_message (gone in
|
|
56
|
-
# RubyLLM 2.0). Under streaming it fires BEFORE the HTTP request —
|
|
57
|
-
# deliberate; don't "fix" the earlier timing.
|
|
58
|
-
chat.before_message { on_event.call(Event.new(type: :message_start, payload: {})) }
|
|
59
|
-
end
|
|
60
|
-
chat
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Rebuild the provider conversation from Silas's canonical rows. The last
|
|
64
|
-
# user message is delivered via ask-equivalent add_message; RubyLLM sends
|
|
65
|
-
# the whole array on complete.
|
|
66
|
-
def replay_history(chat, messages)
|
|
67
|
-
i = 0
|
|
68
|
-
while i < messages.length
|
|
69
|
-
msg = messages[i]
|
|
70
|
-
case msg[:role]
|
|
71
|
-
when "user"
|
|
72
|
-
chat.add_message(role: :user, content: msg[:content])
|
|
73
|
-
i += 1
|
|
74
|
-
when "assistant"
|
|
75
|
-
chat.add_message(
|
|
76
|
-
role: :assistant,
|
|
77
|
-
content: text_from(msg[:content]),
|
|
78
|
-
tool_calls: tool_calls_from(msg[:content])
|
|
79
|
-
)
|
|
80
|
-
i += 1
|
|
81
|
-
when "tool"
|
|
82
|
-
# Anthropic requires every tool_result for one assistant turn to sit
|
|
83
|
-
# in a single user message. The model can emit parallel tool_use
|
|
84
|
-
# blocks, so batch all consecutive tool results into one Raw message
|
|
85
|
-
# (a one-element batch is the ordinary single-tool-call case).
|
|
86
|
-
first_id = msg[:tool_call_id]
|
|
87
|
-
blocks = []
|
|
88
|
-
while i < messages.length && messages[i][:role] == "tool"
|
|
89
|
-
t = messages[i]
|
|
90
|
-
blocks << {
|
|
91
|
-
type: "tool_result",
|
|
92
|
-
tool_use_id: t[:tool_call_id],
|
|
93
|
-
content: JSON.generate(t[:content])
|
|
94
|
-
}
|
|
95
|
-
i += 1
|
|
96
|
-
end
|
|
97
|
-
chat.add_message(role: :tool, tool_call_id: first_id,
|
|
98
|
-
content: ::RubyLLM::Content::Raw.new(blocks))
|
|
99
|
-
else
|
|
100
|
-
i += 1
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def text_from(blocks)
|
|
106
|
-
Array(blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def tool_calls_from(blocks)
|
|
110
|
-
calls = Array(blocks).select { |b| b["type"] == "tool_call" }
|
|
111
|
-
return nil if calls.empty?
|
|
112
|
-
|
|
113
|
-
calls.to_h do |b|
|
|
114
|
-
[ b["id"], ::RubyLLM::ToolCall.new(id: b["id"], name: b["name"], arguments: b["arguments"]) ]
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# The halt-proxies stop the loop after the assistant's tool_use message
|
|
119
|
-
# was recorded on the chat; pull the LAST assistant message for the step.
|
|
120
|
-
def to_result(chat, response)
|
|
121
|
-
assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
|
|
122
|
-
|
|
123
|
-
blocks = []
|
|
124
|
-
content = assistant.content
|
|
125
|
-
if content.is_a?(Hash)
|
|
126
|
-
# with_schema active: RubyLLM parsed the response to a Hash. Persist
|
|
127
|
-
# it as its own block type — content.to_s here would write Ruby's
|
|
128
|
-
# Hash#inspect string into the transcript as "text".
|
|
129
|
-
blocks << { "type" => "structured", "data" => content }
|
|
130
|
-
elsif content.to_s.present?
|
|
131
|
-
blocks << { "type" => "text", "text" => content.to_s }
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
tool_calls = (assistant.tool_calls || {}).values.map do |tc|
|
|
135
|
-
blocks << { "type" => "tool_call", "id" => tc.id, "name" => tc.name,
|
|
136
|
-
"arguments" => tc.arguments || {} }
|
|
137
|
-
ToolCall.new(id: tc.id, name: tc.name, arguments: (tc.arguments || {}).stringify_keys)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
Result.new(
|
|
141
|
-
blocks: blocks,
|
|
142
|
-
tool_calls: tool_calls,
|
|
143
|
-
stop_reason: tool_calls.any? ? "tool_use" : "end_turn",
|
|
144
|
-
usage: { input_tokens: assistant.tokens&.input, output_tokens: assistant.tokens&.output }
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Presents an Silas tool schema to RubyLLM; halts instead of executing.
|
|
149
|
-
class HaltProxy < ::RubyLLM::Tool
|
|
150
|
-
def initialize(definition)
|
|
151
|
-
super()
|
|
152
|
-
@definition = definition
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def name = @definition["name"]
|
|
156
|
-
def description = @definition["description"]
|
|
157
|
-
def params_schema = @definition["input_schema"]
|
|
158
|
-
|
|
159
|
-
def execute(**)
|
|
160
|
-
::RubyLLM::Tool::Halt.new(INTERCEPTED)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|