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
@@ -0,0 +1,178 @@
1
+ module Silas
2
+ # Keeps long sessions under the model's context window by summarising prior
3
+ # turns into a Compaction row — the alternative today is the provider
4
+ # rejecting the prompt and the turn failing.
5
+ #
6
+ # The design constraint comes from MessageBuilder: replayed executions must
7
+ # produce byte-identical message arrays, so a summary can never be computed
8
+ # at build time. Compaction is therefore an EFFECT, made exactly-once the
9
+ # same way tool effects are:
10
+ #
11
+ # - ensure! runs at the top of each step, INSIDE the isolated continuation
12
+ # step. A crash anywhere re-runs the whole step, and the unique index on
13
+ # (session_id, up_to_turn_index) makes the claim idempotent.
14
+ # - The summary model call happens once; the row it completes is what
15
+ # MessageBuilder reads from then on. A crash mid-summary leaves a
16
+ # pending row; the re-run summarises again and completes it — no step
17
+ # ever executed against the lost draft.
18
+ # - The boundary is fixed for the whole turn (all PRIOR turns, 0..index-1),
19
+ # so a re-executed step finds the same row a crashed attempt created:
20
+ # same rows in, same messages out.
21
+ #
22
+ # What it deliberately does not do (v1): compact within the current turn —
23
+ # a single turn's growth is bounded by max_steps, while a session's turn
24
+ # count is unbounded, and mid-turn boundaries would cut between a tool_use
25
+ # and its result. If the current turn alone outgrows the window, that is
26
+ # max_steps' problem, not compaction's.
27
+ module Compactor
28
+ module_function
29
+
30
+ SUMMARY_SYSTEM = <<~PROMPT.freeze
31
+ You are compacting the earlier part of a long conversation so it can
32
+ continue within the model's context window. Write a dense summary that
33
+ preserves everything a future turn might rely on: facts and figures,
34
+ decisions made, tool calls and their results (ids, amounts, names),
35
+ commitments given to the user, and anything explicitly left open.
36
+ Write it as plain prose. Do not add commentary about the summarisation
37
+ itself.
38
+ PROMPT
39
+
40
+ # Per-block caps keep the summarisation call itself well under the window.
41
+ TOOL_RESULT_CHARS = 2_000
42
+ TEXT_CHARS = 4_000
43
+
44
+ # Called before each step's model call. Returns the applicable completed
45
+ # Compaction (or nil), creating it first when the context has outgrown the
46
+ # threshold.
47
+ def ensure!(turn)
48
+ threshold = threshold_for(turn)
49
+ return Compaction.latest_for(turn) if threshold.nil? # feature off: existing rows still apply
50
+ return nil if turn.index.zero? # no prior turns to compact
51
+
52
+ boundary = turn.index - 1
53
+ if (existing = Compaction.find_by(session_id: turn.session_id, up_to_turn_index: boundary))
54
+ # Pending = a crash mid-summary. The trigger decision was already made
55
+ # and no step ever ran against the lost draft, so finish the claimed
56
+ # work rather than re-litigating the threshold.
57
+ summarise!(turn, existing) unless existing.completed?
58
+ return Compaction.latest_for(turn)
59
+ end
60
+
61
+ measured = measured_context(turn)
62
+ return Compaction.latest_for(turn) if measured.nil? || measured < threshold
63
+
64
+ row = claim!(turn, boundary, measured)
65
+ summarise!(turn, row) if row # nil: a racer claimed AND completed it
66
+ Compaction.latest_for(turn)
67
+ end
68
+
69
+ # The provider's own measure of the prompt: the last completed step's
70
+ # input_tokens IS the context size of the previous model call. Persisted,
71
+ # so a replayed trigger decision reads the same number.
72
+ def measured_context(turn)
73
+ Step.joins(:turn)
74
+ .where(silas_turns: { session_id: turn.session_id })
75
+ .where.not(input_tokens: nil)
76
+ .order(id: :desc).limit(1).pick(:input_tokens)
77
+ end
78
+
79
+ # config.compact_at: a Float in (0, 1] is a fraction of the model's
80
+ # registry context window (unknown window => off); an Integer is an
81
+ # absolute token threshold (works with any adapter — custom engines and
82
+ # the chaos harness have no registry entry). nil/false disables.
83
+ def threshold_for(turn)
84
+ setting = Silas.config.compact_at
85
+ return nil unless setting
86
+ return setting if setting.is_a?(Integer)
87
+
88
+ window = context_window_for(StepRunner.turn_model(turn))
89
+ window && (window * setting.to_f).to_i
90
+ end
91
+
92
+ def context_window_for(model)
93
+ ::RubyLLM.models.find(model).context_window
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ def claim!(turn, boundary, measured)
99
+ Compaction.create!(
100
+ session_id: turn.session_id,
101
+ up_to_turn: Turn.find_by!(session_id: turn.session_id, index: boundary),
102
+ up_to_turn_index: boundary,
103
+ status: "pending",
104
+ tokens_before: measured
105
+ )
106
+ rescue ActiveRecord::RecordNotUnique
107
+ # A racing execution claimed it. Pending -> finish their summary work;
108
+ # completed -> nothing to do (nil tells the caller to just read).
109
+ row = Compaction.find_by!(session_id: turn.session_id, up_to_turn_index: boundary)
110
+ row.completed? ? nil : row
111
+ end
112
+
113
+ def summarise!(turn, row)
114
+ model = StepRunner.turn_model(turn)
115
+ Silas.instrument(:compact, session_id: turn.session_id, turn_id: turn.id,
116
+ up_to_turn_index: row.up_to_turn_index,
117
+ tokens_before: row.tokens_before, model: model) do |payload|
118
+ context = {
119
+ turn: turn, index: nil, compaction: true,
120
+ system: SUMMARY_SYSTEM,
121
+ messages: [ { role: "user", content: transcript_for(turn, row) } ],
122
+ tools: [], model: model, final_answer: nil, limits: {}
123
+ }
124
+
125
+ engine = Silas.resolved_adapter
126
+ result =
127
+ if (hook = Silas.config.around_model_call)
128
+ hook.call(context) { engine.execute_step(context) }
129
+ else
130
+ engine.execute_step(context)
131
+ end
132
+
133
+ summary = result.blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
134
+ row.update!(status: "completed", summary: summary, model: model,
135
+ input_tokens: result.usage&.dig(:input_tokens),
136
+ output_tokens: result.usage&.dig(:output_tokens))
137
+ payload[:input_tokens] = row.input_tokens
138
+ payload[:output_tokens] = row.output_tokens
139
+ end
140
+ end
141
+
142
+ # The span rendered as a plain-text transcript — one user message into the
143
+ # summary call. Recursive by construction: a previous summary opens the
144
+ # transcript, so each compaction folds the last one in.
145
+ def transcript_for(turn, row)
146
+ parts = []
147
+ previous = Compaction.completed
148
+ .where(session_id: turn.session_id)
149
+ .where(up_to_turn_index: ...row.up_to_turn_index)
150
+ .order(:up_to_turn_index).last
151
+ parts << "Earlier conversation, already summarised:\n#{previous.summary}" if previous
152
+
153
+ floor = previous ? previous.up_to_turn_index : -1
154
+ Turn.where(session_id: turn.session_id, index: (floor + 1)..row.up_to_turn_index)
155
+ .order(:index).each do |prior|
156
+ parts << "User: #{prior.input}"
157
+ Step.where(turn_id: prior.id).order(:index).select(&:completed?).each do |step|
158
+ parts.concat(step_lines(step))
159
+ end
160
+ end
161
+ parts.join("\n\n")
162
+ end
163
+
164
+ def step_lines(step)
165
+ lines = Array(step.response_blocks).filter_map do |b|
166
+ case b["type"]
167
+ when "text" then "Assistant: #{b['text'].to_s.truncate(TEXT_CHARS)}"
168
+ when "structured" then "Assistant (structured answer): #{JSON.generate(b['data'])}"
169
+ end
170
+ end
171
+ ToolInvocation.where(step_id: step.id).order(:id).each do |inv|
172
+ lines << "Assistant called #{inv.tool_name}(#{JSON.generate(inv.arguments || {})}) " \
173
+ "-> #{JSON.generate(inv.result || inv.status).truncate(TOOL_RESULT_CHARS)}"
174
+ end
175
+ lines
176
+ end
177
+ end
178
+ end
@@ -1,7 +1,9 @@
1
1
  module Silas
