silas 0.4.0 → 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 +110 -0
- data/README.md +7 -1
- 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/channel_delivery_job.rb +15 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- 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/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -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 +8 -5
- data/lib/silas/step_runner.rb +5 -0
- 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 +3 -0
- metadata +9 -1
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
|
+
# 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.
|
|
46
48
|
def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.current.to_i)
|
|
47
|
-
return false if
|
|
48
|
-
return false if (now - timestamp.to_i).abs > REPLAY_WINDOW
|
|
49
|
+
return false if timestamp.blank?
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,6 +51,11 @@ module Silas
|
|
|
51
51
|
|
|
52
52
|
def execute_model_call(turn, index, step)
|
|
53
53
|
assert_definitions_unchanged!(turn)
|
|
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)
|
|
54
59
|
engine = Silas.resolved_adapter
|
|
55
60
|
context = {
|
|
56
61
|
turn: turn,
|
|
@@ -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
|
@@ -19,6 +19,7 @@ require "silas/tools/delegate"
|
|
|
19
19
|
require "silas/nested_runner"
|
|
20
20
|
require "silas/sandbox"
|
|
21
21
|
require "silas/tools/run_code"
|
|
22
|
+
require "silas/webhook"
|
|
22
23
|
require "silas/channel"
|
|
23
24
|
require "silas/slack"
|
|
24
25
|
require "silas/mcp/client"
|
|
@@ -29,8 +30,10 @@ require "silas/inbox/cost"
|
|
|
29
30
|
require "silas/inbox/delta_broadcaster"
|
|
30
31
|
require "silas/delta_buffer"
|
|
31
32
|
require "silas/budget"
|
|
33
|
+
require "silas/compactor"
|
|
32
34
|
require "silas/registry"
|
|
33
35
|
require "silas/agent"
|
|
36
|
+
require "silas/tools/ask_question"
|
|
34
37
|
require "silas/tools/load_skill"
|
|
35
38
|
require "silas/tools/remember"
|
|
36
39
|
require "silas/tools/recall"
|
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
|
|
@@ -162,6 +163,10 @@ files:
|
|
|
162
163
|
- db/migrate/20260721000001_create_silas_memories.rb
|
|
163
164
|
- db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb
|
|
164
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
|
|
165
170
|
- lib/generators/silas/install/install_generator.rb
|
|
166
171
|
- lib/generators/silas/install/templates/agent.yml
|
|
167
172
|
- lib/generators/silas/install/templates/bin_ci
|
|
@@ -182,6 +187,7 @@ files:
|
|
|
182
187
|
- lib/silas/budget.rb
|
|
183
188
|
- lib/silas/channel.rb
|
|
184
189
|
- lib/silas/chat.rb
|
|
190
|
+
- lib/silas/compactor.rb
|
|
185
191
|
- lib/silas/configuration.rb
|
|
186
192
|
- lib/silas/connection.rb
|
|
187
193
|
- lib/silas/connections.rb
|
|
@@ -222,6 +228,7 @@ files:
|
|
|
222
228
|
- lib/silas/slack.rb
|
|
223
229
|
- lib/silas/step_runner.rb
|
|
224
230
|
- lib/silas/tool.rb
|
|
231
|
+
- lib/silas/tools/ask_question.rb
|
|
225
232
|
- lib/silas/tools/delegate.rb
|
|
226
233
|
- lib/silas/tools/handoff.rb
|
|
227
234
|
- lib/silas/tools/load_skill.rb
|
|
@@ -229,6 +236,7 @@ files:
|
|
|
229
236
|
- lib/silas/tools/remember.rb
|
|
230
237
|
- lib/silas/tools/run_code.rb
|
|
231
238
|
- lib/silas/version.rb
|
|
239
|
+
- lib/silas/webhook.rb
|
|
232
240
|
- lib/tasks/silas_chat.rake
|
|
233
241
|
- lib/tasks/silas_doctor.rake
|
|
234
242
|
- lib/tasks/silas_eval.rake
|