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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9abd9603ae899da9a789ded30f092f11f1881ddc9abd2f0edf2638421f469abb
4
- data.tar.gz: 16a684a62b54346ee51d4e7b9f6cb92ec19c2d95e7027667b432044d9a1e7f71
3
+ metadata.gz: 0f722788fa436905b6ea94ee9666ca71013fa6eace683f7d3dc8eec4f257fbe2
4
+ data.tar.gz: 85d3ae116127d4b91207526d7936440092250854380938a94eba177cd26f3eeb
5
5
  SHA512:
6
- metadata.gz: 9518d703a421ed47d1c59e9cb74cdbd46686b8a783a1f5aea8f90f5911e0e27f5ccc5fc86da22c8ebe870a3d811b7de28e5abfc2ff837ab7dbc47722d13e3dcc
7
- data.tar.gz: 954efe8fac65a18a0d936bafda946bcca96e8dda35201d06637d8f6ee5a8ca425ecd50ab322d2f137e7c0d03b2e97321db7d5cedfc5ac7d0a4b016ddf3184b63
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.update!(approval_state: "expired", status: "failed",
64
- result: { "denied" => "approval expired" })
88
+ result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" }
89
+ : { "denied" => "approval expired" }
90
+ inv.update!(approval_state: "expired", status: "failed", result: result)
65
91
  Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
66
92
  invocation_id: inv.id, turn_id: inv.turn_id)
67
93
  inv.turn.finish!(:failed, reason: "approval_expired")
@@ -1,14 +1,28 @@
1
- <div class="approval">
2
- <h3>Approval needed <%= invocation.tool_name %></h3>
3
- <pre><%= pretty_args(invocation.arguments) %></pre>
4
- <%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
5
- through the HOST's renderer, where engine route helpers don't exist and
6
- the mounted proxy has no routing scope to lean on. %>
7
- <%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
8
- <button class="btn approve">Approve</button>
9
- <% end %>
10
- <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
11
- <textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
12
- <button class="btn decline">Decline</button>
13
- <% end %>
14
- </div>
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).