silas 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +211 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -250
  5. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  6. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  7. data/app/helpers/silas/inbox/trace_helper.rb +20 -5
  8. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  9. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  10. data/app/models/silas/compaction.rb +32 -0
  11. data/app/models/silas/tool_invocation.rb +29 -3
  12. data/app/views/layouts/silas/inbox.html.erb +89 -30
  13. data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
  14. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  15. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  17. data/app/views/silas/inbox/sessions/show.html.erb +9 -0
  18. data/config/routes.rb +2 -0
  19. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  20. data/docs/agents.md +81 -0
  21. data/docs/budgets.md +67 -0
  22. data/docs/cancellation.md +41 -0
  23. data/docs/channels.md +290 -0
  24. data/docs/configuration.md +106 -0
  25. data/docs/connections.md +55 -0
  26. data/docs/conventions.md +161 -0
  27. data/docs/evals.md +95 -0
  28. data/docs/guarantees.md +76 -0
  29. data/docs/inbox-and-api.md +84 -0
  30. data/docs/memory.md +35 -0
  31. data/docs/sandbox.md +44 -0
  32. data/docs/tools.md +77 -0
  33. data/docs/tutorial.md +268 -0
  34. data/docs/vs-eve.md +93 -0
  35. data/docs/why-silas.md +87 -0
  36. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  37. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  38. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  39. data/lib/generators/silas/install/install_generator.rb +11 -1
  40. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  41. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  42. data/lib/silas/adapters/ruby_llm.rb +102 -46
  43. data/lib/silas/channel.rb +35 -0
  44. data/lib/silas/compactor.rb +178 -0
  45. data/lib/silas/configuration.rb +16 -0
  46. data/lib/silas/eval/dsl.rb +7 -2
  47. data/lib/silas/instrumentation.rb +7 -3
  48. data/lib/silas/ledger.rb +2 -2
  49. data/lib/silas/log_subscriber.rb +5 -0
  50. data/lib/silas/message_builder.rb +19 -0
  51. data/lib/silas/registry.rb +5 -2
  52. data/lib/silas/schedule.rb +44 -15
  53. data/lib/silas/slack.rb +8 -5
  54. data/lib/silas/step_runner.rb +5 -0
  55. data/lib/silas/tools/ask_question.rb +26 -0
  56. data/lib/silas/version.rb +1 -1
  57. data/lib/silas/webhook.rb +47 -0
  58. data/lib/silas.rb +3 -0
  59. metadata +30 -1
@@ -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
- # interception. Silas's Ledger owns tool execution, so tools are registered
5
- # as halt-proxies — RubyLLM sees the schemas, but the moment the model
6
- # requests a tool the proxy halts the chat loop and the calls are handed
7
- # back to the framework untouched.
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, &on_event)
22
+ chat = build_chat(context)
13
23
 
14
24
  response = Silas.instrument(:step,
15
- turn_id: context[:turn]&.id,
16
- index: context[:index],
17
- model: context[:model]) do
25
+ turn_id: context[:turn]&.id,
26
+ index: context[:index],
27
+ model: context[:model]) do
18
28
  if on_event
19
- # Streamed: RubyLLM's accumulator returns a Message identical in
20
- # shape to the sync path, so to_result needs no branch. Chunks with
21
- # tool-call fragments carry nil/empty content — only text streams.
22
- chat.complete do |chunk|
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.complete
40
+ complete(chat)
28
41
  end
29
42
  end
30
43
 
31
- to_result(chat, response)
44
+ to_result(response, schema: chat.schema)
32
45
  end
33
46
 
34
47
  private
35
48
 
36
- def build_chat(context, &on_event)
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: RubyLLM renders the provider's
47
- # structured-output dialect and JSON-parses the response back to a
48
- # Hash which to_result persists as a "structured" block.
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
- context[:tools].each { |definition| chat.with_tool(HaltProxy.new(definition)) }
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; RubyLLM sends
65
- # the whole array on complete.
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
- # The halt-proxies stop the loop after the assistant's tool_use message
119
- # was recorded on the chat; pull the LAST assistant message for the step.
120
- def to_result(chat, response)
121
- assistant = chat.messages.reverse.find { |m| m.role.to_s == "assistant" } || response
122
-
152
+ def to_result(assistant, schema:)
123
153
  blocks = []
124
- content = assistant.content
154
+ content = structured_content(assistant, schema:)
125
155
  if content.is_a?(Hash)
126
- # with_schema active: RubyLLM parsed the response to a Hash. Persist
127
- # it as its own block type — content.to_s here would write Ruby's
128
- # Hash#inspect string into the transcript as "text".
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
- # Presents an Silas tool schema to RubyLLM; halts instead of executing.
149
- class HaltProxy < ::RubyLLM::Tool
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
- def execute(**)
160
- ::RubyLLM::Tool::Halt.new(INTERCEPTED)
161
- end
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
@@ -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"
@@ -19,13 +19,18 @@ module Silas
19
19
  def max_steps(n) = (@max_steps = n)
20
20
  def approve(tool:) = (@approvals << tool.to_s)
21
21
 
22
- # on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
23
- def on_step(index, text: nil, call: nil, calls: [])
22
+ # on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ], data: {…})
23
+ # `data:` scripts a STRUCTURED answer (the final_answer schema case)
24
+ # it becomes the same {"type"=>"structured"} block the real adapter
25
+ # persists, so assert_answer_data reads it back through Turn#answer_data.
26
+ # Keys are stringified exactly as a JSON-parsed model response would be.
27
+ def on_step(index, text: nil, call: nil, calls: [], data: nil)
24
28
  tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
25
29
  Silas::Adapters::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
26
30
  arguments: (c[:arguments] || {}).stringify_keys)
27
31
  end
28
32
  blocks = []
33
+ blocks << { "type" => "structured", "data" => data.deep_stringify_keys } if data
29
34
  blocks << { "type" => "text", "text" => text } if text
30
35
  @steps[index] = { blocks: blocks, tool_calls: tcs }
31
36
  end
@@ -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), turn_id:, detail:
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), tool:, by:,
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: "approval", turn_id: invocation.turn_id,
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] })
@@ -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?
@@ -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?