silas 0.6.2 → 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.
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
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?
@@ -50,9 +50,23 @@ module Silas
50
50
  # Channels: name -> Channel subclass (wired by the Registry). Slack creds
51
51
  # default to credentials.dig(:silas, :slack, ...); nil disables Slack.
52
52
  attr_accessor :channel_resolver
53
+ # Inbound routing: which named agent an external thread wakes. Data only —
54
+ # { transport => { key => agent_name } }, where transport is the channel's
55
+ # filename identity and key is whatever that transport calls a destination:
56
+ #
57
+ # config.channel_routes = {
58
+ # "slack" => { "C0BILLING" => "bookkeeper" },
59
+ # "email" => { "billing@shop.test" => "bookkeeper" }
60
+ # }
61
+ #
62
+ # Unmatched threads wake the root agent. Names are checked at boot against
63
+ # app/agents/ (Registry.install!) — a typo fails the deploy, never a
64
+ # webhook.
65
+ attr_accessor :channel_routes
53
66
  attr_writer :slack_signing_secret, :slack_bot_token
54
67
  # Bind host for the in-process MCP server (Mcp::Server — the "mount your
55
- # tools as MCP" seam).
68
+ # tools as MCP" seam). Inert: nothing outside Mcp::Server's own specs calls
69
+ # .start, so this setting has no effect until a mounted endpoint ships.
56
70
  attr_accessor :mcp_server_host
57
71
 
58
72
  # Renamed in 0.4, removed in 2.0. "engine" meant two unrelated things —
@@ -134,6 +148,7 @@ module Silas
134
148
  @agent_override = nil
135
149
  @instructions_dir = nil
136
150
  @channel_resolver = nil
151
+ @channel_routes = {}
137
152
  @slack_signing_secret = nil
138
153
  @slack_bot_token = nil
139
154
  @mcp_server_host = "127.0.0.1"
data/lib/silas/doctor.rb CHANGED
@@ -10,6 +10,35 @@ module Silas
10
10
  class Doctor
11
11
  Check = Struct.new(:status, :label, :detail) # status: :pass | :warn | :fail
12
12
 
13
+ # Rails defaults development to :async, which the queue_adapter check below
14
+ # FAILS — so a stock app fails the doctor the installer told it to run. The
15
+ # remedy travels with the failure, and the install generator prints this
16
+ # same constant when it detects :async, so the two surfaces can't drift.
17
+ # Printed, never written: database.yml and cable.yml belong to the host app.
18
+ ASYNC_QUEUE_REMEDY = <<~MSG.freeze
19
+ Add to config/environments/development.rb:
20
+
21
+ config.active_job.queue_adapter = :solid_queue
22
+ config.solid_queue.connects_to = { database: { writing: :queue } }
23
+
24
+ and a queue database to config/database.yml (SQLite shown — drop the
25
+ connects_to line above if Solid Queue shares your primary database):
26
+
27
+ development:
28
+ primary:
29
+ <<: *default
30
+ database: storage/development.sqlite3
31
+ queue:
32
+ <<: *default
33
+ database: storage/development_queue.sqlite3
34
+ migrations_paths: db/queue_migrate
35
+
36
+ Then `bin/rails db:prepare`, and run the worker next to the server:
37
+ `bin/jobs`. On an app that predates Rails 8, `bundle add solid_queue &&
38
+ bin/rails solid_queue:install` first. For scripts and demos `:inline` is
39
+ also safe — synchronous, no durability.
40
+ MSG
41
+
13
42
  def self.run(root: Rails.root) = new(root: root).run
14
43
 
15
44
  def initialize(root:)
@@ -49,11 +78,15 @@ module Silas
49
78
  when /AsyncAdapter/
50
79
  Check.new(:fail, "queue adapter",
51
80
  "in-process :async double-executes continuation steps and voids the durability " \
52
- "contract — use :solid_queue (see DEPLOY.md)")
81
+ "contract — use :solid_queue (see DEPLOY.md)\n\n#{ASYNC_QUEUE_REMEDY.indent(3).chomp}")
53
82
  when /InlineAdapter/
