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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +4 -2
- 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 +2 -1
- data/docs/connections.md +8 -5
- data/docs/guarantees.md +16 -5
- data/docs/headless.md +120 -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/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 +5 -1
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
|
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?
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -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",
|
|
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
|
|
data/lib/silas/registry.rb
CHANGED
|
@@ -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
|
-
|
|
204
|
-
|
|
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?
|