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
data/docs/channels.md ADDED
@@ -0,0 +1,290 @@
1
+ # Channels
2
+
3
+ A channel puts your agent somewhere people already are — Slack, email, WhatsApp,
4
+ Discord, SMS, your own app's webhook — without changing the agent itself. The
5
+ same tools, the same approvals, the same ledger. Only the surface changes.
6
+
7
+ ```bash
8
+ bin/rails generate silas:channel whatsapp
9
+ ```
10
+
11
+ That scaffolds both halves and the route that joins them. The rest of this page
12
+ is the contract they implement, so you can fill in the three TODOs correctly and
13
+ know what you're getting for free.
14
+
15
+ ---
16
+
17
+ ## The two halves
18
+
19
+ A channel is inbound and outbound, and they are deliberately separate: inbound is
20
+ a web request, outbound is a background job. They meet at one name.
21
+
22
+ ```
23
+ app/agent/channels/whatsapp.rb outbound Agent::Channels::Whatsapp
24
+ app/controllers/agent/channels/whatsapp_controller.rb inbound Agent::Channels::WhatsappController
25
+ config/routes.rb POST /agent/channels/whatsapp
26
+ ```
27
+
28
+ **Identity is the filename.** `whatsapp.rb` defines `Agent::Channels::Whatsapp`,
29
+ sessions started through it carry `channel: "whatsapp"`, and Silas uses that
30
+ string to find the class again at delivery time. Rename the file and the channel
31
+ is renamed; delete it and the channel is gone.
32
+
33
+ The webhook controller lives in **your app**, not the engine, because only your
34
+ app knows the vendor's signature scheme and payload shape. Slack is the one
35
+ exception — Silas ships its routes and verification because it ships Slack's
36
+ whole dialect.
37
+
38
+ ---
39
+
40
+ ## Inbound: verify, key, dispatch
41
+
42
+ ```ruby
43
+ class Agent::Channels::WhatsappController < ActionController::Base
44
+ skip_forgery_protection # no browser session -> no CSRF token can exist
45
+ before_action :verify_webhook! # ...so the signature IS the authentication
46
+
47
+ def create
48
+ Agent::Channels::Whatsapp.dispatch(
49
+ thread_key: params[:conversation_id].to_s,
50
+ input: params[:text].to_s,
51
+ metadata: { "whatsapp" => { "conversation_id" => params[:conversation_id] } }
52
+ )
53
+ head :ok
54
+ rescue Silas::TurnInProgressError
55
+ head :ok
56
+ end
57
+ end
58
+ ```
59
+
60
+ `dispatch` is the whole inbound API. It maps the external thread to a session and
61
+ then calls the ordinary public API — `Silas.agent.start` for a new thread,
62
+ `session.continue` for a reply. There is no channel-specific loop.
63
+
64
+ Three things to get right:
65
+
66
+ **The thread key must be stable.** The same conversation must produce the same
67
+ key every time, or every message starts a new session and the agent has no
68
+ memory. Use the vendor's conversation or thread id, never a per-message id. Keys
69
+ are namespaced by channel (`whatsapp:abc123`), so two channels can't collide.
70
+
71
+ **Sign over the raw body.** `request.raw_post`, never re-serialized params —
72
+ re-serializing changes the bytes and the HMAC no longer matches. See
73
+ [Verification](#verification) below.
74
+
75
+ **One active turn per session is an invariant, not a queue.** A reply that
76
+ arrives while the agent is still working raises `Silas::TurnInProgressError`. The
77
+ generated controller answers `200` so the vendor stops retrying; if dropping the
78
+ message matters for your transport, buffer it or tell the user.
79
+
80
+ Metadata is stored on the session and shown in the inbox, so keep it small and
81
+ free of secrets. It exists so outbound knows where to reply.
82
+
83
+ ### Verification
84
+
85
+ `Silas::Webhook.verify_hmac` handles the parts that are identical everywhere —
86
+ constant-time comparison, the replay window, and **failing closed when no secret
87
+ is configured**. You supply the vendor's shape:
88
+
89
+ ```ruby
90
+ Silas::Webhook.verify_hmac(
91
+ secret: Rails.application.credentials.dig(:silas, :whatsapp, :signing_secret),
92
+ signature: request.headers["X-Signature"],
93
+ payload: request.raw_post, # what they signed
94
+ timestamp: request.headers["X-Signature-Timestamp"],
95
+ prefix: "sha256=", # what they put before the digest
96
+ digest: :hex # :hex, or :base64 for Shopify/Twilio
97
+ )
98
+ ```
99
+
100
+ Vendors differ only in three places:
101
+
102
+ | Vendor | `payload` | `prefix` | `digest` |
103
+ |---|---|---|---|
104
+ | Slack | `"v0:#{timestamp}:#{body}"` | `"v0="` | `:hex` |
105
+ | GitHub | raw body | `"sha256="` | `:hex` |
106
+ | Shopify | raw body | `""` | `:base64` |
107
+
108
+ Omitting `timestamp:` disables replay protection — a captured request can then be
109
+ re-sent forever. If your vendor doesn't send one, that's worth knowing rather
110
+ than defaulting past.
111
+
112
+ ---
113
+
114
+ ## Outbound: answers and approvals
115
+
116
+ ```ruby
117
+ class Agent::Channels::Whatsapp < Silas::Channel
118
+ def deliver_answer(session:, text:)
119
+ # post `text` back to session.metadata["whatsapp"]
120
+ end
121
+
122
+ def deliver_approval(session:, invocation:)
123
+ approve = Silas::Channel.approval_url(invocation, :approve)
124
+ decline = Silas::Channel.approval_url(invocation, :decline)
125
+ # send those two links to an OPERATOR
126
+ end
127
+ end
128
+ ```
129
+
130
+ You never call these. `ChannelDeliveryJob` does, from `after_commit` callbacks —
131
+ `deliver_answer` when a turn completes or fails, `deliver_approval` the moment a
132
+ tool parks. Both run **off the durable loop**, which is the important part: a
133
+ transport outage retries the delivery without re-running a single tool or
134
+ touching the ledger. Raising here is safe. It costs a retry, not a duplicate side
135
+ effect.
136
+
137
+ Delivery is claimed compare-and-swap on a marker column (`answered_at`,
138
+ `notified_at`) and released if your method raises, so a retry re-attempts rather
139
+ than double-sending. A duplicate ping is the worst case; it is never a ledger
140
+ violation.
141
+
142
+ ### Approval links
143
+
144
+ `Silas::Channel.approval_url` mints a signed, expiring one-click link that works
145
+ in any transport — a WhatsApp message, a Discord embed, an SMS. Possession of the
146
+ link is the credential, so it needs no session and no login, and it expires with
147
+ `config.approval_ttl` (7 days by default).
148
+
149
+ It needs a host, and never guesses one — a hostless approval link is a dead link:
150
+
151
+ ```ruby
152
+ config.action_mailer.default_url_options = { host: "yourapp.com" }
153
+ ```
154
+
155
+ > **Send approvals to an operator, never to the requester.**
156
+ > For a customer-facing channel, `session.metadata` describes the person who
157
+ > messaged in. Sending them the approve link lets them approve their own refund.
158
+ > Deliver to your ops destination instead, and **fail closed** when it isn't
159
+ > configured — no destination means no approval, not a silent one. The generated
160
+ > channel does this; keep it.
161
+
162
+ Slack is different only because it can do better: it renders real Approve/Decline
163
+ buttons via Block Kit, handled by the engine's own actions webhook. Every other
164
+ transport uses the signed link.
165
+
166
+ ---
167
+
168
+ ## What you get for free
169
+
170
+ Everything that makes the agent durable is already there, because a channel is a
171
+ trigger, not a runtime:
172
+
173
+ - **Crash-safe turns.** Kill the worker mid-conversation; the turn resumes where
174
+ it stopped, and no tool runs twice.
175
+ - **Approvals that park at zero compute.** A parked turn holds no worker, no
176
+ memory, no tokens. It can wait days.
177
+ - **The inbox.** Every channel session appears at `/silas/inbox` with its full
178
+ trace — you can watch and approve a WhatsApp conversation from the operator UI
179
+ without building anything.
180
+ - **Budgets, cancellation, evals, memory.** All unchanged.
181
+
182
+ A channel is also **not** in the definitions digest — it's a transport, not a
183
+ model-visible capability — so adding or changing one never fails a parked turn
184
+ with `NondeterminismError`.
185
+
186
+ ---
187
+
188
+ ## Worked example: WhatsApp Cloud API
189
+
190
+ ```bash
191
+ bin/rails generate silas:channel whatsapp
192
+ bin/rails credentials:edit
193
+ ```
194
+
195
+ ```yaml
196
+ silas:
197
+ whatsapp:
198
+ signing_secret: <your app secret>
199
+ operator: "+441234567890"
200
+ token: <your permanent access token>
201
+ phone_number_id: "123456789"
202
+ ```
203
+
204
+ Meta signs the raw body with your app secret and prefixes `sha256=`:
205
+
206
+ ```ruby
207
+ # app/controllers/agent/channels/whatsapp_controller.rb
208
+ def verify_webhook!
209
+ ok = Silas::Webhook.verify_hmac(
210
+ secret: Rails.application.credentials.dig(:silas, :whatsapp, :signing_secret),
211
+ signature: request.headers["X-Hub-Signature-256"],
212
+ payload: request.raw_post,
213
+ prefix: "sha256=" # Meta sends no timestamp header: no replay window
214
+ )
215
+ head(:unauthorized) unless ok
216
+ ok
217
+ end
218
+
219
+ def create
220
+ message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
221
+ return head(:ok) unless message && message[:type] == "text"
222
+
223
+ Agent::Channels::Whatsapp.dispatch(
224
+ thread_key: message[:from], # the sender's number: stable per conversation
225
+ input: message.dig(:text, :body).to_s,
226
+ metadata: { "whatsapp" => { "to" => message[:from] } }
227
+ )
228
+ head :ok
229
+ rescue Silas::TurnInProgressError
230
+ head :ok
231
+ end
232
+ ```
233
+
234
+ ```ruby
235
+ # app/agent/channels/whatsapp.rb
236
+ class Agent::Channels::Whatsapp < Silas::Channel
237
+ def deliver_answer(session:, text:)
238
+ send_text(to: session.metadata.dig("whatsapp", "to"), body: text)
239
+ end
240
+
241
+ def deliver_approval(session:, invocation:)
242
+ operator = Rails.application.credentials.dig(:silas, :whatsapp, :operator)
243
+ if operator.blank?
244
+ Rails.logger.warn("[Silas] no operator configured — approval #{invocation.id} not delivered")
245
+ return
246
+ end
247
+
248
+ send_text(to: operator, body: <<~MSG)
249
+ Approval needed: #{invocation.tool_name}
250
+ #{JSON.pretty_generate(invocation.arguments)}
251
+
252
+ Approve: #{Silas::Channel.approval_url(invocation, :approve)}
253
+ Decline: #{Silas::Channel.approval_url(invocation, :decline)}
254
+ MSG
255
+ end
256
+
257
+ private
258
+
259
+ def send_text(to:, body:)
260
+ credentials = Rails.application.credentials.silas[:whatsapp]
261
+ uri = URI("https://graph.facebook.com/v20.0/#{credentials[:phone_number_id]}/messages")
262
+ response = Net::HTTP.post(
263
+ uri,
264
+ { messaging_product: "whatsapp", to: to, type: "text", text: { body: body } }.to_json,
265
+ "content-type" => "application/json",
266
+ "authorization" => "Bearer #{credentials[:token]}"
267
+ )
268
+ # Raise on failure: ChannelDeliveryJob releases its claim and retries.
269
+ raise Silas::Error, "WhatsApp send failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
270
+ end
271
+ end
272
+ ```
273
+
274
+ Point Meta's webhook at `https://yourapp.com/agent/channels/whatsapp` and restart
275
+ — `app/agent/` registers at boot.
276
+
277
+ ---
278
+
279
+ ## Why Silas ships one channel and a generator
280
+
281
+ Slack is first-party because it ships a whole dialect: signature scheme, Block
282
+ Kit approvals, an interactive-actions webhook. Every other transport is a
283
+ different vendor's API drifting on its own schedule, and a framework that
284
+ promises to keep six of those working is a framework that breaks.
285
+
286
+ The generator is the leverage instead. It gets the security decisions right —
287
+ signature-first, raw body, fail closed, operators not requesters — and leaves the
288
+ vendor's three variables to you.
289
+
290
+ If you build one worth sharing, open a PR against this page.
@@ -0,0 +1,106 @@
1
+ # Configuration reference
2
+
3
+ Everything lives in one block, usually in `config/initializers/silas.rb` (the
4
+ installer generates a commented starter):
5
+
6
+ ```ruby
7
+ Silas.configure do |c|
8
+ # ...
9
+ end
10
+ ```
11
+
12
+ ## Inference
13
+
14
+ | Option | Default | Meaning |
15
+ |---|---|---|
16
+ | `adapter` | `:ruby_llm` | The inference seam. `:ruby_llm` (any provider RubyLLM supports), or any object responding to `#execute_step` — the eval harness, the chaos suite, and the template's keyless demo all inject one. |
17
+ | `default_model` | `"claude-sonnet-4-5"` | Used when `agent.yml` doesn't set `model:`. Must resolve in your installed RubyLLM registry. |
18
+ | `around_model_call` | `nil` | Wrap every model call — e.g. `->(ctx, &call) { RubyLLM::Resilience.chain(:anthropic) { call.() } }`. |
19
+ | `queue_name` | `:default` | Active Job queue for agent turns. |
20
+
21
+ ## The loop
22
+
23
+ | Option | Default | Meaning |
24
+ |---|---|---|
25
+ | `max_steps` | `25` | Hard cap on model calls per turn; `agent.yml` `limits.max_steps` overrides per agent. Hitting it fails the turn (`max_steps`). |
26
+ | `compact_at` | `0.9` | Context compaction trigger. A Float in (0, 1] = fraction of the model's registry context window; an Integer = absolute token threshold (the form custom adapters need); `nil`/`false` disables. Compaction summarises **prior turns** into a persisted, exactly-once row — the current turn is never compacted, and replays stay byte-identical. |
27
+ | `isolate_steps` | `true` | Continuation isolation per step — the durability contract. Leave on; specs may disable for inline runs. |
28
+
29
+ ## Approvals & questions
30
+
31
+ | Option | Default | Meaning |
32
+ |---|---|---|
33
+ | `approval_ttl` | `7.days` | How long a parked approval (or in-doubt invocation) waits before expiring. Parked-forever ghosts are a bug, not a feature. |
34
+ | `ask_question` | `true` | The built-in tool that parks the turn to ask the operator something. **Toggling it changes the definitions digest** — settle parked turns before flipping, or they fail loudly on resume (the nondeterminism guard working as designed). |
35
+
36
+ ## Memory
37
+
38
+ | Option | Default | Meaning |
39
+ |---|---|---|
40
+ | `memory` | `true` | `false` removes the memory tools entirely (digest note above applies). |
41
+ | `memory_approval` | `:always` | Every `remember` parks a card for a human; `:never` auto-approves. |
42
+ | `memory_injection_limit` | `8` | How many recent memories are injected into each turn; `recall` digs deeper on demand. |
43
+
44
+ ## Inbox (`/silas/inbox`)
45
+
46
+ | Option | Default | Meaning |
47
+ |---|---|---|
48
+ | `inbox_auth` | deny (404) | The lambda DENIES by rendering (or `head`-ing) and PASSES by not rendering — Devise-compatible: `->(controller) { controller.head :not_found unless controller.current_user&.admin? }`. |
49
+ | `inbox_public_read` | `false` | Read-only for anyone; approve/decline/answer stay gated. |
50
+ | `inbox_actor` | `current_user&.email \|\| "inbox"` | Identity recorded on approvals/declines/answers made in the inbox. |
51
+ | `inbox_streaming` | `nil` (auto) | Live Turbo updates when the host has `turbo-rails`; `false` forces the polling fallback. |
52
+ | `model_prices` | `{}` | **Override** map for cost accounting: `{"model-id" => { in:, out: }}` in units per 1k tokens, 1e6 units = $1 (a $3/MTok rate is `3000`). Everything else prices from RubyLLM's registry. |
53
+
54
+ ## JSON API (`/silas/api/v1`)
55
+
56
+ | Option | Default | Meaning |
57
+ |---|---|---|
58
+ | `api_auth` | deny (404) | Same contract as `inbox_auth`. |
59
+ | `api_actor` | `"api"` | Identity recorded on approvals made through the API. |
60
+ | `api_stream_poll_interval` | `0.5` | Seconds between SSE row polls. |
61
+ | `api_stream_max_duration` | `300` | Seconds before an SSE stream closes itself (clients reconnect with `Last-Event-ID`); bounds thread hold. |
62
+
63
+ ## Channels
64
+
65
+ | Option | Default | Meaning |
66
+ |---|---|---|
67
+ | `slack_signing_secret` / `slack_bot_token` | `credentials.silas.slack.*` | Explicit setters override the credentials path; `nil` disables Slack. |
68
+
69
+ ## Evals
70
+
71
+ | Option | Default | Meaning |
72
+ |---|---|---|
73
+ | `eval_dir` | `"test/agent_evals"` | Where `*_eval.rb` scenarios live (`bin/rails silas:eval`). |
74
+ | `eval_grader` | `nil` | Custom LLM grader for `assert_rubric`; offline, rubric asserts skip rather than fail. |
75
+
76
+ ## Sandbox
77
+
78
+ | Option | Default | Meaning |
79
+ |---|---|---|
80
+ | `sandbox` | `:none` | Code execution off. `:docker` (interim), or any object responding to `#run` — e.g. `Hermetic.gvisor(image: "python:3.12-slim")`. Configuring one advertises the `run_code` tool automatically. |
81
+ | `sandbox_image` / `sandbox_network` / `sandbox_memory` / `sandbox_cpus` / `sandbox_pids` / `sandbox_workdir` / `sandbox_docker_bin` / `sandbox_timeout` | `nil` / `"none"` / `"512m"` / `"1"` / `256` / `"/workspace"` / `"docker"` / `30` | Docker knobs; inert unless `sandbox = :docker`. |
82
+
83
+ ## MCP & testing seams
84
+
85
+ | Option | Default | Meaning |
86
+ |---|---|---|
87
+ | `mcp_server_host` | `"127.0.0.1"` | Bind host for the in-process MCP server (serving *your* tools to MCP clients). |
88
+ | `mcp_client_factory` | `nil` | `->(connection) { client }` — inject a fake MCP client per connection in tests. |
89
+
90
+ The remaining accessors (`tool_resolver`, `tool_definitions`,
91
+ `definitions_digest`, `skills`, `schedules`, `subagent_*`,
92
+ `named_agent_scopes`, `channel_resolver`, `instructions_dir`,
93
+ `agent_override`) are internal wiring seams the Registry fills at boot — test
94
+ hooks, not app configuration.
95
+
96
+ ## Boot guards (fail-loud misconfiguration)
97
+
98
+ Two checks run at boot and **raise in production**, warn in development:
99
+
100
+ - **No provider key configured** while `adapter = :ruby_llm` — the most common
101
+ first-run failure, surfaced at boot with the fix instead of dying inside the
102
+ first turn.
103
+ - **ActiveJob on the in-process `:async` adapter** — it runs a re-enqueued
104
+ continuation concurrently with the original, double-executing steps and
105
+ voiding exactly-once. Use Solid Queue (production) or `:inline`
106
+ (scripts/demos). `bin/rails silas:doctor` checks both, everywhere.
@@ -0,0 +1,55 @@
1
+ # Connections
2
+
3
+ A connection plugs a remote MCP server's tools into your agent the same way
4
+ everything else plugs in: **one data-only file, identity = filename.**
5
+
6
+ ```yaml
7
+ # app/agent/connections/crm.yml
8
+ url: https://mcp.example-crm.com/mcp
9
+ auth:
10
+ type: bearer # or `header` with a `header:` name, or omit
11
+ credential: crm.mcp_token # a PATH into Rails credentials — never the secret
12
+ approval: once # never (default) | once | always
13
+ effect: at_most_once # at_most_once (default) | idempotent
14
+ ```
15
+
16
+ Restart, and the server's tools appear to the model namespaced
17
+ `crm__search`, `crm__create_note`, … — alongside your local tools, running
18
+ through the same Ledger.
19
+
20
+ ## The rules the file encodes
21
+
22
+ - **Credentials are paths, not secrets.** `credential: crm.mcp_token` resolves
23
+ `Rails.application.credentials.dig(:crm, :mcp_token)` at call time. The YAML
24
+ is committable; a missing credential fails loudly with the path named.
25
+ - **Approval and effect mode are per-connection**, enforced by the same Ledger
26
+ as local tools. `approval: always` parks every remote call for a human;
27
+ `once` approves an identical (tool, arguments) pair once per session.
28
+ - **`transactional!` does not exist here, by design.** A remote call can't
29
+ join your database transaction, so the honest ceiling is `at_most_once`
30
+ (an ambiguous crash parks in-doubt for a person) or `idempotent` (you're
31
+ asserting the remote op is safe to repeat).
32
+ - **Transport is HTTP** (v1). Filename is the namespace: `crm.yml` →
33
+ `crm__*`.
34
+
35
+ ## Boot-time discovery, fail-loud
36
+
37
+ Each connection's tool list is fetched **once at boot** and cached
38
+ (`tools/list`). A misconfigured or unreachable connection raises **at boot**,
39
+ never inside a turn — the same posture as the Registry's tool validation. The
40
+ remote tool names and schemas are model-visible state, so they're part of the
41
+ definitions digest: if the remote server changes its toolset, parked turns
42
+ from before the change fail loudly on resume (`NondeterminismError`) instead
43
+ of resuming against a different toolset. Settle parked turns before pointing a
44
+ connection at a changed server.
45
+
46
+ ## Testing
47
+
48
+ `config.mcp_client_factory = ->(connection) { fake_client }` injects a fake
49
+ client per connection — the seam the gem's own specs use.
50
+
51
+ ## The other direction
52
+
53
+ Silas can also *serve* your agent's tools as an MCP server (`Silas::Mcp::Server`,
54
+ bound to `config.mcp_server_host`) — the "mount your tools as MCP" seam, so
55
+ other MCP clients can call the tools you wrote for your agent.
@@ -0,0 +1,161 @@
1
+ # Conventions and deliberate deviations
2
+
3
+ Silas follows Rails conventions by default. Where it doesn't, that's a
4
+ decision, and this page records the reasoning so the next reader doesn't have
5
+ to guess (or "fix" something load-bearing).
6
+
7
+ Style is [`rubocop-rails-omakase`](https://github.com/rails/rubocop-rails-omakase)
8
+ — Rails' own baseline, unmodified except for excluding the generated skeleton
9
+ apps (`chaos_host/`, `examples/`, `spec/dummy/`). CI enforces RuboCop,
10
+ Brakeman, bundler-audit, a Zeitwerk eager-load check, and a 90% line-coverage
11
+ floor.
12
+
13
+ ## Naming and structure
14
+
15
+ ### `Adapters::`, not `Engines::`
16
+
17
+ The pluggable inference backend lives at `Silas::Adapters::Base` /
18
+ `Silas::Adapters::RubyLLM`, configured with `config.adapter`. It was called
19
+ `Engines::` until 0.4, which collided with `Silas::Engine` — the Rails engine
20
+ — in the same namespace. Every comparable seam disambiguates the same way:
21
+ ActiveJob has `QueueAdapters::`, ActiveStorage has `Service::`, RubyLLM has
22
+ `Provider`. The old constants and `config.engine` still work with a
23
+ deprecation warning until 2.0.
24
+
25
+ ### `class << self` vs `module_function`
26
+
27
+ Both appear, by rule, not by accident (the same split RubyLLM uses):
28
+
29
+ - **`module_function`** for pure utility modules whose methods are all
30
+ legitimately callable — `Budget`, `MessageBuilder`, `Instructions`, `Inbox`,
31
+ `Slack`, `StepRunner`.
32
+ - **`class << self` + `private`** where a module has real internals worth
33
+ hiding — `Ledger` (`execute!`, `claim!`, `guarded_transaction` are not
34
+ public API) — or holds state, like `Eval`'s scenario registry.
35
+
36
+ `module_function` publishes every method on the module, so it is the wrong
37
+ tool wherever encapsulation matters. Don't "unify" these.
38
+
39
+ ### Instrumentation
40
+
41
+ Every notable moment in the durable loop emits an `ActiveSupport::Notifications`
42
+ event named `<event>.silas` — the Rails convention (`sql.active_record`), so
43
+ `subscribe(/\.silas\z/)` catches everything. `Silas::LogSubscriber` turns them
44
+ into log lines at levels an operator can filter on: parks and rescues INFO,
45
+ budget breaches WARN, failed turns and nondeterminism ERROR, per-step and
46
+ per-token chatter DEBUG.
47
+
48
+ Payloads always carry `turn_id`/`session_id` where they exist, so a subscriber
49
+ never has to join. The full event list lives in `lib/silas/instrumentation.rb`;
50
+ `tool.silas` (duration + effect_mode + how it settled) is the single most
51
+ useful span, and `resume.silas` carries `parked_for` — how long the human took.
52
+
53
+ Event names and payload keys are a public contract: dashboards get built on
54
+ them, so renaming one is a breaking change, and `spec/silas/instrumentation_spec.rb`
55
+ pins them.
56
+
57
+ ### Generator templates are `.rb.tt`
58
+
59
+ Rails' own convention. A template containing ERB is not valid Ruby, so a bare
60
+ `.rb` extension puts it in front of RuboCop's parser and Zeitwerk's loader —
61
+ both of which are right to complain. `.tt` keeps them out of each. The install
62
+ generator's templates predate this and are plain `.rb` because none of them
63
+ interpolate; new templates use `.tt`.
64
+
65
+ ### Channels ship a generator, not a catalogue
66
+
67
+ The engine serves webhook routes for Slack alone, because Slack is the only
68
+ transport whose whole dialect Silas ships (signature scheme, Block Kit
69
+ approvals, the interactive-actions endpoint). Every other transport is scaffolded
70
+ into the *host* app by `rails g silas:channel`, since only the host knows the
71
+ vendor's signature scheme and payload shape — and a framework promising to keep
72
+ six drifting vendor APIs working is a framework that breaks.
73
+
74
+ What that seam does own, because getting it wrong is a security bug rather than
75
+ a preference: `Silas::Webhook.verify_hmac` (constant-time compare, replay
76
+ window, fail-closed on a missing secret) and `Silas::Channel.approval_url` (a
77
+ signed expiring link, host required and never guessed). See `docs/channels.md`.
78
+
79
+ ### The inbox speaks "Signals"; the API speaks database strings
80
+
81
+ The inbox is themed dark-first (direction "Signals": one white "lamp" accent
82
+ that never means state, plus an aspect colour per run state), and two states
83
+ are relabelled **in the UI only**: `waiting` renders as **held**, `completed`
84
+ as **clear** (`TraceHelper::UI_LABEL`). The database strings and the JSON API
85
+ are untouched — an operator who reads "held" in the inbox and greps the API
86
+ will find `waiting`, and both names appear in the docs for exactly that
87
+ reason. Approval and question cards are hoisted to the top of a session (the
88
+ trace keeps a one-line stub); the hoisted card's DOM id is
89
+ `dom_id(invocation, :approval)` — distinct from the trace row's
90
+ `dom_id(invocation)` — so Turbo can replace each independently.
91
+
92
+ ### Deprecations
93
+
94
+ Everything removable goes through `Silas.deprecator`
95
+ (`ActiveSupport::Deprecation`, registered in `app.deprecators[:silas]`), so a
96
+ host silences or raises on Silas deprecations exactly as it does Rails'.
97
+ Messages name the replacement *and* the removal version; a warning you can't
98
+ act on is noise.
99
+
100
+ ## Deliberate deviations
101
+
102
+ ### Status columns are strings with constants, not Rails enums
103
+
104
+ `Turn::STATUSES`, `ToolInvocation::STATUSES`, `Step` states and
105
+ `approval_state` are validated strings.
106
+
107
+ Enums generate scopes and predicate methods on the model, and the ledger
108
+ performs its compare-and-swap claims through `update_all(status: "completed")`
109
+ at the SQL level — where an enum's integer/string mapping is one more thing
110
+ between the code and what the database actually holds. For a state machine
111
+ whose correctness *is* the product, the literal value in the column being the
112
+ literal value in the code is worth more than the generated sugar.
113
+
114
+ ### Webhook controllers skip CSRF
115
+
116
+ `Silas::Channels::BaseController` calls `skip_forgery_protection`. No browser
117
+ session originates a webhook, so no CSRF token can exist. Each route
118
+ authenticates the request itself instead:
119
+
120
+ - Slack routes verify an HMAC-SHA256 signature with a 300-second replay window.
121
+ - Email approve/decline routes require a purpose-scoped, expiring
122
+ `MessageVerifier` token; possession of the token *is* the credential, so CSRF
123
+ would add nothing an attacker holding it couldn't already do.
124
+
125
+ Both are covered by request specs including the negative cases (unsigned,
126
+ wrong secret, stale, tampered, expired, wrong-purpose, replayed). The
127
+ suppression is recorded with this reasoning in `config/brakeman.ignore`. The
128
+ inbox controllers keep `protect_from_forgery`; the JSON API is
129
+ `ActionController::API` and is token-authenticated.
130
+
131
+ ### Nothing is mass-assigned
132
+
133
+ There is no `Model.new(params)` or `update(params)` anywhere, and therefore no
134
+ strong-parameters ceremony. Every controller reads scalars explicitly
135
+ (`params[:input].to_s.strip`) and passes them as keyword arguments. The one
136
+ bulk value — a session's `metadata` hash on the JSON API — is assigned
137
+ explicitly to a single JSON column, so it cannot reach any other attribute.
138
+
139
+ ### Indexes cover query paths, not every foreign key
140
+
141
+ `silas_memories.session_id`, `turn_id` and `superseded_by_id` are provenance
142
+ columns: written, never used in a `WHERE`. `silas_turns.job_id` is
143
+ observability. They are deliberately unindexed — an index nobody reads is a
144
+ write cost on the durable loop's hot path. The columns that *are* queried are
145
+ indexed, including the partial unique index on `silas_turns` that enforces
146
+ one active turn per session.
147
+
148
+ ## Conventions worth knowing
149
+
150
+ - **Escaping**: no `raw`, `html_safe`, or `<%==` anywhere. Tool arguments,
151
+ tool results, and model output are rendered escaped, because a framework
152
+ that displays LLM and third-party output is the last place to hand-wave XSS.
153
+ - **Time**: `Time.current`/`Time.zone` throughout; no `Time.now` or
154
+ `Date.today`.
155
+ - **Concerns** live in `app/models/concerns/silas/`; the engine is
156
+ `isolate_namespace Silas` so host apps can't collide with it.
157
+ - **Trace partials** are rendered in two contexts (the inbox controllers and
158
+ Turbo's broadcast jobs, which use the *host's* renderer). They therefore
159
+ build engine routes through `silas_engine_path`, never bare route helpers,
160
+ and the shared helper is registered host-wide. See
161
+ `spec/silas/inbox/broadcasting_spec.rb`.