silas 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +4 -2
- data/app/controllers/silas/channels/slack_controller.rb +2 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +3 -1
- data/app/helpers/silas/inbox/trace_helper.rb +224 -2
- data/app/mailboxes/silas/agent_mailbox.rb +4 -0
- data/app/models/silas/tool_invocation.rb +37 -6
- data/app/views/layouts/silas/inbox.html.erb +69 -3
- data/app/views/silas/inbox/invocations/_detail.html.erb +21 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +39 -24
- data/app/views/silas/inbox/sessions/_child.html.erb +14 -0
- data/app/views/silas/inbox/sessions/_row.html.erb +13 -2
- data/app/views/silas/inbox/sessions/show.html.erb +33 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -2
- data/docs/agents.md +15 -3
- data/docs/channels.md +53 -2
- data/docs/configuration.md +2 -1
- data/docs/connections.md +8 -5
- data/docs/guarantees.md +16 -5
- data/docs/headless.md +120 -0
- data/docs/traces.md +131 -0
- data/docs/tutorial.md +6 -4
- data/docs/vs-eve.md +41 -17
- data/docs/why-silas.md +6 -6
- data/lib/generators/silas/install/install_generator.rb +21 -0
- data/lib/generators/silas/install/templates/initializer.rb +5 -0
- data/lib/silas/channel.rb +85 -11
- data/lib/silas/configuration.rb +16 -1
- data/lib/silas/doctor.rb +35 -2
- data/lib/silas/registry.rb +20 -2
- data/lib/silas/tool.rb +38 -5
- data/lib/silas/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8544d250988c08dfa8766c6ccfd512391f91977d301c976b9a11bbdf2120a64d
|
|
4
|
+
data.tar.gz: 15ff50ad44a2c1357d8b950c7d6bda8a326f6b1b54e4f4cbb1b02c3d2a8f4a1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b14467a4ebbc1001a6e627b2c06d2ee95f1ecb7fdd086fbd39f36061e02325c7910295158d5cfa775f10e1f0f84a09aa0c43167ec5fe4cb0600ecb7dfba8f707
|
|
7
|
+
data.tar.gz: 32ad0ff55610960130a8427271645a4fe30e264cd9832f14c049d5ee7909165785c62921a235ca80fd81e2746e222ed12534e1a7174bfeec88f2f047b571c473
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,214 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3 (2026-07-29)
|
|
4
|
+
|
|
5
|
+
Two safety declarations were failing open, and one approval card could be
|
|
6
|
+
spent twice. The rest of the release is the console, the channel layer and the
|
|
7
|
+
docs catching up with what the framework already does.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Tool declarations now inherit — a base class silently disarmed both
|
|
12
|
+
gates.** Ruby does not inherit class-level instance variables, and
|
|
13
|
+
`Silas::Tool` stored all four of its declarations in them. So the ordinary
|
|
14
|
+
Rails move of factoring shared declarations into a base class — a `MoneyTool`
|
|
15
|
+
declaring `transactional!` and `approval :always`, with
|
|
16
|
+
`class IssueRefund < MoneyTool; end` under it — left `IssueRefund` running at
|
|
17
|
+
`effect_mode :at_most_once` and `approval :never`: no database transaction
|
|
18
|
+
around the write, no human gate, no error and no warning. The two
|
|
19
|
+
declarations the Ledger acts on both failed **open**, into their least safe
|
|
20
|
+
defaults, and the transcript looked entirely normal. `param` refinements had
|
|
21
|
+
the same defect, which compounds the string-default trap: a subclass lost its
|
|
22
|
+
base's `param :amount_pence, :integer`, so the model was told to send a
|
|
23
|
+
string and any numeric comparison in an approval lambda raised outside the
|
|
24
|
+
ledger's rescue. Settings now resolve up the ancestry with the nearest
|
|
25
|
+
explicit declaration winning; param refinements merge down, so a subclass can
|
|
26
|
+
override one param without dropping the rest. Direct subclasses that declare
|
|
27
|
+
nothing keep the documented defaults. **If your app has a tool base class,
|
|
28
|
+
read `YourTool.effect_mode` and `YourTool.approval_policy` in a console
|
|
29
|
+
before you deploy — that is what those tools have been running as.**
|
|
30
|
+
- **`approve!`, `answer!` and `decline!` are compare-and-swap — one approval
|
|
31
|
+
could be spent twice.** The guard read in-memory state and the settle paths
|
|
32
|
+
then wrote with a bare `update!`, so two holders of the same rendered
|
|
33
|
+
approval card — two people, a double-click, a retried POST — both passed it,
|
|
34
|
+
both wrote, and both reached `resume_turn!`. Each then found no invocation
|
|
35
|
+
still `required` and enqueued a fresh `AgentLoopJob`: **one turn ran twice**,
|
|
36
|
+
two paid model calls, the second minting `tool_call` ids the ledger has never
|
|
37
|
+
seen and therefore cannot dedup. That is the double-execution the
|
|
38
|
+
unsafe-queue-adapter boot guard exists to prevent, arriving instead through
|
|
39
|
+
the approval path — the one path whose entire purpose is to be the safe place
|
|
40
|
+
a human intervenes. A verdict now settles through an `update_all` scoped to
|
|
41
|
+
`approval_state "required"` and raises naming the approver who won, rather
|
|
42
|
+
than silently overwriting a decline with an approval; `resume_turn!` moves
|
|
43
|
+
the turn out of `waiting`/`in_doubt` with a second compare-and-swap and
|
|
44
|
+
enqueues only if it won that one, which covers the case a per-invocation
|
|
45
|
+
claim cannot — two gated invocations on one step, settled concurrently, each
|
|
46
|
+
legitimately winning its own row and both then seeing no remaining gate.
|
|
47
|
+
Chaos on the fixed path: worker 25/25 and parked 15/15, all completed, all
|
|
48
|
+
transcripts byte-identical, zero duplicate side effects.
|
|
49
|
+
- **The installer sent you to a doctor it had already failed.** Rails defaults
|
|
50
|
+
development to the in-process `:async` queue adapter, and the doctor fails
|
|
51
|
+
`:async` — correctly, because a re-enqueued continuation runs concurrently
|
|
52
|
+
with the original there and double-executes steps. The install generator's
|
|
53
|
+
last words were "then `bin/rails silas:doctor` to verify the whole setup", so
|
|
54
|
+
the first thing the framework told a new developer was a red X for a
|
|
55
|
+
condition it knew about before it printed the instruction. The generator now
|
|
56
|
+
detects the adapter and prints the exact config to paste — printed, never
|
|
57
|
+
written — and the doctor prints the same text beside the failure, from the
|
|
58
|
+
same constant, so the two cannot drift. The doctor's unrecognised-adapter
|
|
59
|
+
branch also stops reading as a style preference: a Sidekiq or GoodJob app is
|
|
60
|
+
now told what it actually loses, which is `DeadJobRescuerJob` (it returns
|
|
61
|
+
early unless Solid Queue is defined), so a job killed with its worker is
|
|
62
|
+
never retried and a turn stranded mid-tool stays `running` with its in-doubt
|
|
63
|
+
invocation unswept. It stays a warning, and the boot guard stays scoped to
|
|
64
|
+
`:async`.
|
|
65
|
+
- **Six published claims about the guarantees were false** — all of them in the
|
|
66
|
+
material a reader uses to decide whether the durability contract can be
|
|
67
|
+
trusted. Chaos does not run "hundreds of times per release": `ci.yml` has no
|
|
68
|
+
chaos job and `release.yml` runs only rspec, so the harness is real and
|
|
69
|
+
reproducible and a human runs it. The matrix is **275 runs**, not 295 —
|
|
70
|
+
`bin/full_gate` is 100 + 100 + 10 + 10 + 5 + 25 + 25, and `RESULTS.md`'s own
|
|
71
|
+
table summed to 275 under the inflated total. Handoffs are **at-most-once**,
|
|
72
|
+
not "exactly-once-guarded"; a crash mid-handoff parks in doubt for a person.
|
|
73
|
+
`Silas::Mcp::Server` was documented as a shipped seam when nothing outside
|
|
74
|
+
its specs calls `.start`, so an app cannot turn it on at all — the code stays
|
|
75
|
+
because a mounted endpoint is the planned shape, and both pages now say it is
|
|
76
|
+
not wired up. Two pages still read 0.5.x. `guarantees.md` also told readers
|
|
77
|
+
the gate figures were the last batch in each results file: `results/*.jsonl`
|
|
78
|
+
is append-only and every per-change run appends to it, so the last batch is
|
|
79
|
+
whatever ran most recently. The page now says what is true — batches start
|
|
80
|
+
where `run` restarts at 1, no field marks which one was a gate, and
|
|
81
|
+
`chaos_host/RESULTS.md` is the record — and the rows stay unedited, including
|
|
82
|
+
the inconvenient ones. `CONTRIBUTING.md` promised the chaos gate only "where
|
|
83
|
+
the loop changed" against four published pages promising the full gate before
|
|
84
|
+
every release; it now matches the public promise.
|
|
85
|
+
- **The README's `IssueRefund` example never declared its integer param.** Param
|
|
86
|
+
types default to string, so the model was told to send `"6400"` and the
|
|
87
|
+
example's own `input[:amount_pence] > 2_500` raised `ArgumentError` — inside
|
|
88
|
+
`approval_verdict`, outside `run_tool!`'s rescue, which kills the loop job
|
|
89
|
+
and (in development, where no rescuer is scheduled) strands the turn in
|
|
90
|
+
`running` with no diagnostic. `docs/tools.md`, the skill and
|
|
91
|
+
`templates/desk.rb` all had it right; the one place it was missing was the
|
|
92
|
+
most-copied snippet in the project.
|
|
93
|
+
- **`docs/headless.md` named a scope helper that does not exist** (`with_scope`
|
|
94
|
+
for `Silas.with_agent_scope`) and suggested `Session.where(status: "waiting")`
|
|
95
|
+
— `waiting` is a turn status, and a session query for it validates fine and
|
|
96
|
+
silently returns nothing. Corrected to the approval queue and parked turns,
|
|
97
|
+
with both vocabularies named so the trap is visible rather than inferred.
|
|
98
|
+
- **A named agent asking for a tool it doesn't have raised a bare `KeyError`.**
|
|
99
|
+
It now raises `Silas::Error` naming both the tool and the agent, which is
|
|
100
|
+
what the operator needs to see in the log.
|
|
101
|
+
|
|
102
|
+
### Upgrading
|
|
103
|
+
|
|
104
|
+
- **Settle parked turns for your named agents before deploying this.** Named
|
|
105
|
+
agents gain `ask_question` and the connection tools (below), and adding tools
|
|
106
|
+
to a scope changes that scope's definitions digest — the nondeterminism guard
|
|
107
|
+
refuses to resume a turn against a different agent than the one that started
|
|
108
|
+
it. A turn parked under a named agent — waiting on an approval, a question,
|
|
109
|
+
or an in-doubt call — is failed with `definitions_changed` when it wakes after
|
|
110
|
+
the upgrade, not resumed. Clear the inbox for each named agent and let those
|
|
111
|
+
turns finish first. Root-agent and subagent digests are unchanged, so their
|
|
112
|
+
parked turns are unaffected.
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
|
|
116
|
+
- **Channels route to named staff.** `Silas::Channel.dispatch` hardcoded
|
|
117
|
+
`Silas.agent.start` and neither first-party caller could say otherwise, so
|
|
118
|
+
every Slack thread and every email woke the root agent: an app could employ
|
|
119
|
+
staff with their own tools, instructions and cron and still had no way to
|
|
120
|
+
send `#billing` or `billing@` to the bookkeeper. `config.channel_routes` maps
|
|
121
|
+
a transport-specific key to an agent name
|
|
122
|
+
(`{"slack" => {"C0BILLING" => "bookkeeper"}}`); unmatched threads wake the
|
|
123
|
+
root agent, so nothing changes for an app that sets nothing. Routes are
|
|
124
|
+
checked at boot against the `app/agents/` roster — a typo fails the deploy
|
|
125
|
+
and names the staff that do exist, rather than raising inside a webhook,
|
|
126
|
+
where Slack retries, the retry guard drops the retry, and the message is
|
|
127
|
+
gone. The continuation token now carries the agent as well as the channel
|
|
128
|
+
(`slack:bookkeeper:C1:ts`), and a lookup that misses the new form falls back
|
|
129
|
+
to the old one, so a live thread keeps the session — and the agent — it
|
|
130
|
+
already had. Only new threads route.
|
|
131
|
+
- **Named agents get `ask_question` and the remote connections.** A named
|
|
132
|
+
agent (`app/agents/<name>/`) is staff, not a lesser agent: it can park to
|
|
133
|
+
ask a person, and it can reach the MCP tools declared in
|
|
134
|
+
`app/agent/connections/` — one set of credentials for the whole app,
|
|
135
|
+
resolved the same way the root agent resolves them. Its scope was built by
|
|
136
|
+
the same builder subagents use, which grants only `load_skill`, `run_code`,
|
|
137
|
+
`remember`, `recall` and `handoff` — so the member of staff woken by its own
|
|
138
|
+
Slack channel or email address was structurally the one that could not stop
|
|
139
|
+
and ask, in a framework whose premise is that a turn parks for days waiting
|
|
140
|
+
on a human. And the remote credentials are the app's, not the root agent's,
|
|
141
|
+
yet only the root agent could spend them. `config.ask_question` governs the
|
|
142
|
+
builtin exactly as it governs the root agent. `delegate` stays root-only:
|
|
143
|
+
subagents belong to the root agent's turn.
|
|
144
|
+
- **`docs/headless.md` — Silas without the inbox.** The inbox is a mountable
|
|
145
|
+
engine, not a requirement, and nothing said so. The page names the pattern —
|
|
146
|
+
drive `approve!`/`decline!`/`answer!` from your own controllers, since the
|
|
147
|
+
inbox, the JSON API, the Slack controller and the signed email links are four
|
|
148
|
+
callers of the same three `ToolInvocation` methods — and what you take on
|
|
149
|
+
instead of pretending it is free: rendering model-authored arguments, your
|
|
150
|
+
own authorization, live updates (the engine's broadcast targets are internal,
|
|
151
|
+
not a contract), and the real coupling, which is that
|
|
152
|
+
`Channel.approval_url` mints links against `Engine.routes`. It also covers
|
|
153
|
+
`Silas.with_agent_scope` for apps whose capabilities vary per user or tenant,
|
|
154
|
+
with the two constraints that bite: connection credentials are paths into the
|
|
155
|
+
app's own credentials rather than per-end-user OAuth tokens, and changing an
|
|
156
|
+
agent's capabilities fails its parked turns loudly on resume — while teaching
|
|
157
|
+
it facts does not, because memory is not in the definitions digest.
|
|
158
|
+
- **The console accounts for a handoff.** `Session` has carried
|
|
159
|
+
`parent_session` and `child_sessions` since 0.5 and the JSON API serializes
|
|
160
|
+
both, but no view read either — so the one relationship a multi-agent
|
|
161
|
+
framework exists to express appeared in the inbox as a new row with a
|
|
162
|
+
different agent's name, no explanation and no route back to the session that
|
|
163
|
+
started it. Three places state it now: a child session names the agent that
|
|
164
|
+
handed over and links to its session, the call that made a child renders that
|
|
165
|
+
child in the feed at the point it happened (with its agent and its turn's
|
|
166
|
+
state), and the index marks child rows with the parent's agent, so a row that
|
|
167
|
+
read as a stray reads as the second half of a handoff. The pairing is
|
|
168
|
+
recovered from `session_id` in the handoff's own result and accepted only
|
|
169
|
+
when that session's parent is the calling session — `session_id` is a
|
|
170
|
+
plausible key for any tool to return, and a lineage line that is merely
|
|
171
|
+
probably true is worse than none. `handoff` is at-most-once, so a crash
|
|
172
|
+
between `Session.create!` and the result leaves a colleague started with
|
|
173
|
+
nothing in the trace naming it; those children are listed under the feed
|
|
174
|
+
rather than dropped.
|
|
175
|
+
- **A render-side test for every live-update target.** Turbo addresses an
|
|
176
|
+
element that must already be on the page: broadcast to an id no view renders
|
|
177
|
+
and nothing raises, nothing logs, nothing updates — the trace stops moving
|
|
178
|
+
while every spec stays green. That is exactly how 0.6.1's held-pill bug
|
|
179
|
+
shipped, and seven of the ten targets had never been checked against a real
|
|
180
|
+
render. Each is now pinned to the partial that must render it, the five
|
|
181
|
+
`replace` targets additionally to that partial's root element (the only shape
|
|
182
|
+
that survives a swap), and the contract runs backwards too — every
|
|
183
|
+
broadcasting transition is driven with the seam captured and the emitted set
|
|
184
|
+
held to the nine covered — so a target added with no view to receive it fails
|
|
185
|
+
as well.
|
|
186
|
+
|
|
187
|
+
### Changed
|
|
188
|
+
|
|
189
|
+
- **The session page shows what the agent did, not the trace.** Every tool call
|
|
190
|
+
rendered identically — name, status pill, a key/value table of arguments, a
|
|
191
|
+
disclosure around the result — so a read that returned a row and a
|
|
192
|
+
transactional write that moved money carried the same weight, and the
|
|
193
|
+
sentence an operator came for had to be reassembled from the parts on every
|
|
194
|
+
row. Each invocation is now one line: `issue_refund · order #4821 · GBP 64.00
|
|
195
|
+
→ approved by Dana · refunded`, with arguments and result behind the
|
|
196
|
+
disclosure. Salience comes from effect mode, the property that says whether a
|
|
197
|
+
row mattered: an idempotent call that worked is furniture, `at_most_once` is
|
|
198
|
+
loud, and `transactional` earns the heaviest rule on the page, because its
|
|
199
|
+
write and its ledger row commit together. A failure is loud whatever it
|
|
200
|
+
touched, and so is anything still owing a person a verdict. Both readings
|
|
201
|
+
ship in the DOM with a checkbox choosing between them in CSS — a broadcast
|
|
202
|
+
render has no params and no reader to ask, so a choice baked into the render
|
|
203
|
+
would snap back on the first replace. A settled turn also states its own
|
|
204
|
+
audit: when it was asked, where it stands, how many tools ran and how many of
|
|
205
|
+
them wrote. Three absences now say so out loud instead of showing as gaps — a
|
|
206
|
+
turn that called no tools, a completed step that produced neither text nor a
|
|
207
|
+
call, and a tool that returned nothing — because an unexplained hole in a
|
|
208
|
+
feed reads as a rendering bug. `running` reads **WORKING** in the pill, the
|
|
209
|
+
same word the index rail's Working group uses for the same turn; the database
|
|
210
|
+
strings and the JSON API are untouched.
|
|
211
|
+
|
|
3
212
|
## 0.6.2 (2026-07-26)
|
|
4
213
|
|
|
5
214
|
The providers guide and the remaining community files.
|
data/README.md
CHANGED
|
@@ -75,6 +75,7 @@ Create a tool at `app/agent/tools/issue_refund.rb` — the keyword signature
|
|
|
75
75
|
```ruby
|
|
76
76
|
class Agent::Tools::IssueRefund < Silas::Tool
|
|
77
77
|
description "Refund part or all of an order."
|
|
78
|
+
param :amount_pence, :integer, desc: "Amount in pence (1800 = £18.00)"
|
|
78
79
|
approval ->(session:, input:) { input[:amount_pence] > 2_500 ? :user_approval : :approved }
|
|
79
80
|
transactional! # DB effect + ledger commit atomically -> exactly-once
|
|
80
81
|
|
|
@@ -95,8 +96,9 @@ bin/rails silas:chat
|
|
|
95
96
|
Refunds over £25 hold for a person — in the operator inbox the gem mounts at
|
|
96
97
|
`/silas/inbox`, in Slack, or over the JSON API — and approving resumes the
|
|
97
98
|
turn exactly where it stopped, with exactly one refund row in your database.
|
|
98
|
-
How that's guaranteed
|
|
99
|
-
release
|
|
99
|
+
How that's guaranteed — and verified by a `kill -9` chaos harness run before
|
|
100
|
+
each release, results committed under `chaos_host/results/`:
|
|
101
|
+
[guarantees](https://danielstpaul.github.io/silas/guarantees).
|
|
100
102
|
|
|
101
103
|
## Status
|
|
102
104
|
|
|
@@ -17,6 +17,8 @@ module Silas
|
|
|
17
17
|
thread_key = "#{params[:team_id]}:#{event[:channel]}:#{event[:thread_ts] || event[:ts]}"
|
|
18
18
|
channel_class.dispatch(
|
|
19
19
|
thread_key: thread_key, input: event[:text].to_s,
|
|
20
|
+
# Which staff member owns this Slack channel (nil -> the root agent).
|
|
21
|
+
agent: Silas::Channel.route_for("slack", event[:channel]),
|
|
20
22
|
metadata: { "slack" => { "channel" => event[:channel],
|
|
21
23
|
"thread_ts" => event[:thread_ts] || event[:ts],
|
|
22
24
|
"user" => event[:user] } }
|
|
@@ -22,7 +22,9 @@ module Silas
|
|
|
22
22
|
|
|
23
23
|
# One query for the rows + turns, one for the pending counts — the
|
|
24
24
|
# per-row active_turn/turns.last/counts pattern was ~4 queries a card.
|
|
25
|
-
|
|
25
|
+
# parent_session rides along for the lineage line: every child row
|
|
26
|
+
# names the agent that handed to it, and that must not cost a query.
|
|
27
|
+
@sessions = scope.limit(PER_PAGE).includes(:turns, :parent_session).to_a
|
|
26
28
|
@next_before = @sessions.last&.id if @sessions.size == PER_PAGE
|
|
27
29
|
@pending_counts = Silas::ToolInvocation.joins(:turn)
|
|
28
30
|
.where(approval_state: "required",
|
|
@@ -18,8 +18,10 @@ module Silas
|
|
|
18
18
|
# UI-only relabels — the database strings and the JSON API are untouched
|
|
19
19
|
# (an operator who reads "held" here and greps the API will find
|
|
20
20
|
# `waiting`; docs name both). Safety-system vocabulary: a turn is held
|
|
21
|
-
# at the signal until a person clears it.
|
|
22
|
-
|
|
21
|
+
# at the signal until a person clears it. `running` reads WORKING so the
|
|
22
|
+
# pill and the rail's Working group say the same word about the same
|
|
23
|
+
# turn; the disclosure on every tool row prints the raw enum.
|
|
24
|
+
UI_LABEL = { "waiting" => "held", "running" => "working", "completed" => "clear" }.freeze
|
|
23
25
|
|
|
24
26
|
def status_label(status)
|
|
25
27
|
UI_LABEL[status.to_s] || status.to_s.tr("_", " ")
|
|
@@ -52,6 +54,226 @@ module Silas
|
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
|
|
57
|
+
# ---- the feed ----------------------------------------------------
|
|
58
|
+
#
|
|
59
|
+
# One invocation, one sentence: what ran, on what, how it ended.
|
|
60
|
+
#
|
|
61
|
+
# issue_refund · order #4821 · GBP 64.00 → held for approval
|
|
62
|
+
# issue_refund · order #4821 · GBP 64.00 → approved by Dana · refunded
|
|
63
|
+
#
|
|
64
|
+
# Everything these return is PLAIN TEXT. Arguments and results are
|
|
65
|
+
# model-authored, so they reach the page only through ERB's escape —
|
|
66
|
+
# nothing here may be marked html_safe.
|
|
67
|
+
|
|
68
|
+
# A verdict outranks the status it wrote: `decline!` sets status=failed,
|
|
69
|
+
# but a person refusing is not a crash and must not read like one.
|
|
70
|
+
INVOCATION_STATE = {
|
|
71
|
+
"pending" => "working", "started" => "working", "completed" => "clear",
|
|
72
|
+
"failed" => "failed", "in_doubt" => "in_doubt"
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
STATE_ASPECT = {
|
|
76
|
+
"held" => "amber", "working" => "blue", "clear" => "green", "failed" => "red",
|
|
77
|
+
"declined" => "red", "expired" => "quiet", "in_doubt" => "violet"
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
# The states are settled quietly ONLY here; every other state either
|
|
81
|
+
# needs a person or lost one.
|
|
82
|
+
SETTLED_QUIETLY = %w[clear working].freeze
|
|
83
|
+
|
|
84
|
+
MONEY_KEYS = %w[amount total price subtotal].freeze
|
|
85
|
+
CURRENCY_KEYS = %w[currency currency_code].freeze
|
|
86
|
+
|
|
87
|
+
def invocation_state(invocation)
|
|
88
|
+
return "held" if invocation.awaiting_approval?
|
|
89
|
+
return invocation.approval_state if %w[declined expired].include?(invocation.approval_state)
|
|
90
|
+
|
|
91
|
+
INVOCATION_STATE.fetch(invocation.status, invocation.status)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def invocation_state_class(invocation)
|
|
95
|
+
"deed-#{STATE_ASPECT.fetch(invocation_state(invocation), 'quiet')}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Salience. A read that worked is furniture; a tool that moved money or
|
|
99
|
+
# sent a message is not, and neither is anything a person still owes a
|
|
100
|
+
# verdict on. A FAILED read is loud too — having no effect doesn't make
|
|
101
|
+
# a failure quiet. `idempotent` is the mode a tool declares when it only
|
|
102
|
+
# reads; `transactional` is the one whose write and ledger row commit
|
|
103
|
+
# together, and it gets the heaviest rule on the page.
|
|
104
|
+
def invocation_weight(invocation)
|
|
105
|
+
return "deed-exact" if invocation.effect_mode == "transactional"
|
|
106
|
+
return "deed-loud" unless SETTLED_QUIETLY.include?(invocation_state(invocation))
|
|
107
|
+
|
|
108
|
+
invocation.effect_mode == "idempotent" ? "deed-quiet" : "deed-loud"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def invocation_outcome(invocation)
|
|
112
|
+
state = invocation_state(invocation)
|
|
113
|
+
case state
|
|
114
|
+
when "held" then invocation.question? ? "held for an answer" : "held for approval"
|
|
115
|
+
when "working" then "working"
|
|
116
|
+
when "in_doubt" then "in doubt — nobody knows whether it ran"
|
|
117
|
+
when "declined" then declined_phrase(invocation)
|
|
118
|
+
when "expired" then invocation.question? ? "expired — nobody answered" : "expired — nobody cleared it"
|
|
119
|
+
else [ verdict_phrase(invocation), result_or_failure_phrase(invocation, state) ].compact.join(" · ")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The middle of the sentence: enough of the call to recognise the job
|
|
124
|
+
# without opening anything. Non-scalars are left out — a nested hash
|
|
125
|
+
# flattened onto one line is noise — and wait in the disclosure.
|
|
126
|
+
def invocation_arg_phrases(invocation, limit: 3)
|
|
127
|
+
args = invocation.arguments
|
|
128
|
+
return [] unless args.is_a?(Hash) && args.any?
|
|
129
|
+
|
|
130
|
+
merged, phrases = money_phrase(args)
|
|
131
|
+
args.each do |key, value|
|
|
132
|
+
break if phrases.size >= limit
|
|
133
|
+
next if merged.include?(key.to_s) || !scalar_arg?(value)
|
|
134
|
+
|
|
135
|
+
phrases << arg_phrase(key.to_s, value)
|
|
136
|
+
end
|
|
137
|
+
phrases
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# "GBP 64.00" is what an operator scans a refund line for; as two
|
|
141
|
+
# separate phrases the currency and the number sit apart and neither
|
|
142
|
+
# reads as money. Returns [keys consumed, phrases].
|
|
143
|
+
def money_phrase(args)
|
|
144
|
+
currency = args.find { |k, v| CURRENCY_KEYS.include?(k.to_s) && v.to_s.match?(/\A[A-Za-z]{3}\z/) }
|
|
145
|
+
amount = args.find { |k, v| MONEY_KEYS.include?(k.to_s) && v.is_a?(Numeric) }
|
|
146
|
+
return [ [], [] ] unless currency && amount
|
|
147
|
+
|
|
148
|
+
[ [ currency[0].to_s, amount[0].to_s ], [ format("%s %.2f", currency[1].to_s.upcase, amount[1]) ] ]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `order_id: 4821` reads as "order #4821". A bare flag reads as itself:
|
|
152
|
+
# "dry run" beats "dry run true", and "no dry run" beats "dry run false".
|
|
153
|
+
def arg_phrase(key, value)
|
|
154
|
+
return "#{key.delete_suffix("_id").tr("_", " ")} ##{value}" if key.end_with?("_id")
|
|
155
|
+
|
|
156
|
+
label = key.tr("_", " ")
|
|
157
|
+
return label if value == true
|
|
158
|
+
return "no #{label}" if value == false
|
|
159
|
+
|
|
160
|
+
"#{label} #{value.to_s.truncate(48)}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# What came back. A tool that returned nothing SAYS so — the absence is
|
|
164
|
+
# a fact about the run, not a gap in the sentence.
|
|
165
|
+
def invocation_result_phrase(invocation)
|
|
166
|
+
result = invocation.result
|
|
167
|
+
return "returned nothing" if result.nil? || result == {} || result == ""
|
|
168
|
+
return result.to_s.truncate(48) unless result.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
key, value = result.find { |_k, v| scalar_arg?(v) }
|
|
171
|
+
key ? arg_phrase(key.to_s, value) : "#{result.size} #{"field".pluralize(result.size)} returned"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Where the turn stands, in the words the pill has no room for. Never
|
|
175
|
+
# says "waiting": the enum is `waiting`, the operator's word is HELD.
|
|
176
|
+
def turn_progress(turn)
|
|
177
|
+
case turn.status
|
|
178
|
+
when "queued" then "queued for a worker"
|
|
179
|
+
when "running" then "working now"
|
|
180
|
+
when "waiting" then "held — a person is needed"
|
|
181
|
+
when "in_doubt" then "in doubt — a tool crashed mid-call"
|
|
182
|
+
when "canceled" then "stopped before it finished"
|
|
183
|
+
when "failed" then turn.failure_reason.present? ? "failed — #{turn.failure_reason}" : "failed"
|
|
184
|
+
when "completed" then turn.finished_at ? "answered #{silas_relative_time(turn.finished_at)}" : "answered"
|
|
185
|
+
else status_label(turn.status)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A settled turn's one-line audit: how much ran, and how much of it
|
|
190
|
+
# wrote. Reads off the loaded steps so the session page adds no queries.
|
|
191
|
+
def turn_effects_summary(turn)
|
|
192
|
+
invocations = turn.steps.flat_map(&:tool_invocations)
|
|
193
|
+
return "no tools ran — answered directly" if invocations.empty?
|
|
194
|
+
|
|
195
|
+
wrote = invocations.count { |i| i.effect_mode != "idempotent" && i.status == "completed" }
|
|
196
|
+
"#{pluralize(invocations.size, "tool")} · #{wrote.zero? ? "nothing written" : "#{wrote} wrote"}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ---- lineage -----------------------------------------------------
|
|
200
|
+
#
|
|
201
|
+
# A handoff or a delegation starts a SECOND session. Nothing on the child
|
|
202
|
+
# points back at the call that made it — the only link is the invocation
|
|
203
|
+
# result — so the feed recovers the pair from there, and the child's
|
|
204
|
+
# metadata says which of the two it was.
|
|
205
|
+
|
|
206
|
+
LINEAGE_FROM = { "handoff" => "handed over by", "delegation" => "delegated by" }.freeze
|
|
207
|
+
LINEAGE_TO = { "handoff" => "handed to", "delegation" => "delegated to" }.freeze
|
|
208
|
+
|
|
209
|
+
def lineage_relation(child)
|
|
210
|
+
meta = child.metadata
|
|
211
|
+
return nil unless meta.is_a?(Hash)
|
|
212
|
+
return "handoff" if meta.key?("handoff_from")
|
|
213
|
+
|
|
214
|
+
"delegation" if meta.key?("delegated_from")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def lineage_from_phrase(child) = LINEAGE_FROM.fetch(lineage_relation(child), "started by")
|
|
218
|
+
def lineage_to_phrase(child) = LINEAGE_TO.fetch(lineage_relation(child), "started")
|
|
219
|
+
|
|
220
|
+
def invocation_child_session(invocation)
|
|
221
|
+
result = invocation.result
|
|
222
|
+
return nil unless result.is_a?(Hash) && result["session_id"].present?
|
|
223
|
+
|
|
224
|
+
child = Silas::Session.find_by(id: result["session_id"])
|
|
225
|
+
# Any tool may return a key called `session_id` meaning something of its
|
|
226
|
+
# own. Only a session whose parent IS this call's session was started
|
|
227
|
+
# by this call.
|
|
228
|
+
child if child && child.parent_session_id == invocation.turn.session_id
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Children the feed has no place for. An at_most_once handoff that
|
|
232
|
+
# crashes after Session.create! records no result, so the call that made
|
|
233
|
+
# the child names nothing — without this the child is visible only from
|
|
234
|
+
# the index, which is the orphan the lineage work exists to end.
|
|
235
|
+
def stranded_child_sessions(session, turns)
|
|
236
|
+
placed = turns.flat_map(&:steps).flat_map(&:tool_invocations).filter_map do |i|
|
|
237
|
+
i.result["session_id"].to_i if i.result.is_a?(Hash) && i.result["session_id"].present?
|
|
238
|
+
end
|
|
239
|
+
session.child_sessions.reject { |child| placed.include?(child.id) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The turn a session is judged by: the one in flight, else the last one
|
|
243
|
+
# to settle. Reads off the loaded association — the index depends on that.
|
|
244
|
+
def session_state_turn(session)
|
|
245
|
+
session.turns.detect(&:active?) || session.turns.last
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def scalar_arg?(value)
|
|
249
|
+
case value
|
|
250
|
+
when String then value.present?
|
|
251
|
+
when Numeric, true, false then true
|
|
252
|
+
else false
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def verdict_phrase(invocation)
|
|
257
|
+
case invocation.approval_state
|
|
258
|
+
when "approved"
|
|
259
|
+
invocation.approved_by.present? ? "approved by #{invocation.approved_by}" : "auto-approved by policy"
|
|
260
|
+
when "answered"
|
|
261
|
+
invocation.approved_by.present? ? "answered by #{invocation.approved_by}" : "answered"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def declined_phrase(invocation)
|
|
266
|
+
who = invocation.approved_by.present? ? "declined by #{invocation.approved_by}" : "declined"
|
|
267
|
+
invocation.decline_reason.present? ? "#{who} — “#{invocation.decline_reason}”" : who
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def result_or_failure_phrase(invocation, state)
|
|
271
|
+
return "failed — #{invocation.error.to_s.lines.first.to_s.strip.truncate(72).presence || "no reason recorded"}" if state == "failed"
|
|
272
|
+
return nil if invocation.approval_state == "answered" # the answer IS the result
|
|
273
|
+
|
|
274
|
+
invocation_result_phrase(invocation)
|
|
275
|
+
end
|
|
276
|
+
|
|
55
277
|
# Prefixed: this helper is registered host-wide (broadcast renders need
|
|
56
278
|
# it), and "relative_time" is exactly the name a host app would define.
|
|
57
279
|
def silas_relative_time(time)
|
|
@@ -9,6 +9,10 @@ module Silas
|
|
|
9
9
|
channel_class.dispatch(
|
|
10
10
|
thread_key: self.class.thread_key(mail),
|
|
11
11
|
input: body_text,
|
|
12
|
+
# Which staff member was written to (nil -> the root agent). Every
|
|
13
|
+
# recipient is a candidate — To, Cc, and the forwarding headers Action
|
|
14
|
+
# Mailbox's own routing matches on — first route wins.
|
|
15
|
+
agent: Silas::Channel.route_for("email", mail.recipients),
|
|
12
16
|
metadata: { "email" => { "from" => mail.from&.first, "subject" => mail.subject } }
|
|
13
17
|
)
|
|
14
18
|
rescue Silas::TurnInProgressError
|
|
@@ -44,7 +44,7 @@ module Silas
|
|
|
44
44
|
end
|
|
45
45
|
assert_parked!
|
|
46
46
|
assert_turn_resumable!
|
|
47
|
-
|
|
47
|
+
claim_verdict!(status: "pending", approval_state: "approved", approved_by: by)
|
|
48
48
|
Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
|
|
49
49
|
invocation_id: id, turn_id: turn_id)
|
|
50
50
|
resume_turn!
|
|
@@ -60,8 +60,8 @@ module Silas
|
|
|
60
60
|
|
|
61
61
|
assert_parked!
|
|
62
62
|
assert_turn_resumable!
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
claim_verdict!(status: "completed", approval_state: "answered", approved_by: by,
|
|
64
|
+
result: { "answer" => text })
|
|
65
65
|
Silas.instrument(:approval, action: "answered", tool: tool_name, by: by,
|
|
66
66
|
invocation_id: id, turn_id: turn_id)
|
|
67
67
|
resume_turn!
|
|
@@ -74,8 +74,8 @@ module Silas
|
|
|
74
74
|
def decline!(reason:, by: nil)
|
|
75
75
|
assert_parked!
|
|
76
76
|
assert_turn_resumable!
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
claim_verdict!(status: "failed", approval_state: "declined", approved_by: by,
|
|
78
|
+
decline_reason: reason, result: { "denied" => reason })
|
|
79
79
|
Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
|
|
80
80
|
invocation_id: id, turn_id: turn_id)
|
|
81
81
|
resume_turn!
|
|
@@ -102,6 +102,27 @@ module Silas
|
|
|
102
102
|
raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# A verdict is a state TRANSITION, not a write. `assert_parked!` reads
|
|
106
|
+
# in-memory state, so without a compare-and-swap two holders of the same
|
|
107
|
+
# rendered approval card — two people, a double-click, a retried POST —
|
|
108
|
+
# both pass it, both write, and both reach `resume_turn!`. Two fresh
|
|
109
|
+
# AgentLoopJobs then run one turn concurrently: two paid model calls, and
|
|
110
|
+
# the second mints tool_call ids the ledger has never seen and therefore
|
|
111
|
+
# cannot dedup. That is precisely the double-execution `warn_unsafe_queue_
|
|
112
|
+
# adapter!` refuses to allow the Async adapter to cause, arriving instead
|
|
113
|
+
# through the approval path — the one path whose entire purpose is to be
|
|
114
|
+
# the safe place a human intervenes. Claim the row, or lose loudly.
|
|
115
|
+
def claim_verdict!(attrs)
|
|
116
|
+
claimed = self.class.where(id: id, approval_state: "required")
|
|
117
|
+
.update_all(attrs.merge(updated_at: Time.current))
|
|
118
|
+
if claimed.zero?
|
|
119
|
+
reload
|
|
120
|
+
raise Error, "invocation #{id} was already settled (state: #{approval_state.inspect}, " \
|
|
121
|
+
"by: #{approved_by.inspect}) — another verdict won the race"
|
|
122
|
+
end
|
|
123
|
+
reload
|
|
124
|
+
end
|
|
125
|
+
|
|
105
126
|
# A failed turn must never be zombie-resumed by a stale approval card:
|
|
106
127
|
# force-fail paths expire approvals first, but a card already rendered in
|
|
107
128
|
# someone's browser can still POST — the verdict must land on a live turn.
|
|
@@ -123,7 +144,17 @@ module Silas
|
|
|
123
144
|
# point is waiting for a person. Cost/token budgets stay cumulative;
|
|
124
145
|
# they measure real spend.
|
|
125
146
|
parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
|
|
126
|
-
|
|
147
|
+
# Second CAS, for the case a per-invocation claim cannot cover: two gated
|
|
148
|
+
# invocations on one step, settled concurrently. Both verdicts legitimately
|
|
149
|
+
# win their own row, both then see no remaining gate above, and both would
|
|
150
|
+
# enqueue. Only the verdict that actually moves the turn out of a parked
|
|
151
|
+
# status resumes it.
|
|
152
|
+
claimed = Silas::Turn.where(id: turn.id, status: %w[waiting in_doubt])
|
|
153
|
+
.update_all(status: "queued", started_at: Time.current,
|
|
154
|
+
updated_at: Time.current)
|
|
155
|
+
return if claimed.zero?
|
|
156
|
+
|
|
157
|
+
turn.reload
|
|
127
158
|
Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
|
|
128
159
|
AgentLoopJob.perform_later(turn.id)
|
|
129
160
|
end
|