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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -0
  3. data/README.md +11 -5
  4. data/app/controllers/silas/channels/slack_controller.rb +2 -0
  5. data/app/controllers/silas/inbox/sessions_controller.rb +3 -1
  6. data/app/helpers/silas/inbox/trace_helper.rb +224 -2
  7. data/app/mailboxes/silas/agent_mailbox.rb +4 -0
  8. data/app/models/silas/tool_invocation.rb +37 -6
  9. data/app/views/layouts/silas/inbox.html.erb +69 -3
  10. data/app/views/silas/inbox/invocations/_detail.html.erb +21 -0
  11. data/app/views/silas/inbox/invocations/_invocation.html.erb +39 -24
  12. data/app/views/silas/inbox/sessions/_child.html.erb +14 -0
  13. data/app/views/silas/inbox/sessions/_row.html.erb +13 -2
  14. data/app/views/silas/inbox/sessions/show.html.erb +33 -0
  15. data/app/views/silas/inbox/steps/_step.html.erb +8 -1
  16. data/app/views/silas/inbox/turns/_header.html.erb +7 -2
  17. data/docs/agents.md +15 -3
  18. data/docs/channels.md +53 -2
  19. data/docs/configuration.md +6 -1
  20. data/docs/connections.md +8 -5
  21. data/docs/guarantees.md +16 -5
  22. data/docs/headless.md +120 -0
  23. data/docs/providers.md +171 -0
  24. data/docs/traces.md +131 -0
  25. data/docs/tutorial.md +6 -4
  26. data/docs/vs-eve.md +41 -17
  27. data/docs/why-silas.md +6 -6
  28. data/lib/generators/silas/install/install_generator.rb +21 -0
  29. data/lib/generators/silas/install/templates/initializer.rb +5 -0
  30. data/lib/generators/silas/install/templates/ruby_llm.rb +3 -1
  31. data/lib/silas/channel.rb +85 -11
  32. data/lib/silas/configuration.rb +16 -1
  33. data/lib/silas/doctor.rb +35 -2
  34. data/lib/silas/registry.rb +20 -2
  35. data/lib/silas/tool.rb +38 -5
  36. data/lib/silas/version.rb +1 -1
  37. 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
- 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
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-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
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.6) |
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. Chaos-gated every release: zero duplicate effects, byte-identical replay, SQLite + Postgres. | Workflow-engine replay; interrupted steps re-run. |
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; 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. |
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
- | **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. |
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
- held/working/filed rail, live traces, approval cards, audit trail, cost, web
57
- chat — behind your app's own auth.
74
+ the feed, approval cards, audit trail, cost, lineage — behind your app's own
75
+ auth.
58
76
 
59
- **Memory ships.** Approval-gated, with provenance and supersession. eve
60
- reasonably says "bring your own"; Silas ships the batteries.
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 and
68
- integrations today, with a platform company's release cadence.
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 docs and source at 0.27.6, quoted or
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
- hundreds of times per release (zero duplicate effects, byte-identical replay
32
- [guarantees](guarantees.md)):
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.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.
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 -> Silas.agent.start; reply -> session.continue).
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 so two channels can't collide.
17
- def self.namespaced(thread_key) = "#{channel_name}:#{thread_key}"
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
- # The single inbound entry point for every transport.
20
- def self.dispatch(thread_key:, input:, metadata: {})
21
- token = namespaced(thread_key)
22
- if (session = Silas::Session.find_by(continuation_token: token))
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.start(input: input, metadata: metadata,
27
- channel: channel_name, continuation_token: token)
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
- session = Silas::Session.find_by!(continuation_token: namespaced(thread_key))
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?