2
2
  class Configuration
3
- # Inference engine seam: :ruby_llm, or any object responding to #execute_step.
4
- attr_accessor :engine
3
+ # Inference adapter seam: :ruby_llm, or any object responding to
4
+ # #execute_step. (Named `engine` before 0.4 — see the deprecated alias
5
+ # below; "engine" already meant the Rails engine at Silas::Engine.)
6
+ attr_accessor :adapter
5
7
  # Default model when agent.yml doesn't specify one.
6
8
  attr_accessor :default_model
7
9
  # Active Job queue for agent turns.
@@ -14,6 +16,14 @@ module Silas
14
16
  attr_accessor :approval_ttl
15
17
  # Hard cap on model calls per turn (agent.yml can lower it per-agent).
16
18
  attr_accessor :max_steps
19
+ # Context compaction trigger. A Float in (0, 1] compacts when the measured
20
+ # context passes that fraction of the model's registry context window
21
+ # (models the registry doesn't know are never compacted). An Integer is an
22
+ # absolute token threshold — the form custom adapters need, since they
23
+ # have no registry entry. nil/false disables. Compaction summarises PRIOR
24
+ # turns into a persisted row (exactly-once, replay-deterministic); the
25
+ # current turn is never compacted.
26
+ attr_accessor :compact_at
17
27
  # Continuation isolation for loop steps. true in production (persistence
