silas 0.4.0 → 0.6.0
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 +211 -0
- data/DEPLOY.md +111 -0
- data/README.md +87 -250
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/helpers/silas/inbox/trace_helper.rb +20 -5
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/layouts/silas/inbox.html.erb +89 -30
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
- data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
- data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
- data/app/views/silas/inbox/sessions/index.html.erb +16 -15
- data/app/views/silas/inbox/sessions/show.html.erb +9 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/docs/agents.md +81 -0
- data/docs/budgets.md +67 -0
- data/docs/cancellation.md +41 -0
- data/docs/channels.md +290 -0
- data/docs/configuration.md +106 -0
- data/docs/connections.md +55 -0
- data/docs/conventions.md +161 -0
- data/docs/evals.md +95 -0
- data/docs/guarantees.md +76 -0
- data/docs/inbox-and-api.md +84 -0
- data/docs/memory.md +35 -0
- data/docs/sandbox.md +44 -0
- data/docs/tools.md +77 -0
- data/docs/tutorial.md +268 -0
- data/docs/vs-eve.md +93 -0
- data/docs/why-silas.md +87 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +11 -1
- data/lib/generators/silas/install/templates/claude_skill.md +136 -0
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
- data/lib/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/eval/dsl.rb +7 -2
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +8 -5
- data/lib/silas/step_runner.rb +5 -0
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +3 -0
- metadata +30 -1
data/README.md
CHANGED
|
@@ -1,289 +1,126 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/danielstpaul/silas/main/brand/silas-wordmark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/danielstpaul/silas/main/brand/silas-wordmark-light.svg" alt="silas" width="150">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
the durable stack is already booted inside your app. The only new surface is the
|
|
9
|
-
`app/agent/` directory below.
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://rubygems.org/gems/silas"><img src="https://img.shields.io/gem/v/silas" alt="Gem"></a>
|
|
10
|
+
<a href="https://github.com/danielstpaul/silas/actions/workflows/ci.yml"><img src="https://github.com/danielstpaul/silas/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<a href="https://github.com/danielstpaul/silas/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
|
12
|
+
</p>
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Silas is a Rails-native framework for durable AI agents. An agent's
|
|
15
|
+
capabilities live as plain files in conventional locations inside the app you
|
|
16
|
+
already run — easy to inspect, extend, and operate — and its tool effects
|
|
17
|
+
land **exactly once**, even through a crash.
|
|
18
|
+
|
|
19
|
+
## The filesystem is the authoring interface
|
|
20
|
+
|
|
21
|
+
A typical Silas agent:
|
|
19
22
|
|
|
20
23
|
```
|
|
21
24
|
app/agent/
|
|
22
|
-
instructions.md # the persona
|
|
23
|
-
agent.yml #
|
|
24
|
-
tools/ # one file per tool; identity =
|
|
25
|
-
issue_refund.rb # keyword signature = the schema the model sees
|
|
25
|
+
instructions.md # the persona
|
|
26
|
+
agent.yml # model + limits — data only
|
|
27
|
+
tools/ # one file per tool; filename = identity, keywords = schema
|
|
26
28
|
skills/ # markdown playbooks, loaded on demand
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## Quickstart
|
|
31
|
-
|
|
32
|
-
```sh
|
|
33
|
-
bundle add silas
|
|
34
|
-
bin/rails generate silas:install
|
|
35
|
-
bin/rails db:migrate
|
|
36
|
-
bin/rails silas:doctor # key · queue adapter · model · migrations · tools · rescuer · cable · auth
|
|
29
|
+
schedules/ # cron frontmatter -> recurring turns
|
|
30
|
+
channels/ # slack.rb, email.rb — transports bound to the loop
|
|
31
|
+
connections/ # remote MCP servers as <name>.yml
|
|
37
32
|
```
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
transactional! # DB-only side effects -> exactly-once, guaranteed
|
|
34
|
+
Documentation lives at
|
|
35
|
+
**[danielstpaul.github.io/silas](https://danielstpaul.github.io/silas)** —
|
|
36
|
+
start with the [tutorial](https://danielstpaul.github.io/silas/tutorial). The
|
|
37
|
+
same docs ship inside the gem (`bundle show silas`), and the installer writes
|
|
38
|
+
a Claude Code skill so coding agents working in your app know the conventions.
|
|
45
39
|
|
|
46
|
-
|
|
47
|
-
Refund.create!(order_id:, amount:)
|
|
48
|
-
{ refunded: order_id }
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
```
|
|
40
|
+
## Quick start
|
|
52
41
|
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
session.pending_approvals.first.approve!(by: "daniel")
|
|
56
|
-
session.continue(input: "Now email the customer.")
|
|
42
|
+
```bash
|
|
43
|
+
rails new my-agent -m https://raw.githubusercontent.com/danielstpaul/silas/main/templates/desk.rb
|
|
57
44
|
```
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
This creates a new agent app with Solid Queue wired, a starter refund-desk
|
|
47
|
+
agent installed, and a keyless demo — the first `cd my-agent && bin/dev`
|
|
48
|
+
needs no API key. More starting shapes live in
|
|
49
|
+
[templates/](https://github.com/danielstpaul/silas/tree/main/templates) —
|
|
50
|
+
swap `desk.rb` for `analyst.rb` to start from a scheduled reporting agent
|
|
51
|
+
instead.
|
|
62
52
|
|
|
63
|
-
|
|
64
|
-
$ bin/rails silas:chat
|
|
65
|
-
you> Refund order 42, £12.50
|
|
66
|
-
✓ lookup_order(order_id: 42)
|
|
67
|
-
⏸ issue_refund(order_id: 42, amount: 1250) — awaiting approval
|
|
53
|
+
To add Silas to an existing app:
|
|
68
54
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
55
|
+
```bash
|
|
56
|
+
bundle add silas
|
|
57
|
+
bin/rails generate silas:install
|
|
58
|
+
bin/rails db:migrate
|
|
72
59
|
```
|
|
73
60
|
|
|
74
|
-
`
|
|
61
|
+
`bin/rails silas:doctor` verifies the whole setup.
|
|
75
62
|
|
|
76
|
-
##
|
|
63
|
+
## A minimal example
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
hundreds of times per release (results in `chaos_host/results/`):
|
|
65
|
+
Replace `app/agent/instructions.md` with your agent's persona:
|
|
80
66
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
ledger row commit or roll back together. Zero duplicates across every chaos run.
|
|
86
|
-
- **Other tools are at-least-once within one step** — and when a crash makes an
|
|
87
|
-
execution ambiguous, the default `at_most_once!` policy **parks the run for a
|
|
88
|
-
human verdict** instead of guessing (`idempotent!` opts into automatic re-runs).
|
|
89
|
-
- **Approvals park at zero compute** — the job exits; approving enqueues a fresh
|
|
90
|
-
one that replays completed work from rows, never re-calling the model or
|
|
91
|
-
re-running tools. Parks expire (default 7 days) rather than ghosting forever.
|
|
92
|
-
- **Transient model errors retry from the checkpoint.** A rate limit,
|
|
93
|
-
overload, or timeout backs off and retries the job — and the continuation
|
|
94
|
-
resumes from the last completed step, never re-running completed work.
|
|
95
|
-
Exhausted retries and permanent rejections (bad key, bad request) expire
|
|
96
|
-
pending approvals and fail the turn loudly. **A turn can never sit in
|
|
97
|
-
`running` forever**: the rescuer also fails turns stranded by a loop job
|
|
98
|
-
that died outside the retry list.
|
|
99
|
-
- **The rescuer is part of the contract.** Solid Queue marks a dead worker's
|
|
100
|
-
jobs failed and nothing retries them; the installer wires
|
|
101
|
-
`Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
|
|
102
|
-
`SolidQueue.process_alive_threshold` + that cadence. Do not remove it.
|
|
103
|
-
- **Deploys can't corrupt a run**: instructions are snapshotted per turn, and a
|
|
104
|
-
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
105
|
-
(`NondeterminismError`) instead of resuming into a different agent.
|
|
106
|
-
|
|
107
|
-
## Adapter
|
|
108
|
-
|
|
109
|
-
Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
|
|
110
|
-
via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
|
|
111
|
-
and the production path. Compose resilience via `config.around_model_call`, or
|
|
112
|
-
swap in any object responding to `#execute_step` (the eval harness and the
|
|
113
|
-
chaos tests do exactly that).
|
|
114
|
-
|
|
115
|
-
> The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
|
|
116
|
-
> in 0.2: its subscription-auth rationale was structurally unreachable, and it
|
|
117
|
-
> carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
|
|
118
|
-
> server survives and returns as a first-class *mount your tools as MCP*
|
|
119
|
-
> feature.
|
|
120
|
-
|
|
121
|
-
## Sandbox: run untrusted code with hermetic
|
|
67
|
+
```markdown
|
|
68
|
+
You are the refund desk. Look orders up before promising anything, and never
|
|
69
|
+
quote an amount a tool didn't return.
|
|
70
|
+
```
|
|
122
71
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
honest-but-interim). For real isolation, the companion gem
|
|
126
|
-
[**hermetic**](https://github.com/danielstpaul/hermetic) drops straight in:
|
|
72
|
+
Create a tool at `app/agent/tools/issue_refund.rb` — the keyword signature
|
|
73
|
+
*is* the schema the model sees:
|
|
127
74
|
|
|
128
75
|
```ruby
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
#
|
|
76
|
+
class Agent::Tools::IssueRefund < Silas::Tool
|
|
77
|
+
description "Refund part or all of an order."
|
|
78
|
+
approval ->(session:, input:) { input[:amount_pence] > 2_500 ? :user_approval : :approved }
|
|
79
|
+
transactional! # DB effect + ledger commit atomically -> exactly-once
|
|
80
|
+
|
|
81
|
+
def call(number:, amount_pence:, reason:)
|
|
82
|
+
order = Order.find_by!(number: number)
|
|
83
|
+
refund = order.refunds.create!(amount_pence:, reason:)
|
|
84
|
+
{ refunded_pence: refund.amount_pence, order: order.number }
|
|
85
|
+
end
|
|
133
86
|
end
|
|
134
87
|
```
|
|
135
88
|
|
|
136
|
-
|
|
137
|
-
`run_code` tool is advertised to the model automatically (`at_most_once!` — an
|
|
138
|
-
exec is an external effect). Two properties carry through the seam:
|
|
139
|
-
|
|
140
|
-
- **The trust axis is visible**: every hermetic backend exposes `trust`
|
|
141
|
-
(`:vendor`/`:remote`/`:vm`/`:host`) and `off_host?`, so you can refuse to run
|
|
142
|
-
untrusted code on the box that holds your `RAILS_MASTER_KEY` — pair any local
|
|
143
|
-
backend with `executor:` to push execution to a dedicated sandbox host.
|
|
144
|
-
- **The ledger guard is auto-armed**: configuring a hermetic backend loads its
|
|
145
|
-
Silas shim, so a sandbox exec attempted inside a ledger transaction fails loud
|
|
146
|
-
(sandbox-backed tools must be `at_most_once!`, never `transactional!`).
|
|
147
|
-
|
|
148
|
-
## Named agents: the staff pattern
|
|
149
|
-
|
|
150
|
-
One app can employ several agents, each with its own room:
|
|
151
|
-
|
|
152
|
-
```
|
|
153
|
-
app/agents/
|
|
154
|
-
reader/ # Silas.agent(:reader).start(input: "...")
|
|
155
|
-
instructions.md
|
|
156
|
-
agent.yml # model, limits — same keys as the root agent
|
|
157
|
-
tools/
|
|
158
|
-
skills/
|
|
159
|
-
clerk/
|
|
160
|
-
...
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Sessions are stamped with the agent's name; every turn — including crash
|
|
164
|
-
resumes — runs under that agent's own tools, skills, instructions, and
|
|
165
|
-
definitions digest. The inbox filters by agent; `bin/rails silas:chat
|
|
166
|
-
AGENT=clerk` chats with one staff member. The root `app/agent/` remains the
|
|
167
|
-
default agent, unchanged. (Subagents stay a root-agent delegation feature;
|
|
168
|
-
scope switching is execution-isolated, so concurrent jobs running different
|
|
169
|
-
agents never cross wires.)
|
|
170
|
-
|
|
171
|
-
## Memory & handoffs
|
|
172
|
-
|
|
173
|
-
Silas memory is **graph-shaped, not a graph database**: facts as
|
|
174
|
-
`subject · attribute · content` triples with provenance and supersession
|
|
175
|
-
("author:jane · report_format: prefers CSV" — a new value retires the old).
|
|
176
|
-
The `remember` tool is **approval-gated by default** — the memory card parks
|
|
177
|
-
in your inbox before anything persists; `recall` digs deeper than the few
|
|
178
|
-
recent memories injected into each turn. Private per agent, or `shared: true`
|
|
179
|
-
for the whole staff. Your *domain* data does not belong here — it belongs in
|
|
180
|
-
your own tables, which your tools already read; memory is for the fuzzy
|
|
181
|
-
residue with no natural home.
|
|
182
|
-
|
|
183
|
-
Staff compose through **handoffs, not conversations**: `handoff` files a
|
|
184
|
-
self-contained brief that starts a linked session for another named agent
|
|
185
|
-
(async, or `await: true` for an answer), exactly-once-guarded, cycle-checked.
|
|
186
|
-
Two models chatting freely is a cost and audit hazard — deliberately
|
|
187
|
-
unblessed.
|
|
188
|
-
|
|
189
|
-
## Triggers
|
|
89
|
+
Restart, then talk to it:
|
|
190
90
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
- **`schedules/`** — `app/agent/schedules/*.md` (cron frontmatter, body = the turn
|
|
194
|
-
input) or `*.rb` handlers. `bin/rails silas:schedules` compiles them into
|
|
195
|
-
Solid Queue recurring tasks. A scheduled run is a normal durable turn.
|
|
196
|
-
- **`channels/`** — `app/agent/channels/*.rb` bind email (Action Mailbox) and
|
|
197
|
-
Slack to the loop. A new thread starts a session, a reply continues it, and
|
|
198
|
-
approvals render as Slack buttons / signed email links that call the same
|
|
199
|
-
`approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
|
|
200
|
-
|
|
201
|
-
## Streaming
|
|
202
|
-
|
|
203
|
-
Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
|
|
204
|
-
`bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
|
|
205
|
-
renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
|
|
206
|
-
the durable rows — never persisted, never fed back to the model, and a
|
|
207
|
-
replayed step renders from its row with no deltas at all, so streaming adds
|
|
208
|
-
zero risk to the durability contract. Custom sinks subscribe to the
|
|
209
|
-
`"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
|
|
210
|
-
text: }`, where `text` is the accumulated string so far — filter by ids;
|
|
211
|
-
notifications are process-global).
|
|
212
|
-
|
|
213
|
-
## Structured answers
|
|
214
|
-
|
|
215
|
-
Give the turn's final answer a schema in agent.yml and read it back as a Hash:
|
|
216
|
-
|
|
217
|
-
```yaml
|
|
218
|
-
final_answer:
|
|
219
|
-
type: object
|
|
220
|
-
properties:
|
|
221
|
-
verdict: { type: string }
|
|
222
|
-
amount_pence: { type: integer }
|
|
223
|
-
required: [verdict]
|
|
91
|
+
```bash
|
|
92
|
+
bin/rails silas:chat
|
|
224
93
|
```
|
|
225
94
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
loudly rather than resuming into a different contract.
|
|
95
|
+
Refunds over £25 hold for a person — in the operator inbox the gem mounts at
|
|
96
|
+
`/silas/inbox`, in Slack, or over the JSON API — and approving resumes the
|
|
97
|
+
turn exactly where it stopped, with exactly one refund row in your database.
|
|
98
|
+
How that's guaranteed (and verified with a `kill -9` chaos harness on every
|
|
99
|
+
release): [guarantees](https://danielstpaul.github.io/silas/guarantees).
|
|
232
100
|
|
|
233
|
-
##
|
|
101
|
+
## Status
|
|
234
102
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
103
|
+
Early (0.5.x) and moving fast. Requires Rails >= 8.1 (Active Job
|
|
104
|
+
Continuations) and Solid Queue >= 1.2; any model provider
|
|
105
|
+
[RubyLLM](https://rubyllm.com) supports.
|
|
238
106
|
|
|
239
|
-
|
|
240
|
-
curl -X POST .../silas/api/v1/sessions -d "input=Refund order 42, £12.50"
|
|
241
|
-
curl .../silas/api/v1/sessions/1?trace=1 # turns + steps + tool calls
|
|
242
|
-
curl .../silas/api/v1/sessions/1/approvals # what's parked
|
|
243
|
-
curl -X POST .../silas/api/v1/approvals/7/approve # the same approve! as the inbox
|
|
244
|
-
curl -X POST .../silas/api/v1/sessions/1/turns -d "input=Now email them" # 409 if busy
|
|
245
|
-
curl -X POST .../silas/api/v1/turns/9/cancel
|
|
246
|
-
curl -N .../silas/api/v1/sessions/1/stream # server-sent events
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
The stream is SSE at **row granularity** — turn / completed-step / invocation
|
|
250
|
-
changes, at-least-once with `Last-Event-ID` resume (ids are epoch-ms
|
|
251
|
-
watermarks; `?poll=1` returns the backlog and closes, curl-friendly; streams
|
|
252
|
-
close themselves after `api_stream_max_duration` and clients reconnect).
|
|
253
|
-
Per-token streaming is deliberately the browser/Turbo feature — deltas live in
|
|
254
|
-
the worker process, and the gem requires no cross-process bus.
|
|
255
|
-
|
|
256
|
-
## The inbox
|
|
107
|
+
## Community
|
|
257
108
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
the browser — same durable loop, no separate surface), a live step-trace that
|
|
261
|
-
streams tokens over Turbo Streams as the agent runs, approval cards whose
|
|
262
|
-
Approve/Decline buttons call the exact same `approve!`/`decline!` as Slack and
|
|
263
|
-
email, a full **audit trail** (every tool call's arguments and its result or
|
|
264
|
-
recorded error; who approved; who declined and why), **cancel** on active
|
|
265
|
-
turns (honored at the next step boundary), and per-session token/cost
|
|
266
|
-
accounting. It's **deny-by-default** — invisible until you wire auth:
|
|
109
|
+
Questions, ideas, and bug reports →
|
|
110
|
+
[issues](https://github.com/danielstpaul/silas/issues).
|
|
267
111
|
|
|
268
|
-
|
|
269
|
-
Silas.configure do |c|
|
|
270
|
-
# Devise-compatible: the lambda DENIES by rendering; passes by not rendering.
|
|
271
|
-
c.inbox_auth = ->(controller) { controller.head :not_found unless controller.current_user&.admin? }
|
|
272
|
-
# c.inbox_public_read = true # public read-only demo; approve/decline stay gated
|
|
273
|
-
# c.model_prices["your-model"] = { in: 300, out: 1500 } # microcents / 1k tokens
|
|
274
|
-
end
|
|
275
|
-
```
|
|
112
|
+
## Contributing
|
|
276
113
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
114
|
+
See [CONTRIBUTING.md](https://github.com/danielstpaul/silas/blob/main/CONTRIBUTING.md)
|
|
115
|
+
— including the short list of things Silas deliberately doesn't do, and the
|
|
116
|
+
chaos gate that protects the durability contract.
|
|
280
117
|
|
|
281
|
-
##
|
|
118
|
+
## Security
|
|
282
119
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
120
|
+
Please report vulnerabilities privately via
|
|
121
|
+
[GitHub's vulnerability reporting](https://github.com/danielstpaul/silas/security/advisories/new)
|
|
122
|
+
— see [SECURITY.md](https://github.com/danielstpaul/silas/blob/main/SECURITY.md).
|
|
286
123
|
|
|
287
124
|
## License
|
|
288
125
|
|
|
289
|
-
MIT.
|
|
126
|
+
[MIT](https://github.com/danielstpaul/silas/blob/main/LICENSE).
|
|
@@ -29,6 +29,16 @@ module Silas
|
|
|
29
29
|
rescue Silas::Error => e
|
|
30
30
|
render json: { error: e.message }, status: :conflict
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
# POST /silas/api/v1/approvals/:id/answer { text: "..." }
|
|
34
|
+
# ask_question's verdict: the text becomes the tool result.
|
|
35
|
+
def answer
|
|
36
|
+
invocation = Silas::ToolInvocation.find(params[:id])
|
|
37
|
+
invocation.answer!(text: params[:text].to_s.strip, by: current_actor)
|
|
38
|
+
render json: invocation_json(invocation.reload)
|
|
39
|
+
rescue Silas::Error => e
|
|
40
|
+
render json: { error: e.message }, status: :conflict
|
|
41
|
+
end
|
|
32
42
|
end
|
|
33
43
|
end
|
|
34
44
|
end
|
|
@@ -24,6 +24,14 @@ module Silas
|
|
|
24
24
|
respond_error(e)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# ask_question's verdict: the operator's text becomes the tool result.
|
|
28
|
+
def answer
|
|
29
|
+
@invocation.answer!(text: params[:text].to_s.strip, by: current_actor)
|
|
30
|
+
respond_resolved
|
|
31
|
+
rescue Silas::Error => e
|
|
32
|
+
respond_error(e)
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
private
|
|
28
36
|
|
|
29
37
|
def set_invocation
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
module Inbox
|
|
3
3
|
module TraceHelper
|
|
4
|
+
# The seven run states, in aspect (direction "Signals"): running is the
|
|
5
|
+
# only aspect that pulses, in_doubt gets its own violet (it is neither
|
|
6
|
+
# waiting-by-design nor failed), and canceled is a lamp going OUT —
|
|
7
|
+
# dashed quiet, never red. Failed keeps the only red.
|
|
4
8
|
STATUS_CLASS = {
|
|
5
9
|
"queued" => "pill-grey", "running" => "pill-blue pill-pulse",
|
|
6
|
-
"waiting" => "pill-amber", "in_doubt" => "pill-
|
|
7
|
-
"completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-
|
|
8
|
-
# tool-invocation statuses
|
|
10
|
+
"waiting" => "pill-amber", "in_doubt" => "pill-violet",
|
|
11
|
+
"completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-quiet",
|
|
12
|
+
# tool-invocation statuses map onto the same seven
|
|
9
13
|
"pending" => "pill-grey", "started" => "pill-blue", "declined" => "pill-red",
|
|
10
|
-
"approved" => "pill-green", "
|
|
14
|
+
"approved" => "pill-green", "answered" => "pill-green",
|
|
15
|
+
"required" => "pill-amber", "expired" => "pill-quiet"
|
|
11
16
|
}.freeze
|
|
12
17
|
|
|
18
|
+
# UI-only relabels — the database strings and the JSON API are untouched
|
|
19
|
+
# (an operator who reads "held" here and greps the API will find
|
|
20
|
+
# `waiting`; docs name both). Safety-system vocabulary: a turn is held
|
|
21
|
+
# at the signal until a person clears it.
|
|
22
|
+
UI_LABEL = { "waiting" => "held", "completed" => "clear" }.freeze
|
|
23
|
+
|
|
24
|
+
def status_label(status)
|
|
25
|
+
UI_LABEL[status.to_s] || status.to_s.tr("_", " ")
|
|
26
|
+
end
|
|
27
|
+
|
|
13
28
|
def status_pill(status)
|
|
14
|
-
tag.span(status
|
|
29
|
+
tag.span(status_label(status), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}")
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
def step_text(step)
|
|
@@ -24,6 +24,21 @@ module Silas
|
|
|
24
24
|
channel = Channel.for_session(invocation.turn.session)
|
|
25
25
|
return release!(ToolInvocation, invocation.id, :notified_at) unless channel
|
|
26
26
|
|
|
27
|
+
# Questions want free text, and approve/decline buttons are the wrong UI
|
|
28
|
+
# for that — so a question pings only channels that implement
|
|
29
|
+
# deliver_question. Without it the claim is KEPT (no retries): the
|
|
30
|
+
# question waits in the inbox, which every install has.
|
|
31
|
+
if invocation.question?
|
|
32
|
+
unless channel.respond_to?(:deliver_question)
|
|
33
|
+
Rails.logger&.info("[Silas] #{channel.class} has no deliver_question — " \
|
|
34
|
+
"question ##{invocation.id} awaits its answer in the inbox")
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
return with_release(ToolInvocation, invocation.id, :notified_at) do
|
|
38
|
+
channel.deliver_question(session: invocation.turn.session, invocation: invocation)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
27
42
|
with_release(ToolInvocation, invocation.id, :notified_at) do
|
|
28
43
|
channel.deliver_approval(session: invocation.turn.session, invocation: invocation)
|
|
29
44
|
end
|
|
@@ -57,6 +57,18 @@ module Silas
|
|
|
57
57
|
|
|
58
58
|
silas_inbox_dispatch(:replace, turn.session_id, target: ActionView::RecordIdentifier.dom_id(self),
|
|
59
59
|
partial: "silas/inbox/invocations/invocation", locals: { invocation: self })
|
|
60
|
+
|
|
61
|
+
# The hoisted card (session top): a fresh park APPENDS it there —
|
|
62
|
+
# a replace can't, since no target exists until the park — and any
|
|
63
|
+
# later transition replaces it in place (the partial renders an
|
|
64
|
+
# empty shell once settled, so the card vanishes).
|
|
65
|
+
if saved_change_to_approval_state?
|
|
66
|
+
action = approval_state == "required" ? :append : :replace
|
|
67
|
+
target = action == :append ? "silas-session-#{turn.session_id}-approvals"
|
|
68
|
+
: ActionView::RecordIdentifier.dom_id(self, :approval)
|
|
69
|
+
silas_inbox_dispatch(action, turn.session_id, target: target,
|
|
70
|
+
partial: "silas/inbox/invocations/approval_card", locals: { invocation: self })
|
|
71
|
+
end
|
|
60
72
|
end
|
|
61
73
|
end
|
|
62
74
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One compaction row replaces session turns 0..up_to_turn_index with a
|
|
3
|
+
# persisted summary. Written exactly once (the unique index on
|
|
4
|
+
# session_id + up_to_turn_index is the compare-and-swap claim), read
|
|
5
|
+
# deterministically forever after — which is what lets MessageBuilder stay
|
|
6
|
+
# byte-identical across crash replays: the summary is a row, never a
|
|
7
|
+
# runtime computation.
|
|
8
|
+
class Compaction < ApplicationRecord
|
|
9
|
+
STATUSES = %w[pending completed].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :session, class_name: "Silas::Session"
|
|
12
|
+
belongs_to :up_to_turn, class_name: "Silas::Turn"
|
|
13
|
+
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
validates :up_to_turn_index, presence: true
|
|
16
|
+
|
|
17
|
+
scope :completed, -> { where(status: "completed") }
|
|
18
|
+
|
|
19
|
+
def completed? = status == "completed"
|
|
20
|
+
|
|
21
|
+
# The compaction MessageBuilder applies when building turn: the newest
|
|
22
|
+
# completed summary strictly before it. (A compaction can never cover its
|
|
23
|
+
# own turn — it is created during turn N covering 0..N-1 — so `<` is
|
|
24
|
+
# always satisfiable; it also keeps an eval or replay of an older turn
|
|
25
|
+
# from seeing a summary written after it.)
|
|
26
|
+
def self.latest_for(turn)
|
|
27
|
+
completed.where(session_id: turn.session_id)
|
|
28
|
+
.where(up_to_turn_index: ...turn.index)
|
|
29
|
+
.order(:up_to_turn_index).last
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,7 +2,7 @@ module Silas
|
|
|
2
2
|
class ToolInvocation < ApplicationRecord
|
|
3
3
|
STATUSES = %w[pending started completed failed in_doubt].freeze
|
|
4
4
|
EFFECT_MODES = %w[transactional at_most_once idempotent].freeze
|
|
5
|
-
APPROVAL_STATES = [ nil, "required", "approved", "declined", "expired" ].freeze
|
|
5
|
+
APPROVAL_STATES = [ nil, "required", "approved", "answered", "declined", "expired" ].freeze
|
|
6
6
|
|
|
7
7
|
include Silas::Inbox::Broadcastable
|
|
8
8
|
|
|
@@ -18,6 +18,10 @@ module Silas
|
|
|
18
18
|
def in_doubt? = status == "in_doubt"
|
|
19
19
|
def awaiting_approval? = approval_state == "required"
|
|
20
20
|
|
|
21
|
+
# A parked ask_question — same park, different verdict: it is ANSWERED
|
|
22
|
+
# (free text becomes the tool result), never approved into execution.
|
|
23
|
+
def question? = tool_name == "ask_question"
|
|
24
|
+
|
|
21
25
|
# Outbound: when a channel-bound invocation parks for approval, ping the
|
|
22
26
|
# channel off-loop (covers both approval-gate and in-doubt parking).
|
|
23
27
|
after_update_commit :notify_channel_approval, if: :should_notify_approval?
|
|
@@ -34,6 +38,10 @@ module Silas
|
|
|
34
38
|
# parked job exited normally; its continuation is consumed). For an
|
|
35
39
|
# in-doubt invocation, approval means "it did not run — re-execute".
|
|
36
40
|
def approve!(by: nil)
|
|
41
|
+
if question?
|
|
42
|
+
raise Error, "invocation #{id} is a question — settle it with answer!, not approve! " \
|
|
43
|
+
"(approving would try to EXECUTE ask_question, which has no execution)"
|
|
44
|
+
end
|
|
37
45
|
assert_parked!
|
|
38
46
|
assert_turn_resumable!
|
|
39
47
|
update!(status: "pending", approval_state: "approved", approved_by: by)
|
|
@@ -42,6 +50,23 @@ module Silas
|
|
|
42
50
|
resume_turn!
|
|
43
51
|
end
|
|
44
52
|
|
|
53
|
+
# Answer a parked question. The text IS the tool result — the model resumes
|
|
54
|
+
# with {"answer" => text}, persisted like any other settled invocation, so
|
|
55
|
+
# replay determinism costs nothing. (decline! also works on a question: a
|
|
56
|
+
# refusal to answer, delivered as {"denied" => reason}.)
|
|
57
|
+
def answer!(text:, by: nil)
|
|
58
|
+
raise Error, "invocation #{id} (#{tool_name}) is not a question — answer! settles ask_question only" unless question?
|
|
59
|
+
raise Error, "an answer cannot be blank — decline! is the way to refuse a question" if text.blank?
|
|
60
|
+
|
|
61
|
+
assert_parked!
|
|
62
|
+
assert_turn_resumable!
|
|
63
|
+
update!(status: "completed", approval_state: "answered", approved_by: by,
|
|
64
|
+
result: { "answer" => text })
|
|
65
|
+
Silas.instrument(:approval, action: "answered", tool: tool_name, by: by,
|
|
66
|
+
invocation_id: id, turn_id: turn_id)
|
|
67
|
+
resume_turn!
|
|
68
|
+
end
|
|
69
|
+
|
|
45
70
|
# Decline: for an approval gate, eve's shape — the tool is not executed
|
|
46
71
|
# and the model sees {denied: reason} as the result, then the loop
|
|
47
72
|
# continues. For an in-doubt invocation, decline means "assume it ran /
|
|
@@ -60,8 +85,9 @@ module Silas
|
|
|
60
85
|
# their turns (parked-forever ghosts are a bug, not a feature).
|
|
61
86
|
def self.expire_stale!(now: Time.current)
|
|
62
87
|
where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
|
|
63
|
-
inv.
|
|
64
|
-
|
|
88
|
+
result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" }
|
|
89
|
+
: { "denied" => "approval expired" }
|
|
90
|
+
inv.update!(approval_state: "expired", status: "failed", result: result)
|
|
65
91
|
Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
|
|
66
92
|
invocation_id: inv.id, turn_id: inv.turn_id)
|
|
67
93
|
inv.turn.finish!(:failed, reason: "approval_expired")
|