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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +211 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -250
  5. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  6. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  7. data/app/helpers/silas/inbox/trace_helper.rb +20 -5
  8. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  9. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  10. data/app/models/silas/compaction.rb +32 -0
  11. data/app/models/silas/tool_invocation.rb +29 -3
  12. data/app/views/layouts/silas/inbox.html.erb +89 -30
  13. data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
  14. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  15. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  17. data/app/views/silas/inbox/sessions/show.html.erb +9 -0
  18. data/config/routes.rb +2 -0
  19. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  20. data/docs/agents.md +81 -0
  21. data/docs/budgets.md +67 -0
  22. data/docs/cancellation.md +41 -0
  23. data/docs/channels.md +290 -0
  24. data/docs/configuration.md +106 -0
  25. data/docs/connections.md +55 -0
  26. data/docs/conventions.md +161 -0
  27. data/docs/evals.md +95 -0
  28. data/docs/guarantees.md +76 -0
  29. data/docs/inbox-and-api.md +84 -0
  30. data/docs/memory.md +35 -0
  31. data/docs/sandbox.md +44 -0
  32. data/docs/tools.md +77 -0
  33. data/docs/tutorial.md +268 -0
  34. data/docs/vs-eve.md +93 -0
  35. data/docs/why-silas.md +87 -0
  36. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  37. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  38. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  39. data/lib/generators/silas/install/install_generator.rb +11 -1
  40. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  41. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  42. data/lib/silas/adapters/ruby_llm.rb +102 -46
  43. data/lib/silas/channel.rb +35 -0
  44. data/lib/silas/compactor.rb +178 -0
  45. data/lib/silas/configuration.rb +16 -0
  46. data/lib/silas/eval/dsl.rb +7 -2
  47. data/lib/silas/instrumentation.rb +7 -3
  48. data/lib/silas/ledger.rb +2 -2
  49. data/lib/silas/log_subscriber.rb +5 -0
  50. data/lib/silas/message_builder.rb +19 -0
  51. data/lib/silas/registry.rb +5 -2
  52. data/lib/silas/schedule.rb +44 -15
  53. data/lib/silas/slack.rb +8 -5
  54. data/lib/silas/step_runner.rb +5 -0
  55. data/lib/silas/tools/ask_question.rb +26 -0
  56. data/lib/silas/version.rb +1 -1
  57. data/lib/silas/webhook.rb +47 -0
  58. data/lib/silas.rb +3 -0
  59. metadata +30 -1
data/README.md CHANGED
@@ -1,289 +1,126 @@
1
- # Your Rails app is already an agent runtime.
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
- **Silas** turns the Rails app you already run into a durable AI-agent runtime.
4
- Active Job Continuations, Solid Queue, and one Postgres ledger table make every
5
- turn survive `kill -9` and resume from the last completed step — with a live
6
- operator inbox at `/silas/inbox` and park-at-zero human-in-the-loop approvals
7
- holding the big levers. No new service, no managed platform, no per-run meter:
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
- Honestly early and honestly narrow: **v0.1, one maintainer, zero external
12
- users**, durability proven by an in-repo `kill -9` chaos harness (100/100, zero
13
- duplicate effects, byte-identical replay), and scoped to **trusted code you write
14
- yourself** by default for untrusted or model-generated code, drop in the
15
- companion gem [hermetic](https://github.com/danielstpaul/hermetic) (gVisor /
16
- Firecracker / hosted sandboxes behind one call, see below). The full pitch
17
- and the honest caveats: [Why Silas](docs/why-silas.md) ·
18
- [Silas vs eve](docs/vs-eve.md).
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 (ERB, snapshotted once per turn)
23
- agent.yml # data-only config: model, limits
24
- tools/ # one file per tool; identity = filename
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
- triage.md # description: frontmatter is the routing hint
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
- ```ruby
40
- class Agent::Tools::IssueRefund < Silas::Tool
41
- description "Refund an order."
42
- param :amount, :integer, desc: "Pence"
43
- approval :always # parks the run; a human approves from your app
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
- def call(order_id:, amount:)
47
- Refund.create!(order_id:, amount:)
48
- { refunded: order_id }
49
- end
50
- end
51
- ```
40
+ ## Quick start
52
41
 
53
- ```ruby
54
- session = Silas.agent.start(input: "Refund order 42, £12.50")
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
- Or talk to it from the terminal the REPL runs *inside your app*, so tools hit
60
- your real dev database, and parked approvals prompt inline (the same
61
- `approve!`/`decline!` as the inbox and Slack):
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
- approval needed — issue_refund(order_id: 42, amount: 1250)
70
- approve? [y]es / [d]ecline / [s]kip> y
71
- agent> Done — £12.50 refunded on order 42.
55
+ ```bash
56
+ bundle add silas
57
+ bin/rails generate silas:install
58
+ bin/rails db:migrate
72
59
  ```
73
60
 
74
- `SESSION=id` resumes an existing session.
61
+ `bin/rails silas:doctor` verifies the whole setup.
75
62
 
76
- ## The durability contract (what's actually guaranteed)
63
+ ## A minimal example
77
64
 
78
- Verified by `chaos_host/bin/chaos` the harness that kill -9s a live agent
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
- - **A turn survives hard process death** (worker kill -9, whole-tree kill -9,
82
- SIGTERM deploys) and resumes from the last completed step: 100% completion,
83
- byte-identical transcripts, on SQLite and Postgres.
84
- - **`transactional!` tools execute exactly once.** The tool's DB writes and the
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
- The sandbox is a second pluggable seam (`config.sandbox`). Built-in adapters are
124
- `:none` (default code execution off) and `:docker` (hardened container,
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
- # Gemfile: gem "hermetic" (zero runtime deps)
130
- Silas.configure do |c|
131
- c.sandbox = Hermetic.gvisor(image: "python:3.12-slim") # or .docker /
132
- # .firecracker(kernel:, rootfs:) / .hosted(:e2b, api_key:) # pick your strength
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
- That's the whole integration. When a sandbox is configured and enabled, the
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
- An agent is reached by more than a method call:
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
- `Turn#answer_data` returns the parsed payload (`answer_text` stays for prose
227
- agents); the HTTP API carries it as `answer_data`, evals get
228
- `assert_answer_data(key: :verdict, value: "approve")`. Rendered through
229
- RubyLLM's `with_schema`, so each provider's native structured-output mode is
230
- used. The schema is model-visible state — changing it mid-turn fails the turn
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
- ## The HTTP API
101
+ ## Status
234
102
 
235
- Everything the operator surface can do, over JSON mounted with the engine at
236
- `/silas/api/v1`, **deny-by-default** like the inbox (wire `config.api_auth`;
237
- `config.api_actor` names the identity recorded on approvals):
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
- ```sh
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
- Mount the engine (the generator does this) and a live inbox appears at
259
- `/silas/inbox`: a session list, **web chat** (start a session or reply from
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
- ```ruby
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
- Turbo streaming activates automatically when the host has `turbo-rails` (every
278
- default Rails app does); without it the trace falls back to a polling refresh.
279
- The gem itself takes no turbo dependency.
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
- ## Requirements
118
+ ## Security
282
119
 
283
- Rails >= 8.1 (Active Job Continuations) and Solid Queue >= 1.2 for the
284
- durability contract. macOS dev note: Solid Queue forks + pg need
285
- `PGGSSENCMODE=disable OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`.
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-amber",
7
- "completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-red",
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", "required" => "pill-amber", "expired" => "pill-red"
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.to_s.tr("_", " "), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}")
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.update!(approval_state: "expired", status: "failed",
64
- result: { "denied" => "approval expired" })
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")