18
28
  # per step — the durability contract); specs may disable for inline runs.
19
29
  attr_accessor :isolate_steps
@@ -31,6 +41,12 @@ module Silas
31
41
  # Memory (silas_memories): memory=false disables entirely; memory_approval
32
42
  # :always parks every remember for a human (default), :never auto-approves.
33
43
  attr_accessor :memory, :memory_approval, :memory_injection_limit
44
+ # The ask_question builtin (agent parks to ask the operator something;
45
+ # answered from the inbox/API). false removes it from the toolset — note
46
+ # that adding/removing a builtin changes the definitions digest, so turns
47
+ # PARKED across that change fail loudly on resume (the nondeterminism
48
+ # guard working as designed). Settle parked turns before flipping this.
49
+ attr_accessor :ask_question
34
50
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
35
51
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
36
52
  attr_accessor :channel_resolver
@@ -39,8 +55,21 @@ module Silas
39
55
  # tools as MCP" seam).
40
56
  attr_accessor :mcp_server_host
41
57
 
58
+ # Renamed in 0.4, removed in 2.0. "engine" meant two unrelated things —
59
+ # the Rails engine (Silas::Engine) and the inference backend — the exact
60
+ # collision ActiveJob avoids by calling its seam QueueAdapters.
61
+ def engine
62
+ Silas.deprecator.warn("config.engine is deprecated; use config.adapter")
63
+ adapter
64
+ end
65
+
66
+ def engine=(value)
67
+ Silas.deprecator.warn("config.engine= is deprecated; use config.adapter=")
68
+ self.adapter = value
69
+ end
70
+
42
71
  # config.auth and the agent_sdk_* options were removed with the :agent_sdk
43
- # engine in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
72
+ # adapter in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
44
73
  # a leftover write now raises NoMethodError. Delete them from your
45
74
  # initializer.
46
75
  # JSON API (mounted under /silas/api/v1).
@@ -83,7 +112,7 @@ module Silas
83
112
  end
84
113
 
85
114
  def initialize
86
- @engine = :ruby_llm
115
+ @adapter = :ruby_llm
87
116
  # Must be resolvable by the installed ruby_llm's model registry — newer
