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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +211 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -250
  5. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  6. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  7. data/app/helpers/silas/inbox/trace_helper.rb +20 -5
  8. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  9. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  10. data/app/models/silas/compaction.rb +32 -0
  11. data/app/models/silas/tool_invocation.rb +29 -3
  12. data/app/views/layouts/silas/inbox.html.erb +89 -30
  13. data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
  14. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  15. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  17. data/app/views/silas/inbox/sessions/show.html.erb +9 -0
  18. data/config/routes.rb +2 -0
  19. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  20. data/docs/agents.md +81 -0
  21. data/docs/budgets.md +67 -0
  22. data/docs/cancellation.md +41 -0
  23. data/docs/channels.md +290 -0
  24. data/docs/configuration.md +106 -0
  25. data/docs/connections.md +55 -0
  26. data/docs/conventions.md +161 -0
  27. data/docs/evals.md +95 -0
  28. data/docs/guarantees.md +76 -0
  29. data/docs/inbox-and-api.md +84 -0
  30. data/docs/memory.md +35 -0
  31. data/docs/sandbox.md +44 -0
  32. data/docs/tools.md +77 -0
  33. data/docs/tutorial.md +268 -0
  34. data/docs/vs-eve.md +93 -0
  35. data/docs/why-silas.md +87 -0
  36. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  37. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  38. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  39. data/lib/generators/silas/install/install_generator.rb +11 -1
  40. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  41. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  42. data/lib/silas/adapters/ruby_llm.rb +102 -46
  43. data/lib/silas/channel.rb +35 -0
  44. data/lib/silas/compactor.rb +178 -0
  45. data/lib/silas/configuration.rb +16 -0
  46. data/lib/silas/eval/dsl.rb +7 -2
  47. data/lib/silas/instrumentation.rb +7 -3
  48. data/lib/silas/ledger.rb +2 -2
  49. data/lib/silas/log_subscriber.rb +5 -0
  50. data/lib/silas/message_builder.rb +19 -0
  51. data/lib/silas/registry.rb +5 -2
  52. data/lib/silas/schedule.rb +44 -15
  53. data/lib/silas/slack.rb +8 -5
  54. data/lib/silas/step_runner.rb +5 -0
  55. data/lib/silas/tools/ask_question.rb +26 -0
  56. data/lib/silas/version.rb +1 -1
  57. data/lib/silas/webhook.rb +47 -0
  58. data/lib/silas.rb +3 -0
  59. metadata +30 -1
@@ -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
- # Schedules are NOT model-visible capabilities, so they never enter the
14
- # definitions digest — adding or removing one cannot diverge an in-flight turn.
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
- rel = path.relative_path_from(root.join("app/agent/schedules"))
30
- name = rel.sub_ext("").to_s
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 = "Agent::Schedules::" + name.split("/").map(&:camelize).join("::")
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.start(input: payload, metadata: { "trigger" => "schedule", "schedule" => name })
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 signing_secret.blank? || signature.blank? || timestamp.blank?
48
- return false if (now - timestamp.to_i).abs > REPLAY_WINDOW
49
+ return false if timestamp.blank?
49
50
 
50
- basestring = "v0:#{timestamp}:#{body}"
51
- expected = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, basestring)
52
- ActiveSupport::SecurityUtils.secure_compare(expected, signature)
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -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.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: