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.
@@ -73,6 +73,17 @@
73
73
  padding: 14px 16px; margin-bottom: 12px; }
74
74
  .session-row { display: block; text-decoration: none; color: inherit; }
75
75
  .session-row:hover { border-color: var(--accent); }
76
+ /* Lineage. A handoff or a delegation makes a SECOND session: unmarked, the
77
+ child is a row on the index nobody can account for and the parent's feed
78
+ shows no sign it ever handed anything over. Neutral tokens throughout —
79
+ lineage is not a run state, and the lamp is reserved for what you can
80
+ act on. */
81
+ .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
82
+ margin: 6px 0 0; color: var(--muted); font-size: 13px; }
83
+ .lineage-mark { font-family: var(--mono); color: var(--quiet); }
84
+ .lineage-agent { color: var(--ink); font-weight: 600; }
85
+ .lineage-child { padding-left: 10px; border-left: 2px solid var(--line); margin-top: 8px; }
86
+ .session-child { border-left: 3px solid var(--line); }
76
87
  .row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
77
88
  .row-top .name { font-weight: 600; }
78
89
  .muted { color: var(--muted); font-size: 13px; }
@@ -98,15 +109,70 @@
98
109
  border-radius: 50%; background: var(--accent); }
99
110
  .step-text { margin: 2px 0; }
100
111
  .step-live { white-space: pre-wrap; }
101
- .tool { background: var(--grey-bg); border-radius: var(--radius); padding: 8px 10px; margin: 6px 0; font-size: 13px; }
112
+ .tool { margin: 6px 0; font-size: 13px; }
102
113
  .tool code { font-family: var(--mono); }
103
114
  .tool-args { margin: 6px 0; font-size: 13px; }
104
115
  .tool-args .kv { display: flex; gap: 8px; padding: 2px 0; border-bottom: 1px dotted var(--line); }
105
116
  .tool-args .kv:last-child { border-bottom: 0; }
106
117
  .tool-args .k { font-family: var(--mono); font-size: 12px; color: var(--muted); min-width: 96px; }
