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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -0
  3. data/README.md +12 -6
  4. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  5. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  6. data/app/jobs/silas/agent_loop_job.rb +2 -0
  7. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  8. data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
  9. data/app/models/silas/compaction.rb +32 -0
  10. data/app/models/silas/tool_invocation.rb +37 -3
  11. data/app/models/silas/turn.rb +7 -1
  12. data/app/views/silas/channel_mailer/approval.text.erb +2 -2
  13. data/app/views/silas/channels/approvals/show.html.erb +1 -1
  14. data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
  15. data/config/brakeman.ignore +11 -0
  16. data/config/routes.rb +2 -0
  17. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  18. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  19. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  20. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  21. data/lib/generators/silas/install/install_generator.rb +2 -1
  22. data/lib/generators/silas/install/templates/initializer.rb +1 -1
  23. data/lib/silas/{engines → adapters}/base.rb +12 -2
  24. data/lib/silas/adapters/ruby_llm.rb +221 -0
  25. data/lib/silas/channel.rb +35 -0
  26. data/lib/silas/chat.rb +2 -2
  27. data/lib/silas/compactor.rb +178 -0
  28. data/lib/silas/configuration.rb +40 -9
  29. data/lib/silas/delta_buffer.rb +3 -3
  30. data/lib/silas/deprecator.rb +16 -0
  31. data/lib/silas/engine.rb +15 -2
  32. data/lib/silas/eval/driver.rb +1 -1
  33. data/lib/silas/eval/dsl.rb +1 -1
  34. data/lib/silas/eval/scripted_engine.rb +3 -3
  35. data/lib/silas/inbox/delta_broadcaster.rb +1 -1
  36. data/lib/silas/instrumentation.rb +59 -0
  37. data/lib/silas/ledger.rb +14 -0
  38. data/lib/silas/log_subscriber.rb +83 -0
  39. data/lib/silas/message_builder.rb +19 -0
  40. data/lib/silas/registry.rb +5 -2
  41. data/lib/silas/schedule.rb +44 -15
  42. data/lib/silas/slack.rb +10 -7
  43. data/lib/silas/step_runner.rb +10 -2
  44. data/lib/silas/tools/ask_question.rb +26 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas/webhook.rb +47 -0
  47. data/lib/silas.rb +28 -14
  48. metadata +15 -3
  49. data/lib/silas/engines/ruby_llm.rb +0 -165
@@ -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
- def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.now.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
-
50
- basestring = "v0:#{timestamp}:#{body}"
51
- expected = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, basestring)
52
- ActiveSupport::SecurityUtils.secure_compare(expected, signature)
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
@@ -51,7 +51,12 @@ module Silas
51
51
 
52
52
  def execute_model_call(turn, index, step)
53
53
  assert_definitions_unchanged!(turn)
54
- engine = Silas.resolved_engine
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.delta" notifications. A replayed step never reaches this method
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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.3.2"
2
+ VERSION = "0.5.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
@@ -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/engines/base"
43
+ require "silas/adapters/base"
39
44
  require "ruby_llm"
40
- require "silas/engines/ruby_llm"
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 engines (ScriptedEngine < Engines::Base)
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
- @resolved_engine = nil
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
- @resolved_engine = nil
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.engine may be :ruby_llm or any
112
+ # The inference adapter instance. config.adapter may be :ruby_llm or any
107
113
  # object responding to #execute_step (specs, custom).
108
- def resolved_engine
109
- @resolved_engine ||=
110
- case config.engine
111
- when :ruby_llm then Engines::RubyLLM.new
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 engine was removed in Silas 0.2 — the claude -p " \
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 engine :ruby_llm, the production path."
116
- when Symbol then raise Error, "unknown engine #{config.engine.inspect}"
117
- else config.engine
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.3.2
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