54
83
  Check.new(:warn, "queue adapter", "inline — fine for scripts and demos, no durability")
55
84
  else
56
- Check.new(:warn, "queue adapter", "#{name.demodulize} — durability requires a serializing, DB-backed adapter")
85
+ Check.new(:warn, "queue adapter",
86
+ "#{name.demodulize} — no dead-job rescue path: DeadJobRescuerJob sweeps expired approvals " \
87
+ "but returns early unless SolidQueue is defined, so a job killed with its worker is never " \
88
+ "retried and a turn stranded mid-tool stays running with its in-doubt invocation unswept. " \
89
+ "Durability requires a serializing, DB-backed adapter.")
57
90
  end
58
91
  end
59
92
 
@@ -20,6 +20,10 @@ module Silas
20
20
  Silas.config.agent_override = nil
21
21
  Silas.config.instructions_dir = nil
22
22
  Silas.reset_agent_memo!
23
+ # Boot is the last place a bad channel route can fail safely. Directory
24
+ # names, not built scopes: validation must not force every named agent's
25
+ # tools to constantize before anything needs them.
26
+ Silas::Channel.validate_routes!(registry.named_agent_dirs.map { |dir| File.basename(dir) })
23
27
  registry
24
28
  end
25
29
 
@@ -182,6 +186,13 @@ module Silas
182
186
  # identity under const_base, skills, the load_skill builtin when skills
183
187
  # exist, run_code when asked, and the scope's own digest (the same
184
188
  # NondeterminismError guard root turns get).
189
+ #
190
+ # A named agent is a full member of staff: it gets ask_question (parking to
191
+ # ask a person is the premise, not a root-agent privilege) and the shared
192
+ # remote connections. Connections live in the root app/agent/connections/ —
193
+ # one set of credentials for the app, reachable by everyone. Subagents get
194
+ # neither: they run inside a parent's turn, which is where the human contact
195
+ # and the remote surface belong.
185
196
  def build_agent_scope(dir, name, const_base:, agent: nil, run_code: false, named: false)
186
197
  tools = Dir[dir.join("tools/*.rb")].sort.to_h do |file|
187
198
  tname = File.basename(file, ".rb")
@@ -193,6 +204,7 @@ module Silas
193
204
  end
194
205
  skills = Dir[dir.join("skills/*.md")].sort.map { |f| Skill.parse(f) }
195
206
  builtins = {}
207
+ builtins["ask_question"] = Silas::Tools::AskQuestion if named && Silas.config.ask_question
196
208
  builtins["load_skill"] = Silas::Tools::LoadSkill if skills.any?
197
209
  builtins["run_code"] = Silas::Tools::RunCode if run_code
198
210
  if named && Silas.memory_enabled?
@@ -200,8 +212,14 @@ module Silas
200
212
  builtins["recall"] = Silas::Tools::Recall
201
213
  end
202
214
  builtins["handoff"] = Silas::Tools::Handoff if named && named_agent_dirs.size > 1
203
- resolver = ->(n) { (tools[n] || builtins.fetch(n)).new }
204
- definitions = (tools.values + builtins.values).map(&:schema)
215
+ remote = connections if named
216
+ resolver = lambda do |n|
217
+ klass = tools[n] || builtins[n]
218
+ return klass.new if klass
219
+
220
+ remote&.resolve(n) or raise Error, "unknown tool #{n.inspect} for agent #{name.inspect}"
221
+ end
222
+ definitions = (tools.values + builtins.values).map(&:schema) + (remote&.definitions || [])
205
223
  loaded_agent = agent || Silas::Agent.load(dir: dir)
206
224
  payload = { tools: definitions, skills: skills.map { |s| [ s.name, s.description ] } }
207
225
  payload[:final_answer] = loaded_agent.final_answer if loaded_agent.final_answer.present?