88
117
  # Claude models may need `RubyLLM.models.refresh!` before they resolve.
89
118
  # (Sonnet 4.5 ships in every supported registry; never default a first
@@ -93,6 +122,7 @@ module Silas
93
122
  @around_model_call = nil
94
123
  @approval_ttl = 7.days
95
124
  @max_steps = 25
125
+ @compact_at = 0.9
96
126
  @isolate_steps = true
97
127
  @tool_resolver = nil
98
128
  @tool_definitions = nil
@@ -113,6 +143,7 @@ module Silas
113
143
  @memory = true
114
144
  @memory_approval = :always
115
145
  @memory_injection_limit = 8
146
+ @ask_question = true
116
147
  @sandbox_image = nil
117
148
  @sandbox_network = "none"
118
149
  @sandbox_memory = "512m"
@@ -142,11 +173,11 @@ module Silas
142
173
 
143
174
  # Fail-loud misconfiguration checks, run from Silas.configure and at boot.
144
175
  def boot_guard!
145
- if engine == :agent_sdk
176
+ if adapter == :agent_sdk
146
177
  raise BootGuardError,
147
- "the :agent_sdk engine was removed in Silas 0.2 — the claude -p subprocess " \
178
+ "the :agent_sdk adapter was removed in Silas 0.2 — the claude -p subprocess " \
148
179
  "integration is gone (its subscription-auth rationale was unreachable). " \
149
- "Use engine :ruby_llm, the production path."
180
+ "Use adapter :ruby_llm, the production path."
150
181
  end
151
182
 
152
183
  check_provider_credentials!
@@ -159,7 +190,7 @@ module Silas
159
190
  # always a misconfiguration); warns in development so a fresh app can
160
191
  # still boot and browse the inbox before a key exists.
161
192
  def check_provider_credentials!
162
- return unless engine == :ruby_llm && defined?(::RubyLLM)
193
+ return unless adapter == :ruby_llm && defined?(::RubyLLM)
163
194
 
164
195
  providers = ::RubyLLM::Provider.providers.values
165
196
  configured = providers.any? do |provider|
@@ -168,7 +199,7 @@ module Silas
168
199
  end
169
200
  return if configured
170
201
 
171
- message = "[Silas] engine :ruby_llm has no configured provider — no API key is set on " \
202
+ message = "[Silas] adapter :ruby_llm has no configured provider — no API key is set on " \
172
203
  "RubyLLM.config. Set one in config/initializers/ruby_llm.rb, e.g. " \
173
204
  "RubyLLM.configure { |c| c.anthropic_api_key = ENV[\"ANTHROPIC_API_KEY\"] } " \
174
205
  "— the first agent turn will fail without it."
@@ -1,5 +1,5 @@
1
1
  module Silas
2
- # Coalesces model text deltas into ~10Hz "silas.delta" notifications carrying
2
+ # Coalesces model text deltas into ~10Hz "delta.silas" notifications carrying
3
3
  # the ACCUMULATED text so far — subscribers replace rather than append, which
4
4
  # is idempotent under a crash-restream (same step id, fresh stream overwrites
5
5
  # itself) and ordering-safe under Turbo. Deltas are decoration over the
@@ -38,8 +38,8 @@ module Silas
38
38
 
39
39
  @published = @text.length
40
40
  @last_publish = clock
