silas 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -0
  3. data/README.md +12 -6
  4. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  5. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  6. data/app/jobs/silas/agent_loop_job.rb +2 -0
  7. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  8. data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
  9. data/app/models/silas/compaction.rb +32 -0
  10. data/app/models/silas/tool_invocation.rb +37 -3
  11. data/app/models/silas/turn.rb +7 -1
  12. data/app/views/silas/channel_mailer/approval.text.erb +2 -2
  13. data/app/views/silas/channels/approvals/show.html.erb +1 -1
  14. data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
  15. data/config/brakeman.ignore +11 -0
  16. data/config/routes.rb +2 -0
  17. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  18. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  19. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  20. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  21. data/lib/generators/silas/install/install_generator.rb +2 -1
  22. data/lib/generators/silas/install/templates/initializer.rb +1 -1
  23. data/lib/silas/{engines → adapters}/base.rb +12 -2
  24. data/lib/silas/adapters/ruby_llm.rb +221 -0
  25. data/lib/silas/channel.rb +35 -0
  26. data/lib/silas/chat.rb +2 -2
  27. data/lib/silas/compactor.rb +178 -0
  28. data/lib/silas/configuration.rb +40 -9
  29. data/lib/silas/delta_buffer.rb +3 -3
  30. data/lib/silas/deprecator.rb +16 -0
  31. data/lib/silas/engine.rb +15 -2
  32. data/lib/silas/eval/driver.rb +1 -1
  33. data/lib/silas/eval/dsl.rb +1 -1
  34. data/lib/silas/eval/scripted_engine.rb +3 -3
  35. data/lib/silas/inbox/delta_broadcaster.rb +1 -1
  36. data/lib/silas/instrumentation.rb +59 -0
  37. data/lib/silas/ledger.rb +14 -0
  38. data/lib/silas/log_subscriber.rb +83 -0
  39. data/lib/silas/message_builder.rb +19 -0
  40. data/lib/silas/registry.rb +5 -2
  41. data/lib/silas/schedule.rb +44 -15
  42. data/lib/silas/slack.rb +10 -7
  43. data/lib/silas/step_runner.rb +10 -2
  44. data/lib/silas/tools/ask_question.rb +26 -0
  45. data/lib/silas/version.rb +1 -1
  46. data/lib/silas/webhook.rb +47 -0
  47. data/lib/silas.rb +28 -14
  48. metadata +15 -3
  49. data/lib/silas/engines/ruby_llm.rb +0 -165
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8ece90334c6059bd396179bfae54b62ebd8abc483d72309da718dbb8e147845
4
- data.tar.gz: '014359b0b35a36e7b34b796fbb9194f99c49bf39a1901b4b82cb7b94a10c0ea0'
3
+ metadata.gz: 0f722788fa436905b6ea94ee9666ca71013fa6eace683f7d3dc8eec4f257fbe2
4
+ data.tar.gz: 85d3ae116127d4b91207526d7936440092250854380938a94eba177cd26f3eeb
5
5
  SHA512:
