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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/README.md +12 -6
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/jobs/silas/agent_loop_job.rb +2 -0
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +37 -3
- data/app/models/silas/turn.rb +7 -1
- data/app/views/silas/channel_mailer/approval.text.erb +2 -2
- data/app/views/silas/channels/approvals/show.html.erb +1 -1
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- data/config/brakeman.ignore +11 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +2 -1
- data/lib/generators/silas/install/templates/initializer.rb +1 -1
- data/lib/silas/{engines → adapters}/base.rb +12 -2
- data/lib/silas/adapters/ruby_llm.rb +221 -0
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/chat.rb +2 -2
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +40 -9
- data/lib/silas/delta_buffer.rb +3 -3
- data/lib/silas/deprecator.rb +16 -0
- data/lib/silas/engine.rb +15 -2
- data/lib/silas/eval/driver.rb +1 -1
- data/lib/silas/eval/dsl.rb +1 -1
- data/lib/silas/eval/scripted_engine.rb +3 -3
- data/lib/silas/inbox/delta_broadcaster.rb +1 -1
- data/lib/silas/instrumentation.rb +59 -0
- data/lib/silas/ledger.rb +14 -0
- data/lib/silas/log_subscriber.rb +83 -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 +10 -7
- data/lib/silas/step_runner.rb +10 -2
- 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 +28 -14
- metadata +15 -3
- 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
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
class Configuration
|
|
3
|
-
# Inference
|
|
4
|
-
|
|
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
|
-
#
|
|
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
|
-
@
|
|
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
|
|
176
|
+
if adapter == :agent_sdk
|
|
146
177
|
raise BootGuardError,
|
|
147
|
-
"the :agent_sdk
|
|
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
|
|
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
|
|
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]
|
|
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."
|
data/lib/silas/delta_buffer.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Silas
|
|
2
|
-
# Coalesces model text deltas into ~10Hz "silas
|
|
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
|
-
|
|
42
|
-
|
|
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::
|
|
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
|
|
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
|
data/lib/silas/eval/driver.rb
CHANGED
|
@@ -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.
|
|
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?
|
data/lib/silas/eval/dsl.rb
CHANGED
|
@@ -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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
|
@@ -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?
|
data/lib/silas/registry.rb
CHANGED
|
@@ -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 ||=
|
|
54
|
-
|
|
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?
|