silas 0.6.1 → 0.6.3
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 +238 -0
- data/README.md +11 -5
- data/app/controllers/silas/channels/slack_controller.rb +2 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +3 -1
- data/app/helpers/silas/inbox/trace_helper.rb +224 -2
- data/app/mailboxes/silas/agent_mailbox.rb +4 -0
- data/app/models/silas/tool_invocation.rb +37 -6
- data/app/views/layouts/silas/inbox.html.erb +69 -3
- data/app/views/silas/inbox/invocations/_detail.html.erb +21 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +39 -24
- data/app/views/silas/inbox/sessions/_child.html.erb +14 -0
- data/app/views/silas/inbox/sessions/_row.html.erb +13 -2
- data/app/views/silas/inbox/sessions/show.html.erb +33 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -2
- data/docs/agents.md +15 -3
- data/docs/channels.md +53 -2
- data/docs/configuration.md +6 -1
- data/docs/connections.md +8 -5
- data/docs/guarantees.md +16 -5
- data/docs/headless.md +120 -0
- data/docs/providers.md +171 -0
- data/docs/traces.md +131 -0
- data/docs/tutorial.md +6 -4
- data/docs/vs-eve.md +41 -17
- data/docs/why-silas.md +6 -6
- data/lib/generators/silas/install/install_generator.rb +21 -0
- data/lib/generators/silas/install/templates/initializer.rb +5 -0
- data/lib/generators/silas/install/templates/ruby_llm.rb +3 -1
- data/lib/silas/channel.rb +85 -11
- data/lib/silas/configuration.rb +16 -1
- data/lib/silas/doctor.rb +35 -2
- data/lib/silas/registry.rb +20 -2
- data/lib/silas/tool.rb +38 -5
- data/lib/silas/version.rb +1 -1
- metadata +6 -1
data/docs/providers.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Providers & gateways
|
|
2
|
+
|
|
3
|
+
Silas has exactly one inference seam: the `:ruby_llm` adapter. Every model
|
|
4
|
+
call goes through [RubyLLM](https://rubyllm.com), so every provider RubyLLM
|
|
5
|
+
speaks — Anthropic, OpenAI, Gemini, Bedrock, Vertex AI, Azure, Mistral,
|
|
6
|
+
DeepSeek, Perplexity, xAI, OpenRouter, local runtimes — is available to your
|
|
7
|
+
agents with zero Silas-specific glue. Keys live in
|
|
8
|
+
`config/initializers/ruby_llm.rb`; **which provider serves a turn is decided
|
|
9
|
+
by the model id**. Nothing in `app/agent/` changes when the provider does.
|
|
10
|
+
|
|
11
|
+
## How a model id picks a provider
|
|
12
|
+
|
|
13
|
+
RubyLLM ships a model registry (1,100+ entries, refreshed upstream from
|
|
14
|
+
[models.dev](https://models.dev)). Silas resolves the agent's `model:` — from
|
|
15
|
+
`agent.yml`, falling back to `config.default_model` — against that registry;
|
|
16
|
+
the matching entry names the provider, and the adapter builds that provider's
|
|
17
|
+
client. The same entry supplies the numbers Silas runs on, per
|
|
18
|
+
(model, provider):
|
|
19
|
+
|
|
20
|
+
- **pricing** — the cost lines in the inbox and the `max_cost` budget;
|
|
21
|
+
- **context window** — the `compact_at` compaction threshold.
|
|
22
|
+
|
|
23
|
+
So switching provider is switching model id. A model the registry doesn't
|
|
24
|
+
know fails fast at the first step with the fix in the error
|
|
25
|
+
(`RubyLLM.models.refresh!`, or pick a registry model). And because a
|
|
26
|
+
registry's tie-breaks can change across upgrades, Silas stamps the resolved
|
|
27
|
+
provider on every step row — historical cost lines price against the
|
|
28
|
+
(model, provider) that actually served them, forever.
|
|
29
|
+
|
|
30
|
+
## Direct providers
|
|
31
|
+
|
|
32
|
+
The installer's `config/initializers/ruby_llm.rb` maps environment keys in —
|
|
33
|
+
RubyLLM never reads provider keys from the environment by itself:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
RubyLLM.configure do |c|
|
|
37
|
+
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every provider follows the same pattern (`openai_api_key`,
|
|
42
|
+
`gemini_api_key`, …— the full list is in
|
|
43
|
+
[RubyLLM's configuration docs](https://rubyllm.com/configuration/)). For
|
|
44
|
+
cloud-platform shops the "enterprise gateway" is usually just the native
|
|
45
|
+
provider:
|
|
46
|
+
|
|
47
|
+
| Platform | Keys |
|
|
48
|
+
|---|---|
|
|
49
|
+
| AWS Bedrock | `bedrock_api_key`, `bedrock_secret_key`, `bedrock_region` (+ optional `bedrock_session_token`) |
|
|
50
|
+
| GCP Vertex AI | `vertexai_project_id`, `vertexai_location` (+ optional `vertexai_service_account_key`) |
|
|
51
|
+
| Azure OpenAI | `azure_api_base`, `azure_api_key` (or `azure_ai_auth_token`) |
|
|
52
|
+
|
|
53
|
+
Verify any of this with `bin/rails silas:doctor` — it reports which providers
|
|
54
|
+
have credentials configured and resolves your default model with its price:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
✓ provider credentials — anthropic
|
|
58
|
+
✓ model claude-sonnet-4-5 — anthropic · $3/$15 per MTok
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## OpenRouter: one key, 300+ models
|
|
62
|
+
|
|
63
|
+
[OpenRouter](https://openrouter.ai) is a first-class RubyLLM provider, and
|
|
64
|
+
the registry ships its catalog (341 models as of ruby_llm 1.16) — one key
|
|
65
|
+
buys your agents Claude, GPT, Gemini, Llama, DeepSeek and the rest, billed
|
|
66
|
+
in one place.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# config/initializers/ruby_llm.rb
|
|
70
|
+
RubyLLM.configure do |c|
|
|
71
|
+
c.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Routed models use slash-form ids — `creator/model`, exactly as OpenRouter
|
|
76
|
+
lists them:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# app/agent/agent.yml
|
|
80
|
+
model: anthropic/claude-sonnet-4.5 # via OpenRouter
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
or globally:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
Silas.configure do |c|
|
|
87
|
+
c.default_model = "anthropic/claude-sonnet-4.5"
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The two id families never collide: `claude-sonnet-4-5` is Anthropic direct,
|
|
92
|
+
`anthropic/claude-sonnet-4.5` is the same model via OpenRouter, and each
|
|
93
|
+
resolves to its own registry entry. That per-route entry is the one your
|
|
94
|
+
cost lines and compaction thresholds follow — the route you run, not the
|
|
95
|
+
origin provider. Concretely, in the shipped registry the direct entry lists
|
|
96
|
+
a 200K context window while OpenRouter's route lists 1M, so `compact_at`
|
|
97
|
+
triggers where the route actually overflows.
|
|
98
|
+
|
|
99
|
+
`silas:doctor` confirms the whole chain:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
✓ provider credentials — openrouter
|
|
103
|
+
✓ model anthropic/claude-sonnet-4.5 — openrouter · $3/$15 per MTok
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## OpenAI-compatible gateways
|
|
107
|
+
|
|
108
|
+
Self-hosted and enterprise gateways (LiteLLM, Vercel AI Gateway, an internal
|
|
109
|
+
proxy) mostly speak the OpenAI chat-completions dialect. Two shapes:
|
|
110
|
+
|
|
111
|
+
**The gateway serves OpenAI model ids** (`gpt-5.2`, …) — point the OpenAI
|
|
112
|
+
provider at it:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
RubyLLM.configure do |c|
|
|
116
|
+
c.openai_api_key = ENV["GATEWAY_API_KEY"]
|
|
117
|
+
c.openai_api_base = "https://gateway.internal/v1"
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**The gateway serves models.dev slash ids** (`anthropic/claude-sonnet-4.5`,
|
|
122
|
+
…, as Vercel's AI Gateway does) — repoint the OpenRouter provider, which
|
|
123
|
+
already speaks plain chat-completions against exactly those ids:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
RubyLLM.configure do |c|
|
|
127
|
+
c.openrouter_api_key = ENV["AI_GATEWAY_API_KEY"]
|
|
128
|
+
c.openrouter_api_base = "https://ai-gateway.vercel.sh/v1"
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Either way the model id must still resolve in the registry — the registry
|
|
133
|
+
entry is where Silas gets pricing and the context window, and a gateway
|
|
134
|
+
that bills differently can be corrected per model with the
|
|
135
|
+
`config.model_prices` override ([configuration](configuration.md)).
|
|
136
|
+
|
|
137
|
+
Two gateway footnotes, both cheap to check with one real turn:
|
|
138
|
+
|
|
139
|
+
- RubyLLM renders system messages as role `developer` (OpenAI's current
|
|
140
|
+
dialect) through these providers. OpenRouter normalises it; if your
|
|
141
|
+
gateway insists on `system`, set `c.openai_use_system_role = true`.
|
|
142
|
+
- Streaming must pass through as SSE. If the operator inbox shows a turn
|
|
143
|
+
completing without live text, the gateway buffered the stream.
|
|
144
|
+
|
|
145
|
+
## Local runtimes
|
|
146
|
+
|
|
147
|
+
Ollama and GPUStack are RubyLLM providers too (`ollama_api_base`,
|
|
148
|
+
`gpustack_api_base`/`gpustack_api_key`). Local models aren't in the shipped
|
|
149
|
+
registry, so refresh it after configuring — `RubyLLM.models.refresh!` asks
|
|
150
|
+
every configured provider for its live model list and merges the results —
|
|
151
|
+
then use the id it lists. Local models carry no registry pricing: the inbox
|
|
152
|
+
shows their token counts with "cost unavailable", and a `max_cost` budget
|
|
153
|
+
can't bind (only priced tokens count toward it) — list the model in
|
|
154
|
+
`config.model_prices` to restore both.
|
|
155
|
+
|
|
156
|
+
## Failover and wrapping
|
|
157
|
+
|
|
158
|
+
Provider outages, rate-limit retries and model failover belong at the
|
|
159
|
+
inference seam, not in your tools. `config.around_model_call` wraps every
|
|
160
|
+
model call the loop makes:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
Silas.configure do |c|
|
|
164
|
+
c.around_model_call = ->(ctx, &call) do
|
|
165
|
+
RubyLLM::Resilience.chain(:anthropic) { call.() }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Whatever runs inside still lands in the same durable step — a failover
|
|
171
|
+
retry that succeeds is recorded exactly like a first-try success.
|
data/docs/traces.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# The trace schema
|
|
2
|
+
|
|
3
|
+
Every step your agent takes is rows in **your** database — transactional,
|
|
4
|
+
priced, and joinable to the business rows the agent touched. Most frameworks
|
|
5
|
+
treat their execution record as internal bookkeeping in a store beside your
|
|
6
|
+
app. Silas treats it as **a documented, versioned interface**: your analytics,
|
|
7
|
+
your dashboards, and your own tooling are meant to query these tables
|
|
8
|
+
directly.
|
|
9
|
+
|
|
10
|
+
That is the whole point of the trace living where it does. An external
|
|
11
|
+
observability platform can show you what the agent said; it cannot join the
|
|
12
|
+
tool call to the `refunds` row it created, in one query, with no export:
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
SELECT ti.tool_name, ti.arguments, ti.approved_by, r.amount_pence
|
|
16
|
+
FROM silas_tool_invocations ti
|
|
17
|
+
JOIN refunds r ON r.id = json_extract(ti.result, '$.refund_id')
|
|
18
|
+
WHERE ti.approval_state = 'approved';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The contract
|
|
22
|
+
|
|
23
|
+
Columns documented here are **stable within a major version**: they may gain
|
|
24
|
+
siblings in any release, but renames and semantic changes only happen at a
|
|
25
|
+
major, and appear in the CHANGELOG under an upgrade note. Columns *not* listed
|
|
26
|
+
here are internal — query them if you like, but they can move without notice.
|
|
27
|
+
|
|
28
|
+
## `silas_sessions` — a conversation
|
|
29
|
+
|
|
30
|
+
| Column | Meaning |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `agent_name` | Which agent owns the session (`"agent"` = the root agent). |
|
|
33
|
+
| `status` | `active` \| `archived`. **Note:** parked work is a *turn* state, not a session state. |
|
|
34
|
+
| `parent_session_id` | Set when this session was created by a handoff or delegation — the lineage the inbox renders. |
|
|
35
|
+
| `channel` | The transport that started it (`"slack"`, `"email"`, …), `NULL` for direct/API. |
|
|
36
|
+
| `metadata` | JSON. Channel-specific context (e.g. the inbound email's sender). Plain `json`, not `jsonb` — the schema works identically on SQLite and Postgres. |
|
|
37
|
+
|
|
38
|
+
## `silas_turns` — one request, run durably
|
|
39
|
+
|
|
40
|
+
| Column | Meaning |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `index` | Position within the session; `(session_id, index)` is unique. |
|
|
43
|
+
| `status` | `queued` \| `running` \| `waiting` (parked for a human) \| `in_doubt` \| `completed` \| `failed` \| `canceled`. At most one non-final turn per session — enforced by `index_silas_turns_single_active`. |
|
|
44
|
+
| `input` | What the user (or channel, or schedule) asked. |
|
|
45
|
+
| `instructions_snapshot` | The system prompt as rendered **once** at turn start; immutable after. What the model actually saw, forever. |
|
|
46
|
+
| `definitions_digest` | Hash of tool schemas + skill descriptions at turn start — the nondeterminism guard that refuses to resume a turn against a changed agent. |
|
|
47
|
+
| `failure_reason` | Why a `failed` turn failed (`max_steps`, `definitions_changed`, `job_failed`, budget reasons, …). |
|
|
48
|
+
| `input_tokens` / `output_tokens` / `cost_microcents` | Accumulated across the turn's steps. 1,000,000 microcents = $1. |
|
|
49
|
+
| `budget_overrides` | JSON. A human's top-up on a budget-parked turn, beating `agent.yml` limits. |
|
|
50
|
+
| `started_at` / `finished_at` | Wall-clock bounds. Note `started_at` resets on resume after a park — `limits.timeout` measures *active* time, not human deliberation. |
|
|
51
|
+
|
|
52
|
+
## `silas_steps` — one model call
|
|
53
|
+
|
|
54
|
+
| Column | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `index` | Position within the turn; `(turn_id, index)` is unique — at most one persisted model response per slot. |
|
|
57
|
+
| `status` | `started` \| `completed`. |
|
|
58
|
+
| `model` / `provider` | What actually served this step. Provider is stamped at execution so historical cost survives registry changes. |
|
|
59
|
+
| `response_blocks` | The model's full response — text, tool calls — as JSON. Replay rebuilds the conversation from these rows. |
|
|
60
|
+
| `stop_reason` | Why the model stopped (`tool_use`, `end_turn`, …). |
|
|
61
|
+
| `terminal` | Write-once after completion; **the** loop-control column. A resumed continuation must re-derive the identical step sequence from it. |
|
|
62
|
+
| `input_tokens` / `output_tokens` | This step's usage; cost derives from these plus `(model, provider)` pricing at read time. |
|
|
63
|
+
|
|
64
|
+
## `silas_tool_invocations` — one tool call, exactly once
|
|
65
|
+
|
|
66
|
+
The heart of the ledger.
|
|
67
|
+
|
|
68
|
+
| Column | Meaning |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `tool_call_id` | The model's id for the call. `(step_id, tool_call_id)` is unique — **this index is the exactly-once key**. |
|
|
71
|
+
| `tool_name` / `arguments` | What was called, with what. Arguments are model-authored — render escaped. |
|
|
72
|
+
| `status` | `pending` \| `started` \| `completed` \| `failed` \| `in_doubt`. |
|
|
73
|
+
| `effect_mode` | `transactional` \| `at_most_once` \| `idempotent` — **snapshotted from the tool class at creation**, so editing a tool mid-park cannot change the semantics of an existing invocation. |
|
|
74
|
+
| `result` | The tool's return value (or the question's answer, or `{"denied": reason}`). |
|
|
75
|
+
| `approval_state` | `NULL` (no gate) \| `required` \| `approved` \| `declined` \| `expired`. `NULL` on a completed invocation means *policy cleared it*; a value means *a decision was made* — that distinction is load-bearing for audit. |
|
|
76
|
+
| `approved_by` | Who decided. `NULL` on an auto-cleared gate, an identity string on a human verdict. |
|
|
77
|
+
| `approval_expires_at` / `decline_reason` | The TTL, and the human's stated reason. |
|
|
78
|
+
|
|
79
|
+
**The free labeled dataset.** `approval_state` + `approved_by` +
|
|
80
|
+
`decline_reason` record a human's verdict on a specific agent action, with
|
|
81
|
+
full context, in production, at zero annotation cost. If you ever train or
|
|
82
|
+
evaluate against your own traffic, this is where the labels already are.
|
|
83
|
+
|
|
84
|
+
## `silas_compactions` — summaries that survive replay
|
|
85
|
+
|
|
86
|
+
One row per compacted span: `session_id`, `up_to_turn_index` (unique
|
|
87
|
+
together — exactly one summary per span no matter how many replays race),
|
|
88
|
+
`status`, `summary`, and what the summarisation itself cost (`tokens_before`,
|
|
89
|
+
`input_tokens`, `output_tokens`, `model`). Deterministic by construction: the
|
|
90
|
+
message builder reads the row, never recomputes.
|
|
91
|
+
|
|
92
|
+
## `silas_memories` — what the agent knows across sessions
|
|
93
|
+
|
|
94
|
+
`agent_name`, `scope` (`agent` private \| `app` shared), `subject`,
|
|
95
|
+
`attribute_name`, `content`, `status` (`active` \| `superseded`),
|
|
96
|
+
`superseded_by_id`, and provenance (`session_id`, `turn_id`). Approval-gated
|
|
97
|
+
on write by default. **Single-tenant per deployment** — see
|
|
98
|
+
[guarantees](guarantees.md) for the boundary.
|
|
99
|
+
|
|
100
|
+
## Queries you already own
|
|
101
|
+
|
|
102
|
+
Cost per agent per day:
|
|
103
|
+
|
|
104
|
+
```sql
|
|
105
|
+
SELECT s.agent_name, date(t.created_at) AS day, SUM(t.cost_microcents)/1e6 AS dollars
|
|
106
|
+
FROM silas_turns t JOIN silas_sessions s ON s.id = t.session_id
|
|
107
|
+
GROUP BY 1, 2 ORDER BY 2 DESC;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every action a specific person approved, with what it did:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
SELECT ti.created_at, ti.tool_name, ti.arguments, ti.result
|
|
114
|
+
FROM silas_tool_invocations ti
|
|
115
|
+
WHERE ti.approved_by = 'dana@example.com' ORDER BY ti.created_at DESC;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
What parked, and for how long, before a human answered:
|
|
119
|
+
|
|
120
|
+
```sql
|
|
121
|
+
SELECT ti.tool_name, ti.approval_state,
|
|
122
|
+
(julianday(ti.updated_at) - julianday(ti.created_at)) * 24 AS hours_parked
|
|
123
|
+
FROM silas_tool_invocations ti
|
|
124
|
+
WHERE ti.approval_state IN ('approved','declined','expired');
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The replay principle behind all of this: `Silas::MessageBuilder` reconstructs
|
|
128
|
+
the exact prompt at any step from these rows alone, deterministically. Nothing
|
|
129
|
+
in the trace reads the clock or mutable state — which is why a crashed turn
|
|
130
|
+
resumes byte-identically, and why the trace you query is the trace that
|
|
131
|
+
actually ran.
|
data/docs/tutorial.md
CHANGED
|
@@ -220,10 +220,12 @@ app/agents/escalations/
|
|
|
220
220
|
```
|
|
221
221
|
|
|
222
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
a **self-contained brief** that starts a linked session for the named agent,
|
|
224
|
+
rather than letting two models chat freely (a cost and audit hazard,
|
|
225
|
+
deliberately unblessed). A handoff is cycle-checked and at-most-once: a crash
|
|
226
|
+
mid-handoff parks it in doubt for a person instead of starting the colleague
|
|
227
|
+
twice. Talk to a specialist directly with
|
|
228
|
+
`Silas.agent("escalations").start(input: "…")` or
|
|
227
229
|
`bin/rails silas:chat AGENT=escalations`; the inbox filters by agent.
|
|
228
230
|
|
|
229
231
|
## 10 · Fit the governors: budgets and compaction
|
data/docs/vs-eve.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Silas vs eve
|
|
2
2
|
|
|
3
|
-
> Written **2026-07-
|
|
4
|
-
>
|
|
3
|
+
> Written **2026-07-29** against **eve 0.27.8** (source and shipped docs, not
|
|
4
|
+
> marketing) and Silas 0.6.x. Both projects move fast — if you're reading this
|
|
5
5
|
> much later, re-verify before deciding.
|
|
6
6
|
|
|
7
7
|
Silas and eve share the same organising idea: **an agent is a directory of
|
|
@@ -16,22 +16,31 @@ So the honest first cut is simply your stack: **a TypeScript team should use
|
|
|
16
16
|
eve; a Rails team should use Silas.** The rest of this page is for people near
|
|
17
17
|
the boundary — and for the differences that go deeper than language.
|
|
18
18
|
|
|
19
|
+
One framing note, because it keeps this page honest: eve is a platform
|
|
20
|
+
company's product, shipping weekly at roughly twenty-five times Silas's source
|
|
21
|
+
volume. A surface-area comparison is a race Silas isn't running. What follows
|
|
22
|
+
compares the **guarantees and the shape**, which is where the projects
|
|
23
|
+
genuinely differ.
|
|
24
|
+
|
|
19
25
|
---
|
|
20
26
|
|
|
21
27
|
## The comparison
|
|
22
28
|
|
|
23
|
-
| | Silas | eve (0.27.
|
|
29
|
+
| | Silas | eve (0.27.8) |
|
|
24
30
|
|---|---|---|
|
|
25
31
|
| **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
32
|
| **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.
|
|
33
|
+
| **Durable loop** | Survives `kill -9`, resumes from the last completed step. A `kill -9` harness (275-run matrix, SQLite + Postgres) is run before each release, results committed to the repo. | Workflow-engine replay; its own docs: "A step interrupted mid-execution re-runs." |
|
|
28
34
|
| **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;
|
|
30
|
-
| **
|
|
31
|
-
| **
|
|
35
|
+
| **Human-in-the-loop** | Parks at zero compute for days; cleared from inbox, Slack, signed email, or API; **approvals expire** (`approval_ttl`); verdicts are compare-and-swap (one card can't be spent twice); `ask_question` for the reverse direction. | `needsApproval` on tools, with typed input requests (options, freeform, display hints — a nicer request shape than Silas's). Approvals have **no expiry**: an unanswered one waits forever. |
|
|
36
|
+
| **A message arriving mid-approval** | **Dropped today** — the sender is told delivery succeeded. An honest gap, fix scheduled. | **Held and replayed** after the approval settles — eve is ahead here. |
|
|
37
|
+
| **A deploy while work is parked** | The turn **refuses to resume against a changed agent** (`NondeterminismError`, loud). Snapshot-resume is planned. | The next turn silently uses the new deployment's instructions, model, and tools — a parked approval can resume into a different agent than the one that asked. |
|
|
38
|
+
| **Operator surface** | A production inbox mounted in your app: activity feed, approval cards, audit trail, cost accounting, handoff lineage, web chat — behind your auth. | A capable dev TUI (drives remote deployments: `--url`, `/deploy`, `/connect`) plus frontend SDKs and templates; the production operator surface is assembled by you. |
|
|
39
|
+
| **Memory** | Cross-session memory ships: approval-gated triples with provenance and supersession. | `defineState` ships typed **session-scoped** durable state; anything cross-session "belongs in an external store" (its own docs) — integrations and patterns, not a shipped subsystem. |
|
|
40
|
+
| **Evals** | Scenario DSL with scripted model decisions running the **real ledger and real tools against real rows**, keyless, as a deploy gate. | A broader harness: `defineEval`, dataset fan-out, gate-vs-soft severity, LLM-as-judge, reporters. eve is ahead on breadth; the difference in kind is that Silas evals exercise the real transactional machinery. |
|
|
41
|
+
| **Channels** | Slack + email built in, **routable to named agents**; a generator scaffolds any transport. | More first-party surfaces (Slack, Discord, Teams, Telegram, Twilio, GitHub, Linear, chat SDK), growing quickly. |
|
|
32
42
|
| **Ecosystem** | Ruby/Rails. | TypeScript + the AI SDK — a much larger ecosystem, with Vercel's distribution behind it. |
|
|
33
|
-
| **
|
|
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. |
|
|
43
|
+
| **Maturity** | Early (0.6.x); pre-1.0, evolving deliberately. | Pre-1.0 and explicit about it: "prefer breaking changes… no legacy fallback logic." Backed by a platform company. |
|
|
35
44
|
|
|
36
45
|
---
|
|
37
46
|
|
|
@@ -50,22 +59,37 @@ framework where you don't have to.
|
|
|
50
59
|
|
|
51
60
|
**Ambiguity parks.** At-least-once means a crash can re-fire a side effect.
|
|
52
61
|
Silas's default is at-most-once with **in-doubt → human**: it never
|
|
53
|
-
double-fires, and an ambiguous call waits for a person.
|
|
62
|
+
double-fires, and an ambiguous call waits for a person. Everyone has
|
|
63
|
+
approved/denied; Silas also has *we don't know, so someone decides*.
|
|
64
|
+
|
|
65
|
+
**A park is a contract.** Approvals expire rather than ghosting forever, a
|
|
66
|
+
verdict can't be spent twice, and a resumed turn is guaranteed to be the same
|
|
67
|
+
agent that parked — or it fails loudly rather than continuing as something
|
|
68
|
+
else. eve makes the opposite trade on that last point, deliberately: deploys
|
|
69
|
+
apply to live sessions. For exploratory agents that's convenient; for an agent
|
|
70
|
+
whose parked action a human already approved, Silas treats silently redefining
|
|
71
|
+
it as an audit failure.
|
|
54
72
|
|
|
55
73
|
**The operator surface ships.** `mount Silas::Engine` and the inbox exists —
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
the feed, approval cards, audit trail, cost, lineage — behind your app's own
|
|
75
|
+
auth.
|
|
58
76
|
|
|
59
|
-
**
|
|
60
|
-
|
|
77
|
+
**The trace lives in your database.** Every step, tool call, argument, result,
|
|
78
|
+
approval and cost is rows in *your* schema, joinable to the business rows the
|
|
79
|
+
agent actually touched. See [traces](traces.md) — the schema is a documented,
|
|
80
|
+
versioned interface, not internal bookkeeping.
|
|
61
81
|
|
|
62
82
|
## Where eve goes further
|
|
63
83
|
|
|
64
84
|
- **The ecosystem.** TypeScript and the AI SDK are where most of the agent
|
|
65
85
|
world lives — more examples, more integrations, a bigger hiring pool, and
|
|
66
86
|
Vercel's reach.
|
|
67
|
-
- **Surface breadth and velocity.** More first-party channels
|
|
68
|
-
|
|
87
|
+
- **Surface breadth and velocity.** More first-party channels, a fuller evals
|
|
88
|
+
harness, frontend SDKs, session workspaces with file tools, and a platform
|
|
89
|
+
company's release cadence.
|
|
90
|
+
- **Deferred input while parked.** A message arriving during an approval is
|
|
91
|
+
held and replayed. Silas currently drops it — the most honest single row on
|
|
92
|
+
this page, and scheduled work.
|
|
69
93
|
- **Sandboxing posture.** Container sandboxes are integral to eve's design.
|
|
70
94
|
Silas ships an interim Docker seam and reaches microVM-class isolation via
|
|
71
95
|
the [hermetic](https://github.com/danielstpaul/hermetic) gem.
|
|
@@ -88,6 +112,6 @@ app's database, that's where the agent belongs.
|
|
|
88
112
|
|
|
89
113
|
---
|
|
90
114
|
|
|
91
|
-
*Claims about eve come from its
|
|
115
|
+
*Claims about eve come from its source and docs at 0.27.8, quoted or
|
|
92
116
|
paraphrased in good faith; corrections welcome. Silas's numbers are
|
|
93
117
|
reproducible from `chaos_host/results/`.*
|
data/docs/why-silas.md
CHANGED
|
@@ -28,8 +28,8 @@ beside your app — it's a gem inside the Rails app you already deploy:
|
|
|
28
28
|
|
|
29
29
|
Every serious framework makes the loop durable. Silas draws the line a step
|
|
30
30
|
past that, and verifies it with a chaos harness that `kill -9`s live agents
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
mid-turn — a 275-run matrix before each release, zero duplicate effects,
|
|
32
|
+
byte-identical replay ([guarantees](guarantees.md)):
|
|
33
33
|
|
|
34
34
|
- **Exactly-once tool effects.** A `transactional!` tool's database write and
|
|
35
35
|
the ledger's record of it commit in **one transaction**. A crash mid-refund
|
|
@@ -62,10 +62,10 @@ rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/temp
|
|
|
62
62
|
|
|
63
63
|
## The honest notes
|
|
64
64
|
|
|
65
|
-
- **Silas is early** — 0.
|
|
66
|
-
chaos harness rather than years of production traffic. The
|
|
67
|
-
|
|
68
|
-
what's promised.
|
|
65
|
+
- **Silas is early** — 0.6.x, and the guarantees are proven by a reproducible
|
|
66
|
+
chaos harness rather than years of production traffic. The maintainer runs
|
|
67
|
+
the matrix before each release and commits the results, and the contract is
|
|
68
|
+
stated precisely so you know exactly what's promised.
|
|
69
69
|
- **Rails-only, on purpose.** If your team lives in TypeScript, eve is
|
|
70
70
|
excellent and closer to home — the [comparison](vs-eve.md) is honest about
|
|
71
71
|
that in both directions.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "rails/generators"
|
|
2
|
+
require "silas/doctor" # ASYNC_QUEUE_REMEDY — one remedy text, two surfaces
|
|
2
3
|
|
|
3
4
|
module Silas
|
|
4
5
|
module Generators
|
|
@@ -125,6 +126,26 @@ module Silas
|
|
|
125
126
|
9. Restart your server if it was running (app/agent/ registers at boot).
|
|
126
127
|
MSG
|
|
127
128
|
end
|
|
129
|
+
|
|
130
|
+
# Rails defaults development to the in-process :async adapter, which the
|
|
131
|
+
# doctor step above FAILS — without this the documented happy path ends
|
|
132
|
+
# on a red X. Detected, printed, never written: a gsub against the host's
|
|
133
|
+
# database.yml no-ops silently the moment they've edited it, and a
|
|
134
|
+
# force-written cable.yml clobbers whatever Redis config they run.
|
|
135
|
+
def show_queue_adapter_remedy
|
|
136
|
+
return unless defined?(::ActiveJob::Base)
|
|
137
|
+
return unless ::ActiveJob::Base.queue_adapter.class.name.to_s.include?("AsyncAdapter")
|
|
138
|
+
|
|
139
|
+
say <<~MSG, :yellow
|
|
140
|
+
|
|
141
|
+
Queue adapter: this app is on ActiveJob's in-process :async adapter, and
|
|
142
|
+
`bin/rails silas:doctor` fails it — :async runs a re-enqueued continuation
|
|
143
|
+
concurrently with the original, which double-executes steps and breaks
|
|
144
|
+
exactly-once tool effects.
|
|
145
|
+
|
|
146
|
+
#{Silas::Doctor::ASYNC_QUEUE_REMEDY}
|
|
147
|
+
MSG
|
|
148
|
+
end
|
|
128
149
|
end
|
|
129
150
|
end
|
|
130
151
|
end
|
|
@@ -39,6 +39,11 @@ Silas.configure do |config|
|
|
|
39
39
|
# installed registry (units per 1k tokens; 1e6 units = $1):
|
|
40
40
|
# config.model_prices["your-fine-tune"] = { in: 3000, out: 15_000 }
|
|
41
41
|
|
|
42
|
+
# Which agent an inbound Slack thread or email wakes. Unmatched threads wake
|
|
43
|
+
# the root agent; a name that isn't in app/agents/ fails boot, not a webhook.
|
|
44
|
+
# config.channel_routes = { "slack" => { "C0BILLING" => "bookkeeper" },
|
|
45
|
+
# "email" => { "billing@example.com" => "bookkeeper" } }
|
|
46
|
+
|
|
42
47
|
# Where eval scenarios live (bin/rails silas:eval).
|
|
43
48
|
# config.eval_dir = "test/agent_evals"
|
|
44
49
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
#
|
|
4
4
|
# export ANTHROPIC_API_KEY=sk-ant-...
|
|
5
5
|
#
|
|
6
|
-
# Any provider RubyLLM supports works the same way (openai_api_key, etc.)
|
|
6
|
+
# Any provider RubyLLM supports works the same way (openai_api_key, etc.) —
|
|
7
|
+
# OpenRouter, OpenAI-compatible gateways, and local runtimes are covered in
|
|
8
|
+
# the Silas guide docs/providers.md (shipped in the gem: `bundle show silas`).
|
|
7
9
|
RubyLLM.configure do |c|
|
|
8
10
|
# Silas never uses RubyLLM's acts_as_* ActiveRecord mixins (it owns its own
|
|
9
11
|
# durable schema), so opt into the new API to silence the legacy deprecation
|
data/lib/silas/channel.rb
CHANGED
|
@@ -4,36 +4,110 @@ module Silas
|
|
|
4
4
|
#
|
|
5
5
|
# Inbound is pure trigger reuse: dispatch maps an external thread to a Session
|
|
6
6
|
# via silas_sessions.channel + continuation_token, then calls the UNCHANGED
|
|
7
|
-
# public API (new thread ->
|
|
7
|
+
# public API (new thread -> the routed agent's .start; reply -> continue).
|
|
8
8
|
# Outbound (deliver the agent's answer / an approval request) is a subclass
|
|
9
9
|
# responsibility, invoked off the loop by ChannelDeliveryJob — so the loop's
|
|
10
10
|
# determinism and the ledger's exactly-once are never touched.
|
|
11
11
|
class Channel
|
|
12
12
|
TOKEN_PURPOSE = "silas/channel".freeze
|
|
13
|
+
# silas_sessions.agent_name for the root app/agent — the column's default,
|
|
14
|
+
# and the name the loop reads as "no named scope".
|
|
15
|
+
ROOT_AGENT = "agent".freeze
|
|
13
16
|
|
|
14
17
|
def self.channel_name = name.demodulize.underscore
|
|
15
18
|
|
|
16
|
-
# Stable external-thread key, namespaced by channel
|
|
17
|
-
|
|
19
|
+
# Stable external-thread key, namespaced by channel AND agent: two channels
|
|
20
|
+
# can't collide, and two staff members sharing one transport can't either.
|
|
21
|
+
def self.namespaced(thread_key, agent_name = nil)
|
|
22
|
+
"#{channel_name}:#{agent_name.presence || ROOT_AGENT}:#{thread_key}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Tokens minted before routing existed have no agent segment. Every one of
|
|
26
|
+
# them belongs to the root agent — dispatch could start nothing else — so a
|
|
27
|
+
# miss on the new form falls back to this one and a live thread upgrades
|
|
28
|
+
# without losing its session.
|
|
29
|
+
def self.legacy_namespaced(thread_key) = "#{channel_name}:#{thread_key}"
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
def self.find_session(thread_key, agent_name)
|
|
32
|
+
Silas::Session.find_by(continuation_token: namespaced(thread_key, agent_name)) ||
|
|
33
|
+
Silas::Session.find_by(continuation_token: legacy_namespaced(thread_key))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The single inbound entry point for every transport. `agent` is the NAME of
|
|
37
|
+
# the staff member this thread belongs to (nil or "agent" = the root agent);
|
|
38
|
+
# callers read it off configuration with .route_for.
|
|
39
|
+
def self.dispatch(thread_key:, input:, metadata: {}, agent: nil)
|
|
40
|
+
name = resolve_agent(agent)
|
|
41
|
+
if (session = find_session(thread_key, name))
|
|
23
42
|
session.continue(input: input)
|
|
24
43
|
session
|
|
25
44
|
else
|
|
26
|
-
Silas.agent
|
|
27
|
-
|
|
45
|
+
owner = name ? Silas.agent(name) : Silas.agent
|
|
46
|
+
owner.start(input: input, metadata: metadata,
|
|
47
|
+
channel: channel_name, continuation_token: namespaced(thread_key, name))
|
|
28
48
|
end
|
|
29
49
|
rescue ActiveRecord::RecordNotUnique
|
|
30
50
|
# Concurrent first-inbound race: the other request created the session;
|
|
31
|
-
# treat this message as a continue.
|
|
32
|
-
|
|
51
|
+
# treat this message as a continue. Nothing to continue means the conflict
|
|
52
|
+
# was something else, so let it out.
|
|
53
|
+
session = find_session(thread_key, name) or raise
|
|
33
54
|
session.continue(input: input)
|
|
34
55
|
session
|
|
35
56
|
end
|
|
36
57
|
|
|
58
|
+
# ---- routing: which agent an inbound thread wakes -----------------------
|
|
59
|
+
|
|
60
|
+
# config.channel_routes, normalised to { transport => { key => agent_name } }.
|
|
61
|
+
# Keys are matched downcased because email recipients are case-insensitive;
|
|
62
|
+
# Slack channel ids are unaffected by folding both sides the same way.
|
|
63
|
+
def self.routes
|
|
64
|
+
(Silas.config.channel_routes || {}).to_h do |transport, table|
|
|
65
|
+
[ transport.to_s, table.to_h { |key, agent| [ key.to_s.downcase, agent.to_s ] } ]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The agent name a thread on `transport` belongs to, or nil for the root
|
|
70
|
+
# agent. Candidate keys are tried in order and the first match wins, so a
|
|
71
|
+
# caller holding several (an email's recipients) passes them all.
|
|
72
|
+
def self.route_for(transport, *keys)
|
|
73
|
+
table = routes[transport.to_s] or return nil
|
|
74
|
+
|
|
75
|
+
keys.flatten.filter_map { |key| table[key.to_s.downcase] }.first
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Checked at boot by Registry.install! against the app/agents/ roster. A
|
|
79
|
+
# route naming an agent that doesn't exist is a deploy failure, not a
|
|
80
|
+
# runtime one: Silas.agent raises on an unknown name, and discovering that
|
|
81
|
+
# at dispatch time would strand every future message on the thread.
|
|
82
|
+
def self.validate_routes!(staff)
|
|
83
|
+
staff = staff.map(&:to_s)
|
|
84
|
+
routes.each do |transport, table|
|
|
85
|
+
table.each do |key, agent|
|
|
86
|
+
next if agent == ROOT_AGENT || staff.include?(agent)
|
|
87
|
+
|
|
88
|
+
raise Error, "config.channel_routes[#{transport.inspect}][#{key.inspect}] routes to " \
|
|
89
|
+
"agent #{agent.inspect}, which does not exist" \
|
|
90
|
+
"#{staff.any? ? " (known: #{staff.sort.join(', ')})" : " — no app/agents/ directories found"}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# nil means the root agent. An unknown name resolves to nil instead of
|
|
96
|
+
# raising: dispatch runs inside a webhook handler, and a 500 there is a
|
|
97
|
+
# message Slack retries into its own retry guard and then loses. Boot
|
|
98
|
+
# already refused a bad route, so this only fires when routes were assigned
|
|
99
|
+
# after boot — the thread lands on the root agent, exactly where it landed
|
|
100
|
+
# before routing existed, and the log says so.
|
|
101
|
+
def self.resolve_agent(name)
|
|
102
|
+
name = name.to_s
|
|
103
|
+
return nil if name.empty? || name == ROOT_AGENT
|
|
104
|
+
return name if Silas.named_agent?(name)
|
|
105
|
+
|
|
106
|
+
Rails.logger&.error("[Silas] channel route names unknown agent #{name.inspect}; " \
|
|
107
|
+
"starting the root agent instead. Fix config.channel_routes.")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
37
111
|
# Resolve the channel instance that owns a session (for outbound delivery).
|
|
38
112
|
def self.for_session(session)
|
|
39
113
|
return nil if session.channel.blank?
|