6
- metadata.gz: 25e3610da0c9a62b6e9f2a0f956e01c989c62eec40bfdee6703b1bd9206821bb6b4a7988726671b57cd53949ae518239a76d76511cc260b3279db9a36b4f38c4
7
- data.tar.gz: f891bf52671a8cd2c7fdc9041b036194939995dc4de7dee7bdd8f09cea986593bc526681d161d41add81b16cfe9257d75df7599e09a7fb85d5af52a9b117828d
6
+ metadata.gz: 645b843c5dbde892fa0725c1f33302bf39b3b10eef43ff14b0828efeb0420ead72d82e34b9bb3828cf99aa8f880db4f6017f2913023884a679385ca68a21ae41
7
+ data.tar.gz: 2b5b8df36d8747fde43b1eccffb466f17a3a9ef430ebc77b055b36c73ff987c4e07c66eb14ef2265d34b37b1ff824cf4681737a2bca1333e4e7dad88c8db142b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,198 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-07-26)
4
+
5
+ Two new loop primitives (replay-safe compaction, ask_question), a whole-channel
6
+ generator, the adapter rebound onto RubyLLM's public single-turn seam, and
7
+ per-agent schedules. Chaos-gated: **295 kill/deploy cycles across both stores —
8
+ zero duplicate side effects, byte-identical replay** — including a new compact
9
+ mode that kills mid-summarisation and asserts the compaction claim is
10
+ exactly-once and the rebuilt provider messages are byte-identical
11
+ (`chaos_host/RESULTS.md`).
12
+
13
+ ### Added
14
+
15
+ - **Per-agent schedules.** Named agents own their cron the way they own tools
16
+ and skills: `app/agents/analyst/schedules/monday_kpis.md` is discovered as
17
+ `agents/analyst/monday_kpis`, compiled by `silas:schedules` under a
18
+ collision-free recurring key, and its ticks start **the analyst** — a staff
19
+ member's schedule never wakes the root agent. `.rb` handlers resolve under
20
+ the agent's namespace (`Agents::Analyst::Schedules::MondayKpis`).
21
+
22
+ - **`ask_question` — the agent can park to ask a human something.**
23
+ Information, not permission: the model calls the new builtin with a
24
+ question, the turn parks at zero compute through the same machinery as
25
+ approvals (TTL, channel ping, resume gate), and the operator's free-text
26
+ reply becomes the tool result the model resumes with
27
+ (`{"answer" => "..."}`). Answer from the inbox (a question card with a text
28
+ box replaces approve/decline) or the API
29
+ (`POST /silas/api/v1/approvals/:id/answer {text:}`); `decline!` remains the
30
+ refusal path, and an unanswered question expires as
31
+ `{"answer" => nil, "note" => "question expired unanswered"}`. Channels are
32
+ pinged only if they implement `deliver_question` — buttons are the wrong UI
33
+ for free text, so transports without it simply leave the question in the
34
+ inbox. Disable with `config.ask_question = false`.
35
+
36
+ **Upgrade note:** adding a builtin changes the definitions digest, so turns
37
+ parked across the upgrade fail loudly on resume (the nondeterminism guard
38
+ working as designed). Settle parked turns before upgrading, or set
39
+ `config.ask_question = false` to keep the old digest.
40
+
41
+ - **Context compaction that survives replay.** Long sessions used to grow
42
+ until the provider rejected the prompt and the turn failed. Now, when the
43
+ measured context passes `config.compact_at` (default 0.9 of the model's
44
+ registry context window; set an Integer for an absolute token threshold, or
45
+ nil to disable), Silas summarises all prior turns into a `silas_compactions`
46
+ row and the conversation continues — the current turn is never compacted.
47
+
48
+ The design constraint is the durability contract: replayed executions must
49
+ see byte-identical message arrays, so a summary can never be computed at
50
+ build time. Compaction is an *effect*, made exactly-once the way tool
51
+ effects are — claimed compare-and-swap (unique index per session + span),
52
+ generated once, then read deterministically from the row forever. A crash
53
+ mid-summary leaves a pending row the resume finishes; a crash mid-step
54
+ replays against the identical compacted history. New `compact.silas`
55
+ instrumentation event (duration = the summarisation call). Chaos-gated with
56
+ a dedicated mode: kill -9 during the compacting turn, including
57
+ mid-summarisation.
58
+
59
+ ### Changed
60
+
61
+ - **The `:ruby_llm` adapter no longer fights the library.** `Chat#complete`
62
+ runs RubyLLM's whole agentic loop — model, execute tools, feed results back,
63
+ model again — but Silas needs a single move, because the step boundary *is*
64
+ the durability boundary. It used to get one by registering tool proxies that
65
+ threw `RubyLLM::Tool::Halt` to abort the loop from the inside.
66
+
67
+ Chat is now used as the builder it is (it owns model resolution, schema
68
+ normalisation, system instructions and message construction) and execution
69
+ drops one layer to `RubyLLM::Provider#complete` — the same call Chat makes
70
+ internally for a single turn. Entirely public API, and the adapter got
71
+ smaller: no `Tool::Halt`, no hunting back through `chat.messages` for the
72
+ assistant reply, and the `before_message` streaming-timing oddity is gone in
73
+ favour of an event Silas emits itself.
74
+
75
+ **This removes Silas's exposure to the largest RubyLLM 2.0 breaking change.**
76
+ 2.0 deletes `Tool::Halt` precisely because the loop became caller-controlled;
77
+ Silas no longer needs it either way. The adapter also now calls `with_tools`
78
+ (2.0 drops the singular `with_tool`) and its schema proxy answers to both
79
+ `params_schema` and `parameters_schema` (2.0 renames it), so the tool path is
80
+ version-agnostic today. No behaviour change for users.
81
+
82
+ ### Added
83
+
84
+ - **`rails g silas:channel <name>`** — scaffolds a whole channel, not half of
85
+ one. Channels were reachable before (`Channel.dispatch` is a ~50-line seam)
86
+ but the engine ships webhook routes for Slack only, so any other transport
87
+ meant hand-rolling a controller, a route, and signature verification with no
88
+ documented contract. The generator writes the outbound `Channel` subclass,
89
+ a signature-verifying inbound controller, and the route that joins them —
90
+ with the security decisions already made: verify before anything else, sign
91
+ over the raw body, fail closed on a missing secret, and send approvals to an
92
+ operator rather than to whoever started the session.
93
+ - **`Silas::Webhook.verify_hmac`** — the parts of webhook verification that are
94
+ identical for every vendor (constant-time comparison, replay window,
95
+ fail-closed on a missing secret), with the vendor's shape (`payload`,
96
+ `prefix`, `digest`) supplied by the caller. `Silas::Slack.verify_signature`
97
+ now delegates to it and keeps its exact v0 scheme.
98
+ - **`Silas::Channel.approval_url(invocation, action)`** — a signed, expiring
99
+ one-click approve/decline link for *any* transport, built from the engine's
100
+ route set and the discovered mount point, so it works from a delivery job
101
+ with no routing scope. Raises with the fix when no host is configured rather
102
+ than minting a dead link.
103
+ - `docs/channels.md`: the inbound/outbound contract, a per-vendor signature
104
+ table, and a worked WhatsApp Cloud API example.
105
+
106
+ ### Removed
107
+
108
+ - `demo/refund-desk` and `demo/churn-desk`. Both were copy-paste kits whose
109
+ READMEs instructed deleting a file the generated eval still asserted on —
110
+ broken on arrival. `examples/playground` is the example; `docs/why-silas.md`
111
+ and `docs/vs-eve.md` now point at it.
112
+
113
+ ## 0.4.0
114
+
115
+ The architecture-and-hardening release: one shipped feature that had never
116
+ worked, the naming locked down before 1.0 freezes it, and the durable loop
117
+ finally observable.
118
+
119
+ ### Fixed
120
+
121
+ - **The email approval channel had never worked.** Both the approval email
122
+ template and the confirmation page called `approval_url`/`approval_path`,
123
+ but the route is declared inside `namespace :channels`, so the real helpers
124
+ are `channels_approval_url`/`_path`. Rendering raised — meaning
125
+ **`ChannelMailer#approval` blew up and the "your agent needs approval" email
126
+ was never delivered**, and the confirmation page 500'd. If you relied on
127
+ email approvals, you were silently never notified that a money-moving call
128
+ was parked. Found by writing the first specs for these surfaces.
129
+
130
+ ### Changed (breaking, pre-1.0)
131
+
132
+ - **The inference seam is now `Adapters::`, not `Engines::`** — and
133
+ `config.adapter`, not `config.engine`. "Engine" meant two unrelated things
134
+ in one namespace: the Rails engine at `Silas::Engine`, and the pluggable
135
+ inference backend. Every comparable seam disambiguates — ActiveJob has
136
+ `QueueAdapters::`, ActiveStorage `Service::`, RubyLLM `Provider`. Done now
137
+ because 1.0 freezes the public API and host apps subclass this seam.
138
+ **Nothing breaks today**: `Silas::Engines::Base`, `config.engine`, and
139
+ `Silas.resolved_engine` all still resolve, warn through the new deprecator,
140
+ and are removed in 2.0.
141
+ - **Notification names follow the Rails convention** `<event>.silas` (like
142
+ `sql.active_record`). The two pre-existing events were backwards:
143
+ `silas.step` → `step.silas`, `silas.delta` → `delta.silas`. Update any
144
+ subscriber; `subscribe(/\.silas\z/)` now catches everything.
145
+
146
+ ### Added
147
+
148
+ - **Instrumentation for the durable loop.** It emitted almost nothing before:
149
+ a turn could start, park for a human, be rescued after a `kill -9`, breach a
150
+ budget and finish without a single line. Ten events now — `turn`, `step`,
151
+ `tool`, `park`, `resume`, `approval`, `budget`, `rescue`, `nondeterminism`,
152
+ `delta` — with documented payloads that always carry `turn_id`/`session_id`,
153
+ so a subscriber never has to join. **`tool.silas`** times the tool's own
154
+ execution and reports how it settled (the most useful span in the system);
155
+ **`resume.silas`** carries `parked_for` — how long the human actually took.
156
+ `Silas::LogSubscriber` (modelled on Solid Queue's) turns them into log lines
157
+ at operator-filterable levels: parks and rescues INFO, budget WARN, failed
158
+ turns and nondeterminism ERROR, per-token chatter DEBUG — and stays silent
159
+ when the rescuer did nothing.
160
+ - **`Silas.deprecator`** — an `ActiveSupport::Deprecation` registered in
161
+ `app.deprecators[:silas]`, so hosts silence or raise on Silas deprecations
162
+ exactly as they do Rails'. Every message names the replacement *and* the
163
+ removal version.
164
+ - **Coverage for the four money-path surfaces that had none** (36 specs):
165
+ `Channels::SlackController` (unsigned / wrong-secret / stale-timestamp
166
+ requests refused end to end; retries and bot messages ignored; buttons
167
+ settle through the same `approve!`/`decline!`),
168
+ `Channels::ApprovalsController` (tampered, garbage, expired and
169
+ wrong-purpose tokens refused; **GET never mutates**, so a link preview or
170
+ scanner cannot approve a refund; a replayed link on a settled invocation
171
+ 422s), `AgentMailbox` (References → In-Reply-To → Message-ID threading, so
172
+ replies continue rather than restart), and `ChannelMailer` (renders, shows
173
+ the arguments, embeds two distinct absolute links whose tokens verify back).
174
+ - **Quality tooling, enforced in CI**: `rubocop-rails-omakase` (Rails' own
175
+ style baseline, zero offenses), SimpleCov with a **90% line-coverage floor
176
+ that fails the build** (actual: 92.5%), Brakeman and bundler-audit (clean —
177
+ the single deliberate CSRF suppression is documented with its reasoning in
178
+ `config/brakeman.ignore`), and a `rake zeitwerk:check` job that eager-loads
179
+ every constant to catch naming violations lazy tests never see.
180
+ - **Dependency contract specs.** Silas reaches into Solid Queue and RubyLLM
181
+ internals, where a rename breaks *recovery* silently. Ten specs pin them:
182
+ the dead-process error classes the rescuer allowlists,
183
+ `FailedExecution#retry`, the Solid Queue >= 1.2 continuations floor,
184
+ RubyLLM's `with_schema`/`before_message`/`Tool::Halt`/model registry and the
185
+ error classes `retry_on` names, and `resume_errors_after_advancing` staying
186
+ false. Plus an allowed-to-fail CI canary against ruby_llm edge, for early
187
+ warning on the 2.0 horizon.
188
+ - **`docs/conventions.md`** — the naming and structure rules (why the seam is
189
+ `Adapters::`; the deliberate `class << self` vs `module_function` split) and
190
+ the audited posture: nothing mass-assigned, no `raw`/`html_safe` anywhere,
191
+ `Time.current` throughout, indexes on query paths rather than every foreign
192
+ key. Written down so nobody "fixes" something load-bearing.
193
+
194
+ No migration. 336 specs green on SQLite and Postgres.
195
+
3
196
  ## 0.3.2
4
197
 
5
198
  - **The `timeout` budget no longer counts time spent parked for approval.**
data/README.md CHANGED
@@ -104,15 +104,15 @@ hundreds of times per release (results in `chaos_host/results/`):
104
104
  deploy that changes tools/skills mid-turn fails the turn loudly
105
105
  (`NondeterminismError`) instead of resuming into a different agent.
106
106
 
107
- ## Engine
107
+ ## Adapter
108
108
 
109
- Inference is one pluggable seam (`config.engine`): `:ruby_llm` — API-key auth
109
+ Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
110
110
  via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
111
111
  and the production path. Compose resilience via `config.around_model_call`, or
112
112
  swap in any object responding to `#execute_step` (the eval harness and the
113
113
  chaos tests do exactly that).
114
114
 
115
- > The experimental `:agent_sdk` engine (a `claude -p` subprocess) was removed
115
+ > The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
116
116
  > in 0.2: its subscription-auth rationale was structurally unreachable, and it
117
117
  > carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
118
118
  > server survives and returns as a first-class *mount your tools as MCP*
@@ -192,21 +192,27 @@ An agent is reached by more than a method call:
192
192
 
193
193
  - **`schedules/`** — `app/agent/schedules/*.md` (cron frontmatter, body = the turn
194
194
  input) or `*.rb` handlers. `bin/rails silas:schedules` compiles them into
195
- Solid Queue recurring tasks. A scheduled run is a normal durable turn.
195
+ Solid Queue recurring tasks. A scheduled run is a normal durable turn. Named
196
+ agents own their cron the same way they own tools:
197
+ `app/agents/analyst/schedules/monday_kpis.md` ticks start the analyst, not
198
+ the root agent.
196
199
  - **`channels/`** — `app/agent/channels/*.rb` bind email (Action Mailbox) and
197
200
  Slack to the loop. A new thread starts a session, a reply continues it, and
198
201
  approvals render as Slack buttons / signed email links that call the same
199
202
  `approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
203
+ `bin/rails g silas:channel whatsapp` scaffolds any other transport — a
204
+ signature-verifying webhook and the outbound half, wired together. See
205
+ [docs/channels.md](https://github.com/danielstpaul/silas/blob/main/docs/channels.md).
200
206
 
201
207
  ## Streaming
202
208
 
203
- Turns stream. The `:ruby_llm` engine emits text deltas as the model responds:
209
+ Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
204
210
  `bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
205
211
  renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
206
212
  the durable rows — never persisted, never fed back to the model, and a
207
213
  replayed step renders from its row with no deltas at all, so streaming adds
208
214
  zero risk to the durability contract. Custom sinks subscribe to the
209
- `"silas.delta"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
215
+ `"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
210
216
  text: }`, where `text` is the accumulated string so far — filter by ids;
211
217
  notifications are process-global).
212
218
 
@@ -29,6 +29,16 @@ module Silas
29
29
  rescue Silas::Error => e
30
30
  render json: { error: e.message }, status: :conflict
31
31
  end
32
+
33
+ # POST /silas/api/v1/approvals/:id/answer { text: "..." }
34
+ # ask_question's verdict: the text becomes the tool result.
35
+ def answer
36
+ invocation = Silas::ToolInvocation.find(params[:id])
37
+ invocation.answer!(text: params[:text].to_s.strip, by: current_actor)
38
+ render json: invocation_json(invocation.reload)
39
+ rescue Silas::Error => e
40
+ render json: { error: e.message }, status: :conflict
41
+ end
32
42
  end
33
43
  end
34
44
  end
@@ -24,6 +24,14 @@ module Silas
24
24
  respond_error(e)
25
25
  end
26
26
 
27
+ # ask_question's verdict: the operator's text becomes the tool result.
28
+ def answer
29
+ @invocation.answer!(text: params[:text].to_s.strip, by: current_actor)
30
+ respond_resolved
31
+ rescue Silas::Error => e
32
+ respond_error(e)
33
+ end
34
+
27
35
  private
28
36
 
29
37
  def set_invocation
@@ -112,6 +112,8 @@ module Silas
112
112
  # replays completed steps from rows, resuming where it left off.
113
113
  if (reason = Budget.exceeded_reason(turn))
114
114
  turn.update!(status: "waiting", failure_reason: reason)
115
+ Silas.instrument(:budget, reason: reason, turn_id: turn.id)
116
+ Silas.instrument(:park, reason: "budget", turn_id: turn.id, detail: reason)
115
117
  return
116
118
  end
117
119
 
@@ -24,6 +24,21 @@ module Silas
24
24
  channel = Channel.for_session(invocation.turn.session)
25
25
  return release!(ToolInvocation, invocation.id, :notified_at) unless channel
26
26
 
27
+ # Questions want free text, and approve/decline buttons are the wrong UI
28
+ # for that — so a question pings only channels that implement
29
+ # deliver_question. Without it the claim is KEPT (no retries): the
30
+ # question waits in the inbox, which every install has.
31
+ if invocation.question?
32
+ unless channel.respond_to?(:deliver_question)
33
+ Rails.logger&.info("[Silas] #{channel.class} has no deliver_question — " \
34
+ "question ##{invocation.id} awaits its answer in the inbox")
35
+ return
36
+ end
37
+ return with_release(ToolInvocation, invocation.id, :notified_at) do
38
+ channel.deliver_question(session: invocation.turn.session, invocation: invocation)
39
+ end
40
+ end
41
+
27
42
  with_release(ToolInvocation, invocation.id, :notified_at) do
28
43
  channel.deliver_approval(session: invocation.turn.session, invocation: invocation)
29
44
  end
@@ -15,7 +15,13 @@ module Silas
15
15
  queue_as { Silas.config.queue_name }
16
16
 
17
17
  def perform
18
+ Silas.instrument(:rescue) { |payload| sweep(payload) }
19
+ end
20
+
21
+ def sweep(payload)
18
22
  ToolInvocation.expire_stale!
23
+ payload[:rescued] = 0
24
+ payload[:stranded] = 0
19
25
  return 0 unless defined?(SolidQueue)
20
26
 
21
27
  rescued = 0
@@ -24,9 +30,10 @@ module Silas
24
30
  failed.retry
25
31
  rescued += 1
26
32
  elsif failed.job&.class_name == "Silas::AgentLoopJob"
27
- fail_stranded_turn(failed)
33
+ payload[:stranded] += 1 if fail_stranded_turn(failed)
28
34
  end
29
35
  end
36
+ payload[:rescued] = rescued
30
37
  rescued
31
38
  end
32
39
 
@@ -40,13 +47,14 @@ module Silas
40
47
  # loudly with its approvals expired.
41
48
  def fail_stranded_turn(failed)
42
49
  turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
43
- return unless turn&.active?
50
+ return false unless turn&.active?
44
51
 
45
52
  exception = failed.error&.dig("exception_class")
46
53
  turn.expire_pending_approvals!("turn failed: #{exception}")
47
54
  turn.finish!(:failed, reason: "job_failed")
48
55
  Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
49
56
  "#{exception} — #{failed.error&.dig('message')}")
57
+ true
50
58
  end
51
59
  end
52
60
  end
@@ -0,0 +1,32 @@
1
+ module Silas
2
+ # One compaction row replaces session turns 0..up_to_turn_index with a
3
+ # persisted summary. Written exactly once (the unique index on
4
+ # session_id + up_to_turn_index is the compare-and-swap claim), read
5
+ # deterministically forever after — which is what lets MessageBuilder stay
6
+ # byte-identical across crash replays: the summary is a row, never a
7
+ # runtime computation.
8
+ class Compaction < ApplicationRecord
9
+ STATUSES = %w[pending completed].freeze
10
+
11
+ belongs_to :session, class_name: "Silas::Session"
12
+ belongs_to :up_to_turn, class_name: "Silas::Turn"
13
+
14
+ validates :status, inclusion: { in: STATUSES }
15
+ validates :up_to_turn_index, presence: true
16
+
17
+ scope :completed, -> { where(status: "completed") }
18
+
19
+ def completed? = status == "completed"
20
+
21
+ # The compaction MessageBuilder applies when building turn: the newest
22
+ # completed summary strictly before it. (A compaction can never cover its
23
+ # own turn — it is created during turn N covering 0..N-1 — so `<` is
24
+ # always satisfiable; it also keeps an eval or replay of an older turn
25
+ # from seeing a summary written after it.)
26
+ def self.latest_for(turn)
27
+ completed.where(session_id: turn.session_id)
28
+ .where(up_to_turn_index: ...turn.index)
29
+ .order(:up_to_turn_index).last
30
+ end
31
+ end
32
+ end
@@ -2,7 +2,7 @@ module Silas
2
2
  class ToolInvocation < ApplicationRecord
3
3
  STATUSES = %w[pending started completed failed in_doubt].freeze
4
4
  EFFECT_MODES = %w[transactional at_most_once idempotent].freeze
5
- APPROVAL_STATES = [ nil, "required", "approved", "declined", "expired" ].freeze
5
+ APPROVAL_STATES = [ nil, "required", "approved", "answered", "declined", "expired" ].freeze
6
6
 
7
7
  include Silas::Inbox::Broadcastable
8
8
 
@@ -18,6 +18,10 @@ module Silas
18
18
  def in_doubt? = status == "in_doubt"
19
19
  def awaiting_approval? = approval_state == "required"
20
20
 
21
+ # A parked ask_question — same park, different verdict: it is ANSWERED
22
+ # (free text becomes the tool result), never approved into execution.
23
+ def question? = tool_name == "ask_question"
24
+
21
25
  # Outbound: when a channel-bound invocation parks for approval, ping the
22
26
  # channel off-loop (covers both approval-gate and in-doubt parking).
23
27
  after_update_commit :notify_channel_approval, if: :should_notify_approval?
@@ -34,9 +38,32 @@ module Silas
34
38
  # parked job exited normally; its continuation is consumed). For an
35
39
  # in-doubt invocation, approval means "it did not run — re-execute".
36
40
  def approve!(by: nil)
41
+ if question?
42
+ raise Error, "invocation #{id} is a question — settle it with answer!, not approve! " \
43
+ "(approving would try to EXECUTE ask_question, which has no execution)"
44
+ end
37
45
  assert_parked!
38
46
  assert_turn_resumable!
39
47
  update!(status: "pending", approval_state: "approved", approved_by: by)
48
+ Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
49
+ invocation_id: id, turn_id: turn_id)
50
+ resume_turn!
51
+ end
52
+
53
+ # Answer a parked question. The text IS the tool result — the model resumes
54
+ # with {"answer" => text}, persisted like any other settled invocation, so
55
+ # replay determinism costs nothing. (decline! also works on a question: a
56
+ # refusal to answer, delivered as {"denied" => reason}.)
57
+ def answer!(text:, by: nil)
58
+ raise Error, "invocation #{id} (#{tool_name}) is not a question — answer! settles ask_question only" unless question?
59
+ raise Error, "an answer cannot be blank — decline! is the way to refuse a question" if text.blank?
60
+
61
+ assert_parked!
62
+ assert_turn_resumable!
63
+ update!(status: "completed", approval_state: "answered", approved_by: by,
64
+ result: { "answer" => text })
65
+ Silas.instrument(:approval, action: "answered", tool: tool_name, by: by,
66
+ invocation_id: id, turn_id: turn_id)
40
67
  resume_turn!
41
68
  end
42
69
 
@@ -49,6 +76,8 @@ module Silas
49
76
  assert_turn_resumable!
50
77
  update!(status: "failed", approval_state: "declined", approved_by: by,
51
78
  decline_reason: reason, result: { "denied" => reason })
79
+ Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
80
+ invocation_id: id, turn_id: turn_id)
52
81
  resume_turn!
53
82
  end
54
83
 
@@ -56,8 +85,11 @@ module Silas
56
85
  # their turns (parked-forever ghosts are a bug, not a feature).
57
86
  def self.expire_stale!(now: Time.current)
58
87
  where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
59
- inv.update!(approval_state: "expired", status: "failed",
60
- result: { "denied" => "approval expired" })
88
+ result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" }
89
+ : { "denied" => "approval expired" }
90
+ inv.update!(approval_state: "expired", status: "failed", result: result)
91
+ Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
92
+ invocation_id: inv.id, turn_id: inv.turn_id)
61
93
  inv.turn.finish!(:failed, reason: "approval_expired")
62
94
  end
63
95
  end
@@ -90,7 +122,9 @@ module Silas
90
122
  # instantly re-park on "timeout" — pathological for a gate whose whole
91
123
  # point is waiting for a person. Cost/token budgets stay cumulative;
92
124
  # they measure real spend.
125
+ parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
93
126
  turn.update!(status: "queued", started_at: Time.current)
127
+ Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
94
128
  AgentLoopJob.perform_later(turn.id)
95
129
  end
96
130
  end
@@ -21,8 +21,14 @@ module Silas
21
21
  def active? = ACTIVE_STATUSES.include?(status)
22
22
  def parked? = status == "waiting" || status == "in_doubt"
23
23
 
24
+ # Duration on this event spans the WHOLE turn, parked time included — it
25
+ # answers "how long did the customer wait", not "how much compute".
24
26
  def finish!(new_status, reason: nil)
25
- update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
27
+ Silas.instrument(:turn, status: new_status.to_s, reason: reason, turn_id: id,
28
+ session_id: session_id, agent: session.agent_name,
29
+ steps: steps.count) do
30
+ update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
31
+ end
26
32
  end
27
33
 
28
34
  def canceled? = status == "canceled"
@@ -3,7 +3,7 @@ Your agent needs approval to run: <%= @invocation.tool_name %>
3
3
  Arguments:
4
4
  <%= JSON.pretty_generate(@invocation.arguments) %>
5
5
 
6
- Approve: <%= silas.approval_url(token: @approve_token) %>
7
- Decline: <%= silas.approval_url(token: @decline_token) %>
6
+ Approve: <%= silas.channels_approval_url(token: @approve_token) %>
7
+ Decline: <%= silas.channels_approval_url(token: @decline_token) %>
8
8
 
9
9
  These links expire; do not forward them.
@@ -1,7 +1,7 @@
1
1
  <% if @invocation&.awaiting_approval? %>
2
2
  <h1>Approval needed: <%= @invocation.tool_name %></h1>
3
3
  <pre><%= JSON.pretty_generate(@invocation.arguments) %></pre>
4
- <%= form_with url: silas.approval_path(token: params[:token]), method: :post do %>
4
+ <%= form_with url: silas.channels_approval_path(token: params[:token]), method: :post do %>
5
5
  <button type="submit">Confirm <%= @action %></button>
6
6
  <% end %>
7
7
  <% else %>
@@ -1,14 +1,28 @@
1
- <div class="approval">
2
- <h3>Approval needed <%= invocation.tool_name %></h3>
3
- <pre><%= pretty_args(invocation.arguments) %></pre>
4
- <%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
5
- through the HOST's renderer, where engine route helpers don't exist and
6
- the mounted proxy has no routing scope to lean on. %>
7
- <%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
8
- <button class="btn approve">Approve</button>
9
- <% end %>
10
- <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
11
- <textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
12
- <button class="btn decline">Decline</button>
13
- <% end %>
14
- </div>
1
+ <%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
2
+ through the HOST's renderer, where engine route helpers don't exist and
3
+ the mounted proxy has no routing scope to lean on. %>
4
+ <% if invocation.question? %>
5
+ <div class="approval">
6
+ <h3>The agent has a question</h3>
7
+ <p class="question-text"><%= invocation.arguments["question"] %></p>
8
+ <%= form_with url: silas_engine_path(:answer_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
9
+ <textarea name="text" rows="3" placeholder="Your answer — sent back to the agent as the tool result"></textarea>
10
+ <button class="btn approve">Answer</button>
11
+ <% end %>
12
+ <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
13
+ <button class="btn decline">Decline to answer</button>
14
+ <% end %>
15
+ </div>
16
+ <% else %>
17
+ <div class="approval">
18
+ <h3>Approval needed — <%= invocation.tool_name %></h3>
19
+ <pre><%= pretty_args(invocation.arguments) %></pre>
20
+ <%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
21
+ <button class="btn approve">Approve</button>
22
+ <% end %>
23
+ <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
24
+ <textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
25
+ <button class="btn decline">Decline</button>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
@@ -0,0 +1,11 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "Cross-Site Request Forgery",
5
+ "warning_code": 7,
6
+ "fingerprint": "64dd0725204c2cd5d6b2dba2fac6c278d20cebd07aaed38493f229193f44ed79",
7
+ "file": "app/controllers/silas/channels/base_controller.rb",
8
+ "note": "Deliberate: Silas::Channels::BaseController serves inbound WEBHOOKS, which no browser session originates, so a CSRF token cannot exist. Each route authenticates the request itself instead: Slack routes verify an HMAC-SHA256 request signature with a 300s replay window (Silas::Slack.verify_signature, covered by spec/silas/channels/slack_controller_spec.rb), and the email approve/decline routes require a purpose-scoped, expiring MessageVerifier token whose possession IS the credential — CSRF would add nothing an attacker holding the token could not already do (covered by spec/silas/channels/approvals_controller_spec.rb, including tampered/expired/wrong-purpose/replayed tokens and the GET-never-mutates property). The inbox controllers keep protect_from_forgery; the JSON API is ActionController::API and is token-authenticated."
9
+ }
10
+ ]
11
+ }
data/config/routes.rb CHANGED
@@ -13,6 +13,7 @@ Silas::Engine.routes.draw do
13
13
  member do
14
14
  post :approve
15
15
  post :decline
16
+ post :answer
16
17
  end
17
18
  end
18
19
  end
@@ -40,6 +41,7 @@ Silas::Engine.routes.draw do
40
41
  member do
41
42
  post :approve
42
43
  post :decline
44
+ post :answer
43
45
  end
44
46
  end
45
47
  end
@@ -0,0 +1,26 @@
1
+ class CreateSilasCompactions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :silas_compactions do |t|
4
+ t.references :session, null: false, index: false # covered by the unique composite below
5
+ # Provenance only (never in a WHERE — see docs/conventions.md on indexes):
6
+ # the turn whose index is up_to_turn_index, kept so an operator can walk
7
+ # from a summary back to the rows it replaced.
8
+ t.references :up_to_turn, null: false, index: false
9
+ # THE query + claim column: a compaction covers session turns
10
+ # 0..up_to_turn_index inclusive.
11
+ t.integer :up_to_turn_index, null: false
12
+ t.string :status, null: false, default: "pending" # pending | completed
13
+ t.text :summary
14
+ t.integer :tokens_before # the measured context size that triggered this
15
+ t.integer :input_tokens # what the summarisation call itself cost
16
+ t.integer :output_tokens
17
+ t.string :model # which model wrote the summary
18
+ t.timestamps
19
+ end
20
+
21
+ # The compare-and-swap claim: only one execution may create the compaction
22
+ # for a given span, however many racing replays attempt it. Also serves the
23
+ # read path (latest completed compaction per session).
24
+ add_index :silas_compactions, [ :session_id, :up_to_turn_index ], unique: true
25
+ end
26
+ end