41
- ActiveSupport::Notifications.instrument(
42
- "silas.delta",
41
+ Silas.instrument(
42
+ :delta,
43
43
  session_id: @turn.session_id, turn_id: @turn.id,
44
44
  step_id: @step.id, step_index: @step.index, text: @text.dup
45
45
  )
@@ -0,0 +1,16 @@
1
+ module Silas
2
+ # One deprecator for the whole gem, so hosts can control the noise the way
3
+ # they control Rails' own:
4
+ #
5
+ # Silas.deprecator.behavior = :raise # or :warn (default), :silence
6
+ #
7
+ # Rails registers it in application.deprecators (see Silas::Engine), which
8
+ # means `config.active_support.report_deprecations = false` silences Silas
9
+ # along with everything else, and a host can opt into raising in CI.
10
+ #
11
+ # Every deprecation message names BOTH the replacement and the version it
12
+ # disappears in — a warning you can't act on is just noise.
13
+ def self.deprecator
14
+ @deprecator ||= ActiveSupport::Deprecation.new("2.0", "Silas")
15
+ end
16
+ end
data/lib/silas/engine.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Silas
2
2
  # The Rails engine (not to be confused with inference adapters under
3
- # Silas::Engines::*). Full engine: Silas is Rails-native by thesis.
3
+ # Silas::Adapters::*). Full engine: Silas is Rails-native by thesis.
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Silas
6
6
 
@@ -33,12 +33,25 @@ module Silas
33
33
  end
34
34
  end
35
35
 
36
+ # Register with Rails so hosts control Silas's deprecations exactly as they
37
+ # control everyone else's: config.active_support.report_deprecations,
38
+ # or `config.silas.deprecator.behavior = :raise` in CI.
39
+ initializer "silas.deprecator" do |app|
40
+ app.deprecators[:silas] = Silas.deprecator if app.respond_to?(:deprecators)
41
+ end
42
+
36
43
  initializer "silas.boot_guard", after: :load_config_initializers do
37
44
  Silas.config.boot_guard!
38
45
  end
39
46
 
47
+ # Turn the loop's notifications into log lines. Attaching here (rather than
48
+ # at require time) means a host that never boots Rails pays nothing.
49
+ initializer "silas.log_subscriber" do
50
+ Silas::LogSubscriber.attach_to :silas if defined?(Silas::LogSubscriber)
51
+ end
52
+
40
53
  # Live token streaming into the inbox trace: one process-wide subscriber on
41
- # "silas.delta"; a no-op unless turbo-rails is present and streaming is on.
54
+ # "delta.silas"; a no-op unless turbo-rails is present and streaming is on.
42
55
  initializer "silas.delta_broadcaster" do
43
56
  Silas::Inbox::DeltaBroadcaster.subscribe!
44
57
  end
@@ -34,7 +34,7 @@ module Silas
34
34
  engine = scenario.real? ? nil : ScriptedEngine.new(scenario.steps)
35
35
  base_resolver = Silas.config.tool_resolver
36
36
  Silas.configure do |c|
37
- c.engine = engine if engine
37
+ c.adapter = engine if engine
38
38
  c.isolate_steps = false
39
39
  c.max_steps = scenario.max_steps if scenario.max_steps
40
40
  if scenario.stubs.any?
@@ -22,7 +22,7 @@ module Silas
22
22
  # on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
23
23
  def on_step(index, text: nil, call: nil, calls: [])
24
24
  tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
25
- Silas::Engines::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
25
+ Silas::Adapters::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
26
26
  arguments: (c[:arguments] || {}).stringify_keys)
27
27
  end
28
28
  blocks = []
@@ -3,7 +3,7 @@ module Silas
3
3
  # The productized FakeEngine: a pure function of context[:index] that lets an
4
4
  # eval script the MODEL's decisions while the REAL Ledger runs the REAL tools —
5
5
  # so assertions see a genuine transcript.
6
- class ScriptedEngine < Silas::Engines::Base
6
+ class ScriptedEngine < Silas::Adapters::Base
7
7
  attr_reader :calls
8
8
 
9
9
  def initialize(steps)
@@ -17,7 +17,7 @@ module Silas
17
17
  spec = @steps[i]
18
18
  return terminal("OK.") unless spec
19
19
 
