silas 0.3.2 → 0.5.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 +193 -0
- data/README.md +12 -6
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/jobs/silas/agent_loop_job.rb +2 -0
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +37 -3
- data/app/models/silas/turn.rb +7 -1
- data/app/views/silas/channel_mailer/approval.text.erb +2 -2
- data/app/views/silas/channels/approvals/show.html.erb +1 -1
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- data/config/brakeman.ignore +11 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -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 +2 -1
- data/lib/generators/silas/install/templates/initializer.rb +1 -1
- data/lib/silas/{engines → adapters}/base.rb +12 -2
- data/lib/silas/adapters/ruby_llm.rb +221 -0
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/chat.rb +2 -2
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +40 -9
- data/lib/silas/delta_buffer.rb +3 -3
- data/lib/silas/deprecator.rb +16 -0
- data/lib/silas/engine.rb +15 -2
- data/lib/silas/eval/driver.rb +1 -1
- data/lib/silas/eval/dsl.rb +1 -1
- data/lib/silas/eval/scripted_engine.rb +3 -3
- data/lib/silas/inbox/delta_broadcaster.rb +1 -1
- data/lib/silas/instrumentation.rb +59 -0
- data/lib/silas/ledger.rb +14 -0
- data/lib/silas/log_subscriber.rb +83 -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 +10 -7
- data/lib/silas/step_runner.rb +10 -2
- 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 +28 -14
- metadata +15 -3
- data/lib/silas/engines/ruby_llm.rb +0 -165
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f722788fa436905b6ea94ee9666ca71013fa6eace683f7d3dc8eec4f257fbe2
|
|
4
|
+
data.tar.gz: 85d3ae116127d4b91207526d7936440092250854380938a94eba177cd26f3eeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 645b843c5dbde892fa0725c1f33302bf39b3b10eef43ff14b0828efeb0420ead72d82e34b9bb3828cf99aa8f880db4f6017f2913023884a679385ca68a21ae41
|
|
7
|
+
data.tar.gz: 2b5b8df36d8747fde43b1eccffb466f17a3a9ef430ebc77b055b36c73ff987c4e07c66eb14ef2265d34b37b1ff824cf4681737a2bca1333e4e7dad88c8db142b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,198 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-07-26)
|
|
4
|
+
|
|
5
|
+
Two new loop primitives (replay-safe compaction, ask_question), a whole-channel
|
|
6
|
+
generator, the adapter rebound onto RubyLLM's public single-turn seam, and
|
|
7
|
+
per-agent schedules. Chaos-gated: **295 kill/deploy cycles across both stores —
|
|
8
|
+
zero duplicate side effects, byte-identical replay** — including a new compact
|
|
9
|
+
mode that kills mid-summarisation and asserts the compaction claim is
|
|
10
|
+
exactly-once and the rebuilt provider messages are byte-identical
|
|
11
|
+
(`chaos_host/RESULTS.md`).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Per-agent schedules.** Named agents own their cron the way they own tools
|
|
16
|
+
and skills: `app/agents/analyst/schedules/monday_kpis.md` is discovered as
|
|
17
|
+
`agents/analyst/monday_kpis`, compiled by `silas:schedules` under a
|
|
18
|
+
collision-free recurring key, and its ticks start **the analyst** — a staff
|
|
19
|
+
member's schedule never wakes the root agent. `.rb` handlers resolve under
|
|
20
|
+
the agent's namespace (`Agents::Analyst::Schedules::MondayKpis`).
|
|
21
|
+
|
|
22
|
+
- **`ask_question` — the agent can park to ask a human something.**
|
|
23
|
+
Information, not permission: the model calls the new builtin with a
|
|
24
|
+
question, the turn parks at zero compute through the same machinery as
|
|
25
|
+
approvals (TTL, channel ping, resume gate), and the operator's free-text
|
|
26
|
+
reply becomes the tool result the model resumes with
|
|
27
|
+
(`{"answer" => "..."}`). Answer from the inbox (a question card with a text
|
|
28
|
+
box replaces approve/decline) or the API
|
|
29
|
+
(`POST /silas/api/v1/approvals/:id/answer {text:}`); `decline!` remains the
|
|
30
|
+
refusal path, and an unanswered question expires as
|
|
31
|
+
`{"answer" => nil, "note" => "question expired unanswered"}`. Channels are
|
|
32
|
+
pinged only if they implement `deliver_question` — buttons are the wrong UI
|
|
33
|
+
for free text, so transports without it simply leave the question in the
|
|
34
|
+
inbox. Disable with `config.ask_question = false`.
|
|
35
|
+
|
|
36
|
+
**Upgrade note:** adding a builtin changes the definitions digest, so turns
|
|
37
|
+
parked across the upgrade fail loudly on resume (the nondeterminism guard
|
|
38
|
+
working as designed). Settle parked turns before upgrading, or set
|
|
39
|
+
`config.ask_question = false` to keep the old digest.
|
|
40
|
+
|
|
41
|
+
- **Context compaction that survives replay.** Long sessions used to grow
|
|
42
|
+
until the provider rejected the prompt and the turn failed. Now, when the
|
|
43
|
+
measured context passes `config.compact_at` (default 0.9 of the model's
|
|
44
|
+
registry context window; set an Integer for an absolute token threshold, or
|
|
45
|
+
nil to disable), Silas summarises all prior turns into a `silas_compactions`
|
|
46
|
+
row and the conversation continues — the current turn is never compacted.
|
|
47
|
+
|
|
48
|
+
The design constraint is the durability contract: replayed executions must
|
|
49
|
+
see byte-identical message arrays, so a summary can never be computed at
|
|
50
|
+
build time. Compaction is an *effect*, made exactly-once the way tool
|
|
51
|
+
effects are — claimed compare-and-swap (unique index per session + span),
|
|
52
|
+
generated once, then read deterministically from the row forever. A crash
|
|
53
|
+
mid-summary leaves a pending row the resume finishes; a crash mid-step
|
|
54
|
+
replays against the identical compacted history. New `compact.silas`
|
|
55
|
+
instrumentation event (duration = the summarisation call). Chaos-gated with
|
|
56
|
+
a dedicated mode: kill -9 during the compacting turn, including
|
|
57
|
+
mid-summarisation.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **The `:ruby_llm` adapter no longer fights the library.** `Chat#complete`
|
|
62
|
+
runs RubyLLM's whole agentic loop — model, execute tools, feed results back,
|
|
63
|
+
model again — but Silas needs a single move, because the step boundary *is*
|
|
64
|
+
the durability boundary. It used to get one by registering tool proxies that
|
|
65
|
+
threw `RubyLLM::Tool::Halt` to abort the loop from the inside.
|
|
66
|
+
|
|
67
|
+
Chat is now used as the builder it is (it owns model resolution, schema
|
|
68
|
+
normalisation, system instructions and message construction) and execution
|
|
69
|
+
drops one layer to `RubyLLM::Provider#complete` — the same call Chat makes
|
|
70
|
+
internally for a single turn. Entirely public API, and the adapter got
|
|
71
|
+
smaller: no `Tool::Halt`, no hunting back through `chat.messages` for the
|
|
72
|
+
assistant reply, and the `before_message` streaming-timing oddity is gone in
|
|
73
|
+
favour of an event Silas emits itself.
|
|
74
|
+
|
|
75
|
+
**This removes Silas's exposure to the largest RubyLLM 2.0 breaking change.**
|
|
76
|
+
2.0 deletes `Tool::Halt` precisely because the loop became caller-controlled;
|
|
77
|
+
Silas no longer needs it either way. The adapter also now calls `with_tools`
|
|
78
|
+
(2.0 drops the singular `with_tool`) and its schema proxy answers to both
|
|
79
|
+
`params_schema` and `parameters_schema` (2.0 renames it), so the tool path is
|
|
80
|
+
version-agnostic today. No behaviour change for users.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- **`rails g silas:channel <name>`** — scaffolds a whole channel, not half of
|
|
85
|
+
one. Channels were reachable before (`Channel.dispatch` is a ~50-line seam)
|
|
86
|
+
but the engine ships webhook routes for Slack only, so any other transport
|
|
87
|
+
meant hand-rolling a controller, a route, and signature verification with no
|
|
88
|
+
documented contract. The generator writes the outbound `Channel` subclass,
|
|
89
|
+
a signature-verifying inbound controller, and the route that joins them —
|
|
90
|
+
with the security decisions already made: verify before anything else, sign
|
|
91
|
+
over the raw body, fail closed on a missing secret, and send approvals to an
|
|
92
|
+
operator rather than to whoever started the session.
|
|
93
|
+
- **`Silas::Webhook.verify_hmac`** — the parts of webhook verification that are
|
|
94
|
+
identical for every vendor (constant-time comparison, replay window,
|
|
95
|
+
fail-closed on a missing secret), with the vendor's shape (`payload`,
|
|
96
|
+
`prefix`, `digest`) supplied by the caller. `Silas::Slack.verify_signature`
|
|
97
|
+
now delegates to it and keeps its exact v0 scheme.
|
|
98
|
+
- **`Silas::Channel.approval_url(invocation, action)`** — a signed, expiring
|
|
99
|
+
one-click approve/decline link for *any* transport, built from the engine's
|
|
100
|
+
route set and the discovered mount point, so it works from a delivery job
|
|
101
|
+
with no routing scope. Raises with the fix when no host is configured rather
|
|
102
|
+
than minting a dead link.
|
|
103
|
+
- `docs/channels.md`: the inbound/outbound contract, a per-vendor signature
|
|
104
|
+
table, and a worked WhatsApp Cloud API example.
|
|
105
|
+
|
|
106
|
+
### Removed
|
|
107
|
+
|
|
108
|
+
- `demo/refund-desk` and `demo/churn-desk`. Both were copy-paste kits whose
|
|
109
|
+
READMEs instructed deleting a file the generated eval still asserted on —
|
|
110
|
+
broken on arrival. `examples/playground` is the example; `docs/why-silas.md`
|
|
111
|
+
and `docs/vs-eve.md` now point at it.
|
|
112
|
+
|
|
113
|
+
## 0.4.0
|
|
114
|
+
|
|
115
|
+
The architecture-and-hardening release: one shipped feature that had never
|
|
116
|
+
worked, the naming locked down before 1.0 freezes it, and the durable loop
|
|
117
|
+
finally observable.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- **The email approval channel had never worked.** Both the approval email
|
|
122
|
+
template and the confirmation page called `approval_url`/`approval_path`,
|
|
123
|
+
but the route is declared inside `namespace :channels`, so the real helpers
|
|
124
|
+
are `channels_approval_url`/`_path`. Rendering raised — meaning
|
|
125
|
+
**`ChannelMailer#approval` blew up and the "your agent needs approval" email
|
|
126
|
+
was never delivered**, and the confirmation page 500'd. If you relied on
|
|
127
|
+
email approvals, you were silently never notified that a money-moving call
|
|
128
|
+
was parked. Found by writing the first specs for these surfaces.
|
|
129
|
+
|
|
130
|
+
### Changed (breaking, pre-1.0)
|
|
131
|
+
|
|
132
|
+
- **The inference seam is now `Adapters::`, not `Engines::`** — and
|
|
133
|
+
`config.adapter`, not `config.engine`. "Engine" meant two unrelated things
|
|
134
|
+
in one namespace: the Rails engine at `Silas::Engine`, and the pluggable
|
|
135
|
+
inference backend. Every comparable seam disambiguates — ActiveJob has
|
|
136
|
+
`QueueAdapters::`, ActiveStorage `Service::`, RubyLLM `Provider`. Done now
|
|
137
|
+
because 1.0 freezes the public API and host apps subclass this seam.
|
|
138
|
+
**Nothing breaks today**: `Silas::Engines::Base`, `config.engine`, and
|
|
139
|
+
`Silas.resolved_engine` all still resolve, warn through the new deprecator,
|
|
140
|
+
and are removed in 2.0.
|
|
141
|
+
- **Notification names follow the Rails convention** `<event>.silas` (like
|
|
142
|
+
`sql.active_record`). The two pre-existing events were backwards:
|
|
143
|
+
`silas.step` → `step.silas`, `silas.delta` → `delta.silas`. Update any
|
|
144
|
+
subscriber; `subscribe(/\.silas\z/)` now catches everything.
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
|
|
148
|
+
- **Instrumentation for the durable loop.** It emitted almost nothing before:
|
|
149
|
+
a turn could start, park for a human, be rescued after a `kill -9`, breach a
|
|
150
|
+
budget and finish without a single line. Ten events now — `turn`, `step`,
|
|
151
|
+
`tool`, `park`, `resume`, `approval`, `budget`, `rescue`, `nondeterminism`,
|
|
152
|
+
`delta` — with documented payloads that always carry `turn_id`/`session_id`,
|
|
153
|
+
so a subscriber never has to join. **`tool.silas`** times the tool's own
|
|
154
|
+
execution and reports how it settled (the most useful span in the system);
|
|
155
|
+
**`resume.silas`** carries `parked_for` — how long the human actually took.
|
|
156
|
+
`Silas::LogSubscriber` (modelled on Solid Queue's) turns them into log lines
|
|
157
|
+
at operator-filterable levels: parks and rescues INFO, budget WARN, failed
|
|
158
|
+
turns and nondeterminism ERROR, per-token chatter DEBUG — and stays silent
|
|
159
|
+
when the rescuer did nothing.
|
|
160
|
+
- **`Silas.deprecator`** — an `ActiveSupport::Deprecation` registered in
|
|
161
|
+
`app.deprecators[:silas]`, so hosts silence or raise on Silas deprecations
|
|
162
|
+
exactly as they do Rails'. Every message names the replacement *and* the
|
|
163
|
+
removal version.
|
|
164
|
+
- **Coverage for the four money-path surfaces that had none** (36 specs):
|
|
165
|
+
`Channels::SlackController` (unsigned / wrong-secret / stale-timestamp
|
|
166
|
+
requests refused end to end; retries and bot messages ignored; buttons
|
|
167
|
+
settle through the same `approve!`/`decline!`),
|
|
168
|
+
`Channels::ApprovalsController` (tampered, garbage, expired and
|
|
169
|
+
wrong-purpose tokens refused; **GET never mutates**, so a link preview or
|
|
170
|
+
scanner cannot approve a refund; a replayed link on a settled invocation
|
|
171
|
+
422s), `AgentMailbox` (References → In-Reply-To → Message-ID threading, so
|
|
172
|
+
replies continue rather than restart), and `ChannelMailer` (renders, shows
|
|
173
|
+
the arguments, embeds two distinct absolute links whose tokens verify back).
|
|
174
|
+
- **Quality tooling, enforced in CI**: `rubocop-rails-omakase` (Rails' own
|
|
175
|
+
style baseline, zero offenses), SimpleCov with a **90% line-coverage floor
|
|
176
|
+
that fails the build** (actual: 92.5%), Brakeman and bundler-audit (clean —
|
|
177
|
+
the single deliberate CSRF suppression is documented with its reasoning in
|
|
178
|
+
`config/brakeman.ignore`), and a `rake zeitwerk:check` job that eager-loads
|
|
179
|
+
every constant to catch naming violations lazy tests never see.
|
|
180
|
+
- **Dependency contract specs.** Silas reaches into Solid Queue and RubyLLM
|
|
181
|
+
internals, where a rename breaks *recovery* silently. Ten specs pin them:
|
|
182
|
+
the dead-process error classes the rescuer allowlists,
|
|
183
|
+
`FailedExecution#retry`, the Solid Queue >= 1.2 continuations floor,
|
|
184
|
+
RubyLLM's `with_schema`/`before_message`/`Tool::Halt`/model registry and the
|
|
185
|
+
error classes `retry_on` names, and `resume_errors_after_advancing` staying
|
|
186
|
+
false. Plus an allowed-to-fail CI canary against ruby_llm edge, for early
|
|
187
|
+
warning on the 2.0 horizon.
|
|
188
|
+
- **`docs/conventions.md`** — the naming and structure rules (why the seam is
|
|
189
|
+
`Adapters::`; the deliberate `class << self` vs `module_function` split) and
|
|
190
|
+
the audited posture: nothing mass-assigned, no `raw`/`html_safe` anywhere,
|
|
191
|
+
`Time.current` throughout, indexes on query paths rather than every foreign
|
|
192
|
+
key. Written down so nobody "fixes" something load-bearing.
|
|
193
|
+
|
|
194
|
+
No migration. 336 specs green on SQLite and Postgres.
|
|
195
|
+
|
|
3
196
|
## 0.3.2
|
|
4
197
|
|
|
5
198
|
- **The `timeout` budget no longer counts time spent parked for approval.**
|
data/README.md
CHANGED
|
@@ -104,15 +104,15 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
104
104
|
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
105
105
|
(`NondeterminismError`) instead of resuming into a different agent.
|
|
106
106
|
|
|
107
|
-
##
|
|
107
|
+
## Adapter
|
|
108
108
|
|
|
109
|
-
Inference is one pluggable seam (`config.
|
|
109
|
+
Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
|
|
110
110
|
via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
|
|
111
111
|
and the production path. Compose resilience via `config.around_model_call`, or
|
|
112
112
|
swap in any object responding to `#execute_step` (the eval harness and the
|
|
113
113
|
chaos tests do exactly that).
|
|
114
114
|
|
|
115
|
-
> The experimental `:agent_sdk`
|
|
115
|
+
> The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
|
|
116
116
|
> in 0.2: its subscription-auth rationale was structurally unreachable, and it
|
|
117
117
|
> carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
|
|
118
118
|
> server survives and returns as a first-class *mount your tools as MCP*
|
|
@@ -192,21 +192,27 @@ An agent is reached by more than a method call:
|
|
|
192
192
|
|
|
193
193
|
- **`schedules/`** — `app/agent/schedules/*.md` (cron frontmatter, body = the turn
|
|
194
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.
|
|
195
|
+
Solid Queue recurring tasks. A scheduled run is a normal durable turn. Named
|
|
196
|
+
agents own their cron the same way they own tools:
|
|
197
|
+
`app/agents/analyst/schedules/monday_kpis.md` ticks start the analyst, not
|
|
198
|
+
the root agent.
|
|
196
199
|
- **`channels/`** — `app/agent/channels/*.rb` bind email (Action Mailbox) and
|
|
197
200
|
Slack to the loop. A new thread starts a session, a reply continues it, and
|
|
198
201
|
approvals render as Slack buttons / signed email links that call the same
|
|
199
202
|
`approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
|
|
203
|
+
`bin/rails g silas:channel whatsapp` scaffolds any other transport — a
|
|
204
|
+
signature-verifying webhook and the outbound half, wired together. See
|
|
205
|
+
[docs/channels.md](https://github.com/danielstpaul/silas/blob/main/docs/channels.md).
|
|
200
206
|
|
|
201
207
|
## Streaming
|
|
202
208
|
|
|
203
|
-
Turns stream. The `:ruby_llm`
|
|
209
|
+
Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
|
|
204
210
|
`bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
|
|
205
211
|
renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
|
|
206
212
|
the durable rows — never persisted, never fed back to the model, and a
|
|
207
213
|
replayed step renders from its row with no deltas at all, so streaming adds
|
|
208
214
|
zero risk to the durability contract. Custom sinks subscribe to the
|
|
209
|
-
`"silas
|
|
215
|
+
`"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
|
|
210
216
|
text: }`, where `text` is the accumulated string so far — filter by ids;
|
|
211
217
|
notifications are process-global).
|
|
212
218
|
|
|
@@ -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
|
|
@@ -112,6 +112,8 @@ module Silas
|
|
|
112
112
|
# replays completed steps from rows, resuming where it left off.
|
|
113
113
|
if (reason = Budget.exceeded_reason(turn))
|
|
114
114
|
turn.update!(status: "waiting", failure_reason: reason)
|
|
115
|
+
Silas.instrument(:budget, reason: reason, turn_id: turn.id)
|
|
116
|
+
Silas.instrument(:park, reason: "budget", turn_id: turn.id, detail: reason)
|
|
115
117
|
return
|
|
116
118
|
end
|
|
117
119
|
|
|
@@ -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
|
|
@@ -15,7 +15,13 @@ module Silas
|
|
|
15
15
|
queue_as { Silas.config.queue_name }
|
|
16
16
|
|
|
17
17
|
def perform
|
|
18
|
+
Silas.instrument(:rescue) { |payload| sweep(payload) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sweep(payload)
|
|
18
22
|
ToolInvocation.expire_stale!
|
|
23
|
+
payload[:rescued] = 0
|
|
24
|
+
payload[:stranded] = 0
|
|
19
25
|
return 0 unless defined?(SolidQueue)
|
|
20
26
|
|
|
21
27
|
rescued = 0
|
|
@@ -24,9 +30,10 @@ module Silas
|
|
|
24
30
|
failed.retry
|
|
25
31
|
rescued += 1
|
|
26
32
|
elsif failed.job&.class_name == "Silas::AgentLoopJob"
|
|
27
|
-
fail_stranded_turn(failed)
|
|
33
|
+
payload[:stranded] += 1 if fail_stranded_turn(failed)
|
|
28
34
|
end
|
|
29
35
|
end
|
|
36
|
+
payload[:rescued] = rescued
|
|
30
37
|
rescued
|
|
31
38
|
end
|
|
32
39
|
|
|
@@ -40,13 +47,14 @@ module Silas
|
|
|
40
47
|
# loudly with its approvals expired.
|
|
41
48
|
def fail_stranded_turn(failed)
|
|
42
49
|
turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
|
|
43
|
-
return unless turn&.active?
|
|
50
|
+
return false unless turn&.active?
|
|
44
51
|
|
|
45
52
|
exception = failed.error&.dig("exception_class")
|
|
46
53
|
turn.expire_pending_approvals!("turn failed: #{exception}")
|
|
47
54
|
turn.finish!(:failed, reason: "job_failed")
|
|
48
55
|
Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
|
|
49
56
|
"#{exception} — #{failed.error&.dig('message')}")
|
|
57
|
+
true
|
|
50
58
|
end
|
|
51
59
|
end
|
|
52
60
|
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,9 +38,32 @@ 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)
|
|
48
|
+
Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
|
|
49
|
+
invocation_id: id, turn_id: turn_id)
|
|
50
|
+
resume_turn!
|
|
51
|
+
end
|
|
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)
|
|
40
67
|
resume_turn!
|
|
41
68
|
end
|
|
42
69
|
|
|
@@ -49,6 +76,8 @@ module Silas
|
|
|
49
76
|
assert_turn_resumable!
|
|
50
77
|
update!(status: "failed", approval_state: "declined", approved_by: by,
|
|
51
78
|
decline_reason: reason, result: { "denied" => reason })
|
|
79
|
+
Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
|
|
80
|
+
invocation_id: id, turn_id: turn_id)
|
|
52
81
|
resume_turn!
|
|
53
82
|
end
|
|
54
83
|
|
|
@@ -56,8 +85,11 @@ module Silas
|
|
|
56
85
|
# their turns (parked-forever ghosts are a bug, not a feature).
|
|
57
86
|
def self.expire_stale!(now: Time.current)
|
|
58
87
|
where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
|
|
59
|
-
inv.
|
|
60
|
-
|
|
88
|
+
result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" }
|
|
89
|
+
: { "denied" => "approval expired" }
|
|
90
|
+
inv.update!(approval_state: "expired", status: "failed", result: result)
|
|
91
|
+
Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
|
|
92
|
+
invocation_id: inv.id, turn_id: inv.turn_id)
|
|
61
93
|
inv.turn.finish!(:failed, reason: "approval_expired")
|
|
62
94
|
end
|
|
63
95
|
end
|
|
@@ -90,7 +122,9 @@ module Silas
|
|
|
90
122
|
# instantly re-park on "timeout" — pathological for a gate whose whole
|
|
91
123
|
# point is waiting for a person. Cost/token budgets stay cumulative;
|
|
92
124
|
# they measure real spend.
|
|
125
|
+
parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
|
|
93
126
|
turn.update!(status: "queued", started_at: Time.current)
|
|
127
|
+
Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
|
|
94
128
|
AgentLoopJob.perform_later(turn.id)
|
|
95
129
|
end
|
|
96
130
|
end
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -21,8 +21,14 @@ module Silas
|
|
|
21
21
|
def active? = ACTIVE_STATUSES.include?(status)
|
|
22
22
|
def parked? = status == "waiting" || status == "in_doubt"
|
|
23
23
|
|
|
24
|
+
# Duration on this event spans the WHOLE turn, parked time included — it
|
|
25
|
+
# answers "how long did the customer wait", not "how much compute".
|
|
24
26
|
def finish!(new_status, reason: nil)
|
|
25
|
-
|
|
27
|
+
Silas.instrument(:turn, status: new_status.to_s, reason: reason, turn_id: id,
|
|
28
|
+
session_id: session_id, agent: session.agent_name,
|
|
29
|
+
steps: steps.count) do
|
|
30
|
+
update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
|
|
31
|
+
end
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def canceled? = status == "canceled"
|
|
@@ -3,7 +3,7 @@ Your agent needs approval to run: <%= @invocation.tool_name %>
|
|
|
3
3
|
Arguments:
|
|
4
4
|
<%= JSON.pretty_generate(@invocation.arguments) %>
|
|
5
5
|
|
|
6
|
-
Approve: <%= silas.
|
|
7
|
-
Decline: <%= silas.
|
|
6
|
+
Approve: <%= silas.channels_approval_url(token: @approve_token) %>
|
|
7
|
+
Decline: <%= silas.channels_approval_url(token: @decline_token) %>
|
|
8
8
|
|
|
9
9
|
These links expire; do not forward them.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<% if @invocation&.awaiting_approval? %>
|
|
2
2
|
<h1>Approval needed: <%= @invocation.tool_name %></h1>
|
|
3
3
|
<pre><%= JSON.pretty_generate(@invocation.arguments) %></pre>
|
|
4
|
-
<%= form_with url: silas.
|
|
4
|
+
<%= form_with url: silas.channels_approval_path(token: params[:token]), method: :post do %>
|
|
5
5
|
<button type="submit">Confirm <%= @action %></button>
|
|
6
6
|
<% end %>
|
|
7
7
|
<% else %>
|
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
<%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
|
|
2
|
+
through the HOST's renderer, where engine route helpers don't exist and
|
|
3
|
+
the mounted proxy has no routing scope to lean on. %>
|
|
4
|
+
<% if invocation.question? %>
|
|
5
|
+
<div class="approval">
|
|
6
|
+
<h3>The agent has a question</h3>
|
|
7
|
+
<p class="question-text"><%= invocation.arguments["question"] %></p>
|
|
8
|
+
<%= form_with url: silas_engine_path(:answer_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
|
|
9
|
+
<textarea name="text" rows="3" placeholder="Your answer — sent back to the agent as the tool result"></textarea>
|
|
10
|
+
<button class="btn approve">Answer</button>
|
|
11
|
+
<% end %>
|
|
12
|
+
<%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
|
|
13
|
+
<button class="btn decline">Decline to answer</button>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="approval">
|
|
18
|
+
<h3>Approval needed — <%= invocation.tool_name %></h3>
|
|
19
|
+
<pre><%= pretty_args(invocation.arguments) %></pre>
|
|
20
|
+
<%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
|
|
21
|
+
<button class="btn approve">Approve</button>
|
|
22
|
+
<% end %>
|
|
23
|
+
<%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
|
|
24
|
+
<textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
|
|
25
|
+
<button class="btn decline">Decline</button>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
5
|
+
"warning_code": 7,
|
|
6
|
+
"fingerprint": "64dd0725204c2cd5d6b2dba2fac6c278d20cebd07aaed38493f229193f44ed79",
|
|
7
|
+
"file": "app/controllers/silas/channels/base_controller.rb",
|
|
8
|
+
"note": "Deliberate: Silas::Channels::BaseController serves inbound WEBHOOKS, which no browser session originates, so a CSRF token cannot exist. Each route authenticates the request itself instead: Slack routes verify an HMAC-SHA256 request signature with a 300s replay window (Silas::Slack.verify_signature, covered by spec/silas/channels/slack_controller_spec.rb), and the email approve/decline routes require a purpose-scoped, expiring MessageVerifier token whose possession IS the credential — CSRF would add nothing an attacker holding the token could not already do (covered by spec/silas/channels/approvals_controller_spec.rb, including tampered/expired/wrong-purpose/replayed tokens and the GET-never-mutates property). The inbox controllers keep protect_from_forgery; the JSON API is ActionController::API and is token-authenticated."
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
data/config/routes.rb
CHANGED
|
@@ -13,6 +13,7 @@ Silas::Engine.routes.draw do
|
|
|
13
13
|
member do
|
|
14
14
|
post :approve
|
|
15
15
|
post :decline
|
|
16
|
+
post :answer
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -40,6 +41,7 @@ Silas::Engine.routes.draw do
|
|
|
40
41
|
member do
|
|
41
42
|
post :approve
|
|
42
43
|
post :decline
|
|
44
|
+
post :answer
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
47
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class CreateSilasCompactions < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :silas_compactions do |t|
|
|
4
|
+
t.references :session, null: false, index: false # covered by the unique composite below
|
|
5
|
+
# Provenance only (never in a WHERE — see docs/conventions.md on indexes):
|
|
6
|
+
# the turn whose index is up_to_turn_index, kept so an operator can walk
|
|
7
|
+
# from a summary back to the rows it replaced.
|
|
8
|
+
t.references :up_to_turn, null: false, index: false
|
|
9
|
+
# THE query + claim column: a compaction covers session turns
|
|
10
|
+
# 0..up_to_turn_index inclusive.
|
|
11
|
+
t.integer :up_to_turn_index, null: false
|
|
12
|
+
t.string :status, null: false, default: "pending" # pending | completed
|
|
13
|
+
t.text :summary
|
|
14
|
+
t.integer :tokens_before # the measured context size that triggered this
|
|
15
|
+
t.integer :input_tokens # what the summarisation call itself cost
|
|
16
|
+
t.integer :output_tokens
|
|
17
|
+
t.string :model # which model wrote the summary
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The compare-and-swap claim: only one execution may create the compaction
|
|
22
|
+
# for a given span, however many racing replays attempt it. Also serves the
|
|
23
|
+
# read path (latest completed compaction per session).
|
|
24
|
+
add_index :silas_compactions, [ :session_id, :up_to_turn_index ], unique: true
|
|
25
|
+
end
|
|
26
|
+
end
|