silas 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +211 -0
- data/DEPLOY.md +111 -0
- data/README.md +87 -250
- data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
- data/app/helpers/silas/inbox/trace_helper.rb +20 -5
- data/app/jobs/silas/channel_delivery_job.rb +15 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
- data/app/models/silas/compaction.rb +32 -0
- data/app/models/silas/tool_invocation.rb +29 -3
- data/app/views/layouts/silas/inbox.html.erb +89 -30
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
- data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
- data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
- data/app/views/silas/inbox/sessions/index.html.erb +16 -15
- data/app/views/silas/inbox/sessions/show.html.erb +9 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
- data/docs/agents.md +81 -0
- data/docs/budgets.md +67 -0
- data/docs/cancellation.md +41 -0
- data/docs/channels.md +290 -0
- data/docs/configuration.md +106 -0
- data/docs/connections.md +55 -0
- data/docs/conventions.md +161 -0
- data/docs/evals.md +95 -0
- data/docs/guarantees.md +76 -0
- data/docs/inbox-and-api.md +84 -0
- data/docs/memory.md +35 -0
- data/docs/sandbox.md +44 -0
- data/docs/tools.md +77 -0
- data/docs/tutorial.md +268 -0
- data/docs/vs-eve.md +93 -0
- data/docs/why-silas.md +87 -0
- data/lib/generators/silas/channel/channel_generator.rb +72 -0
- data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
- data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
- data/lib/generators/silas/install/install_generator.rb +11 -1
- data/lib/generators/silas/install/templates/claude_skill.md +136 -0
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
- data/lib/silas/adapters/ruby_llm.rb +102 -46
- data/lib/silas/channel.rb +35 -0
- data/lib/silas/compactor.rb +178 -0
- data/lib/silas/configuration.rb +16 -0
- data/lib/silas/eval/dsl.rb +7 -2
- data/lib/silas/instrumentation.rb +7 -3
- data/lib/silas/ledger.rb +2 -2
- data/lib/silas/log_subscriber.rb +5 -0
- data/lib/silas/message_builder.rb +19 -0
- data/lib/silas/registry.rb +5 -2
- data/lib/silas/schedule.rb +44 -15
- data/lib/silas/slack.rb +8 -5
- data/lib/silas/step_runner.rb +5 -0
- data/lib/silas/tools/ask_question.rb +26 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas/webhook.rb +47 -0
- data/lib/silas.rb +3 -0
- metadata +30 -1
|
@@ -4,37 +4,71 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title><%= content_for?(:title) ? yield(:title) : "Silas Inbox" %></title>
|
|
7
|
+
<%# The night tile, inlined — the engine ships no assets and keeps shipping none. %>
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230F1013'/%3E%3Cg transform='translate(4,4)'%3E%3Ccircle cx='12' cy='12' r='9.6' fill='none' stroke='%23E9EBEF' stroke-width='2.2'/%3E%3Ccircle cx='8.4' cy='15.6' r='2.5' fill='%23F2F4F8'/%3E%3Ccircle cx='15.6' cy='8.4' r='2.5' fill='%23F2F4F8'/%3E%3C/g%3E%3C/svg%3E">
|
|
7
9
|
<% if Silas::Inbox.streaming_available? %><%= turbo_include_tags rescue nil %><% end %>
|
|
8
10
|
<style>
|
|
11
|
+
/* Direction "Signals" (see docs/conventions.md and the brand book).
|
|
12
|
+
Dark is PRIMARY — the audience lives in terminals; light is the
|
|
13
|
+
prefers-color-scheme override, the reverse of most sites. The lamp
|
|
14
|
+
(--accent) means "you can act on this" — links, focus, the live-step
|
|
15
|
+
dot — and NEVER means state. State is the aspect colours below. */
|
|
9
16
|
:root {
|
|
10
|
-
--bg:
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
--
|
|
17
|
+
--bg: #0F1013; /* night */
|
|
18
|
+
--panel: #16181D; /* card, input, button face */
|
|
19
|
+
--ink: #E9EBEF; /* body text, wordmark */
|
|
20
|
+
--muted: #969CA8; /* metadata */
|
|
21
|
+
--line: #262A33; /* borders, rules */
|
|
22
|
+
--grey-bg: #1D2027; /* tool rows, <pre> */
|
|
23
|
+
|
|
24
|
+
/* the lamp — white at night, route-blue on paper: the ONE token allowed
|
|
25
|
+
to change hue between modes, because it represents light, not pigment */
|
|
26
|
+
--accent: #F2F4F8;
|
|
27
|
+
--accent-soft: #1D2027;
|
|
28
|
+
|
|
29
|
+
/* aspects — run states */
|
|
30
|
+
--blue: #58A6FF; --blue-bg: #14233D; /* running */
|
|
31
|
+
--amber: #E3B341; --amber-bg: #2E2611; /* waiting -> "held" */
|
|
32
|
+
--violet: #B49AE8; --violet-bg: #241E33; /* in_doubt */
|
|
33
|
+
--green: #3FB950; --green-bg: #12291B; /* completed -> "clear" */
|
|
34
|
+
--red: #F85149; --red-bg: #331815; /* failed */
|
|
35
|
+
--quiet: #737A87; --quiet-line:#4A5160; /* canceled — a lamp goes out */
|
|
36
|
+
|
|
37
|
+
--radius: 8px; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
38
|
+
--sans-display: Archivo, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
15
39
|
}
|
|
16
|
-
@media (prefers-color-scheme:
|
|
40
|
+
@media (prefers-color-scheme: light) {
|
|
17
41
|
:root {
|
|
18
|
-
--bg: #
|
|
19
|
-
--line: #
|
|
20
|
-
--
|
|
21
|
-
--blue:
|
|
42
|
+
--bg: #F4F4F2; --panel: #FFFFFF; --ink: #15171B; --muted: #5B5F68;
|
|
43
|
+
--line: #DCDEE3; --grey-bg: #EDEEF1;
|
|
44
|
+
--accent: #274FBF; --accent-soft: #E3E9FA;
|
|
45
|
+
--blue: #1D4ED8; --blue-bg: #DCE7F5;
|
|
46
|
+
--amber: #92650E; --amber-bg: #FBEED0;
|
|
47
|
+
--violet: #63459B; --violet-bg: #EAE3F7;
|
|
48
|
+
--green: #157A3D; --green-bg: #DCF3E3;
|
|
49
|
+
--red: #B91C1C; --red-bg: #FBDCDA;
|
|
50
|
+
--quiet: #5B5F68; --quiet-line:#C7CAD1;
|
|
22
51
|
}
|
|
23
52
|
}
|
|
24
53
|
* { box-sizing: border-box; }
|
|
25
54
|
body {
|
|
26
55
|
margin: 0; background: var(--bg); color: var(--ink);
|
|
27
|
-
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
56
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
28
57
|
-webkit-font-smoothing: antialiased;
|
|
29
58
|
}
|
|
30
59
|
.wrap { max-width: 640px; margin: 0 auto; padding: 20px 16px 64px; }
|
|
31
60
|
header.top { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
|
|
32
|
-
header.top .logo {
|
|
33
|
-
|
|
61
|
+
header.top .logo {
|
|
62
|
+
font-family: var(--sans-display); font-weight: 800;
|
|
63
|
+
letter-spacing: -0.03em; font-size: 20px;
|
|
64
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
65
|
+
}
|
|
66
|
+
header.top .logo a { color: var(--ink); text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
|
|
67
|
+
header.top .logo svg { display: block; }
|
|
34
68
|
header.top .spacer { flex: 1; }
|
|
35
69
|
.badge { background: var(--amber-bg); color: var(--amber); font-weight: 600;
|
|
36
70
|
font-size: 12px; padding: 3px 9px; border-radius: 999px; }
|
|
37
|
-
a { color: var(--accent); }
|
|
71
|
+
a { color: var(--accent); text-underline-offset: 2px; }
|
|
38
72
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
|
39
73
|
padding: 14px 16px; margin-bottom: 12px; }
|
|
40
74
|
.session-row { display: block; text-decoration: none; color: inherit; }
|
|
@@ -49,8 +83,13 @@
|
|
|
49
83
|
.pill-amber { background: var(--amber-bg); color: var(--amber); }
|
|
50
84
|
.pill-green { background: var(--green-bg); color: var(--green); }
|
|
51
85
|
.pill-red { background: var(--red-bg); color: var(--red); }
|
|
52
|
-
.pill-
|
|
53
|
-
|
|
86
|
+
.pill-violet { background: var(--violet-bg); color: var(--violet); }
|
|
87
|
+
/* canceled: a person stopped it — a lamp goes out, it doesn't turn red */
|
|
88
|
+
.pill-quiet { background: transparent; color: var(--quiet);
|
|
89
|
+
border: 1px dashed var(--quiet-line); padding: 1px 7px; }
|
|
90
|
+
/* running is the only aspect that moves */
|
|
91
|
+
.pill-pulse { animation: lamppulse 1.4s ease-in-out infinite; }
|
|
92
|
+
@keyframes lamppulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
|
|
54
93
|
.turn { border-left: 2px solid var(--line); padding-left: 14px; margin: 16px 0; }
|
|
55
94
|
.turn-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
56
95
|
.turn-input { font-weight: 600; }
|
|
@@ -59,51 +98,71 @@
|
|
|
59
98
|
border-radius: 50%; background: var(--accent); }
|
|
60
99
|
.step-text { margin: 2px 0; }
|
|
61
100
|
.step-live { white-space: pre-wrap; }
|
|
62
|
-
.tool { background: var(--grey-bg); border-radius:
|
|
101
|
+
.tool { background: var(--grey-bg); border-radius: var(--radius); padding: 8px 10px; margin: 6px 0; font-size: 13px; }
|
|
63
102
|
.tool code { font-family: var(--mono); }
|
|
64
|
-
|
|
103
|
+
.tool-args { margin: 6px 0; font-size: 13px; }
|
|
104
|
+
.tool-args .kv { display: flex; gap: 8px; padding: 2px 0; border-bottom: 1px dotted var(--line); }
|
|
105
|
+
.tool-args .kv:last-child { border-bottom: 0; }
|
|
106
|
+
.tool-args .k { font-family: var(--mono); font-size: 12px; color: var(--muted); min-width: 96px; }
|
|
107
|
+
.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); }
|
|
110
|
+
pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: var(--radius);
|
|
65
111
|
padding: 8px 10px; overflow-x: auto; margin: 6px 0; }
|
|
66
|
-
.approval { border: 1.5px solid var(--amber); background: var(--amber-bg); border-radius:
|
|
112
|
+
.approval { border: 1.5px solid var(--amber); background: var(--amber-bg); border-radius: var(--radius);
|
|
67
113
|
padding: 12px 14px; margin: 10px 0; }
|
|
68
114
|
.approval h3 { margin: 0 0 8px; font-size: 14px; color: var(--amber); }
|
|
69
|
-
.
|
|
115
|
+
.approval .question-text { margin: 0 0 8px; }
|
|
116
|
+
.held-stub { font-size: 13px; color: var(--amber); margin: 6px 0; }
|
|
117
|
+
.btn { display: inline-block; border: 0; border-radius: var(--radius); padding: 11px 18px; min-height: 44px;
|
|
70
118
|
font-size: 15px; font-weight: 600; cursor: pointer; }
|
|
71
119
|
.btn.approve { background: var(--green); color: #fff; }
|
|
72
120
|
.btn.decline { background: var(--panel); color: var(--red); border: 1px solid var(--red); }
|
|
73
|
-
.decline-form textarea { width: 100%; border: 1px solid var(--line); border-radius:
|
|
121
|
+
.decline-form textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius);
|
|
74
122
|
padding: 8px; margin: 8px 0; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
|
|
75
123
|
form.inline { display: inline; }
|
|
76
124
|
.cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
77
|
-
.composer textarea { width: 100%; border: 1px solid var(--line); border-radius:
|
|
125
|
+
.composer textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius);
|
|
78
126
|
padding: 10px 12px; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
|
|
79
127
|
.composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
80
128
|
.composer-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
|
|
81
|
-
|
|
82
|
-
|
|
129
|
+
/* The lamp as a button: dark text on the white lamp at night, paper text
|
|
130
|
+
on route-blue in the light — var(--bg) tracks both. */
|
|
131
|
+
.btn.send { background: var(--accent); color: var(--bg); }
|
|
132
|
+
select.composer-agent { border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 8px;
|
|
83
133
|
font: inherit; background: var(--panel); color: var(--ink); margin-bottom: 8px; }
|
|
84
|
-
.flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius:
|
|
134
|
+
.flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: var(--radius); margin-bottom: 12px; }
|
|
85
135
|
.flash-notice { background: var(--green-bg); color: var(--green); }
|
|
86
136
|
.btn-cancel { border: 1px solid var(--red); background: transparent; color: var(--red);
|
|
87
|
-
border-radius:
|
|
137
|
+
border-radius: var(--radius); padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; margin-left: auto; }
|
|
88
138
|
pre.error { background: var(--red-bg); color: var(--red); }
|
|
89
139
|
pre.args { opacity: 0.85; }
|
|
90
140
|
.empty { text-align: center; color: var(--muted); padding: 48px 0; }
|
|
141
|
+
.rail-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
|
142
|
+
color: var(--muted); margin: 20px 0 8px; }
|
|
91
143
|
.agent-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
92
|
-
.agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid
|
|
144
|
+
.agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid var(--line);
|
|
93
145
|
border-radius: 999px; text-decoration: none; color: inherit; }
|
|
94
|
-
.agent-filter .chip-on { background:
|
|
146
|
+
.agent-filter .chip-on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
|
95
147
|
.pager { text-align: center; margin: 16px 0; }
|
|
96
148
|
.pager .chip { font-size: 13px; padding: 6px 14px; border: 1px solid var(--line);
|
|
97
149
|
border-radius: 999px; text-decoration: none; color: inherit; }
|
|
98
150
|
.topup-form { display: flex; gap: 8px; margin-top: 8px; }
|
|
99
|
-
.topup-input { flex: 1; border: 1px solid var(--line); border-radius:
|
|
151
|
+
.topup-input { flex: 1; border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px;
|
|
100
152
|
font: inherit; background: var(--panel); color: var(--ink); }
|
|
101
153
|
</style>
|
|
102
154
|
</head>
|
|
103
155
|
<body>
|
|
104
156
|
<div class="wrap">
|
|
105
157
|
<header class="top">
|
|
106
|
-
<span class="logo"
|
|
158
|
+
<span class="logo">
|
|
159
|
+
<%= link_to inbox_sessions_path do %>
|
|
160
|
+
<%# The mark: a position-light signal, diagonal = "proceed". Bezel in
|
|
161
|
+
ink, lamps in the accent — so the lamps are white at night and
|
|
162
|
+
route-blue on paper, per the one-token-may-change-hue rule. %>
|
|
163
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" role="img" aria-hidden="true"><circle cx="12" cy="12" r="9.6" fill="none" style="stroke: var(--ink)" stroke-width="2.2"/><circle cx="8.4" cy="15.6" r="2.5" style="fill: var(--accent)"/><circle cx="15.6" cy="8.4" r="2.5" style="fill: var(--accent)"/></svg><span>silas</span>
|
|
164
|
+
<% end %>
|
|
165
|
+
</span>
|
|
107
166
|
<span class="spacer"></span>
|
|
108
167
|
<% if content_for?(:header_extra) %><%= yield :header_extra %><% end %>
|
|
109
168
|
</header>
|
|
@@ -1,14 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
|
|
5
|
+
The wrapper id is dom_id(invocation, :approval) — DISTINCT from the trace
|
|
6
|
+
row's dom_id(invocation) — because this card lives at the TOP of the
|
|
7
|
+
session (04) while the trace keeps a stub. When the invocation settles,
|
|
8
|
+
Broadcastable replaces this target with the empty shell below and the card
|
|
9
|
+
disappears from the top. %>
|
|
10
|
+
<div id="<%= dom_id(invocation, :approval) %>">
|
|
11
|
+
<% if !invocation.awaiting_approval? %>
|
|
12
|
+
<% elsif invocation.question? %>
|
|
13
|
+
<div class="approval">
|
|
14
|
+
<h3>The agent has a question</h3>
|
|
15
|
+
<p class="question-text"><%= invocation.arguments["question"] %></p>
|
|
16
|
+
<%= form_with url: silas_engine_path(:answer_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
|
|
17
|
+
<textarea name="text" rows="3" placeholder="Your answer — sent back to the agent as the tool result"></textarea>
|
|
18
|
+
<button class="btn approve">Answer</button>
|
|
19
|
+
<% end %>
|
|
20
|
+
<%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
|
|
21
|
+
<button class="btn decline">Decline to answer</button>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
<% else %>
|
|
25
|
+
<div class="approval">
|
|
26
|
+
<h3>Approval needed — <%= invocation.tool_name %></h3>
|
|
27
|
+
<pre><%= pretty_args(invocation.arguments) %></pre>
|
|
28
|
+
<%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
|
|
29
|
+
<button class="btn approve">Approve</button>
|
|
30
|
+
<% end %>
|
|
31
|
+
<%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
|
|
32
|
+
<textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
|
|
33
|
+
<button class="btn decline">Decline</button>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
14
37
|
</div>
|
|
@@ -5,23 +5,35 @@
|
|
|
5
5
|
<%# The audit line — who held the lever, and why it moved. %>
|
|
6
6
|
<% if invocation.approval_state == "approved" %>
|
|
7
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>
|
|
8
10
|
<% elsif invocation.approval_state == "declined" %>
|
|
9
11
|
<span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
|
|
10
12
|
<% elsif invocation.approval_state == "expired" %>
|
|
11
|
-
<span class="muted"
|
|
13
|
+
<span class="muted"><%= invocation.question? ? "question expired unanswered" : "approval expired unanswered" %></span>
|
|
12
14
|
<% end %>
|
|
13
15
|
</div>
|
|
14
16
|
<% if invocation.awaiting_approval? %>
|
|
15
|
-
|
|
17
|
+
<%# The card itself lives at the TOP of the session (04) — the trace keeps
|
|
18
|
+
its place in the movement with a one-line stub. %>
|
|
19
|
+
<div class="held-stub">↑ held at the signal — <%= invocation.question? ? "answer" : "clear" %> it above</div>
|
|
16
20
|
<% else %>
|
|
17
|
-
<%# What the agent passed — the question an audit surface must answer.
|
|
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. %>
|
|
18
23
|
<% if invocation.arguments.present? %>
|
|
19
|
-
<
|
|
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>
|
|
20
29
|
<% end %>
|
|
21
30
|
<% if invocation.error.present? %>
|
|
22
31
|
<pre class="error"><%= invocation.error %></pre>
|
|
23
32
|
<% elsif invocation.result.present? %>
|
|
24
|
-
<
|
|
33
|
+
<details class="tool-result">
|
|
34
|
+
<summary>result</summary>
|
|
35
|
+
<pre><%= pretty_args(invocation.result) %></pre>
|
|
36
|
+
</details>
|
|
25
37
|
<% end %>
|
|
26
38
|
<% end %>
|
|
27
39
|
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%= link_to inbox_session_path(session), class: "card session-row" do %>
|
|
2
|
+
<div class="row-top">
|
|
3
|
+
<span class="name"><%= session.agent_name %></span>
|
|
4
|
+
<%# Loaded association — no per-row queries. %>
|
|
5
|
+
<% if (turn = session.turns.detect(&:active?) || session.turns.last) %><%= status_pill(turn.status) %><% end %>
|
|
6
|
+
<% pending = pending_counts.fetch(session.id, 0) %>
|
|
7
|
+
<% if pending.positive? %><span class="badge"><%= pending %> held</span><% end %>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="muted">
|
|
10
|
+
<%= pluralize(session.turns.size, "turn") %> ·
|
|
11
|
+
<%= session.channel.presence || "direct" %> ·
|
|
12
|
+
<%= silas_relative_time(session.updated_at) %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
@@ -33,21 +33,22 @@
|
|
|
33
33
|
<% if @sessions.empty? %>
|
|
34
34
|
<div class="empty">No agent sessions yet — start one above.</div>
|
|
35
35
|
<% else %>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
<%# 06: the rail, grouped by who's blocked. Held = a person is needed
|
|
37
|
+
(pending approvals/questions, or a turn parked waiting/in_doubt);
|
|
38
|
+
Working = a worker holds it; Filed = nothing needs anyone. Grouping is
|
|
39
|
+
over the loaded page — same queries the flat list already ran. %>
|
|
40
|
+
<%
|
|
41
|
+
held, rest = @sessions.partition do |s|
|
|
42
|
+
@pending_counts.fetch(s.id, 0).positive? ||
|
|
43
|
+
s.turns.any? { |t| %w[waiting in_doubt].include?(t.status) }
|
|
44
|
+
end
|
|
45
|
+
working, filed = rest.partition { |s| s.turns.any? { |t| %w[queued running].include?(t.status) } }
|
|
46
|
+
%>
|
|
47
|
+
<% { "Held" => held, "Working" => working, "Filed" => filed }.each do |title, group| %>
|
|
48
|
+
<% next if group.empty? %>
|
|
49
|
+
<div class="rail-title"><%= title %></div>
|
|
50
|
+
<%= render partial: "silas/inbox/sessions/row", collection: group, as: :session,
|
|
51
|
+
locals: { pending_counts: @pending_counts } %>
|
|
51
52
|
<% end %>
|
|
52
53
|
<% if @next_before %>
|
|
53
54
|
<div class="pager">
|
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
<div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
+
<%# 04: everything awaiting a person sits ABOVE the movement — the operator
|
|
19
|
+
never scrolls a long trace hunting for the card. Live parks are appended
|
|
20
|
+
here by Broadcastable; settled cards replace themselves with an empty
|
|
21
|
+
shell and vanish. %>
|
|
22
|
+
<div id="silas-session-<%= @session.id %>-approvals">
|
|
23
|
+
<%= render partial: "silas/inbox/invocations/approval_card",
|
|
24
|
+
collection: @session.pending_approvals.order(:id), as: :invocation %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
18
27
|
<div id="silas-turns">
|
|
19
28
|
<%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
|
|
20
29
|
</div>
|
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
|
data/docs/agents.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Agents & staff
|
|
2
|
+
|
|
3
|
+
## The root agent
|
|
4
|
+
|
|
5
|
+
`app/agent/` is the default agent: `instructions.md` (the persona — ERB,
|
|
6
|
+
snapshotted once per turn), `agent.yml` (data-only config), and directories
|
|
7
|
+
for tools, skills, schedules, channels, and connections. Start it from
|
|
8
|
+
anywhere in your app:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
session = Silas.agent.start(input: "Refund order 42, £12.50")
|
|
12
|
+
session.continue(input: "Now email the customer.")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`agent.yml` keys:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
model: claude-sonnet-4-5 # must resolve in ruby_llm's registry; defaults to config.default_model
|
|
19
|
+
description: One line, shown in rosters.
|
|
20
|
+
limits: # per-turn caps — see docs/budgets.md
|
|
21
|
+
max_steps: 10
|
|
22
|
+
max_cost: 0.25
|
|
23
|
+
timeout: 300
|
|
24
|
+
final_answer: # optional JSON schema -> Turn#answer_data (structured answers)
|
|
25
|
+
type: object
|
|
26
|
+
properties: { verdict: { type: string } }
|
|
27
|
+
required: [verdict]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Named agents — the staff pattern
|
|
31
|
+
|
|
32
|
+
One app can employ several agents, each with its own room:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
app/agents/
|
|
36
|
+
escalations/
|
|
37
|
+
instructions.md
|
|
38
|
+
agent.yml # same keys as the root agent
|
|
39
|
+
tools/ # its own toolset — the root agent's tools are not inherited
|
|
40
|
+
skills/
|
|
41
|
+
schedules/ # its own cron, ticking IT — not the root agent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Sessions are stamped with the agent's name, and every turn — including crash
|
|
45
|
+
resumes — runs under that agent's own tools, skills, instructions, and
|
|
46
|
+
definitions digest. Scope switching is execution-isolated, so concurrent jobs
|
|
47
|
+
running different agents never cross wires.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Silas.agent("escalations").start(input: "…")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
bin/rails silas:chat AGENT=escalations # talk to one staff member
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The inbox filters by agent; the root `app/agent/` stays the default.
|
|
58
|
+
|
|
59
|
+
## Subagents — delegation within a turn
|
|
60
|
+
|
|
61
|
+
The built-in `delegate` tool runs a scoped sub-task under a subagent's own
|
|
62
|
+
instructions and toolset and returns its answer into the parent turn. Use it
|
|
63
|
+
to keep a specialist's tools out of the main agent's prompt until needed.
|
|
64
|
+
|
|
65
|
+
## Handoffs — durable work between staff
|
|
66
|
+
|
|
67
|
+
Staff compose through **handoffs, not conversations**: the built-in `handoff`
|
|
68
|
+
tool files a self-contained brief that starts a **linked session** for another
|
|
69
|
+
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.
|
|
73
|
+
|
|
74
|
+
## Schedules give any agent a clock
|
|
75
|
+
|
|
76
|
+
A markdown file in the agent's `schedules/` directory — cron frontmatter, body
|
|
77
|
+
= the turn input — compiled by `bin/rails silas:schedules` into Solid Queue
|
|
78
|
+
recurring tasks (cron that fires real work stays a reviewable git diff). A
|
|
79
|
+
scheduled tick is a normal durable turn for **that** agent: it can hold for
|
|
80
|
+
approval, spend budget, and appear in the inbox like any other. `.rb` handlers
|
|
81
|
+
(subclassing `Silas::Schedule::Handler`) cover the programmatic cases.
|
data/docs/budgets.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Budgets
|
|
2
|
+
|
|
3
|
+
A turn gets four caps. Breaching `max_input_tokens`, `max_cost`, or `timeout`
|
|
4
|
+
**parks the turn** — like an approval, at zero compute — completed work is
|
|
5
|
+
never destroyed, and a human can top the cap up and resume from the inbox.
|
|
6
|
+
`max_steps` is different: it's the runaway-loop guard, and hitting it **fails
|
|
7
|
+
the turn loudly** (`failure_reason: "max_steps"`).
|
|
8
|
+
|
|
9
|
+
## The caps
|
|
10
|
+
|
|
11
|
+
Declared per agent in `agent.yml` (each is optional; unset = uncapped, except
|
|
12
|
+
`max_steps` which defaults from `config.max_steps`):
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
limits:
|
|
16
|
+
max_steps: 10 # model calls per turn
|
|
17
|
+
max_input_tokens: 200000 # cumulative input tokens across the turn's steps
|
|
18
|
+
max_cost: 0.25 # dollars per turn (priced tokens only)
|
|
19
|
+
timeout: 300 # seconds of ACTIVE work — held time doesn't count
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Semantics — the details that matter
|
|
23
|
+
|
|
24
|
+
- **Checks run between steps**, in the framework-owned loop, never inside a
|
|
25
|
+
continuation step. A cap can't fire mid-model-call; the in-flight step's
|
|
26
|
+
work always commits.
|
|
27
|
+
- **Token and cost checks are deterministic** — they read the persisted step
|
|
28
|
+
rows, so a crash-replay reaches the same verdict.
|
|
29
|
+
- **Cost only counts priced tokens.** Pricing comes from RubyLLM's model
|
|
30
|
+
registry, overridable per model with `config.model_prices` (fine-tunes,
|
|
31
|
+
custom deployments, models newer than your installed registry). A model the
|
|
32
|
+
registry can't price can't be cost-capped.
|
|
33
|
+
- **`timeout` measures active wall-clock.** The clock **restarts when an
|
|
34
|
+
approval resumes the turn** — it bounds active stretches of work, never the
|
|
35
|
+
hours a human spends deciding. Crash-rescue resumes keep the original clock:
|
|
36
|
+
the turn was live the whole time. (Reading the clock is benign
|
|
37
|
+
non-determinism: a cap firing later on a replay is still a correct cap — the
|
|
38
|
+
turn genuinely ran too long across the crash.)
|
|
39
|
+
|
|
40
|
+
## What a breach looks like
|
|
41
|
+
|
|
42
|
+
The turn parks with status `waiting` and the breached cap as its park reason
|
|
43
|
+
(`max_input_tokens` / `max_cost` / `timeout`). In the inbox the turn shows
|
|
44
|
+
**held** with a raise-budget control; over the API it's visible on the
|
|
45
|
+
session's turns.
|
|
46
|
+
|
|
47
|
+
## Topping up
|
|
48
|
+
|
|
49
|
+
`Turn#raise_budget!` records a **per-turn override** that beats the agent's
|
|
50
|
+
configured limit for that cap, then resumes the turn:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
turn.raise_budget!(max_cost: 1.00)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The inbox's raise-budget button does exactly this. Overrides live on the turn
|
|
57
|
+
(`budget_overrides`), so one generous exception never loosens the agent's
|
|
58
|
+
standing limits.
|
|
59
|
+
|
|
60
|
+
## Choosing limits
|
|
61
|
+
|
|
62
|
+
Set `max_cost` on anything using an expensive model — it's the cap that maps
|
|
63
|
+
to money. `max_steps` is the runaway-loop guard. `timeout` protects worker
|
|
64
|
+
slots from a wedged provider (the adapter's own request timeout should be
|
|
65
|
+
tighter). `max_input_tokens` is mostly superseded by compaction
|
|
66
|
+
(`config.compact_at` summarises long conversations instead of failing them) —
|
|
67
|
+
keep it as a hard ceiling if you cap spend per turn strictly.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Cancellation
|
|
2
|
+
|
|
3
|
+
Cancel means *a person stopped it* — in the UI it renders as a lamp going out
|
|
4
|
+
(quiet, dashed), not a failure. Under the hood there are exactly two cases,
|
|
5
|
+
decided by whether anything is live:
|
|
6
|
+
|
|
7
|
+
## Parked or queued: settles immediately
|
|
8
|
+
|
|
9
|
+
A turn with no live execution — held on an approval, a question, a budget
|
|
10
|
+
breach, or still queued — cancels synchronously:
|
|
11
|
+
|
|
12
|
+
- status → `canceled`, finished stamp set;
|
|
13
|
+
- every pending approval on the turn is **expired** with the cancel reason
|
|
14
|
+
recorded as its denial — so a later `approve!` can't zombie-resume a turn
|
|
15
|
+
someone already killed.
|
|
16
|
+
|
|
17
|
+
## Running: honored at the next step boundary
|
|
18
|
+
|
|
19
|
+
A turn a worker is actively executing gets **flagged**
|
|
20
|
+
(`cancel_requested_at`), and the loop honors the flag at the next step
|
|
21
|
+
boundary. The in-flight model call completes and its step commits first —
|
|
22
|
+
aborting mid-step would forfeit paid tokens and create an in-doubt tool window
|
|
23
|
+
for nothing. That's why the inbox button says *"honored at the next step
|
|
24
|
+
boundary."*
|
|
25
|
+
|
|
26
|
+
If the process crashes between the flag and the boundary, the resume sees the
|
|
27
|
+
flag and cancels then. A cancel landing later than intended is still a correct
|
|
28
|
+
cancel — this is the same benign wall-clock non-determinism as the budget
|
|
29
|
+
timeout.
|
|
30
|
+
|
|
31
|
+
## Surfaces
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
turn.cancel! # => :canceled or :cancel_requested
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Inbox**: the cancel button on an active turn.
|
|
38
|
+
- **API**: `POST /silas/api/v1/turns/:id/cancel`.
|
|
39
|
+
|
|
40
|
+
Canceling raises if the turn is already terminal. The session stays usable —
|
|
41
|
+
`session.continue(input: "…")` starts a fresh turn after a canceled one.
|