silas 0.5.0 → 0.6.1
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 +145 -0
- data/DEPLOY.md +111 -0
- data/README.md +87 -256
- data/app/helpers/silas/inbox/trace_helper.rb +29 -6
- data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
- data/app/views/layouts/silas/inbox.html.erb +89 -30
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -2
- data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
- data/app/views/silas/inbox/sessions/_cost.html.erb +3 -1
- 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 +10 -1
- data/app/views/silas/inbox/turns/_header.html.erb +31 -25
- data/app/views/silas/inbox/turns/_turn.html.erb +5 -3
- 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 +58 -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 +91 -0
- data/docs/memory.md +35 -0
- data/docs/sandbox.md +44 -0
- data/docs/tools.md +77 -0
- data/docs/tutorial.md +272 -0
- data/docs/vs-eve.md +93 -0
- data/docs/why-silas.md +87 -0
- data/lib/generators/silas/install/install_generator.rb +9 -0
- 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/connection.rb +16 -0
- data/lib/silas/eval/dsl.rb +7 -2
- data/lib/silas/version.rb +1 -1
- metadata +22 -1
data/docs/tutorial.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Tutorial: build the desk
|
|
2
|
+
|
|
3
|
+
One running app, eleven short chapters, one new primitive each. You start with
|
|
4
|
+
a working refund-desk agent and finish with a scheduled, Slack-connected,
|
|
5
|
+
memory-having, staff-employing one — with evals gating every change and every
|
|
6
|
+
consequential action holding at the signal until a person clears it.
|
|
7
|
+
|
|
8
|
+
No API key is needed until chapter 3 (and even then it's optional — everything
|
|
9
|
+
except "talk to a real model" works keyless).
|
|
10
|
+
|
|
11
|
+
## 1 · Open the desk
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/templates/desk.rb
|
|
15
|
+
cd desk && bin/dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open <http://localhost:3000> — the **signal board**:
|
|
19
|
+
|
|
20
|
+
<img src="https://raw.githubusercontent.com/danielstpaul/silas/main/docs/img/silas-board.png" width="740"
|
|
21
|
+
alt="The desk's signal board: aspect tiles for held/working/clear/in doubt, the keyless demo banner, the agent card, and try-it prompts">
|
|
22
|
+
|
|
23
|
+
Then *operator inbox →* and start a session:
|
|
24
|
+
|
|
25
|
+
> The walnut monitor stand (order R-1002) arrived cracked.
|
|
26
|
+
|
|
27
|
+
Watch the trace: `lookup_order` clears, then `issue_refund` **holds** — an
|
|
28
|
+
amber card at the top of the session, and the turn costs nothing while it
|
|
29
|
+
waits. Approve it. The turn resumes exactly where it stopped, notifies the
|
|
30
|
+
customer, and answers. Now check the till:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bin/rails runner 'puts Refund.count' # => 1
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Try the £18 story too (`order R-1001`) — under the gate, it never holds.
|
|
37
|
+
|
|
38
|
+
What you just saw is the whole thesis: a **turn** (one request to its answer)
|
|
39
|
+
made of **steps** (model calls) whose tool effects go through a **ledger**;
|
|
40
|
+
anything gated **parks at zero compute** until a human verdict; and a crash
|
|
41
|
+
anywhere in that story resumes from the last completed step. Kill `bin/dev`
|
|
42
|
+
mid-run and restart it if you want to test that claim right now.
|
|
43
|
+
|
|
44
|
+
## 2 · Read the agent (it's a directory)
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
app/agent/
|
|
48
|
+
instructions.md # the persona — plain markdown, ERB allowed
|
|
49
|
+
agent.yml # model + per-turn limits; data only
|
|
50
|
+
tools/
|
|
51
|
+
lookup_order.rb # idempotent! — read-only, replays freely
|
|
52
|
+
issue_refund.rb # transactional! — DB effect: exactly-once, gated over £25
|
|
53
|
+
notify_customer.rb # at_most_once! — external effect: parks IN DOUBT on a crash
|
|
54
|
+
skills/ schedules/ channels/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Open the three tools. Each declares an **effect mode**, and the mode is the
|
|
58
|
+
entire durability decision:
|
|
59
|
+
|
|
60
|
+
| The tool… | Declare | Because |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| writes this app's database | `transactional!` | effect + ledger commit atomically → **exactly-once** |
|
|
63
|
+
| calls anything external | `at_most_once!` (default) | an ambiguous crash **parks for a human**, never re-fires blind |
|
|
64
|
+
| only reads | `idempotent!` | replays may re-run it freely |
|
|
65
|
+
|
|
66
|
+
Never mark an external call `transactional!` — the ledger cannot roll back a
|
|
67
|
+
sent email. And note `issue_refund`'s approval lambda: policy lives **on the
|
|
68
|
+
tool**, next to the code it gates.
|
|
69
|
+
|
|
70
|
+
## 3 · Change it: your first tool
|
|
71
|
+
|
|
72
|
+
The desk can't answer "what did Ada buy this year?" Give it history. Create
|
|
73
|
+
`app/agent/tools/order_history.rb`:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class Agent::Tools::OrderHistory < Silas::Tool
|
|
77
|
+
description "List a customer's orders by email, newest first."
|
|
78
|
+
idempotent!
|
|
79
|
+
|
|
80
|
+
def call(email:)
|
|
81
|
+
orders = Order.where(email: email.to_s.strip.downcase).order(created_at: :desc)
|
|
82
|
+
return { error: "no orders for #{email}" } if orders.none?
|
|
83
|
+
|
|
84
|
+
{ orders: orders.map { |o| { number: o.number, item: o.item,
|
|
85
|
+
amount_pence: o.amount_pence, status: o.status } } }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The keyword signature of `#call` **is** the schema the model sees; the
|
|
91
|
+
filename is the tool's name. Restart `bin/dev` (files register at boot), then:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bin/rails silas:doctor # "tools — 4 tool(s) validate"
|
|
95
|
+
bin/rails silas:chat # you> what has ada@example.com ordered?
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
(Keyless, the scripted stand-in won't improvise about your new tool — export
|
|
99
|
+
`ANTHROPIC_API_KEY` and restart to watch a real model use it. Everything else
|
|
100
|
+
in this tutorial stays keyless-friendly.)
|
|
101
|
+
|
|
102
|
+
## 4 · Prove it: evals as the deploy gate
|
|
103
|
+
|
|
104
|
+
Open `test/agent_evals/refund_desk_eval.rb` — three scenarios already assert
|
|
105
|
+
the desk's contract, including *the refund holds* and *clearing it executes
|
|
106
|
+
exactly once*. Add one for your tool:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
Silas::Eval.scenario "order history is grounded in rows" do
|
|
110
|
+
input "What has ada@example.com ordered?"
|
|
111
|
+
|
|
112
|
+
on_step 0, call: { name: "order_history", arguments: { email: "ada@example.com" } }
|
|
113
|
+
on_step 1, text: "Ada has two orders: the field notebook (£18.00) and the walnut monitor stand (£64.00)."
|
|
114
|
+
|
|
115
|
+
expect do
|
|
116
|
+
assert_tool_called "order_history", times: 1
|
|
117
|
+
assert_turn_completed
|
|
118
|
+
assert_no_hallucinated_price # every £ in the answer must trace to data the agent saw
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bin/rails silas:eval # 4 scenarios, 0 failing
|
|
125
|
+
bin/ci # tests + evals — the deploy gate
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
You script the **model's decisions**; the **real ledger** runs your real
|
|
129
|
+
tools. That's why `assert_parked` and `times: 1` are trustworthy — they read
|
|
130
|
+
durable rows, not mocks. Details: [evals.md](evals.md).
|
|
131
|
+
|
|
132
|
+
## 5 · Give it a clock: schedules
|
|
133
|
+
|
|
134
|
+
A schedule is a markdown file whose body becomes the turn input. Create
|
|
135
|
+
`app/agent/schedules/daily_digest.md`:
|
|
136
|
+
|
|
137
|
+
```markdown
|
|
138
|
+
---
|
|
139
|
+
cron: "0 9 * * *"
|
|
140
|
+
---
|
|
141
|
+
Summarize yesterday's refunds: count, total pence, and anything still held at
|
|
142
|
+
the signal. Keep it to five lines.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bin/rails silas:schedules
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
That **compiles** schedules into `config/recurring.yml` — cron that fires real
|
|
150
|
+
work stays a reviewable git diff, and each tick is a normal durable turn (it
|
|
151
|
+
can hold for approval like any other). Prefer code? Drop a `.rb` subclassing
|
|
152
|
+
`Silas::Schedule::Handler` in the same directory.
|
|
153
|
+
|
|
154
|
+
## 6 · Give it a doorway: Slack
|
|
155
|
+
|
|
156
|
+
The installer already scaffolded `app/agent/channels/slack.rb`. Wire
|
|
157
|
+
credentials and it's live:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
bin/rails credentials:edit # silas: { slack: { signing_secret: ..., bot_token: ... } }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Point your Slack app's events at `/silas/channels/slack/events` (the mounted
|
|
164
|
+
engine verifies signatures). A new thread starts a session; replies continue
|
|
165
|
+
it; and a held refund renders as **Approve/Decline buttons in Slack** that
|
|
166
|
+
call the exact same `approve!` as the inbox. Any other transport:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bin/rails g silas:channel whatsapp
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
scaffolds the signature-verifying webhook and the outbound half. See
|
|
173
|
+
[channels.md](channels.md).
|
|
174
|
+
|
|
175
|
+
## 7 · Let it ask: `ask_question`
|
|
176
|
+
|
|
177
|
+
Approvals are the human saying yes/no. `ask_question` is the reverse — the
|
|
178
|
+
agent needs *information*, not permission. It's built in; instruct it in
|
|
179
|
+
`app/agent/instructions.md`:
|
|
180
|
+
|
|
181
|
+
```markdown
|
|
182
|
+
- If the customer's request is ambiguous (which order? partial or full?), use
|
|
183
|
+
ask_question to ask the operator before touching money.
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
When the model calls it, the turn parks the same way an approval does — zero
|
|
187
|
+
compute, amber card — but the card has a **text box**. Your typed answer
|
|
188
|
+
becomes the tool result and the turn resumes with it. Same TTL, same audit
|
|
189
|
+
trail, same API (`POST .../approvals/:id/answer`).
|
|
190
|
+
|
|
191
|
+
## 8 · Let it keep notes: memory
|
|
192
|
+
|
|
193
|
+
Memory is for the fuzzy residue with no natural home in your tables —
|
|
194
|
+
"ada@example.com prefers replacement over refund" — stored as
|
|
195
|
+
`subject · attribute · content` triples with provenance and supersession.
|
|
196
|
+
|
|
197
|
+
It's already on. Tell the agent when to use it (instructions again):
|
|
198
|
+
|
|
199
|
+
```markdown
|
|
200
|
+
- When a customer states a durable preference, remember it.
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The first time the model calls `remember`, the memory **parks as a card in
|
|
204
|
+
your inbox** — nothing persists until you approve it
|
|
205
|
+
(`config.memory_approval = :always` is the default). Approved memories inject
|
|
206
|
+
into future turns automatically; `recall` digs deeper on demand. Your *domain*
|
|
207
|
+
data stays in your tables, where your tools already read it.
|
|
208
|
+
|
|
209
|
+
## 9 · Hire staff: named agents and handoffs
|
|
210
|
+
|
|
211
|
+
One desk, many specialists. A named agent is the same directory shape under
|
|
212
|
+
`app/agents/<name>/`:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
app/agents/escalations/
|
|
216
|
+
instructions.md # "You handle disputes the desk hands you…"
|
|
217
|
+
agent.yml
|
|
218
|
+
tools/ # its own toolset — the desk's tools are NOT inherited
|
|
219
|
+
schedules/ # its own clock, ticking IT — not the root agent
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Restart, and the desk can delegate durably: the built-in `handoff` tool files
|
|
223
|
+
a **self-contained brief** that starts a linked session for the named agent —
|
|
224
|
+
exactly-once-guarded and cycle-checked — instead of two models chatting
|
|
225
|
+
freely (a cost and audit hazard, deliberately unblessed). Talk to a
|
|
226
|
+
specialist directly with `Silas.agent("escalations").start(input: "…")` or
|
|
227
|
+
`bin/rails silas:chat AGENT=escalations`; the inbox filters by agent.
|
|
228
|
+
|
|
229
|
+
## 10 · Fit the governors: budgets and compaction
|
|
230
|
+
|
|
231
|
+
Open `app/agent/agent.yml`:
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
limits:
|
|
235
|
+
max_steps: 8 # runaway guard — breaching this FAILS the turn
|
|
236
|
+
max_cost: 0.25 # dollars per turn — breaching PARKS it
|
|
237
|
+
timeout: 300 # active seconds — held time never counts
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A parked budget breach is an amber card with a **raise-budget** control: the
|
|
241
|
+
top-up is recorded per turn, so one exception never loosens the standing
|
|
242
|
+
limits ([budgets.md](budgets.md)). And long conversations don't die at the
|
|
243
|
+
context window: past `config.compact_at` (default 90% of the model's window)
|
|
244
|
+
Silas summarises prior turns — as a **persisted, exactly-once effect**, so a
|
|
245
|
+
crash replay rebuilds byte-identical messages. You configure nothing; it's a
|
|
246
|
+
default.
|
|
247
|
+
|
|
248
|
+
## 11 · Ship it
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
bin/rails silas:doctor
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Work through its checklist for production:
|
|
255
|
+
|
|
256
|
+
1. A real provider key in the production environment.
|
|
257
|
+
2. Real inbox auth — replace the template's dev-only lambda in
|
|
258
|
+
`config/initializers/silas.rb` with your `current_user` check.
|
|
259
|
+
3. Keep `silas_dead_job_rescuer` in `config/recurring.yml` — it's part of the
|
|
260
|
+
crash-recovery contract — and **monitor worker liveness**: the rescuer can
|
|
261
|
+
requeue work; it cannot conjure a consumer.
|
|
262
|
+
4. `bin/ci` in your pipeline — the evals you wrote are now the gate that stops
|
|
263
|
+
a deploy from changing the desk's behavior unnoticed.
|
|
264
|
+
5. Deploy with Kamal as usual. The hard-won operational notes (what the chaos
|
|
265
|
+
harness taught us about dead workers, why you never hand-delete
|
|
266
|
+
`solid_queue_processes` rows) are in
|
|
267
|
+
[DEPLOY.md](https://github.com/danielstpaul/silas/blob/main/DEPLOY.md).
|
|
268
|
+
|
|
269
|
+
Then delete the desk — models, tools, seeds — and build your own agent in the
|
|
270
|
+
hole it leaves. The shape you learned is the whole framework: **a directory of
|
|
271
|
+
plain files, a ledger that makes effects land exactly once, and a signal that
|
|
272
|
+
holds anything consequential until a person clears it.**
|
data/docs/vs-eve.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Silas vs eve
|
|
2
|
+
|
|
3
|
+
> Written **2026-07-26** against **eve 0.27.6** and Silas 0.5.x, from eve's
|
|
4
|
+
> published docs and source. Both projects move fast — if you're reading this
|
|
5
|
+
> much later, re-verify before deciding.
|
|
6
|
+
|
|
7
|
+
Silas and eve share the same organising idea: **an agent is a directory of
|
|
8
|
+
conventional files** — instructions, one file per tool, skills, schedules,
|
|
9
|
+
channels, connections — running on a durable loop. You can build the same
|
|
10
|
+
things with either: support desks, scheduled analysts, Slack copilots,
|
|
11
|
+
back-office automations. eve is TypeScript-native, built by Vercel on the AI
|
|
12
|
+
SDK, and fully self-hostable. Silas is Rails-native, a gem inside your
|
|
13
|
+
existing app.
|
|
14
|
+
|
|
15
|
+
So the honest first cut is simply your stack: **a TypeScript team should use
|
|
16
|
+
eve; a Rails team should use Silas.** The rest of this page is for people near
|
|
17
|
+
the boundary — and for the differences that go deeper than language.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The comparison
|
|
22
|
+
|
|
23
|
+
| | Silas | eve (0.27.6) |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| **Shape** | A gem in your Rails app — your database, your job queue, your auth, one deploy. | A TypeScript service beside your app, with its own workflow store and deploy. |
|
|
26
|
+
| **Authoring** | A directory of plain files. | A directory of plain files — genuinely the same idea. |
|
|
27
|
+
| **Durable loop** | Survives `kill -9`, resumes from the last completed step. Chaos-gated every release: zero duplicate effects, byte-identical replay, SQLite + Postgres. | Workflow-engine replay; interrupted steps re-run. |
|
|
28
|
+
| **Tool-effect semantics** | **Exactly-once** for DB-recorded effects (`transactional!` — effect + ledger in one transaction). Default at-most-once: an ambiguous crash **parks for a human**. | **At-least-once, documented as such** — "make non-idempotent side effects like charges or emails idempotent, or gate them with approval." Dedup is the tool author's job. |
|
|
29
|
+
| **Human-in-the-loop** | Parks at zero compute; cleared from inbox, Slack, signed email, or API; parks expire; `ask_question` for the reverse direction. | `needsApproval` on tools. |
|
|
30
|
+
| **Operator surface** | A production inbox mounted in your app: live traces, approval cards, audit trail, cost accounting, web chat. | A dev TUI ("not a production chat UI or customer-facing dashboard"); production UIs are assembled from templates. |
|
|
31
|
+
| **Memory** | Shipped: approval-gated triples with provenance and supersession. | Deliberately out of scope. |
|
|
32
|
+
| **Ecosystem** | Ruby/Rails. | TypeScript + the AI SDK — a much larger ecosystem, with Vercel's distribution behind it. |
|
|
33
|
+
| **Channels & integrations** | Slack + email built in; a generator scaffolds any transport. | More first-party surfaces, growing quickly. |
|
|
34
|
+
| **Maturity** | Early (0.5.x); the durability contract is chaos-verified on every release. | Weeks old publicly; backed by a platform company, shipping at platform speed. |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Where Silas goes further
|
|
39
|
+
|
|
40
|
+
**The transaction boundary.** When an agent's consequential action is a row in
|
|
41
|
+
your own database — a refund, a balance move, a ledger entry — Silas commits
|
|
42
|
+
the effect and the ledger's dedup record **in the same transaction**. Crash
|
|
43
|
+
before commit: both roll back. Crash after: the step is skipped on resume.
|
|
44
|
+
No idempotency keys, no reconciliation. This isn't about hosting — it's about
|
|
45
|
+
*whose transaction it is*: any runtime outside your application database,
|
|
46
|
+
self-hosted or not, keeps its "this step ran" record in its own store, and two
|
|
47
|
+
stores can't commit atomically. eve's docs draw the same conclusion from the
|
|
48
|
+
other side: make your side effects idempotent, or gate them. Silas is the
|
|
49
|
+
framework where you don't have to.
|
|
50
|
+
|
|
51
|
+
**Ambiguity parks.** At-least-once means a crash can re-fire a side effect.
|
|
52
|
+
Silas's default is at-most-once with **in-doubt → human**: it never
|
|
53
|
+
double-fires, and an ambiguous call waits for a person.
|
|
54
|
+
|
|
55
|
+
**The operator surface ships.** `mount Silas::Engine` and the inbox exists —
|
|
56
|
+
held/working/filed rail, live traces, approval cards, audit trail, cost, web
|
|
57
|
+
chat — behind your app's own auth.
|
|
58
|
+
|
|
59
|
+
**Memory ships.** Approval-gated, with provenance and supersession. eve
|
|
60
|
+
reasonably says "bring your own"; Silas ships the batteries.
|
|
61
|
+
|
|
62
|
+
## Where eve goes further
|
|
63
|
+
|
|
64
|
+
- **The ecosystem.** TypeScript and the AI SDK are where most of the agent
|
|
65
|
+
world lives — more examples, more integrations, a bigger hiring pool, and
|
|
66
|
+
Vercel's reach.
|
|
67
|
+
- **Surface breadth and velocity.** More first-party channels and
|
|
68
|
+
integrations today, with a platform company's release cadence.
|
|
69
|
+
- **Sandboxing posture.** Container sandboxes are integral to eve's design.
|
|
70
|
+
Silas ships an interim Docker seam and reaches microVM-class isolation via
|
|
71
|
+
the [hermetic](https://github.com/danielstpaul/hermetic) gem.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Choosing
|
|
76
|
+
|
|
77
|
+
**Pick Silas** if you're on Rails — your agent's tools are ordinary Ruby
|
|
78
|
+
against your own models, the inbox lives behind your auth, and effects your
|
|
79
|
+
app records in its own database get exactly-once semantics no external
|
|
80
|
+
runtime can match. Especially if your agents move money.
|
|
81
|
+
|
|
82
|
+
**Pick eve** if you're in TypeScript — it's an excellent framework with the
|
|
83
|
+
same authoring model, the ecosystem's momentum, and Vercel behind it.
|
|
84
|
+
|
|
85
|
+
Near the boundary (a Rails shop with a TS front-of-house, say): decide by
|
|
86
|
+
where your consequential side effects live. If they're rows in the Rails
|
|
87
|
+
app's database, that's where the agent belongs.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
*Claims about eve come from its docs and source at 0.27.6, quoted or
|
|
92
|
+
paraphrased in good faith; corrections welcome. Silas's numbers are
|
|
93
|
+
reproducible from `chaos_host/results/`.*
|
data/docs/why-silas.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Why Silas
|
|
2
|
+
|
|
3
|
+
## Build agents the way you build Rails apps
|
|
4
|
+
|
|
5
|
+
Silas is for the same things every modern agent framework is for: a support
|
|
6
|
+
desk that actually resolves tickets, an analyst that posts the Monday digest,
|
|
7
|
+
an ops copilot in Slack, a back-office agent that chases invoices. An agent is
|
|
8
|
+
a directory of plain files — a persona, a data-only config, one file per tool
|
|
9
|
+
— and the framework supplies the durable loop, the scheduling, the channels,
|
|
10
|
+
the memory, and the operator surface. If you've looked at eve, the authoring
|
|
11
|
+
model will feel immediately familiar; that shape is the category's best idea,
|
|
12
|
+
and Silas embraces it.
|
|
13
|
+
|
|
14
|
+
What's different is *where it runs*. Silas isn't a second runtime you stand
|
|
15
|
+
beside your app — it's a gem inside the Rails app you already deploy:
|
|
16
|
+
|
|
17
|
+
- **Your tools are your app.** `Order.find_by!`, `refunds.create!`, your
|
|
18
|
+
service objects, your validations — no RPC layer between the agent and the
|
|
19
|
+
domain, because the agent lives where the domain lives.
|
|
20
|
+
- **Your auth is the agent's auth.** The operator inbox mounts as an engine
|
|
21
|
+
and hides behind whatever `current_user` already means. No second dashboard,
|
|
22
|
+
no second login, no second audit domain.
|
|
23
|
+
- **Your deploy is the agent's deploy.** One repo, one CI, one Kamal push.
|
|
24
|
+
The durable substrate — a database, Solid Queue, Active Job Continuations —
|
|
25
|
+
is already booted.
|
|
26
|
+
|
|
27
|
+
## The guarantees go further
|
|
28
|
+
|
|
29
|
+
Every serious framework makes the loop durable. Silas draws the line a step
|
|
30
|
+
past that, and verifies it with a chaos harness that `kill -9`s live agents
|
|
31
|
+
hundreds of times per release (zero duplicate effects, byte-identical replay
|
|
32
|
+
— [guarantees](guarantees.md)):
|
|
33
|
+
|
|
34
|
+
- **Exactly-once tool effects.** A `transactional!` tool's database write and
|
|
35
|
+
the ledger's record of it commit in **one transaction**. A crash mid-refund
|
|
36
|
+
leaves exactly one refund row — never two, never zero — with no idempotency
|
|
37
|
+
key required. Only a framework *inside* your app can offer this: an external
|
|
38
|
+
runtime's ledger can never join your database transaction.
|
|
39
|
+
- **Ambiguity waits for a person.** The default mode is at-most-once: a crash
|
|
40
|
+
that makes "did it send?" unanswerable parks the call **in doubt** for a
|
|
41
|
+
human verdict instead of re-firing blind. Never double-pay; sometimes ask.
|
|
42
|
+
- **Holds cost nothing.** An approval, a question, or a budget breach parks
|
|
43
|
+
the turn at zero compute — the job exits, and clearing it resumes from
|
|
44
|
+
durable rows without re-calling the model.
|
|
45
|
+
|
|
46
|
+
If your agents only ever read, any durable loop will do. The moment one
|
|
47
|
+
touches money, inventory, or anything you'd hate to see happen twice, this is
|
|
48
|
+
the difference you'll feel.
|
|
49
|
+
|
|
50
|
+
## Batteries included
|
|
51
|
+
|
|
52
|
+
A production operator inbox (live traces, approval cards, audit trail, cost
|
|
53
|
+
accounting), approval-gated memory with provenance, deterministic evals as a
|
|
54
|
+
deploy gate, Slack and email channels plus a generator for any transport, a
|
|
55
|
+
JSON API with SSE, per-turn budgets, replay-safe compaction — shipped in the
|
|
56
|
+
gem, not assembled from templates. And one command builds a working agent app
|
|
57
|
+
from nothing:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/templates/desk.rb
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## The honest notes
|
|
64
|
+
|
|
65
|
+
- **Silas is early** — 0.5.x, and the guarantees are proven by a reproducible
|
|
66
|
+
chaos harness rather than years of production traffic. The contract is
|
|
67
|
+
measured on every release, and it's stated precisely so you know exactly
|
|
68
|
+
what's promised.
|
|
69
|
+
- **Rails-only, on purpose.** If your team lives in TypeScript, eve is
|
|
70
|
+
excellent and closer to home — the [comparison](vs-eve.md) is honest about
|
|
71
|
+
that in both directions.
|
|
72
|
+
- **Exactly-once applies to effects in your database.** A raw external call
|
|
73
|
+
with no idempotency key of its own gets at-most-once + in-doubt parking —
|
|
74
|
+
strong, but not transactional. Model money as rows (your app probably
|
|
75
|
+
already does) and you get the full guarantee.
|
|
76
|
+
- **Inference goes to your configured provider.** Agent state stays in your
|
|
77
|
+
database; prompts still travel to the model you choose.
|
|
78
|
+
- **Untrusted code needs a real sandbox.** The built-in Docker seam is
|
|
79
|
+
interim; configure [hermetic](https://github.com/danielstpaul/hermetic) for
|
|
80
|
+
microVM-class isolation before running code you didn't write.
|
|
81
|
+
|
|
82
|
+
## Try the claim
|
|
83
|
+
|
|
84
|
+
Run the template, ask for the £64 refund, clear it from the inbox — and while
|
|
85
|
+
the turn is resuming, `kill -9` the worker. Restart it. The turn completes
|
|
86
|
+
from its last checkpoint, and `Refund.count` is exactly 1. That's the pitch,
|
|
87
|
+
reproduced on your laptop in five minutes.
|
|
@@ -34,6 +34,15 @@ module Silas
|
|
|
34
34
|
template "channel_email.rb", "app/agent/channels/email.rb"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# A Claude Code / coding-agent skill: the app/agent/ conventions, the
|
|
38
|
+
# effect-mode and approval decision rules, and the ledger rules an agent
|
|
39
|
+
# must never violate — so a coding agent driving this app builds Silas
|
|
40
|
+
# agents correctly without the human learning the framework first.
|
|
41
|
+
# Delete the file if you don't use coding agents.
|
|
42
|
+
def create_claude_skill
|
|
43
|
+
template "claude_skill.md", ".claude/skills/silas/SKILL.md"
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
def mount_engine
|
|
38
47
|
route 'mount Silas::Engine => "/silas"'
|
|
39
48
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: silas
|
|
3
|
+
description: Build and modify AI agents in this Rails app with the Silas gem — tools, approvals, effect modes, schedules, channels, evals. Use whenever a task touches app/agent/, app/agents/, a Silas tool, an agent's instructions or limits, or agent durability/approval behaviour.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building Silas agents
|
|
7
|
+
|
|
8
|
+
Silas runs durable AI agents inside this Rails app: every turn survives
|
|
9
|
+
`kill -9` and resumes from its last completed step, tool effects are
|
|
10
|
+
exactly-once, and risky calls park for a human at zero compute. The agent is
|
|
11
|
+
the `app/agent/` directory — you build agents by writing ordinary Ruby files
|
|
12
|
+
there, not by calling a framework API.
|
|
13
|
+
|
|
14
|
+
**Deep reference lives in the installed gem** — read it from the bundle when
|
|
15
|
+
you need more than this file:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bundle show silas # then read README.md, docs/*.md, DEPLOY.md there
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The directory is the agent
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
app/agent/
|
|
25
|
+
instructions.md # persona (ERB; snapshotted once per turn)
|
|
26
|
+
agent.yml # model + limits — data only, no code
|
|
27
|
+
tools/ # one file per tool; TOOL IDENTITY IS THE FILENAME
|
|
28
|
+
skills/ # markdown playbooks, loaded on demand (description: frontmatter)
|
|
29
|
+
schedules/ # cron.md (frontmatter) or .rb handlers -> silas:schedules compiles them
|
|
30
|
+
channels/ # transports (slack.rb, email.rb; generate more, see below)
|
|
31
|
+
app/agents/<name>/ # NAMED agents: same tree per agent, own tools/skills/schedules
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Files register at boot — restart the server after adding one.
|
|
35
|
+
|
|
36
|
+
## Writing a tool (the part to get right)
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# app/agent/tools/issue_refund.rb -> tool "issue_refund"
|
|
40
|
+
class Agent::Tools::IssueRefund < Silas::Tool
|
|
41
|
+
description "Refund part or all of an order."
|
|
42
|
+
param :amount_pence, :integer, desc: "Amount in pence"
|
|
43
|
+
approval ->(session:, input:) { input[:amount_pence] > 2000 ? :user_approval : :approved }
|
|
44
|
+
transactional!
|
|
45
|
+
|
|
46
|
+
def call(order_id:, amount_pence:, reason:)
|
|
47
|
+
Refund.create!(order_id:, amount_pence:, reason:)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **The keyword signature of `#call` IS the schema** the model sees. Keywords
|
|
53
|
+
only — never positional. `param` refines types/descriptions.
|
|
54
|
+
- **Return a Hash** (anything else is wrapped as `{"value" => ...}`). Raising
|
|
55
|
+
records a failed invocation the model sees — don't rescue-and-swallow.
|
|
56
|
+
- `session` is available inside `call` (the `Silas::Session` row).
|
|
57
|
+
|
|
58
|
+
### Effect mode — decide by where the side effect lives
|
|
59
|
+
|
|
60
|
+
| The tool… | Declare | Why |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| writes this app's database | `transactional!` | effect + ledger row commit atomically: **exactly-once**, even through `kill -9` |
|
|
63
|
+
| calls an external API / sends anything | `at_most_once!` (the default) | a crash mid-call leaves it IN DOUBT → parks for a human verdict, never re-fires blind |
|
|
64
|
+
| only reads, safe to repeat | `idempotent!` | replays re-run it freely |
|
|
65
|
+
|
|
66
|
+
Never mark an external call `transactional!` — the ledger cannot roll back a
|
|
67
|
+
sent email. Model money as rows in this app's own DB whenever possible; that
|
|
68
|
+
is what upgrades the guarantee to exactly-once.
|
|
69
|
+
|
|
70
|
+
### Approval — who holds the lever
|
|
71
|
+
|
|
72
|
+
`approval :never` (default) · `:always` · `:once` (one approval per identical
|
|
73
|
+
(tool, arguments) pair per session) · or a lambda returning `:user_approval`,
|
|
74
|
+
`:approved`, `:not_applicable`, or `{denied: "reason"}`. Approval parks the
|
|
75
|
+
turn at zero compute; a human settles it in the inbox (`/silas/inbox`), Slack,
|
|
76
|
+
email, or the JSON API. Gate anything that moves money or is hard to reverse.
|
|
77
|
+
|
|
78
|
+
The built-in `ask_question` tool is the reverse direction: the agent parks to
|
|
79
|
+
ask the operator something and resumes with their text as the tool result.
|
|
80
|
+
|
|
81
|
+
## agent.yml
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
model: claude-sonnet-4-5 # must exist in ruby_llm's registry
|
|
85
|
+
description: One line, shown in rosters.
|
|
86
|
+
limits:
|
|
87
|
+
max_steps: 10 # model calls per turn
|
|
88
|
+
max_cost: 0.25 # dollars per turn
|
|
89
|
+
timeout: 300 # seconds of ACTIVE work — approval waits don't count
|
|
90
|
+
final_answer: # optional JSON schema -> Turn#answer_data
|
|
91
|
+
type: object
|
|
92
|
+
properties: { verdict: { type: string } }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Budget breaches PARK the turn (a human can top up in the inbox); they don't
|
|
96
|
+
destroy work.
|
|
97
|
+
|
|
98
|
+
## Rules that protect the durability contract
|
|
99
|
+
|
|
100
|
+
1. **Solid Queue (or `:inline` for scripts) — never the Async adapter.** Async
|
|
101
|
+
double-executes continuation steps and silently voids exactly-once. Boot
|
|
102
|
+
raises in production if misconfigured.
|
|
103
|
+
2. **Don't deploy tool/skill changes while turns are parked.** The definitions
|
|
104
|
+
digest fails a parked turn loudly on resume rather than running it against
|
|
105
|
+
a different agent (`NondeterminismError`). Settle parked turns first —
|
|
106
|
+
the same applies to toggling built-ins like `config.ask_question`.
|
|
107
|
+
3. **Keep `Silas::DeadJobRescuerJob` in `config/recurring.yml`** — it is part
|
|
108
|
+
of the crash-recovery contract, and monitor worker liveness: the rescuer
|
|
109
|
+
can requeue work, it cannot conjure a consumer.
|
|
110
|
+
4. **Never hand-delete `solid_queue_processes` rows** — a claimed job whose
|
|
111
|
+
process row is gone is invisible to every reaper.
|
|
112
|
+
5. Tools must not spawn threads or run work outside `call` — everything the
|
|
113
|
+
ledger can't see is outside the guarantee.
|
|
114
|
+
|
|
115
|
+
## Verify your work
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
bin/rails silas:doctor # key, queue adapter, model, migrations, tools, rescuer
|
|
119
|
+
bin/rails silas:chat # talk to the agent from the terminal
|
|
120
|
+
bin/rails silas:eval # run test/agent_evals/*_eval.rb (deploy gate)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Write an eval per behaviour you care about (`Silas::Eval.scenario` — see the
|
|
124
|
+
generated `test/agent_evals/example_eval.rb`). The inbox at `/silas/inbox` is
|
|
125
|
+
deny-by-default: wire `config.inbox_auth` in `config/initializers/silas.rb`
|
|
126
|
+
before expecting to see it.
|
|
127
|
+
|
|
128
|
+
## More surface, same pattern
|
|
129
|
+
|
|
130
|
+
- **Another transport**: `bin/rails g silas:channel whatsapp` scaffolds the
|
|
131
|
+
signature-verifying webhook AND the outbound half (docs/channels.md in the gem).
|
|
132
|
+
- **A staff of agents**: `app/agents/<name>/` with its own tree;
|
|
133
|
+
`Silas.agent("name").start(input: ...)`; schedules in that directory tick
|
|
134
|
+
that agent.
|
|
135
|
+
- **Subagents / handoffs / memory / connections (MCP)**: see the gem README —
|
|
136
|
+
each is a directory or YAML file, never an orchestration graph.
|
|
@@ -11,9 +11,12 @@ RubyLLM.configure do |c|
|
|
|
11
11
|
# hasn't migrated yet — see https://rubyllm.com/upgrading-to-1-7/
|
|
12
12
|
c.use_new_acts_as = true if c.respond_to?(:use_new_acts_as=)
|
|
13
13
|
|
|
14
|
+
# A nil key is inert (Silas's boot check reports "no provider configured"),
|
|
15
|
+
# so this needs no ENV guard — and the acts_as opt-in above must run even
|
|
16
|
+
# on keyless boots, or the deprecation warning returns.
|
|
14
17
|
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
15
18
|
# The per-request timeout (seconds; RubyLLM default 300). Under streaming
|
|
16
19
|
# this is an idle-between-chunks timeout — the hang protection for a stuck
|
|
17
20
|
# provider connection.
|
|
18
21
|
# c.request_timeout = 120
|
|
19
|
-
end
|
|
22
|
+
end
|
data/lib/silas/connection.rb
CHANGED
|
@@ -51,6 +51,22 @@ module Silas
|
|
|
51
51
|
raise Error, "connection #{name}: transport #{transport.inspect} unsupported (v1: http)" unless TRANSPORTS.include?(transport)
|
|
52
52
|
raise Error, "connection #{name}: approval #{approval.inspect} invalid" unless APPROVALS.include?(approval)
|
|
53
53
|
raise Error, "connection #{name}: effect #{effect.inspect} invalid" unless EFFECTS.include?(effect)
|
|
54
|
+
# Never send a credential over plaintext: an auth'd connection must be
|
|
55
|
+
# https (loopback exempt for local development servers). Boot-time and
|
|
56
|
+
# loud, like every other connection misconfiguration.
|
|
57
|
+
if @auth["type"].present? && plaintext_remote?
|
|
58
|
+
raise Error, "connection #{name}: refusing to send credentials over plaintext http — " \
|
|
59
|
+
"use https (localhost/127.0.0.1 are exempt)"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def plaintext_remote?
|
|
64
|
+
uri = URI(url)
|
|
65
|
+
# Scheme case-insensitively (HTTP:// is still plaintext); URI#host keeps
|
|
66
|
+
# brackets on IPv6 literals, so the loopback exemption lists both forms.
|
|
67
|
+
uri.scheme&.downcase == "http" && !%w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
true # an unparseable url with auth configured fails closed
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
# One remote tool, resolved. Quacks like a resolved Silas::Tool for the
|
data/lib/silas/eval/dsl.rb
CHANGED
|
@@ -19,13 +19,18 @@ module Silas
|
|
|
19
19
|
def max_steps(n) = (@max_steps = n)
|
|
20
20
|
def approve(tool:) = (@approvals << tool.to_s)
|
|
21
21
|
|
|
22
|
-
# on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
|
|
23
|
-
|
|
22
|
+
# on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ], data: {…})
|
|
23
|
+
# `data:` scripts a STRUCTURED answer (the final_answer schema case) —
|
|
24
|
+
# it becomes the same {"type"=>"structured"} block the real adapter
|
|
25
|
+
# persists, so assert_answer_data reads it back through Turn#answer_data.
|
|
26
|
+
# Keys are stringified exactly as a JSON-parsed model response would be.
|
|
27
|
+
def on_step(index, text: nil, call: nil, calls: [], data: nil)
|
|
24
28
|
tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
|
|
25
29
|
Silas::Adapters::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
|
|
26
30
|
arguments: (c[:arguments] || {}).stringify_keys)
|
|
27
31
|
end
|
|
28
32
|
blocks = []
|
|
33
|
+
blocks << { "type" => "structured", "data" => data.deep_stringify_keys } if data
|
|
29
34
|
blocks << { "type" => "text", "text" => text } if text
|
|
30
35
|
@steps[index] = { blocks: blocks, tool_calls: tcs }
|
|
31
36
|
end
|
data/lib/silas/version.rb
CHANGED