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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +211 -0
- data/DEPLOY.md +111 -0
- data/README.md +87 -250
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/helpers/silas/inbox/trace_helper.rb +20 -5
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/layouts/silas/inbox.html.erb +89 -30
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
- data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
- data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
- data/app/views/silas/inbox/sessions/index.html.erb +16 -15
- data/app/views/silas/inbox/sessions/show.html.erb +9 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/docs/agents.md +81 -0
- data/docs/budgets.md +67 -0
- data/docs/cancellation.md +41 -0
- data/docs/channels.md +290 -0
- data/docs/configuration.md +106 -0
- data/docs/connections.md +55 -0
- data/docs/conventions.md +161 -0
- data/docs/evals.md +95 -0
- data/docs/guarantees.md +76 -0
- data/docs/inbox-and-api.md +84 -0
- data/docs/memory.md +35 -0
- data/docs/sandbox.md +44 -0
- data/docs/tools.md +77 -0
- data/docs/tutorial.md +268 -0
- data/docs/vs-eve.md +93 -0
- data/docs/why-silas.md +87 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +11 -1
- data/lib/generators/silas/install/templates/claude_skill.md +136 -0
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
- data/lib/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/eval/dsl.rb +7 -2
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +8 -5
- data/lib/silas/step_runner.rb +5 -0
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +3 -0
- metadata +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.
|
data/docs/connections.md
ADDED
|
@@ -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.
|
data/docs/conventions.md
ADDED
|
@@ -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`.
|