107
118
  .tool-args .v { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
108
- details.tool-result { margin: 6px 0; }
109
- details.tool-result summary { cursor: pointer; font-size: 12px; color: var(--muted); }
119
+ /* The feed. One invocation, one sentence — and SALIENCE is the point:
120
+ effect mode sets the weight (a read that worked is furniture; a tool
121
+ that moved money or sent a message is not), run state colours the rule
122
+ (see the aspect tokens above). A failure is loud whatever it touched. */
123
+ .deed-line { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
124
+ .deed-tool { font-family: var(--mono); font-weight: 600; }
125
+ .deed-arg { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
126
+ .deed-sep { color: var(--line); }
127
+ .deed-arrow { color: var(--muted); }
128
+ .deed-outcome { font-weight: 600; }
129
+ .deed-quiet { padding-left: 10px; border-left: 2px solid var(--line); opacity: 0.7; }
130
+ .deed-quiet .deed-tool { font-weight: 400; }
131
+ .deed-loud, .deed-exact { background: var(--grey-bg); border-radius: var(--radius);
132
+ padding: 8px 10px; border-left: 3px solid var(--line); }
133
+ /* transactional: the effect and its ledger row commit together. The one
134
+ mode that earns the heaviest rule on the page. */
135
+ .deed-exact { border-left-width: 5px; }
136
+ /* state last — it wins the rule colour from the weight classes above */
137
+ .deed-amber { border-left-color: var(--amber); }
138
+ .deed-blue { border-left-color: var(--blue); }
139
+ .deed-green { border-left-color: var(--green); }
140
+ .deed-red { border-left-color: var(--red); }
141
+ .deed-violet { border-left-color: var(--violet); }
142
+ .deed-amber .deed-outcome { color: var(--amber); }
143
+ .deed-blue .deed-outcome { color: var(--blue); }
144
+ .deed-green .deed-outcome { color: var(--green); }
145
+ .deed-red .deed-outcome { color: var(--red); }
146
+ .deed-violet .deed-outcome { color: var(--violet); }
147
+ .deed-quiet .deed-outcome { color: var(--muted); font-weight: 400; }
148
+ .deed-detail { margin: 6px 0 0; }
149
+ .deed-detail summary { cursor: pointer; font-size: 12px; color: var(--muted); }
150
+ /* [hidden] is a UA rule at the bottom of the cascade, so ANY author
151
+ `display` on these elements beats it and BOTH readings render at once.
152
+ The raw blocks carry `hidden` so a host rendering these partials without
153
+ our stylesheet still sees one reading — the attribute has to keep winning
154
+ here too. Scoped rather than !important, so the :checked rules below
155
+ (higher specificity) can still reveal the trace. */
156
+ #silas-turns [hidden] { display: none; }
157
+ .deed-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
158
+ .raw-state { font-family: var(--mono); font-size: 11px; color: var(--muted); margin: 4px 0 6px; }
159
+ .turn-meta { margin: -4px 0 8px; }
160
+ .step-silent { margin: 2px 0; }
161
+ /* The raw switch. CSS, not a param: every row below can be replaced by a
162
+ broadcast at any moment and a broadcast render has no reader to ask —
163
+ both readings ship in the DOM and :checked picks. */
164
+ .raw-switch { position: absolute; width: 1px; height: 1px; opacity: 0; }
165
+ .feed-bar { display: flex; justify-content: flex-end; margin: 12px 0 4px; }
166
+ .feed-bar .chip { font-size: 12px; padding: 3px 10px; border: 1px solid var(--line);
167
+ border-radius: 999px; color: var(--muted); cursor: pointer; user-select: none; }
168
+ .raw-switch:checked ~ .feed-bar .chip { background: var(--ink); color: var(--bg); border-color: var(--ink); }
169
+ .raw-switch:focus-visible ~ .feed-bar .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
170
+ .raw-switch:checked ~ #silas-turns .feed-only { display: none; }
171
+ .raw-switch:checked ~ #silas-turns .raw-only { display: block; }
172
+ /* raw is FLAT — the graded weights are the feed's reading, not the trace's */
173
+ .raw-switch:checked ~ #silas-turns .tool { background: var(--grey-bg); border-left: 0;
174
+ border-radius: var(--radius); padding: 8px 10px; opacity: 1; }
175
+ #silas-turns:has(.turn) + .feed-empty { display: none; }
110
176
  pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: var(--radius);
111
177
  padding: 8px 10px; overflow-x: auto; margin: 6px 0; }
112
178
  .approval { border: 1.5px solid var(--amber); background: var(--amber-bg); border-radius: var(--radius);
@@ -0,0 +1,21 @@
1
+ <%# The call itself, shared by the feed's disclosure and the raw trace.
2
+
3
+ Arguments and results are MODEL-authored. Every value below goes through
4
+ ERB's escape and nothing here is ever marked html_safe. %>
5
+ <div class="raw-state">status <%= invocation.status %> · approval <%= invocation.approval_state || "none" %> · effect <%= invocation.effect_mode %></div>
6
+ <%# What the agent passed — the question an audit surface must answer.
7
+ Key/value rows (05): scannable at a glance, JSON only per value. %>
8
+ <% if invocation.arguments.present? %>
9
+ <div class="tool-args">
10
+ <% invocation.arguments.each do |key, value| %>
11
+ <div class="kv"><span class="k"><%= key %></span><span class="v"><%= value.is_a?(String) ? value : JSON.generate(value) %></span></div>
12
+ <% end %>
13
+ </div>
14
+ <% else %>
15
+ <div class="muted">called with no arguments</div>
16
+ <% end %>
17
+ <% if invocation.error.present? %>
18
+ <pre class="error"><%= invocation.error %></pre>
19
+ <% elsif invocation.result.present? %>
20
+ <pre><%= pretty_args(invocation.result) %></pre>
21
+ <% end %>
@@ -1,14 +1,32 @@
1
- <div class="tool" id="<%= dom_id(invocation) %>">
2
- <div>
1
+ <%# One invocation, one sentence — what ran, on what, how it ended — with the
2
+ call itself behind a disclosure.
3
+
4
+ The technical row is rendered TOO, hidden, and the page's raw switch picks
5
+ between them in CSS. It has to work that way: this partial is
6
+ broadcast-replaced on every transition, and a broadcast render has no
7
+ params and no reader to ask. CSS survives every replace; a choice baked
8
+ into the render would snap back on the first one.
9
+
10
+ `hidden` on the raw blocks, not just a class: a host that renders these
11
+ partials outside the inbox layout (examples/playground does) has none of
12
+ our stylesheet, and must see the sentence alone rather than both. %>
13
+ <div class="tool deed <%= invocation_weight(invocation) %> <%= invocation_state_class(invocation) %>" id="<%= dom_id(invocation) %>">
14
+ <p class="deed-line feed-only">
15
+ <code class="deed-tool"><%= invocation.tool_name %></code>
16
+ <% invocation_arg_phrases(invocation).each do |phrase| %>
17
+ <span class="deed-sep">·</span><span class="deed-arg"><%= phrase %></span>
18
+ <% end %>
19
+ <span class="deed-arrow">→</span>
20
+ <span class="deed-outcome"><%= invocation_outcome(invocation) %></span>
21
+ </p>
22
+ <div class="deed-head raw-only" hidden>
3
23
  <code><%= invocation.tool_name %></code>
4
24
  <%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
5
25
  <%# The audit line — who held the lever, and why it moved. %>
6
- <% if invocation.approval_state == "approved" %>
7
- <span class="muted"><%= invocation.approved_by.present? ? "approved by #{invocation.approved_by}" : "auto-approved by policy" %></span>
8
- <% elsif invocation.approval_state == "answered" %>
9
- <span class="muted">answered<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %></span>
26
+ <% if (verdict = verdict_phrase(invocation)) %>
27
+ <span class="muted"><%= verdict %></span>
10
28
  <% elsif invocation.approval_state == "declined" %>
11
- <span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
29
+ <span class="muted"><%= declined_phrase(invocation) %></span>
12
30
  <% elsif invocation.approval_state == "expired" %>
13
31
  <span class="muted"><%= invocation.question? ? "question expired unanswered" : "approval expired unanswered" %></span>
14
32
  <% end %>
@@ -18,22 +36,19 @@
18
36
  its place in the movement with a one-line stub. %>
19
37
  <div class="held-stub">↑ held at the signal — <%= invocation.question? ? "answer" : "clear" %> it above</div>
20
38
  <% else %>
21
- <%# What the agent passed — the question an audit surface must answer.
22
- Key/value rows (05): scannable at a glance, JSON only per value. %>
23
- <% if invocation.arguments.present? %>
24
- <div class="tool-args">
25
- <% invocation.arguments.each do |key, value| %>
26
- <div class="kv"><span class="k"><%= key %></span><span class="v"><%= value.is_a?(String) ? value : JSON.generate(value) %></span></div>
27
- <% end %>
28
- </div>
29
- <% end %>
30
- <% if invocation.error.present? %>
31
- <pre class="error"><%= invocation.error %></pre>
32
- <% elsif invocation.result.present? %>
33
- <details class="tool-result">
34
- <summary>result</summary>
35
- <pre><%= pretty_args(invocation.result) %></pre>
36
- </details>
37
- <% end %>
39
+ <details class="deed-detail feed-only">
40
+ <summary>arguments and result</summary>
41
+ <%= render "silas/inbox/invocations/detail", invocation: invocation %>
42
+ </details>
43
+ <div class="raw-only" hidden>
44
+ <%= render "silas/inbox/invocations/detail", invocation: invocation %>
45
+ </div>
46
+ <% end %>
47
+ <%# A handoff or a delegation is the one call whose outcome is another
48
+ session. It belongs here, at the call that made it — that is the only
49
+ place in the product where "A handed to B" is a fact you can read.
50
+ Outside the raw/feed split: lineage is not a reading, it happened. %>
51
+ <% if (child = invocation_child_session(invocation)) %>
52
+ <%= render "silas/inbox/sessions/child", child: child %>
38
53
  <% end %>
39
54
  </div>
@@ -0,0 +1,14 @@
1
+ <%# One session this one started, as a row on the parent's page.
2
+
3
+ silas_engine_path, not the bare helper: this partial is reached from
4
+ _invocation, which is broadcast-rendered on every tool transition, and
5
+ bare engine helpers 500 in that renderer. %>
6
+ <div class="lineage lineage-child">
7
+ <span class="lineage-mark">↳</span>
8
+ <%= lineage_to_phrase(child) %>
9
+ <span class="lineage-agent"><%= child.agent_name %></span>
10
+ <%= link_to "session ##{child.id}", silas_engine_path(:inbox_session_path, child) %>
11
+ <%# The child's state as it stood when this page rendered — the parent's
12
+ stream carries no broadcast from the child's session. %>
13
+ <% if (turn = session_state_turn(child)) %><%= status_pill(turn.status) %><% end %>
14
+ </div>
@@ -1,8 +1,9 @@
1
- <%= link_to inbox_session_path(session), class: "card session-row" do %>
1
+ <%= link_to inbox_session_path(session),
2
+ class: [ "card", "session-row", ("session-child" if session.parent_session) ] do %>
2
3
  <div class="row-top">
3
4
  <span class="name"><%= session.agent_name %></span>
4
5
  <%# Loaded association — no per-row queries. %>
5
- <% if (turn = session.turns.detect(&:active?) || session.turns.last) %><%= status_pill(turn.status) %><% end %>
6
+ <% if (turn = session_state_turn(session)) %><%= status_pill(turn.status) %><% end %>
6
7
  <% pending = pending_counts.fetch(session.id, 0) %>
7
8
  <% if pending.positive? %><span class="badge"><%= pending %> held</span><% end %>
8
9
  </div>
@@ -11,4 +12,14 @@
11
12
  <%= session.channel.presence || "direct" %> ·
12
13
  <%= silas_relative_time(session.updated_at) %>
13
14
  </div>
15
+ <%# A child session is nobody's stray: its first turn is another agent's
16
+ brief. Named here, or the index shows a row no one can account for.
17
+ Text, not a link — the whole card already is one. %>
18
+ <% if (parent = session.parent_session) %>
19
+ <div class="lineage">
20
+ <span class="lineage-mark">↖</span>
21
+ <%= lineage_from_phrase(session) %>
22
+ <span class="lineage-agent"><%= parent.agent_name %></span>
23
+ </div>
24
+ <% end %>
14
25
  <% end %>
@@ -12,6 +12,16 @@
12
12
  <span class="muted">#<%= @session.id %></span>
13
13
  </div>
14
14
  <div class="muted"><%= @session.channel.presence || "direct" %> · started <%= silas_relative_time(@session.created_at) %></div>
15
+ <%# Lineage up. This session's first turn is another agent's brief; without
16
+ the line it reads as a session that started itself. %>
17
+ <% if (parent = @session.parent_session) %>
18
+ <div class="lineage">
19
+ <span class="lineage-mark">↖</span>
20
+ <%= lineage_from_phrase(@session) %>
21
+ <span class="lineage-agent"><%= parent.agent_name %></span>
22
+ <%= link_to "session ##{parent.id}", inbox_session_path(parent) %>
23
+ </div>
24
+ <% end %>
15
25
  <%= render "silas/inbox/sessions/cost", session: @session %>
16
26
  </div>
17
27
 
@@ -24,9 +34,32 @@
24
34
  collection: @session.pending_approvals.order(:id), as: :invocation %>
25
35
  </div>
26
36
 
37
+ <%# The switch is a bare checkbox and #silas-turns is its SIBLING, because the
38
+ swap has to be pure CSS: every row in the feed can be replaced by a
39
+ broadcast at any moment, and a broadcast render has no params to consult.
40
+ Both readings are in the DOM; :checked decides which one is on. %>
41
+ <input type="checkbox" id="silas-raw" class="raw-switch">
42
+ <div class="feed-bar">
43
+ <label class="chip" for="silas-raw">raw trace</label>
44
+ </div>
45
+
27
46
  <div id="silas-turns">
28
47
  <%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
29
48
  </div>
49
+ <%# :has, not a Ruby conditional — a turn appended live has to retire this
50
+ line, and the append never re-renders the page. %>
51
+ <div class="empty feed-empty">Nothing has happened here yet.</div>
52
+
53
+ <%# Lineage down, for the children the feed cannot place: an at_most_once
54
+ handoff that crashed after starting the colleague leaves the session
55
+ created and the result unrecorded, so no row in the trace names it. %>
56
+ <% stranded = stranded_child_sessions(@session, @turns) %>
57
+ <% if stranded.any? %>
58
+ <div class="card">
59
+ <div class="muted">Started from this session, at a point the trace never recorded:</div>
60
+ <%= render partial: "silas/inbox/sessions/child", collection: stranded, as: :child %>
61
+ </div>
62
+ <% end %>
30
63
 
31
64
  <div class="card composer">
32
65
  <%= form_with url: inbox_session_turns_path(@session) do |f| %>
@@ -1,8 +1,9 @@
1
1
  <div class="step" id="<%= dom_id(step) %>">
2
+ <% text = step_text(step) %>
2
3
  <%# The text container is unconditional: silas-step-<id>-text is the live
3
4
  delta target. While the step runs, deltas replace its innerHTML; the
4
5
  completed row render (this partial, re-broadcast) supersedes them. %>
5
- <% if (text = step_text(step)) %>
6
+ <% if text %>
6
7
  <div class="step-text" id="silas-step-<%= step.id %>-text"><%= simple_format(text) %></div>
7
8
  <% else %>
8
9
  <div class="step-text step-live" id="silas-step-<%= step.id %>-text"></div>
@@ -13,4 +14,10 @@
13
14
  <div id="silas-step-<%= step.id %>-tools">
14
15
  <%= render partial: "silas/inbox/invocations/invocation", collection: step.tool_invocations, as: :invocation %>
15
16
  </div>
17
+ <%# A completed step with neither text nor a tool call is a model turn that
18
+ produced nothing. Rare, and worth naming — an unexplained gap in a feed
19
+ reads as a rendering bug. %>
20
+ <% if step.completed? && text.blank? && step.tool_invocations.empty? %>
21
+ <div class="step-silent muted">the model returned nothing on this step</div>
22
+ <% end %>
16
23
  </div>
@@ -17,6 +17,13 @@
17
17
  <span class="muted">cancel requested…</span>
18
18
  <% end %>
19
19
  </div>
20
+ <%# A settled turn's audit line. A turn that used no tools says exactly
21
+ that: nothing ran, nothing was written — the absence is the answer an
22
+ operator came here for, and it has to be stated, not inferred from an
23
+ empty space. %>
24
+ <div class="turn-meta muted">
25
+ asked <%= silas_relative_time(turn.created_at) %> · <%= turn_progress(turn) %><%= " · #{turn_effects_summary(turn)}" unless turn.active? %>
26
+ </div>
20
27
  <% if turn.budget_parked? %>
21
28
  <div class="approval">
22
29
  <h3>Budget reached — <%= turn.failure_reason %></h3>
@@ -28,7 +35,5 @@
28
35
  <button class="btn approve">Raise &amp; resume</button>
29
36
  <% end %>
30
37
  </div>
31
- <% elsif turn.failure_reason.present? %>
32
- <div class="muted">failed: <%= turn.failure_reason %></div>
33
38
  <% end %>
34
39
  </div>
data/docs/agents.md CHANGED
@@ -46,6 +46,12 @@ resumes — runs under that agent's own tools, skills, instructions, and
46
46
  definitions digest. Scope switching is execution-isolated, so concurrent jobs
47
47
  running different agents never cross wires.
48
48
 
49
+ A named agent is staff, not a lesser agent: it gets `ask_question` (subject to
50
+ `config.ask_question`), `load_skill` when it has skills, `run_code` when the
51
+ sandbox is on, `remember`/`recall`, `handoff`, and the remote tools from
52
+ `app/agent/connections/` — one set of credentials for the whole app. It does
53
+ not get `delegate`; subagents belong to the root agent's turn.
54
+
49
55
  ```ruby
50
56
  Silas.agent("escalations").start(input: "…")
51
57
  ```
@@ -56,6 +62,10 @@ bin/rails silas:chat AGENT=escalations # talk to one staff member
56
62
 
57
63
  The inbox filters by agent; the root `app/agent/` stays the default.
58
64
 
65
+ Staff answer their own mail: `config.channel_routes` sends a Slack channel or an
66
+ email address to a named agent, and every turn on that thread runs under their
67
+ scope. See [channels](channels.md#routing-which-agent-wakes).
68
+
59
69
  ## Subagents — delegation within a turn
60
70
 
61
71
  The built-in `delegate` tool runs a scoped sub-task under a subagent's own
@@ -67,9 +77,11 @@ to keep a specialist's tools out of the main agent's prompt until needed.
67
77
  Staff compose through **handoffs, not conversations**: the built-in `handoff`
68
78
  tool files a self-contained brief that starts a **linked session** for another
69
79
  named agent — asynchronously by default, or `await: true` for an answer.
70
- Handoffs are exactly-once-guarded and cycle-checked. Two models chatting
71
- freely is a cost and audit hazard, so it's deliberately unblessed; a handoff
72
- is a work order, with the paper trail that implies.
80
+ Handoffs are cycle-checked, and at-most-once: filing one is a single external
81
+ effect, so a crash mid-handoff parks the call **in doubt** for a person rather
82
+ than starting the colleague twice. Two models chatting freely is a cost and
83
+ audit hazard, so it's deliberately unblessed; a handoff is a work order, with
84
+ the paper trail that implies.
73
85
 
74
86
  ## Schedules give any agent a clock
75
87
 
data/docs/channels.md CHANGED
@@ -58,7 +58,7 @@ end
58
58
  ```
59
59
 
60
60
  `dispatch` is the whole inbound API. It maps the external thread to a session and
61
- then calls the ordinary public API — `Silas.agent.start` for a new thread,
61
+ then calls the ordinary public API — `<agent>.start` for a new thread,
62
62
  `session.continue` for a reply. There is no channel-specific loop.
63
63
 
64
64
  Three things to get right:
@@ -66,7 +66,8 @@ Three things to get right:
66
66
  **The thread key must be stable.** The same conversation must produce the same
67
67
  key every time, or every message starts a new session and the agent has no
68
68
  memory. Use the vendor's conversation or thread id, never a per-message id. Keys
69
- are namespaced by channel (`whatsapp:abc123`), so two channels can't collide.
69
+ are namespaced by channel *and* agent (`whatsapp:bookkeeper:abc123`), so neither
70
+ two channels nor two staff members sharing a transport can collide.
70
71
 
71
72
  **Sign over the raw body.** `request.raw_post`, never re-serialized params —
72
73
  re-serializing changes the bytes and the HMAC no longer matches. See
@@ -80,6 +81,56 @@ message matters for your transport, buffer it or tell the user.
80
81
  Metadata is stored on the session and shown in the inbox, so keep it small and
81
82
  free of secrets. It exists so outbound knows where to reply.
82
83
 
84
+ ### Routing: which agent wakes
85
+
86
+ By default an inbound thread wakes the root agent. `config.channel_routes` sends
87
+ it to a [named agent](agents.md#named-agents--the-staff-pattern) instead — data
88
+ only, one entry per destination:
89
+
90
+ ```ruby
91
+ # config/initializers/silas.rb
92
+ config.channel_routes = {
93
+ "slack" => { "C0BILLING" => "bookkeeper" }, # Slack channel id
94
+ "email" => { "billing@shop.test" => "bookkeeper" }
95
+ }
96
+ ```
97
+
98
+ The outer key is the transport's filename identity (`slack`, `email`, and for a
99
+ generated channel, whatever you named the file). The inner key is whatever that
100
+ transport calls a destination. Both shipped channels read it for you: Slack
101
+ matches the channel the message arrived in, email matches every recipient
102
+ address — To, Cc, and the forwarding headers Action Mailbox itself routes on —
103
+ first match wins, case-insensitively. Anything unmatched wakes the root agent.
104
+
105
+ The session is stamped with the agent's name, so **every** turn on that thread —
106
+ including crash resumes weeks later — runs under that agent's tools, skills,
107
+ instructions, and definitions digest.
108
+
109
+ A custom channel opts in with one argument:
110
+
111
+ ```ruby
112
+ Agent::Channels::Whatsapp.dispatch(
113
+ thread_key: message[:from],
114
+ input: message.dig(:text, :body).to_s,
115
+ agent: Silas::Channel.route_for("whatsapp", message[:to]),
116
+ metadata: { "whatsapp" => { "to" => message[:from] } }
117
+ )
118
+ ```
119
+
120
+ **A route naming an agent that doesn't exist fails the boot that installs it**,
121
+ listing the staff that do exist. That is deliberate: `Silas.agent(name)` raises
122
+ on an unknown name, and finding that out at dispatch time would 500 the webhook
123
+ — which for Slack means a retry, which the retry guard drops, which means the
124
+ message is gone. If a bad name is assigned *after* boot, dispatch does not
125
+ raise: it logs the error and wakes the root agent, which is where that thread
126
+ went before routing existed.
127
+
128
+ Routing changes how continuation tokens are derived — `whatsapp:abc123` became
129
+ `whatsapp:agent:abc123`. Sessions that predate the change keep working: a lookup
130
+ that misses the new form falls back to the old one, so a live thread carries on
131
+ in the session it already has (with the agent it already had — a conversation
132
+ does not change hands mid-thread). New threads route normally.
133
+
83
134
  ### Verification
84
135
 
85
136
  `Silas::Webhook.verify_hmac` handles the parts that are identical everywhere —
@@ -69,6 +69,7 @@ recipes in [providers](providers.md).
69
69
  | Option | Default | Meaning |
70
70
  |---|---|---|
71
71
  | `slack_signing_secret` / `slack_bot_token` | `credentials.silas.slack.*` | Explicit setters override the credentials path; `nil` disables Slack. |
72
+ | `channel_routes` | `{}` | Which agent an inbound thread wakes: `{ "slack" => { "C0BILLING" => "bookkeeper" }, "email" => { "billing@shop.test" => "bookkeeper" } }`. Unmatched threads wake the root agent. Names are checked at boot against `app/agents/` — a typo fails the deploy, never a webhook. See [channels](channels.md#routing-which-agent-wakes). |
72
73
 
73
74
  ## Evals
74
75
 
@@ -88,7 +89,7 @@ recipes in [providers](providers.md).
88
89
 
89
90
  | Option | Default | Meaning |
90
91
  |---|---|---|
91
- | `mcp_server_host` | `"127.0.0.1"` | Bind host for the in-process MCP server (serving *your* tools to MCP clients). |
92
+ | `mcp_server_host` | `"127.0.0.1"` | Bind host for the in-process MCP server (`Silas::Mcp::Server`, serving *your* tools to MCP clients). Inert — nothing starts that server yet; see [connections](connections.md). |
92
93
  | `mcp_client_factory` | `nil` | `->(connection) { client }` — inject a fake MCP client per connection in tests. |
93
94
 
94
95
  The remaining accessors (`tool_resolver`, `tool_definitions`,
data/docs/connections.md CHANGED
@@ -51,8 +51,11 @@ connection at a changed server.
51
51
  `config.mcp_client_factory = ->(connection) { fake_client }` injects a fake
52
52
  client per connection — the seam the gem's own specs use.
53
53
 
54
- ## The other direction
55
-
56
- Silas can also *serve* your agent's tools as an MCP server (`Silas::Mcp::Server`,
57
- bound to `config.mcp_server_host`) the "mount your tools as MCP" seam, so
58
- other MCP clients can call the tools you wrote for your agent.
54
+ ## The other direction — not wired up yet
55
+
56
+ `Silas::Mcp::Server` is an in-process HTTP server that hosts your agent's tools
57
+ for outside MCP clients, and `config.mcp_server_host` is its bind host. **You
58
+ cannot turn it on.** Nothing in the gem starts it the class is exercised by
59
+ its own specs and nothing else — so serving your tools as MCP is not a feature
60
+ you can use today. The code stays because a mounted endpoint is the planned
61
+ shape; until that ships, treat this direction as absent.
data/docs/guarantees.md CHANGED
@@ -1,11 +1,22 @@
1
1
  # Guarantees
2
2
 
3
3
  Durability in Silas is a contract, not a slide. Everything on this page is
4
- verified by the in-repo chaos harness (`chaos_host/bin/chaos`), which
5
- `kill -9`s live agents hundreds of times per release the current gate is
6
- 100/100 completions per mode across a 295-run matrix, zero duplicate effects,
7
- byte-identical replay, on SQLite and Postgres. Results live in
8
- `chaos_host/results/` and every run is reproducible.
4
+ verified by the in-repo chaos harness (`chaos_host/bin/chaos`), which `kill -9`s
5
+ live agents mid-turn. The maintainer runs the full matrix before a release —
6
+ `chaos_host/bin/full_gate`, 275 runs across five kill modes on SQLite and
7
+ Postgres and commits the output. Last gate: every run completed, zero
8
+ duplicate effects, byte-identical replay. CI runs the spec suite and RuboCop,
9
+ not chaos; the harness is a local gate, and every run is reproducible.
10
+
11
+ `chaos_host/results/*.jsonl` is an append-only, unedited history, so it carries
12
+ batches from before harness fixes alongside the gate batch. The first two rows
13
+ of `sqlite_compact.jsonl` are one of those: their duplicate counts are the
14
+ harness's turn-scoped side-effect keys colliding across a multi-turn session,
15
+ not effects that happened twice (`chaos_host/RESULTS.md` records the fix). It
16
+ also carries the smaller batches run against individual changes — a batch
17
+ starts wherever `run` restarts at 1, and no field on a row says which batch was
18
+ a gate. `chaos_host/RESULTS.md` is that record; its per-mode run counts are the
19
+ gate's.
9
20
 
10
21
  ## Turns survive hard process death
11
22
 
data/docs/headless.md ADDED
@@ -0,0 +1,120 @@
1
+ # Headless Silas
2
+
3
+ The inbox is a *mountable* engine, not a requirement. Don't mount it and you
4
+ have the whole durable runtime — loop, ledger, approvals, memory, evals — with
5
+ no screens of ours in your app. You build the operator surface; Silas keeps
6
+ the guarantees.
7
+
8
+ This is the shape to reach for when the person approving things is **your
9
+ user, not your ops team**: a product where "review what the agent wants to
10
+ do" is a first-class feature of your own UI, styled and worded like the rest
11
+ of it.
12
+
13
+ ## The whole approval lifecycle is on the model
14
+
15
+ The inbox controllers are thin. Everything they do, you can do:
16
+
17
+ ```ruby
18
+ invocation = Silas::ToolInvocation.find(id)
19
+
20
+ invocation.approve!(by: current_user.email) # resumes the parked turn
21
+ invocation.decline!(reason: "wrong customer", by: current_user.email)
22
+ invocation.answer!(text: "yes, refund the delivery charge", by: current_user.email)
23
+ ```
24
+
25
+ `answer!` is the reply to an `ask_question` park — information, not
26
+ permission. Each of these settles the invocation, records the actor, and
27
+ resumes the turn. The mounted inbox, the JSON API, the Slack controller and
28
+ the email approve/decline links are simply four callers of the same three
29
+ methods; yours is a fifth.
30
+
31
+ For the queue itself:
32
+
33
+ ```ruby
34
+ session.pending_approvals # what this session is parked on
35
+ Silas::ToolInvocation.where(approval_state: "required") # the whole queue
36
+ Silas::Turn.where(status: "waiting") # parked turns
37
+ ```
38
+
39
+ Note the two status vocabularies: a **Turn** is
40
+ `queued/running/waiting/in_doubt/completed/failed/canceled`, while a
41
+ **Session** is only `active/archived`. Parked work is a turn in `waiting`.
42
+
43
+ Nothing about parking is UI-coupled: a turn parked for approval holds at zero
44
+ compute until one of those methods is called, whatever calls it.
45
+
46
+ ## What you take on
47
+
48
+ Four things the engine was doing for you:
49
+
50
+ - **Rendering.** Arguments, results, the transcript, cost lines. The
51
+ invocation carries `tool_name`, `arguments`, `result`, `approval_state` and
52
+ its turn/session associations — you decide what an operator sees. Note that
53
+ a tool's arguments are model-authored text: render them **escaped**, the
54
+ way the engine's views do.
55
+ - **Authorization.** `config.inbox_auth` and `api_auth` guard *engine*
56
+ routes. Your controllers are yours — apply your own policy, and remember
57
+ that approving is a privileged action even when reading isn't.
58
+ - **Live updates.** The engine's Turbo broadcasts target its own DOM ids. If
59
+ you want live traces, broadcast from your own views; the `Broadcastable`
60
+ concern's targets are engine-internal and not a public contract.
61
+ - **Approval links in channels.** `Silas::Channel.approval_url` mints signed,
62
+ expiring one-click links against **engine routes**
63
+ (`Silas::Engine.routes.url_helpers`), so email and Slack approve/decline
64
+ depend on the engine being mounted. Going fully headless with channel
65
+ approvals means minting your own signed links to your own routes;
66
+ `Channel.token_for` / `Channel.verify_token` are the signing seam, so you
67
+ keep the token semantics (expiry via `approval_ttl`, no session state in
68
+ the URL) without the routes.
69
+
70
+ ## Mount it anyway, for the engine room
71
+
72
+ Headless and mounted are not exclusive, and the most useful arrangement is
73
+ usually both:
74
+
75
+ - **Your UI is the front office** — the approval moment your user actually
76
+ touches, in your product's voice.
77
+ - **The mounted inbox is the engine room** — the full technical history,
78
+ every step, every tool call, every cost, for when someone needs to know
79
+ exactly what happened. Mount it behind staff-only auth
80
+ (`config.inbox_auth`) and link into it from your admin.
81
+
82
+ You get a bespoke operator experience without rebuilding the audit trail, and
83
+ support questions have somewhere to be answered.
84
+
85
+ ## Per-execution scopes, if capabilities vary
86
+
87
+ If different users, teams or tenants need *different* tools, skills or
88
+ instructions, note that Silas resolves those through the active scope, not
89
+ process-global config:
90
+
91
+ ```ruby
92
+ Silas.with_agent_scope(scope) { ... } # per-thread AND per-fiber
93
+ ```
94
+
95
+ An `AgentScope` carries `name`, `dir`, `agent`, `resolver`, `definitions`,
96
+ `digest` and `skills` — every model-visible capability. It exists for named
97
+ agents and subagents, and `Silas.scope_for_session` resolves a session's
98
+ scope from its `agent_name` on every turn, so scoped capabilities survive
99
+ parking and resumption.
100
+
101
+ Constraints worth knowing before you build on this:
102
+
103
+ - **Credentials are paths, not values.** Connections resolve
104
+ `credential: crm.token` against `Rails.application.credentials` at call
105
+ time — designed for *your* app's credentials, not per-end-user OAuth
106
+ tokens. Per-user credentials need a tool of your own that reads them;
107
+ `Tool#session` is the context the Ledger injects, so the session is where
108
+ you hang "whose account is this".
109
+ - **Changing capabilities fails parked turns, by design.** The definitions
110
+ digest is a nondeterminism guard: a turn parked before its tools changed is
111
+ *failed* on resume rather than resumed against a different agent. The
112
+ digest is per-scope, so scoped agents don't invalidate each other — but
113
+ within a scope, settle parked work before rolling a capability change out.
114
+ - **Memory is not in the digest.** It covers tool schemas, skill names and
115
+ descriptions, and `final_answer` — so teaching an agent new *facts* is safe
116
+ for parked turns, while changing its *tools* is not. If you build an
117
+ end-user-facing way to steer an agent, prefer memory for the frequent path.
118
+ - **Named-agent scopes don't include connection tools.** A scope's resolver
119
+ is built from its own directory's tools plus builtins; remote MCP tools are
120
+ wired into the root agent only.