silas 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +4 -2
- data/app/controllers/silas/channels/slack_controller.rb +2 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +3 -1
- data/app/helpers/silas/inbox/trace_helper.rb +224 -2
- data/app/mailboxes/silas/agent_mailbox.rb +4 -0
- data/app/models/silas/tool_invocation.rb +37 -6
- data/app/views/layouts/silas/inbox.html.erb +69 -3
- data/app/views/silas/inbox/invocations/_detail.html.erb +21 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +39 -24
- data/app/views/silas/inbox/sessions/_child.html.erb +14 -0
- data/app/views/silas/inbox/sessions/_row.html.erb +13 -2
- data/app/views/silas/inbox/sessions/show.html.erb +33 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -2
- data/docs/agents.md +15 -3
- data/docs/channels.md +53 -2
- data/docs/configuration.md +2 -1
- data/docs/connections.md +8 -5
- data/docs/guarantees.md +16 -5
- data/docs/headless.md +120 -0
- data/docs/traces.md +131 -0
- data/docs/tutorial.md +6 -4
- data/docs/vs-eve.md +41 -17
- data/docs/why-silas.md +6 -6
- data/lib/generators/silas/install/install_generator.rb +21 -0
- data/lib/generators/silas/install/templates/initializer.rb +5 -0
- data/lib/silas/channel.rb +85 -11
- data/lib/silas/configuration.rb +16 -1
- data/lib/silas/doctor.rb +35 -2
- data/lib/silas/registry.rb +20 -2
- data/lib/silas/tool.rb +38 -5
- data/lib/silas/version.rb +1 -1
- metadata +5 -1
|
@@ -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 {
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
7
|
-
<span class="muted"><%=
|
|
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"
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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),
|
|
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 =
|
|
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
|
|
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 & 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
|
|
71
|
-
|
|
72
|
-
|
|
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 —
|
|
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
|
|
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 —
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|