silas 0.4.0 → 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 +110 -0
- data/README.md +7 -1
- 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/channel_delivery_job.rb +15 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +28 -14
- 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/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +8 -5
- data/lib/silas/step_runner.rb +5 -0
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +3 -0
- metadata +9 -1
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,115 @@
|
|
|
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
|
+
|
|
3
113
|
## 0.4.0
|
|
4
114
|
|
|
5
115
|
The architecture-and-hardening release: one shipped feature that had never
|
data/README.md
CHANGED
|
@@ -192,11 +192,17 @@ 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
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One compaction row replaces session turns 0..up_to_turn_index with a
|
|
3
|
+
# persisted summary. Written exactly once (the unique index on
|
|
4
|
+
# session_id + up_to_turn_index is the compare-and-swap claim), read
|
|
5
|
+
# deterministically forever after — which is what lets MessageBuilder stay
|
|
6
|
+
# byte-identical across crash replays: the summary is a row, never a
|
|
7
|
+
# runtime computation.
|
|
8
|
+
class Compaction < ApplicationRecord
|
|
9
|
+
STATUSES = %w[pending completed].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :session, class_name: "Silas::Session"
|
|
12
|
+
belongs_to :up_to_turn, class_name: "Silas::Turn"
|
|
13
|
+
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
validates :up_to_turn_index, presence: true
|
|
16
|
+
|
|
17
|
+
scope :completed, -> { where(status: "completed") }
|
|
18
|
+
|
|
19
|
+
def completed? = status == "completed"
|
|
20
|
+
|
|
21
|
+
# The compaction MessageBuilder applies when building turn: the newest
|
|
22
|
+
# completed summary strictly before it. (A compaction can never cover its
|
|
23
|
+
# own turn — it is created during turn N covering 0..N-1 — so `<` is
|
|
24
|
+
# always satisfiable; it also keeps an eval or replay of an older turn
|
|
25
|
+
# from seeing a summary written after it.)
|
|
26
|
+
def self.latest_for(turn)
|
|
27
|
+
completed.where(session_id: turn.session_id)
|
|
28
|
+
.where(up_to_turn_index: ...turn.index)
|
|
29
|
+
.order(:up_to_turn_index).last
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,7 +2,7 @@ module Silas
|
|
|
2
2
|
class ToolInvocation < ApplicationRecord
|
|
3
3
|
STATUSES = %w[pending started completed failed in_doubt].freeze
|
|
4
4
|
EFFECT_MODES = %w[transactional at_most_once idempotent].freeze
|
|
5
|
-
APPROVAL_STATES = [ nil, "required", "approved", "declined", "expired" ].freeze
|
|
5
|
+
APPROVAL_STATES = [ nil, "required", "approved", "answered", "declined", "expired" ].freeze
|
|
6
6
|
|
|
7
7
|
include Silas::Inbox::Broadcastable
|
|
8
8
|
|
|
@@ -18,6 +18,10 @@ module Silas
|
|
|
18
18
|
def in_doubt? = status == "in_doubt"
|
|
19
19
|
def awaiting_approval? = approval_state == "required"
|
|
20
20
|
|
|
21
|
+
# A parked ask_question — same park, different verdict: it is ANSWERED
|
|
22
|
+
# (free text becomes the tool result), never approved into execution.
|
|
23
|
+
def question? = tool_name == "ask_question"
|
|
24
|
+
|
|
21
25
|
# Outbound: when a channel-bound invocation parks for approval, ping the
|
|
22
26
|
# channel off-loop (covers both approval-gate and in-doubt parking).
|
|
23
27
|
after_update_commit :notify_channel_approval, if: :should_notify_approval?
|
|
@@ -34,6 +38,10 @@ module Silas
|
|
|
34
38
|
# parked job exited normally; its continuation is consumed). For an
|
|
35
39
|
# in-doubt invocation, approval means "it did not run — re-execute".
|
|
36
40
|
def approve!(by: nil)
|
|
41
|
+
if question?
|
|
42
|
+
raise Error, "invocation #{id} is a question — settle it with answer!, not approve! " \
|
|
43
|
+
"(approving would try to EXECUTE ask_question, which has no execution)"
|
|
44
|
+
end
|
|
37
45
|
assert_parked!
|
|
38
46
|
assert_turn_resumable!
|
|
39
47
|
update!(status: "pending", approval_state: "approved", approved_by: by)
|
|
@@ -42,6 +50,23 @@ module Silas
|
|
|
42
50
|
resume_turn!
|
|
43
51
|
end
|
|
44
52
|
|
|
53
|
+
# Answer a parked question. The text IS the tool result — the model resumes
|
|
54
|
+
# with {"answer" => text}, persisted like any other settled invocation, so
|
|
55
|
+
# replay determinism costs nothing. (decline! also works on a question: a
|
|
56
|
+
# refusal to answer, delivered as {"denied" => reason}.)
|
|
57
|
+
def answer!(text:, by: nil)
|
|
58
|
+
raise Error, "invocation #{id} (#{tool_name}) is not a question — answer! settles ask_question only" unless question?
|
|
59
|
+
raise Error, "an answer cannot be blank — decline! is the way to refuse a question" if text.blank?
|
|
60
|
+
|
|
61
|
+
assert_parked!
|
|
62
|
+
assert_turn_resumable!
|
|
63
|
+
update!(status: "completed", approval_state: "answered", approved_by: by,
|
|
64
|
+
result: { "answer" => text })
|
|
65
|
+
Silas.instrument(:approval, action: "answered", tool: tool_name, by: by,
|
|
66
|
+
invocation_id: id, turn_id: turn_id)
|
|
67
|
+
resume_turn!
|
|
68
|
+
end
|
|
69
|
+
|
|
45
70
|
# Decline: for an approval gate, eve's shape — the tool is not executed
|
|
46
71
|
# and the model sees {denied: reason} as the result, then the loop
|
|
47
72
|
# continues. For an in-doubt invocation, decline means "assume it ran /
|
|
@@ -60,8 +85,9 @@ module Silas
|
|
|
60
85
|
# their turns (parked-forever ghosts are a bug, not a feature).
|
|
61
86
|
def self.expire_stale!(now: Time.current)
|
|
62
87
|
where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
|
|
63
|
-
inv.
|
|
64
|
-
|
|
88
|
+
result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" }
|
|
89
|
+
: { "denied" => "approval expired" }
|
|
90
|
+
inv.update!(approval_state: "expired", status: "failed", result: result)
|
|
65
91
|
Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
|
|
66
92
|
invocation_id: inv.id, turn_id: inv.turn_id)
|
|
67
93
|
inv.turn.finish!(:failed, reason: "approval_expired")
|
|
@@ -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 %>
|
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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module Generators
|
|
5
|
+
# rails g silas:channel whatsapp
|
|
6
|
+
#
|
|
7
|
+
# A channel is two halves that must agree on one name, and hand-rolling
|
|
8
|
+
# them is where the mistakes live: inbound needs signature verification and
|
|
9
|
+
# a stable thread key, outbound needs the approval link to reach an
|
|
10
|
+
# operator. This scaffolds both, wired together, with the security
|
|
11
|
+
# decisions already made.
|
|
12
|
+
class ChannelGenerator < Rails::Generators::NamedBase
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Scaffold a Silas channel: outbound Channel class, inbound webhook controller, and its route."
|
|
16
|
+
|
|
17
|
+
# Channel identity is the filename (app/agent/channels/whatsapp.rb ->
|
|
18
|
+
# Agent::Channels::Whatsapp), and Registry#channels resolves it with
|
|
19
|
+
# `camelize`. A filename that isn't a snake_case identifier produces a
|
|
20
|
+
# constant Zeitwerk can't define, and the channel then fails at boot
|
|
21
|
+
# rather than here — so refuse it here, where the message is useful.
|
|
22
|
+
# (Rails' usual normalisation still applies first: `MsTeams` and
|
|
23
|
+
# `ms_teams` both land on ms_teams.)
|
|
24
|
+
def validate_name
|
|
25
|
+
return if file_name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
26
|
+
|
|
27
|
+
raise Thor::Error, "#{file_name.inspect} is not a valid channel name — " \
|
|
28
|
+
"it must be lowercase words separated by underscores, " \
|
|
29
|
+
"starting with a letter (e.g. whatsapp, ms_teams)."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Templates are .rb.tt (Rails' own convention): the .tt keeps ERB-bearing
|
|
33
|
+
# files out of the linter and off Zeitwerk's radar.
|
|
34
|
+
def create_channel
|
|
35
|
+
template "channel.rb.tt", "app/agent/channels/#{file_name}.rb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The webhook lives in the HOST app, not the engine: only the host knows
|
|
39
|
+
# the vendor's signature scheme and payload shape. The engine ships
|
|
40
|
+
# routes for Slack alone because it also ships Slack's verification.
|
|
41
|
+
def create_controller
|
|
42
|
+
template "controller.rb.tt", "app/controllers/agent/channels/#{file_name}_controller.rb"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_route
|
|
46
|
+
route %(post "/agent/channels/#{file_name}", to: "agent/channels/#{file_name}#create")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_next_steps
|
|
50
|
+
say <<~MSG, :green
|
|
51
|
+
|
|
52
|
+
Channel "#{file_name}" scaffolded:
|
|
53
|
+
app/agent/channels/#{file_name}.rb (outbound: answers + approvals)
|
|
54
|
+
app/controllers/agent/channels/#{file_name}_controller.rb (inbound: webhook)
|
|
55
|
+
config/routes.rb POST /agent/channels/#{file_name}
|
|
56
|
+
|
|
57
|
+
Next:
|
|
58
|
+
1. Set the signing secret:
|
|
59
|
+
bin/rails credentials:edit -> silas:
|
|
60
|
+
#{file_name}:
|
|
61
|
+
signing_secret: ...
|
|
62
|
+
2. Fill in the three TODOs — the vendor's signature scheme, how a
|
|
63
|
+
message maps to a thread key, and how to post a message back.
|
|
64
|
+
3. Point the vendor's webhook at https://<your-host>/agent/channels/#{file_name}
|
|
65
|
+
4. Restart: app/agent/ registers at boot.
|
|
66
|
+
|
|
67
|
+
Full contract and a worked example: docs/channels.md
|
|
68
|
+
MSG
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# <%= class_name %> channel — OUTBOUND delivery for sessions that started in
|
|
2
|
+
# <%= human_name %>. Inbound lives in
|
|
3
|
+
# app/controllers/agent/channels/<%= file_name %>_controller.rb.
|
|
4
|
+
#
|
|
5
|
+
# Identity is this filename: <%= file_name %>.rb -> Agent::Channels::<%= class_name %>,
|
|
6
|
+
# and the controller starts sessions with channel: "<%= file_name %>", which is how
|
|
7
|
+
# Silas finds this class again at delivery time. Delete this file to disable
|
|
8
|
+
# the channel.
|
|
9
|
+
#
|
|
10
|
+
# Both methods run OFF the durable loop, in ChannelDeliveryJob — so a transport
|
|
11
|
+
# outage retries the delivery without re-running a single tool or touching the
|
|
12
|
+
# ledger. Raising here is safe; it costs a retry, not a duplicate side effect.
|
|
13
|
+
class Agent::Channels::<%= class_name %> < Silas::Channel
|
|
14
|
+
# The agent's final answer for a turn.
|
|
15
|
+
def deliver_answer(session:, text:)
|
|
16
|
+
thread = session.metadata["<%= file_name %>"] || {}
|
|
17
|
+
|
|
18
|
+
# TODO: post `text` back to the conversation. `thread` is whatever the
|
|
19
|
+
# controller stashed in metadata (chat id, phone number, message id).
|
|
20
|
+
raise NotImplementedError,
|
|
21
|
+
"Agent::Channels::<%= class_name %>#deliver_answer: post #{text.inspect} to #{thread.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A tool has parked for human approval; the turn is holding zero compute
|
|
25
|
+
# until someone answers. `approval_url` mints a signed, expiring one-click
|
|
26
|
+
# link that works in any transport.
|
|
27
|
+
def deliver_approval(session:, invocation:)
|
|
28
|
+
approve = Silas::Channel.approval_url(invocation, :approve)
|
|
29
|
+
decline = Silas::Channel.approval_url(invocation, :decline)
|
|
30
|
+
|
|
31
|
+
# SECURITY: an approval must reach an OPERATOR, never whoever started the
|
|
32
|
+
# session — mailing the approve link to the customer who asked for the
|
|
33
|
+
# refund lets them approve it themselves. Deliver to your ops destination
|
|
34
|
+
# (a staff channel, an on-call number), and FAIL CLOSED when it isn't
|
|
35
|
+
# configured: no destination means no approval, not a silent one.
|
|
36
|
+
operator = Rails.application.credentials.dig(:silas, :<%= file_name %>, :operator)
|
|
37
|
+
if operator.blank?
|
|
38
|
+
Rails.logger.warn("[Silas] no credentials.silas.<%= file_name %>.operator — approval for " \
|
|
39
|
+
"invocation #{invocation.id} not delivered (won't send it to the requester).")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# TODO: send `approve` / `decline` to `operator`, with enough context to
|
|
44
|
+
# decide: invocation.tool_name and invocation.arguments.
|
|
45
|
+
raise NotImplementedError,
|
|
46
|
+
"Agent::Channels::<%= class_name %>#deliver_approval: send #{approve} / #{decline} to #{operator}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# <%= class_name %> channel — INBOUND webhook. Outbound delivery lives in
|
|
2
|
+
# app/agent/channels/<%= file_name %>.rb.
|
|
3
|
+
#
|
|
4
|
+
# Inbound is pure trigger reuse: verify the request is genuine, derive a stable
|
|
5
|
+
# thread key, and hand it to Channel.dispatch — which starts a session for a new
|
|
6
|
+
# thread and continues the existing one for a reply. Nothing here touches the
|
|
7
|
+
# durable loop.
|
|
8
|
+
class Agent::Channels::<%= class_name %>Controller < ActionController::Base
|
|
9
|
+
# A webhook carries no browser session, so no CSRF token can exist. The
|
|
10
|
+
# signature below IS the authentication — do not remove one without the other.
|
|
11
|
+
skip_forgery_protection
|
|
12
|
+
|
|
13
|
+
before_action :verify_webhook!
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
# TODO: derive a STABLE thread key — the same conversation must produce the
|
|
17
|
+
# same key every time, or every message starts a new session. Use the
|
|
18
|
+
# vendor's conversation/thread id, never a per-message id.
|
|
19
|
+
thread_key = params[:conversation_id].to_s
|
|
20
|
+
text = params[:text].to_s
|
|
21
|
+
|
|
22
|
+
return head(:ok) if thread_key.blank? || text.blank?
|
|
23
|
+
|
|
24
|
+
Agent::Channels::<%= class_name %>.dispatch(
|
|
25
|
+
thread_key: thread_key,
|
|
26
|
+
input: text,
|
|
27
|
+
# Whatever deliver_answer needs to reply. Stored on the session, so keep
|
|
28
|
+
# it small and free of secrets — it is visible in the inbox.
|
|
29
|
+
metadata: { "<%= file_name %>" => { "conversation_id" => thread_key } }
|
|
30
|
+
)
|
|
31
|
+
head :ok
|
|
32
|
+
rescue Silas::TurnInProgressError
|
|
33
|
+
# One active turn per session is an invariant, not a queue: a reply that
|
|
34
|
+
# arrives mid-turn is dropped. Tell the user, or buffer it, if that matters
|
|
35
|
+
# for your transport.
|
|
36
|
+
head :ok
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Rejects anything not genuinely signed. Silas::Webhook.verify_hmac handles
|
|
42
|
+
# the parts that are identical everywhere — constant-time comparison, the
|
|
43
|
+
# replay window, and failing closed when no secret is configured; you supply
|
|
44
|
+
# the vendor's shape.
|
|
45
|
+
#
|
|
46
|
+
# TODO: match your vendor's scheme. The three that vary:
|
|
47
|
+
# payload what they sign. Slack signs "v0:#{timestamp}:#{body}"; GitHub
|
|
48
|
+
# and Shopify sign the raw body. It must be request.raw_post,
|
|
49
|
+
# never re-serialized params — different bytes, different HMAC.
|
|
50
|
+
# prefix what they put before the digest ("v0=", "sha256=", or "").
|
|
51
|
+
# digest :hex (nearly everyone) or :base64 (Shopify, Twilio).
|
|
52
|
+
def verify_webhook!
|
|
53
|
+
ok = Silas::Webhook.verify_hmac(
|
|
54
|
+
secret: Rails.application.credentials.dig(:silas, :<%= file_name %>, :signing_secret),
|
|
55
|
+
signature: request.headers["X-Signature"],
|
|
56
|
+
payload: request.raw_post,
|
|
57
|
+
# Omitting a timestamp disables replay protection: a captured request can
|
|
58
|
+
# then be re-sent forever. Pass the vendor's timestamp header if it sends
|
|
59
|
+
# one, and treat its absence as a reason to check the vendor's docs.
|
|
60
|
+
timestamp: request.headers["X-Signature-Timestamp"],
|
|
61
|
+
prefix: "sha256="
|
|
62
|
+
)
|
|
63
|
+
head(:unauthorized) unless ok
|
|
64
|
+
ok
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -109,7 +109,8 @@ module Silas
|
|
|
109
109
|
6. Schedules: edit app/agent/schedules/*, then `bin/rails silas:schedules`
|
|
110
110
|
7. Channels (optional): set credentials.silas.slack.{signing_secret,bot_token}
|
|
111
111
|
for Slack; route inbound mail to Silas::AgentMailbox for email.
|
|
112
|
-
Delete app/agent/channels/{slack,email}.rb to disable.
|
|
112
|
+
Delete app/agent/channels/{slack,email}.rb to disable. Any other
|
|
113
|
+
transport: `bin/rails g silas:channel whatsapp`.
|
|
113
114
|
8. Inbox + web chat: /silas/inbox, deny-by-default — uncomment
|
|
114
115
|
config.inbox_auth in config/initializers/silas.rb to make it visible.
|
|
115
116
|
9. Restart your server if it was running (app/agent/ registers at boot).
|