20
- Silas::Engines::Result.new(
20
+ Silas::Adapters::Result.new(
21
21
  blocks: spec[:blocks],
22
22
  tool_calls: spec[:tool_calls],
23
23
  stop_reason: spec[:tool_calls].empty? ? "end_turn" : "tool_use",
@@ -28,7 +28,7 @@ module Silas
28
28
  private
29
29
 
30
30
  def terminal(text)
31
- Silas::Engines::Result.new(blocks: [ { "type" => "text", "text" => text } ],
31
+ Silas::Adapters::Result.new(blocks: [ { "type" => "text", "text" => text } ],
32
32
  tool_calls: [], stop_reason: "end_turn",
33
33
  usage: { input_tokens: 1, output_tokens: 1 })
34
34
  end
@@ -12,7 +12,7 @@ module Silas
12
12
  # the whole step partial (dom_id target) and supersedes anything streamed
13
13
  # into the inner text container.
14
14
  module DeltaBroadcaster
15
- EVENT = "silas.delta".freeze
15
+ EVENT = "delta.silas".freeze
16
16
 
17
17
  class << self
18
18
  def subscribe!
@@ -0,0 +1,59 @@
1
+ module Silas
2
+ # ActiveSupport::Notifications for the durable loop.
3
+ #
4
+ # Until now the loop was silent: a turn could start, park for a human, get
5
+ # rescued after a kill -9, breach a budget, and finish — without emitting a
6
+ # single line. This is the seam for logs, APM spans, and metrics.
7
+ #
8
+ # Names follow the Rails convention `<event>.silas` (like `sql.active_record`),
9
+ # so `ActiveSupport::Notifications.subscribe(/\.silas\z/)` gets everything.
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("tool.silas") do |event|
12
+ # StatsD.timing("agent.tool", event.duration, tags: ["tool:#{event.payload[:tool]}"])
13
+ # end
14
+ #
15
+ # ## Events and payloads
16
+ #
17
+ # Every payload carries `turn_id` and `session_id` where they exist, so any
18
+ # subscriber can correlate without joining.
19
+ #
20
+ # turn.silas status:, reason:, steps:, session_id:, turn_id:, agent:
21
+ # Duration = the whole turn INCLUDING parked time.
22
+ # step.silas index:, model:, turn_id: (one model call)
23
+ # tool.silas tool:, effect_mode:, status:, approval_state:,
24
+ # invocation_id:, turn_id:
25
+ # Duration = the tool's own execution. The single most
26
+ # useful span in the system.
27
+ # delta.silas session_id:, turn_id:, step_id:, step_index:, text:
28
+ # Streamed text so far (see DeltaBuffer). High frequency.
29
+ # park.silas reason: (approval | question | in_doubt | budget),
30
+ # turn_id:, detail:
31
+ # resume.silas turn_id:, parked_for: (seconds a human took)
32
+ # approval.silas action: (approved | answered | declined | expired),
33
+ # tool:, by:, invocation_id:, turn_id:
34
+ # budget.silas reason: (max_cost | max_input_tokens | timeout), turn_id:
35
+ # compact.silas session_id:, turn_id:, up_to_turn_index:, tokens_before:,
36
+ # input_tokens:, output_tokens:, model:
37
+ # Duration = the summarisation model call.
38
+ # rescue.silas rescued: (jobs retried), stranded: (turns failed)
39
+ # nondeterminism.silas turn_id:, was:, now: (digest changed mid-turn)
40
+ module Instrumentation
41
+ module_function
42
+
43
+ def instrument(event, **payload, &block)
44
+ ActiveSupport::Notifications.instrument("#{event}.silas", **payload, &block)
45
+ end
46
+ end
47
+
48
+ # Silas.instrument(:tool, tool: "issue_refund") { ... }
49
+ def self.instrument(event, **payload, &block)
50
+ Instrumentation.instrument(event, **payload, &block)
51
+ end
52
+
53
+ # The logger Silas writes through; hosts can point it elsewhere.
54
+ mattr_accessor :logger, default: nil
55
+
56
+ def self.logger
57
+ @@logger ||= (defined?(Rails) && Rails.logger) || ActiveSupport::Logger.new($stdout)
58
+ end
59
+ end
data/lib/silas/ledger.rb CHANGED
@@ -88,6 +88,8 @@ module Silas
88
88
  when :user_approval
89
89
  invocation.update!(approval_state: "required",
90
90
  approval_expires_at: Silas.config.approval_ttl.from_now)
91
+ Silas.instrument(:park, reason: invocation.question? ? "question" : "approval",
92
+ turn_id: invocation.turn_id, detail: invocation.tool_name)
91
93
  return :parked
92
94
  when Hash # {denied: "reason"} — eve's shape
93
95
  invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
@@ -107,6 +109,16 @@ module Silas
107
109
  end
108
110
 
109
111
  def execute!(invocation, tool)
112
+ Silas.instrument(:tool, tool: invocation.tool_name, effect_mode: invocation.effect_mode,
113
+ invocation_id: invocation.id, turn_id: invocation.turn_id) do |payload|
114
+ run_tool!(invocation, tool).tap do
115
+ payload[:status] = invocation.reload.status
116
+ payload[:approval_state] = invocation.approval_state
117
+ end
118
+ end
119
+ end
120
+
121
+ def run_tool!(invocation, tool)
110
122
  tool.session = invocation.turn.session if tool.respond_to?(:session=)
111
123
  args = invocation.arguments.symbolize_keys
112
124
 
@@ -149,6 +161,8 @@ module Silas
149
161
  # decline! = "it ran / abandon", operator supplies the outcome.
150
162
  invocation.update!(status: "in_doubt", approval_state: "required",
151
163
  approval_expires_at: Silas.config.approval_ttl.from_now)
164
+ Silas.instrument(:park, reason: "in_doubt", turn_id: invocation.turn_id,
165
+ detail: invocation.tool_name)
152
166
  :parked
153
167
  end
154
168
  end
@@ -0,0 +1,83 @@
1
+ require "active_support/log_subscriber"
2
+
3
+ # Turns the loop's notifications into log lines, at levels that match what an
4
+ # operator actually wants paged about: parks and rescues are INFO (a human is
5
+ # now in the loop, or a crash was recovered), budget breaches and
6
+ # nondeterminism are WARN, failed turns are ERROR, and the per-step/per-token
7
+ # chatter stays DEBUG.
8
+ #
9
+ # Attach is automatic (see Silas::Engine). To silence just Silas:
10
+ # Silas.logger = Logger.new(IO::NULL)
11
+ class Silas::LogSubscriber < ActiveSupport::LogSubscriber
12
+ def turn(event)
13
+ status = event.payload[:status]
14
+ line = formatted_event(event, action: "Turn #{status}",
15
+ **event.payload.slice(:turn_id, :session_id, :agent, :steps, :reason).compact)
16
+ status.to_s == "failed" ? error(line) : info(line)
17
+ end
18
+
19
+ def step(event)
20
+ debug formatted_event(event, action: "Model call",
21
+ **event.payload.slice(:turn_id, :index, :model).compact)
22
+ end
23
+
24
+ def tool(event)
25
+ line = formatted_event(event, action: "Tool #{event.payload[:tool]}",
26
+ **event.payload.slice(:effect_mode, :status, :approval_state, :turn_id).compact)
27
+ event.payload[:status].to_s == "failed" ? warn(line) : info(line)
28
+ end
29
+
30
+ def park(event)
31
+ info formatted_event(event, action: "Turn parked (#{event.payload[:reason]})",
32
+ **event.payload.slice(:turn_id, :detail).compact)
33
+ end
34
+
35
+ def resume(event)
36
+ parked_for = event.payload[:parked_for]
37
+ info formatted_event(event, action: "Turn resumed after #{parked_for&.round(1)}s parked",
38
+ **event.payload.slice(:turn_id).compact)
39
+ end
40
+
41
+ def approval(event)
42
+ info formatted_event(event, action: "Approval #{event.payload[:action]}",
43
+ **event.payload.slice(:tool, :by, :turn_id).compact)
44
+ end
45
+
46
+ def budget(event)
47
+ warn formatted_event(event, action: "Budget reached (#{event.payload[:reason]})",
48
+ **event.payload.slice(:turn_id).compact)
49
+ end
50
+
51
+ def compact(event)
52
+ info formatted_event(event, action: "Compacted through turn #{event.payload[:up_to_turn_index]}",
53
+ **event.payload.slice(:session_id, :turn_id, :tokens_before, :output_tokens).compact)
54
+ end
55
+
56
+ def nondeterminism(event)
57
+ error formatted_event(event, action: "Definitions changed mid-turn",
58
+ **event.payload.slice(:turn_id, :was, :now).compact)
59
+ end
60
+
61
+ def rescue(event)
62
+ payload = event.payload
63
+ return if payload[:rescued].to_i.zero? && payload[:stranded].to_i.zero?
64
+
65
+ info formatted_event(event, action: "Rescuer", **payload.slice(:rescued, :stranded))
66
+ end
67
+
68
+ # delta.silas is deliberately NOT logged — it fires many times per second
69
+ # per running turn. Subscribe to it directly if you want the firehose.
70
+
71
+ private
72
+ def formatted_event(event, action:, **attributes)
73
+ "Silas-#{Silas::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
74
+ end
75
+
76
+ def formatted_attributes(**attributes)
77
+ attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
78
+ end
79
+
80
+ def logger
81
+ Silas.logger
82
+ end
83
+ end
@@ -16,7 +16,19 @@ module Silas
16
16
  def call(turn, upto_index:)
17
17
  messages = []
18
18
 
19
+ # A completed Compaction row replaces turns 0..up_to_turn_index with its
20
+ # persisted summary. Reading the ROW keeps this deterministic: the
21
+ # summary was generated once (Compactor's CAS claim) and never
22
+ # recomputed, so same rows -> same array, compacted or not.
23
+ compaction = Compaction.latest_for(turn)
24
+ floor = -1
25
+ if compaction
26
+ messages << { role: "user", content: compaction_preamble(compaction) }
27
+ floor = compaction.up_to_turn_index
28
+ end
29
+
19
30
  Turn.where(session_id: turn.session_id).order(:index).each do |prior|
31
+ next if prior.index <= floor
20
32
  break if prior.index >= turn.index
21
33
 
22
34
  messages << { role: "user", content: prior.input }
@@ -28,6 +40,13 @@ module Silas
28
40
  messages
29
41
  end
30
42
 
43
+ # Pure function of the row — no clock, no counts of anything mutable.
44
+ def compaction_preamble(compaction)
45
+ "[The earlier part of this conversation was summarised to stay within " \
46
+ "the context window. Summary of everything before this point:]\n\n" \
47
+ "#{compaction.summary}"
48
+ end
49
+
31
50
  def step_messages(turn, upto_index:)
32
51
  Step.where(turn_id: turn.id).order(:index).each_with_object([]) do |step, acc|
33
52
  next unless step.completed?
@@ -50,8 +50,10 @@ module Silas
50
50
  # and compile order are deterministic. Deliberately NOT in #definitions or
51
51
  # #digest — a schedule is a trigger, not a model-visible capability.
52
52
  def schedules
53
- @schedules ||= Dir[@root.join("app/agent/schedules/**/*.{md,rb}")].sort
54
- .map { |f| Schedule.parse(Pathname(f), root: @root) }
53
+ @schedules ||= (
54
+ Dir[@root.join("app/agent/schedules/**/*.{md,rb}")].sort +
55
+ Dir[@root.join("app/agents/*/schedules/**/*.{md,rb}")].sort
56
+ ).map { |f| Schedule.parse(Pathname(f), root: @root) }
55
57
  end
56
58
 
57
59
  # name => Channel subclass. Filename identity, like tools. Also not in the
@@ -70,6 +72,7 @@ module Silas
70
72
  # subagents exist (root only — subagents never get delegate: depth-1).
71
73
  def builtins
72
74
  b = {}
75
+ b["ask_question"] = Silas::Tools::AskQuestion if Silas.config.ask_question
73
76
  b["load_skill"] = Silas::Tools::LoadSkill if skills.any?
74
77
  b["delegate"] = Silas::Tools::Delegate if subagent_dirs.any?
75
78
  b["run_code"] = Silas::Tools::RunCode if Silas.sandbox_enabled?