silas 0.4.0 → 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 +110 -0
- data/README.md +7 -1
- 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/channel_delivery_job.rb +15 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- 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/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +8 -5
- data/lib/silas/step_runner.rb +5 -0
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +3 -0
- metadata +9 -1
|
@@ -1,39 +1,78 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
module Adapters
|
|
3
|
-
# The :ruby_llm adapter: ONE model call per step, streamed, with tool
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# back
|
|
3
|
+
# The :ruby_llm adapter: ONE model call per step, streamed, with the tool
|
|
4
|
+
# calls handed back unexecuted.
|
|
5
|
+
#
|
|
6
|
+
# RubyLLM's `Chat#complete` runs the whole agentic loop — model, run tools,
|
|
7
|
+
# feed results back, model again. Silas needs a single move, because the
|
|
8
|
+
# step boundary IS the durability boundary (checkpoint, ledger, park). So
|
|
9
|
+
# Chat is used as the BUILDER it is — it owns model resolution, schema
|
|
10
|
+
# normalisation, system instructions and message construction — and the
|
|
11
|
+
# execution goes one layer down to `Provider#complete`, which is exactly
|
|
12
|
+
# what Chat itself calls for a single turn.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is RubyLLM's public API: Chat's attr_readers (model,
|
|
15
|
+
# messages, tools, schema, tool_prefs), Provider.resolve, and
|
|
16
|
+
# Provider#complete. (Until 0.5 this used tool proxies that threw
|
|
17
|
+
# `RubyLLM::Tool::Halt` to abort the loop from inside — RubyLLM 2.0 removes
|
|
18
|
+
# Halt precisely because the loop became caller-controlled, so this binding
|
|
19
|
+
# is both simpler now and the forward-compatible one.)
|
|
8
20
|
class RubyLLM < Base
|
|
9
|
-
INTERCEPTED = "__silas_intercepted__".freeze
|
|
10
|
-
|
|
11
21
|
def execute_step(context, &on_event)
|
|
12
|
-
chat = build_chat(context
|
|
22
|
+
chat = build_chat(context)
|
|
13
23
|
|
|
14
24
|
response = Silas.instrument(:step,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
turn_id: context[:turn]&.id,
|
|
26
|
+
index: context[:index],
|
|
27
|
+
model: context[:model]) do
|
|
18
28
|
if on_event
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
29
|
+
# Fires before the HTTP request, matching what RubyLLM's
|
|
30
|
+
# before_message callback did under streaming — but ours, and
|
|
31
|
+
# explicitly ordered rather than incidentally so.
|
|
32
|
+
on_event.call(Event.new(type: :message_start, payload: {}))
|
|
33
|
+
complete(chat) do |chunk|
|
|
34
|
+
# Chunks carrying tool-call fragments have nil/empty content —
|
|
35
|
+
# only text streams.
|
|
23
36
|
text = chunk.content
|
|
24
37
|
on_event.call(Event.new(type: :text_delta, payload: { text: text })) if text.is_a?(String) && !text.empty?
|
|
25
38
|
end
|
|
26
39
|
else
|
|
27
|
-
chat
|
|
40
|
+
complete(chat)
|
|
28
41
|
end
|
|
29
42
|
end
|
|
30
43
|
|
|
31
|
-
to_result(
|
|
44
|
+
to_result(response, schema: chat.schema)
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
private
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
# One turn, tools advertised but never run. Streamed and sync return the
|
|
50
|
+
# same Message shape (the stream accumulator builds it), so there is no
|
|
51
|
+
# branch below this point.
|
|
52
|
+
def complete(chat, &block)
|
|
53
|
+
provider_for(chat.model).complete(
|
|
54
|
+
chat.messages,
|
|
55
|
+
tools: chat.tools,
|
|
56
|
+
tool_prefs: chat.tool_prefs,
|
|
57
|
+
temperature: nil,
|
|
58
|
+
model: chat.model,
|
|
59
|
+
schema: chat.schema,
|
|
60
|
+
&block
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Resolved from the model Chat already resolved, so the two can never
|
|
65
|
+
# disagree. Memoised per provider slug: the adapter instance is itself
|
|
66
|
+
# memoised on Silas (and dropped whenever config changes), and building a
|
|
67
|
+
# provider builds a Faraday connection — not something to redo per step.
|
|
68
|
+
# A benign race here costs one extra connection, never correctness.
|
|
69
|
+
def provider_for(model_info)
|
|
70
|
+
@providers ||= {}
|
|
71
|
+
@providers[model_info.provider] ||=
|
|
72
|
+
::RubyLLM::Provider.resolve(model_info.provider).new(::RubyLLM.config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_chat(context)
|
|
37
76
|
chat = begin
|
|
38
77
|
::RubyLLM.chat(model: context[:model])
|
|
39
78
|
rescue ::RubyLLM::ModelNotFoundError
|
|
@@ -43,26 +82,21 @@ module Silas
|
|
|
43
82
|
"or pick a registry-known model in config.default_model / agent.yml."
|
|
44
83
|
end
|
|
45
84
|
chat.with_instructions(context[:system]) if context[:system].present?
|
|
46
|
-
# agent.yml's final_answer schema:
|
|
47
|
-
# structured-output dialect
|
|
48
|
-
#
|
|
85
|
+
# agent.yml's final_answer schema: with_schema renders the provider's
|
|
86
|
+
# structured-output dialect. The response comes back as a JSON string
|
|
87
|
+
# (Chat#complete would have parsed it for us) — to_result does that.
|
|
49
88
|
chat.with_schema(context[:final_answer]) if context[:final_answer].present?
|
|
50
|
-
|
|
89
|
+
# with_tools (plural), not with_tool — 2.0 drops the singular form and
|
|
90
|
+
# the plural exists in both.
|
|
91
|
+
context[:tools].each { |definition| chat.with_tools(SchemaProxy.new(definition)) }
|
|
51
92
|
|
|
52
93
|
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
94
|
chat
|
|
61
95
|
end
|
|
62
96
|
|
|
63
97
|
# Rebuild the provider conversation from Silas's canonical rows. The last
|
|
64
|
-
# user message is delivered via ask-equivalent add_message;
|
|
65
|
-
# the
|
|
98
|
+
# user message is delivered via ask-equivalent add_message; the whole
|
|
99
|
+
# array goes to the provider on complete.
|
|
66
100
|
def replay_history(chat, messages)
|
|
67
101
|
i = 0
|
|
68
102
|
while i < messages.length
|
|
@@ -115,17 +149,13 @@ module Silas
|
|
|
115
149
|
end
|
|
116
150
|
end
|
|
117
151
|
|
|
118
|
-
|
|
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
|
-
|
|
152
|
+
def to_result(assistant, schema:)
|
|
123
153
|
blocks = []
|
|
124
|
-
content = assistant
|
|
154
|
+
content = structured_content(assistant, schema:)
|
|
125
155
|
if content.is_a?(Hash)
|
|
126
|
-
# with_schema active:
|
|
127
|
-
#
|
|
128
|
-
#
|
|
156
|
+
# with_schema active: persist the parsed payload as its own block type
|
|
157
|
+
# — content.to_s here would write Ruby's Hash#inspect string into the
|
|
158
|
+
# transcript as "text".
|
|
129
159
|
blocks << { "type" => "structured", "data" => content }
|
|
130
160
|
elsif content.to_s.present?
|
|
131
161
|
blocks << { "type" => "text", "text" => content.to_s }
|
|
@@ -145,8 +175,31 @@ module Silas
|
|
|
145
175
|
)
|
|
146
176
|
end
|
|
147
177
|
|
|
148
|
-
#
|
|
149
|
-
|
|
178
|
+
# Chat#complete normally JSON-parses a schema response before handing it
|
|
179
|
+
# back; calling the provider directly means we do it. A response that
|
|
180
|
+
# doesn't parse stays a string rather than raising — a malformed payload
|
|
181
|
+
# is the model's problem to see in the transcript, not a crash.
|
|
182
|
+
def structured_content(assistant, schema:)
|
|
183
|
+
content = assistant.content
|
|
184
|
+
return content unless schema && content.is_a?(String) && !assistant.tool_call?
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
JSON.parse(content)
|
|
188
|
+
rescue JSON::ParserError
|
|
189
|
+
content
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Carries a Silas tool's schema to the provider. Subclasses RubyLLM::Tool
|
|
194
|
+
# so it satisfies whatever the provider tool-renderers read (today: name,
|
|
195
|
+
# description, params_schema, parameters, provider_params) without Silas
|
|
196
|
+
# having to track that list.
|
|
197
|
+
#
|
|
198
|
+
# It has no #execute on purpose. Nothing calls it — the ledger owns tool
|
|
199
|
+
# execution — and RubyLLM::Tool#execute raises NotImplementedError, so if
|
|
200
|
+
# anything ever did, it fails loudly instead of feeding the model a
|
|
201
|
+
# sentinel.
|
|
202
|
+
class SchemaProxy < ::RubyLLM::Tool
|
|
150
203
|
def initialize(definition)
|
|
151
204
|
super()
|
|
152
205
|
@definition = definition
|
|
@@ -154,11 +207,14 @@ module Silas
|
|
|
154
207
|
|
|
155
208
|
def name = @definition["name"]
|
|
156
209
|
def description = @definition["description"]
|
|
157
|
-
def params_schema = @definition["input_schema"]
|
|
158
210
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
211
|
+
# RubyLLM 1.x reads params_schema; 2.0 renames it parameters_schema
|
|
212
|
+
# (alongside parameters -> declared_parameters and provider_params ->
|
|
213
|
+
# provider_options, which we inherit rather than override). Answering to
|
|
214
|
+
# both is two lines and makes the proxy version-agnostic — confirmed
|
|
215
|
+
# against ruby_llm edge by the CI canary.
|
|
216
|
+
def params_schema = @definition["input_schema"]
|
|
217
|
+
def parameters_schema = @definition["input_schema"]
|
|
162
218
|
end
|
|
163
219
|
end
|
|
164
220
|
end
|
data/lib/silas/channel.rb
CHANGED
|
@@ -57,6 +57,34 @@ module Silas
|
|
|
57
57
|
Rails.application.message_verifier(TOKEN_PURPOSE)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# A full one-click approve/decline URL for ANY transport — the signed token
|
|
61
|
+
# is the credential, so the link works in a WhatsApp message, a Discord
|
|
62
|
+
# embed, or an SMS exactly as it does in email.
|
|
63
|
+
#
|
|
64
|
+
# Built from the engine's own route set plus the discovered mount point,
|
|
65
|
+
# because a channel runs in a delivery job with no routing scope. The host
|
|
66
|
+
# is required and never guessed: a hostless approval link is a dead link,
|
|
67
|
+
# so this raises with the fix rather than shipping one.
|
|
68
|
+
def self.approval_url(invocation, action, host: nil)
|
|
69
|
+
options = default_url_options.merge(host: host || default_url_options[:host])
|
|
70
|
+
if options[:host].blank?
|
|
71
|
+
raise Error, "Silas::Channel.approval_url needs a host. Set " \
|
|
72
|
+
"config.action_mailer.default_url_options = { host: \"example.com\" } " \
|
|
73
|
+
"(or Rails.application.routes.default_url_options), or pass host:."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Silas::Engine.routes.url_helpers.channels_approval_url(
|
|
77
|
+
token: token_for(invocation, action),
|
|
78
|
+
script_name: Silas::Inbox.mount_path, **options
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.default_url_options
|
|
83
|
+
mailer = Rails.application.config.action_mailer.default_url_options || {}
|
|
84
|
+
Rails.application.routes.default_url_options.merge(mailer)
|
|
85
|
+
end
|
|
86
|
+
private_class_method :default_url_options
|
|
87
|
+
|
|
60
88
|
# --- outbound interface (subclasses implement) ---
|
|
61
89
|
def deliver_answer(session:, text:)
|
|
62
90
|
raise NotImplementedError, "#{self.class}#deliver_answer"
|
|
@@ -65,5 +93,12 @@ module Silas
|
|
|
65
93
|
def deliver_approval(session:, invocation:)
|
|
66
94
|
raise NotImplementedError, "#{self.class}#deliver_approval"
|
|
67
95
|
end
|
|
96
|
+
|
|
97
|
+
# OPTIONAL: ask_question parks ping this instead of deliver_approval —
|
|
98
|
+
# define it on transports that can collect free text (the question is
|
|
99
|
+
# invocation.arguments["question"]; settle with invocation.answer!).
|
|
100
|
+
# Channels without it are simply not pinged; the question waits in the
|
|
101
|
+
# inbox. Deliberately NOT declared here raising NotImplementedError:
|
|
102
|
+
# respond_to? is the capability check.
|
|
68
103
|
end
|
|
69
104
|
end
|
|
@@ -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
|
@@ -16,6 +16,14 @@ module Silas
|
|
|
16
16
|
attr_accessor :approval_ttl
|
|
17
17
|
# Hard cap on model calls per turn (agent.yml can lower it per-agent).
|
|
18
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
|
|
19
27
|
# Continuation isolation for loop steps. true in production (persistence
|
|
20
28
|
# per step — the durability contract); specs may disable for inline runs.
|
|
21
29
|
attr_accessor :isolate_steps
|
|
@@ -33,6 +41,12 @@ module Silas
|
|
|
33
41
|
# Memory (silas_memories): memory=false disables entirely; memory_approval
|
|
34
42
|
# :always parks every remember for a human (default), :never auto-approves.
|
|
35
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
|
|
36
50
|
# Channels: name -> Channel subclass (wired by the Registry). Slack creds
|
|
37
51
|
# default to credentials.dig(:silas, :slack, ...); nil disables Slack.
|
|
38
52
|
attr_accessor :channel_resolver
|
|
@@ -108,6 +122,7 @@ module Silas
|
|
|
108
122
|
@around_model_call = nil
|
|
109
123
|
@approval_ttl = 7.days
|
|
110
124
|
@max_steps = 25
|
|
125
|
+
@compact_at = 0.9
|
|
111
126
|
@isolate_steps = true
|
|
112
127
|
@tool_resolver = nil
|
|
113
128
|
@tool_definitions = nil
|
|
@@ -128,6 +143,7 @@ module Silas
|
|
|
128
143
|
@memory = true
|
|
129
144
|
@memory_approval = :always
|
|
130
145
|
@memory_injection_limit = 8
|
|
146
|
+
@ask_question = true
|
|
131
147
|
@sandbox_image = nil
|
|
132
148
|
@sandbox_network = "none"
|
|
133
149
|
@sandbox_memory = "512m"
|
|
@@ -26,11 +26,15 @@ module Silas
|
|
|
26
26
|
# useful span in the system.
|
|
27
27
|
# delta.silas session_id:, turn_id:, step_id:, step_index:, text:
|
|
28
28
|
# Streamed text so far (see DeltaBuffer). High frequency.
|
|
29
|
-
# park.silas reason: (approval | in_doubt | budget),
|
|
29
|
+
# park.silas reason: (approval | question | in_doubt | budget),
|
|
30
|
+
# turn_id:, detail:
|
|
30
31
|
# resume.silas turn_id:, parked_for: (seconds a human took)
|
|
31
|
-
# approval.silas action: (approved | declined | expired),
|
|
32
|
-
# invocation_id:, turn_id:
|
|
32
|
+
# approval.silas action: (approved | answered | declined | expired),
|
|
33
|
+
# tool:, by:, invocation_id:, turn_id:
|
|
33
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.
|
|
34
38
|
# rescue.silas rescued: (jobs retried), stranded: (turns failed)
|
|
35
39
|
# nondeterminism.silas turn_id:, was:, now: (digest changed mid-turn)
|
|
36
40
|
module Instrumentation
|
data/lib/silas/ledger.rb
CHANGED
|
@@ -88,8 +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: "
|
|
92
|
-
detail: invocation.tool_name)
|
|
91
|
+
Silas.instrument(:park, reason: invocation.question? ? "question" : "approval",
|
|
92
|
+
turn_id: invocation.turn_id, detail: invocation.tool_name)
|
|
93
93
|
return :parked
|
|
94
94
|
when Hash # {denied: "reason"} — eve's shape
|
|
95
95
|
invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
|
data/lib/silas/log_subscriber.rb
CHANGED
|
@@ -48,6 +48,11 @@ class Silas::LogSubscriber < ActiveSupport::LogSubscriber
|
|
|
48
48
|
**event.payload.slice(:turn_id).compact)
|
|
49
49
|
end
|
|
50
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
|
+
|
|
51
56
|
def nondeterminism(event)
|
|
52
57
|
error formatted_event(event, action: "Definitions changed mid-turn",
|
|
53
58
|
**event.payload.slice(:turn_id, :was, :now).compact)
|
|
@@ -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?
|