silas 0.4.0 → 0.6.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 +211 -0
- data/DEPLOY.md +111 -0
- data/README.md +87 -250
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/helpers/silas/inbox/trace_helper.rb +20 -5
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/layouts/silas/inbox.html.erb +89 -30
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
- data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
- data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
- data/app/views/silas/inbox/sessions/index.html.erb +16 -15
- data/app/views/silas/inbox/sessions/show.html.erb +9 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/docs/agents.md +81 -0
- data/docs/budgets.md +67 -0
- data/docs/cancellation.md +41 -0
- data/docs/channels.md +290 -0
- data/docs/configuration.md +106 -0
- data/docs/connections.md +55 -0
- data/docs/conventions.md +161 -0
- data/docs/evals.md +95 -0
- data/docs/guarantees.md +76 -0
- data/docs/inbox-and-api.md +84 -0
- data/docs/memory.md +35 -0
- data/docs/sandbox.md +44 -0
- data/docs/tools.md +77 -0
- data/docs/tutorial.md +268 -0
- data/docs/vs-eve.md +93 -0
- data/docs/why-silas.md +87 -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 +11 -1
- data/lib/generators/silas/install/templates/claude_skill.md +136 -0
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -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/eval/dsl.rb +7 -2
- 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 +30 -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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -109,6 +109,7 @@ extensions: []
|
|
|
109
109
|
extra_rdoc_files: []
|
|
110
110
|
files:
|
|
111
111
|
- CHANGELOG.md
|
|
112
|
+
- DEPLOY.md
|
|
112
113
|
- LICENSE
|
|
113
114
|
- README.md
|
|
114
115
|
- app/controllers/concerns/silas/api/serialization.rb
|
|
@@ -133,6 +134,7 @@ files:
|
|
|
133
134
|
- app/mailers/silas/channel_mailer.rb
|
|
134
135
|
- app/models/concerns/silas/inbox/broadcastable.rb
|
|
135
136
|
- app/models/silas/application_record.rb
|
|
137
|
+
- app/models/silas/compaction.rb
|
|
136
138
|
- app/models/silas/memory.rb
|
|
137
139
|
- app/models/silas/session.rb
|
|
138
140
|
- app/models/silas/step.rb
|
|
@@ -146,6 +148,7 @@ files:
|
|
|
146
148
|
- app/views/silas/inbox/invocations/_approval_card.html.erb
|
|
147
149
|
- app/views/silas/inbox/invocations/_invocation.html.erb
|
|
148
150
|
- app/views/silas/inbox/sessions/_cost.html.erb
|
|
151
|
+
- app/views/silas/inbox/sessions/_row.html.erb
|
|
149
152
|
- app/views/silas/inbox/sessions/index.html.erb
|
|
150
153
|
- app/views/silas/inbox/sessions/show.html.erb
|
|
151
154
|
- app/views/silas/inbox/steps/_step.html.erb
|
|
@@ -162,11 +165,32 @@ files:
|
|
|
162
165
|
- db/migrate/20260721000001_create_silas_memories.rb
|
|
163
166
|
- db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb
|
|
164
167
|
- db/migrate/20260725000001_add_provider_to_silas_steps.rb
|
|
168
|
+
- db/migrate/20260725000002_create_silas_compactions.rb
|
|
169
|
+
- docs/agents.md
|
|
170
|
+
- docs/budgets.md
|
|
171
|
+
- docs/cancellation.md
|
|
172
|
+
- docs/channels.md
|
|
173
|
+
- docs/configuration.md
|
|
174
|
+
- docs/connections.md
|
|
175
|
+
- docs/conventions.md
|
|
176
|
+
- docs/evals.md
|
|
177
|
+
- docs/guarantees.md
|
|
178
|
+
- docs/inbox-and-api.md
|
|
179
|
+
- docs/memory.md
|
|
180
|
+
- docs/sandbox.md
|
|
181
|
+
- docs/tools.md
|
|
182
|
+
- docs/tutorial.md
|
|
183
|
+
- docs/vs-eve.md
|
|
184
|
+
- docs/why-silas.md
|
|
185
|
+
- lib/generators/silas/channel/channel_generator.rb
|
|
186
|
+
- lib/generators/silas/channel/templates/channel.rb.tt
|
|
187
|
+
- lib/generators/silas/channel/templates/controller.rb.tt
|
|
165
188
|
- lib/generators/silas/install/install_generator.rb
|
|
166
189
|
- lib/generators/silas/install/templates/agent.yml
|
|
167
190
|
- lib/generators/silas/install/templates/bin_ci
|
|
168
191
|
- lib/generators/silas/install/templates/channel_email.rb
|
|
169
192
|
- lib/generators/silas/install/templates/channel_slack.rb
|
|
193
|
+
- lib/generators/silas/install/templates/claude_skill.md
|
|
170
194
|
- lib/generators/silas/install/templates/example_eval.rb
|
|
171
195
|
- lib/generators/silas/install/templates/example_schedule.md
|
|
172
196
|
- lib/generators/silas/install/templates/example_skill.md
|
|
@@ -182,6 +206,7 @@ files:
|
|
|
182
206
|
- lib/silas/budget.rb
|
|
183
207
|
- lib/silas/channel.rb
|
|
184
208
|
- lib/silas/chat.rb
|
|
209
|
+
- lib/silas/compactor.rb
|
|
185
210
|
- lib/silas/configuration.rb
|
|
186
211
|
- lib/silas/connection.rb
|
|
187
212
|
- lib/silas/connections.rb
|
|
@@ -222,6 +247,7 @@ files:
|
|
|
222
247
|
- lib/silas/slack.rb
|
|
223
248
|
- lib/silas/step_runner.rb
|
|
224
249
|
- lib/silas/tool.rb
|
|
250
|
+
- lib/silas/tools/ask_question.rb
|
|
225
251
|
- lib/silas/tools/delegate.rb
|
|
226
252
|
- lib/silas/tools/handoff.rb
|
|
227
253
|
- lib/silas/tools/load_skill.rb
|
|
@@ -229,6 +255,7 @@ files:
|
|
|
229
255
|
- lib/silas/tools/remember.rb
|
|
230
256
|
- lib/silas/tools/run_code.rb
|
|
231
257
|
- lib/silas/version.rb
|
|
258
|
+
- lib/silas/webhook.rb
|
|
232
259
|
- lib/tasks/silas_chat.rake
|
|
233
260
|
- lib/tasks/silas_doctor.rake
|
|
234
261
|
- lib/tasks/silas_eval.rake
|
|
@@ -240,6 +267,8 @@ metadata:
|
|
|
240
267
|
homepage_uri: https://github.com/danielstpaul/silas
|
|
241
268
|
source_code_uri: https://github.com/danielstpaul/silas
|
|
242
269
|
changelog_uri: https://github.com/danielstpaul/silas/blob/main/CHANGELOG.md
|
|
270
|
+
documentation_uri: https://github.com/danielstpaul/silas/tree/main/docs
|
|
271
|
+
bug_tracker_uri: https://github.com/danielstpaul/silas/issues
|
|
243
272
|
rubygems_mfa_required: 'true'
|
|
244
273
|
rdoc_options: []
|
|
245
274
|
require_paths:
|