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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +211 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -250
  5. data/app/controllers/silas/api/v1/approvals_controller.rb +10 -0
  6. data/app/controllers/silas/inbox/invocations_controller.rb +8 -0
  7. data/app/helpers/silas/inbox/trace_helper.rb +20 -5
  8. data/app/jobs/silas/channel_delivery_job.rb +15 -0
  9. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  10. data/app/models/silas/compaction.rb +32 -0
  11. data/app/models/silas/tool_invocation.rb +29 -3
  12. data/app/views/layouts/silas/inbox.html.erb +89 -30
  13. data/app/views/silas/inbox/invocations/_approval_card.html.erb +36 -13
  14. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  15. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  16. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  17. data/app/views/silas/inbox/sessions/show.html.erb +9 -0
  18. data/config/routes.rb +2 -0
  19. data/db/migrate/20260725000002_create_silas_compactions.rb +26 -0
  20. data/docs/agents.md +81 -0
  21. data/docs/budgets.md +67 -0
  22. data/docs/cancellation.md +41 -0
  23. data/docs/channels.md +290 -0
  24. data/docs/configuration.md +106 -0
  25. data/docs/connections.md +55 -0
  26. data/docs/conventions.md +161 -0
  27. data/docs/evals.md +95 -0
  28. data/docs/guarantees.md +76 -0
  29. data/docs/inbox-and-api.md +84 -0
  30. data/docs/memory.md +35 -0
  31. data/docs/sandbox.md +44 -0
  32. data/docs/tools.md +77 -0
  33. data/docs/tutorial.md +268 -0
  34. data/docs/vs-eve.md +93 -0
  35. data/docs/why-silas.md +87 -0
  36. data/lib/generators/silas/channel/channel_generator.rb +72 -0
  37. data/lib/generators/silas/channel/templates/channel.rb.tt +48 -0
  38. data/lib/generators/silas/channel/templates/controller.rb.tt +66 -0
  39. data/lib/generators/silas/install/install_generator.rb +11 -1
  40. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  41. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  42. data/lib/silas/adapters/ruby_llm.rb +102 -46
  43. data/lib/silas/channel.rb +35 -0
  44. data/lib/silas/compactor.rb +178 -0
  45. data/lib/silas/configuration.rb +16 -0
  46. data/lib/silas/eval/dsl.rb +7 -2
  47. data/lib/silas/instrumentation.rb +7 -3
  48. data/lib/silas/ledger.rb +2 -2
  49. data/lib/silas/log_subscriber.rb +5 -0
  50. data/lib/silas/message_builder.rb +19 -0
  51. data/lib/silas/registry.rb +5 -2
  52. data/lib/silas/schedule.rb +44 -15
  53. data/lib/silas/slack.rb +8 -5
  54. data/lib/silas/step_runner.rb +5 -0
  55. data/lib/silas/tools/ask_question.rb +26 -0
  56. data/lib/silas/version.rb +1 -1
  57. data/lib/silas/webhook.rb +47 -0
  58. data/lib/silas.rb +3 -0
  59. 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: #f7f7f8; --panel: #ffffff; --ink: #16181d; --muted: #6b7280;
11
- --line: #e5e7eb; --accent: #4f46e5; --amber: #b45309; --amber-bg: #fef3c7;
12
- --green: #047857; --green-bg: #d1fae5; --red: #b91c1c; --red-bg: #fee2e2;
13
- --blue: #1d4ed8; --blue-bg: #dbeafe; --grey-bg: #eef0f3;
14
- --radius: 14px; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
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: dark) {
40
+ @media (prefers-color-scheme: light) {
17
41
  :root {
18
- --bg: #0d0f14; --panel: #161a22; --ink: #e6e8ec; --muted: #9aa2af;
19
- --line: #262b36; --accent: #818cf8; --amber: #fbbf24; --amber-bg: #3a2e12;
20
- --green: #34d399; --green-bg: #10291f; --red: #f87171; --red-bg: #35191a;
21
- --blue: #60a5fa; --blue-bg: #14233d; --grey-bg: #1e232d;
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 { font-weight: 700; letter-spacing: -0.02em; font-size: 20px; }
33
- header.top .logo a { color: var(--ink); text-decoration: none; }
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-pulse { animation: pulse 1.4s ease-in-out infinite; }
53
- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
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: 10px; padding: 8px 10px; margin: 6px 0; font-size: 13px; }
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
- pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: 8px;
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: 12px;
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
- .btn { display: inline-block; border: 0; border-radius: 10px; padding: 11px 18px; min-height: 44px;
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: 8px;
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: 10px;
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
- .btn.send { background: var(--accent); color: #fff; }
82
- select.composer-agent { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
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: 10px; margin-bottom: 12px; }
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: 8px; padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; margin-left: auto; }
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 #d9dce1;
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: #16181d; color: #fff; border-color: #16181d; }
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: 8px; padding: 8px 10px;
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"><%= link_to "Silas", inbox_sessions_path %></span>
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
- <div class="approval">
2
- <h3>Approval needed <%= invocation.tool_name %></h3>
3
- <pre><%= pretty_args(invocation.arguments) %></pre>
4
- <%# silas_engine_path, not bare helpers: this partial is broadcast-rendered
5
- through the HOST's renderer, where engine route helpers don't exist and
6
- the mounted proxy has no routing scope to lean on. %>
7
- <%= form_with url: silas_engine_path(:approve_inbox_invocation_path, invocation), method: :post, class: "inline" do %>
8
- <button class="btn approve">Approve</button>
9
- <% end %>
10
- <%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
11
- <textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
12
- <button class="btn decline">Decline</button>
13
- <% end %>
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">approval expired unanswered</span>
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
- <%= render "silas/inbox/invocations/approval_card", invocation: invocation %>
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
- <pre class="args"><%= pretty_args(invocation.arguments) %></pre>
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
- <pre><%= pretty_args(invocation.result) %></pre>
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
- <% @sessions.each do |session| %>
37
- <%= link_to inbox_session_path(session), class: "card session-row" do %>
38
- <div class="row-top">
39
- <span class="name"><%= session.agent_name %></span>
40
- <%# Loaded association — no per-row queries. %>
41
- <% if (turn = session.turns.detect(&:active?) || session.turns.last) %><%= status_pill(turn.status) %><% end %>
42
- <% pending = @pending_counts.fetch(session.id, 0) %>
43
- <% if pending.positive? %><span class="badge"><%= pending %> to approve</span><% end %>
44
- </div>
45
- <div class="muted">
46
- <%= pluralize(session.turns.size, "turn") %> ·
47
- <%= session.channel.presence || "direct" %> ·
48
- <%= silas_relative_time(session.updated_at) %>
49
- </div>
50
- <% end %>
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.