silas 0.3.1 → 0.4.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 +109 -0
- data/README.md +5 -5
- data/app/helpers/silas/inbox/trace_helper.rb +11 -1
- data/app/jobs/silas/agent_loop_job.rb +2 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +10 -2
- data/app/models/silas/tool_invocation.rb +15 -1
- data/app/models/silas/turn.rb +7 -1
- data/app/views/silas/channel_mailer/approval.text.erb +2 -2
- data/app/views/silas/channels/approvals/show.html.erb +1 -1
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +5 -2
- data/app/views/silas/inbox/sessions/index.html.erb +1 -1
- data/app/views/silas/inbox/sessions/show.html.erb +1 -1
- data/app/views/silas/inbox/turns/_header.html.erb +5 -2
- data/config/brakeman.ignore +11 -0
- data/lib/generators/silas/install/templates/initializer.rb +1 -1
- data/lib/silas/{engines → adapters}/base.rb +12 -2
- data/lib/silas/{engines → adapters}/ruby_llm.rb +2 -2
- data/lib/silas/budget.rb +5 -3
- data/lib/silas/chat.rb +2 -2
- data/lib/silas/configuration.rb +24 -9
- data/lib/silas/delta_buffer.rb +3 -3
- data/lib/silas/deprecator.rb +16 -0
- data/lib/silas/engine.rb +26 -2
- data/lib/silas/eval/driver.rb +1 -1
- data/lib/silas/eval/dsl.rb +1 -1
- data/lib/silas/eval/scripted_engine.rb +3 -3
- data/lib/silas/inbox/delta_broadcaster.rb +1 -1
- data/lib/silas/inbox.rb +15 -0
- data/lib/silas/instrumentation.rb +55 -0
- data/lib/silas/ledger.rb +14 -0
- data/lib/silas/log_subscriber.rb +78 -0
- data/lib/silas/slack.rb +1 -1
- data/lib/silas/step_runner.rb +5 -2
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +25 -14
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9abd9603ae899da9a789ded30f092f11f1881ddc9abd2f0edf2638421f469abb
|
|
4
|
+
data.tar.gz: 16a684a62b54346ee51d4e7b9f6cb92ec19c2d95e7027667b432044d9a1e7f71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9518d703a421ed47d1c59e9cb74cdbd46686b8a783a1f5aea8f90f5911e0e27f5ccc5fc86da22c8ebe870a3d811b7de28e5abfc2ff837ab7dbc47722d13e3dcc
|
|
7
|
+
data.tar.gz: 954efe8fac65a18a0d936bafda946bcca96e8dda35201d06637d8f6ee5a8ca425ecd50ab322d2f137e7c0d03b2e97321db7d5cedfc5ac7d0a4b016ddf3184b63
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,114 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
The architecture-and-hardening release: one shipped feature that had never
|
|
6
|
+
worked, the naming locked down before 1.0 freezes it, and the durable loop
|
|
7
|
+
finally observable.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **The email approval channel had never worked.** Both the approval email
|
|
12
|
+
template and the confirmation page called `approval_url`/`approval_path`,
|
|
13
|
+
but the route is declared inside `namespace :channels`, so the real helpers
|
|
14
|
+
are `channels_approval_url`/`_path`. Rendering raised — meaning
|
|
15
|
+
**`ChannelMailer#approval` blew up and the "your agent needs approval" email
|
|
16
|
+
was never delivered**, and the confirmation page 500'd. If you relied on
|
|
17
|
+
email approvals, you were silently never notified that a money-moving call
|
|
18
|
+
was parked. Found by writing the first specs for these surfaces.
|
|
19
|
+
|
|
20
|
+
### Changed (breaking, pre-1.0)
|
|
21
|
+
|
|
22
|
+
- **The inference seam is now `Adapters::`, not `Engines::`** — and
|
|
23
|
+
`config.adapter`, not `config.engine`. "Engine" meant two unrelated things
|
|
24
|
+
in one namespace: the Rails engine at `Silas::Engine`, and the pluggable
|
|
25
|
+
inference backend. Every comparable seam disambiguates — ActiveJob has
|
|
26
|
+
`QueueAdapters::`, ActiveStorage `Service::`, RubyLLM `Provider`. Done now
|
|
27
|
+
because 1.0 freezes the public API and host apps subclass this seam.
|
|
28
|
+
**Nothing breaks today**: `Silas::Engines::Base`, `config.engine`, and
|
|
29
|
+
`Silas.resolved_engine` all still resolve, warn through the new deprecator,
|
|
30
|
+
and are removed in 2.0.
|
|
31
|
+
- **Notification names follow the Rails convention** `<event>.silas` (like
|
|
32
|
+
`sql.active_record`). The two pre-existing events were backwards:
|
|
33
|
+
`silas.step` → `step.silas`, `silas.delta` → `delta.silas`. Update any
|
|
34
|
+
subscriber; `subscribe(/\.silas\z/)` now catches everything.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Instrumentation for the durable loop.** It emitted almost nothing before:
|
|
39
|
+
a turn could start, park for a human, be rescued after a `kill -9`, breach a
|
|
40
|
+
budget and finish without a single line. Ten events now — `turn`, `step`,
|
|
41
|
+
`tool`, `park`, `resume`, `approval`, `budget`, `rescue`, `nondeterminism`,
|
|
42
|
+
`delta` — with documented payloads that always carry `turn_id`/`session_id`,
|
|
43
|
+
so a subscriber never has to join. **`tool.silas`** times the tool's own
|
|
44
|
+
execution and reports how it settled (the most useful span in the system);
|
|
45
|
+
**`resume.silas`** carries `parked_for` — how long the human actually took.
|
|
46
|
+
`Silas::LogSubscriber` (modelled on Solid Queue's) turns them into log lines
|
|
47
|
+
at operator-filterable levels: parks and rescues INFO, budget WARN, failed
|
|
48
|
+
turns and nondeterminism ERROR, per-token chatter DEBUG — and stays silent
|
|
49
|
+
when the rescuer did nothing.
|
|
50
|
+
- **`Silas.deprecator`** — an `ActiveSupport::Deprecation` registered in
|
|
51
|
+
`app.deprecators[:silas]`, so hosts silence or raise on Silas deprecations
|
|
52
|
+
exactly as they do Rails'. Every message names the replacement *and* the
|
|
53
|
+
removal version.
|
|
54
|
+
- **Coverage for the four money-path surfaces that had none** (36 specs):
|
|
55
|
+
`Channels::SlackController` (unsigned / wrong-secret / stale-timestamp
|
|
56
|
+
requests refused end to end; retries and bot messages ignored; buttons
|
|
57
|
+
settle through the same `approve!`/`decline!`),
|
|
58
|
+
`Channels::ApprovalsController` (tampered, garbage, expired and
|
|
59
|
+
wrong-purpose tokens refused; **GET never mutates**, so a link preview or
|
|
60
|
+
scanner cannot approve a refund; a replayed link on a settled invocation
|
|
61
|
+
422s), `AgentMailbox` (References → In-Reply-To → Message-ID threading, so
|
|
62
|
+
replies continue rather than restart), and `ChannelMailer` (renders, shows
|
|
63
|
+
the arguments, embeds two distinct absolute links whose tokens verify back).
|
|
64
|
+
- **Quality tooling, enforced in CI**: `rubocop-rails-omakase` (Rails' own
|
|
65
|
+
style baseline, zero offenses), SimpleCov with a **90% line-coverage floor
|
|
66
|
+
that fails the build** (actual: 92.5%), Brakeman and bundler-audit (clean —
|
|
67
|
+
the single deliberate CSRF suppression is documented with its reasoning in
|
|
68
|
+
`config/brakeman.ignore`), and a `rake zeitwerk:check` job that eager-loads
|
|
69
|
+
every constant to catch naming violations lazy tests never see.
|
|
70
|
+
- **Dependency contract specs.** Silas reaches into Solid Queue and RubyLLM
|
|
71
|
+
internals, where a rename breaks *recovery* silently. Ten specs pin them:
|
|
72
|
+
the dead-process error classes the rescuer allowlists,
|
|
73
|
+
`FailedExecution#retry`, the Solid Queue >= 1.2 continuations floor,
|
|
74
|
+
RubyLLM's `with_schema`/`before_message`/`Tool::Halt`/model registry and the
|
|
75
|
+
error classes `retry_on` names, and `resume_errors_after_advancing` staying
|
|
76
|
+
false. Plus an allowed-to-fail CI canary against ruby_llm edge, for early
|
|
77
|
+
warning on the 2.0 horizon.
|
|
78
|
+
- **`docs/conventions.md`** — the naming and structure rules (why the seam is
|
|
79
|
+
`Adapters::`; the deliberate `class << self` vs `module_function` split) and
|
|
80
|
+
the audited posture: nothing mass-assigned, no `raw`/`html_safe` anywhere,
|
|
81
|
+
`Time.current` throughout, indexes on query paths rather than every foreign
|
|
82
|
+
key. Written down so nobody "fixes" something load-bearing.
|
|
83
|
+
|
|
84
|
+
No migration. 336 specs green on SQLite and Postgres.
|
|
85
|
+
|
|
86
|
+
## 0.3.2
|
|
87
|
+
|
|
88
|
+
- **The `timeout` budget no longer counts time spent parked for approval.**
|
|
89
|
+
The wall clock restarts when an approval resumes a turn, because any
|
|
90
|
+
approval slower than `limits.timeout` previously made the approved resume
|
|
91
|
+
*instantly* re-park on "timeout" — pathological for a gate whose whole point
|
|
92
|
+
is waiting for a person (found live in the playground: a 3-minute approval
|
|
93
|
+
against a 120s timeout). Timeout now bounds **active** stretches — hung
|
|
94
|
+
providers, runaway loops; crash-rescue resumes keep the original clock (the
|
|
95
|
+
turn was genuinely live), and cost/token budgets stay cumulative because
|
|
96
|
+
they measure real spend.
|
|
97
|
+
- **Broadcast-rendered trace partials work in host apps.** The inbox's live
|
|
98
|
+
trace renders through Turbo's broadcast jobs — i.e. the HOST's default
|
|
99
|
+
renderer — where the engine-scoped `TraceHelper` and bare engine route
|
|
100
|
+
helpers didn't exist, so **every broadcast render raised and the live trace
|
|
101
|
+
silently never streamed in real host apps** (the gem's specs stubbed the
|
|
102
|
+
dispatch seam and never rendered). The helper is now registered host-wide,
|
|
103
|
+
partials build routes context-free via `silas_engine_path` (engine route
|
|
104
|
+
set + discovered mount point), and four host-renderer regression specs pin
|
|
105
|
+
the real path. `relative_time` is renamed `silas_relative_time` (it is now
|
|
106
|
+
host-visible, and the bare name is exactly what a host app would define).
|
|
107
|
+
Also new: `examples/playground` gets a customer-facing chat page that
|
|
108
|
+
renders and live-streams the engine's own trace partials with zero custom
|
|
109
|
+
streaming code, plus a scripted keyless demo mode (`bin/setup && bin/dev`
|
|
110
|
+
with no API key).
|
|
111
|
+
|
|
3
112
|
## 0.3.1
|
|
4
113
|
|
|
5
114
|
- **Fixed: only the first scenario in a `silas:eval` run was really tested.**
|
data/README.md
CHANGED
|
@@ -104,15 +104,15 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
104
104
|
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
105
105
|
(`NondeterminismError`) instead of resuming into a different agent.
|
|
106
106
|
|
|
107
|
-
##
|
|
107
|
+
## Adapter
|
|
108
108
|
|
|
109
|
-
Inference is one pluggable seam (`config.
|
|
109
|
+
Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
|
|
110
110
|
via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
|
|
111
111
|
and the production path. Compose resilience via `config.around_model_call`, or
|
|
112
112
|
swap in any object responding to `#execute_step` (the eval harness and the
|
|
113
113
|
chaos tests do exactly that).
|
|
114
114
|
|
|
115
|
-
> The experimental `:agent_sdk`
|
|
115
|
+
> The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
|
|
116
116
|
> in 0.2: its subscription-auth rationale was structurally unreachable, and it
|
|
117
117
|
> carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
|
|
118
118
|
> server survives and returns as a first-class *mount your tools as MCP*
|
|
@@ -200,13 +200,13 @@ An agent is reached by more than a method call:
|
|
|
200
200
|
|
|
201
201
|
## Streaming
|
|
202
202
|
|
|
203
|
-
Turns stream. The `:ruby_llm`
|
|
203
|
+
Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
|
|
204
204
|
`bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
|
|
205
205
|
renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
|
|
206
206
|
the durable rows — never persisted, never fed back to the model, and a
|
|
207
207
|
replayed step renders from its row with no deltas at all, so streaming adds
|
|
208
208
|
zero risk to the durability contract. Custom sinks subscribe to the
|
|
209
|
-
`"silas
|
|
209
|
+
`"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
|
|
210
210
|
text: }`, where `text` is the accumulated string so far — filter by ids;
|
|
211
211
|
notifications are process-global).
|
|
212
212
|
|
|
@@ -37,11 +37,21 @@ module Silas
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
# Prefixed: this helper is registered host-wide (broadcast renders need
|
|
41
|
+
# it), and "relative_time" is exactly the name a host app would define.
|
|
42
|
+
def silas_relative_time(time)
|
|
41
43
|
return "" unless time
|
|
42
44
|
|
|
43
45
|
"#{time_ago_in_words(time)} ago"
|
|
44
46
|
end
|
|
47
|
+
|
|
48
|
+
# Engine paths that resolve in EVERY render context. The mounted proxy
|
|
49
|
+
# (`silas.`) leans on the rendering scope's url_options, which Turbo's
|
|
50
|
+
# bare broadcast renderer doesn't have — so broadcast-rendered partials
|
|
51
|
+
# build paths from the engine's own route set + the discovered mount.
|
|
52
|
+
def silas_engine_path(helper, *args)
|
|
53
|
+
Silas::Engine.routes.url_helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
|
|
54
|
+
end
|
|
45
55
|
end
|
|
46
56
|
end
|
|
47
57
|
end
|
|
@@ -112,6 +112,8 @@ module Silas
|
|
|
112
112
|
# replays completed steps from rows, resuming where it left off.
|
|
113
113
|
if (reason = Budget.exceeded_reason(turn))
|
|
114
114
|
turn.update!(status: "waiting", failure_reason: reason)
|
|
115
|
+
Silas.instrument(:budget, reason: reason, turn_id: turn.id)
|
|
116
|
+
Silas.instrument(:park, reason: "budget", turn_id: turn.id, detail: reason)
|
|
115
117
|
return
|
|
116
118
|
end
|
|
117
119
|
|
|
@@ -15,7 +15,13 @@ module Silas
|
|
|
15
15
|
queue_as { Silas.config.queue_name }
|
|
16
16
|
|
|
17
17
|
def perform
|
|
18
|
+
Silas.instrument(:rescue) { |payload| sweep(payload) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sweep(payload)
|
|
18
22
|
ToolInvocation.expire_stale!
|
|
23
|
+
payload[:rescued] = 0
|
|
24
|
+
payload[:stranded] = 0
|
|
19
25
|
return 0 unless defined?(SolidQueue)
|
|
20
26
|
|
|
21
27
|
rescued = 0
|
|
@@ -24,9 +30,10 @@ module Silas
|
|
|
24
30
|
failed.retry
|
|
25
31
|
rescued += 1
|
|
26
32
|
elsif failed.job&.class_name == "Silas::AgentLoopJob"
|
|
27
|
-
fail_stranded_turn(failed)
|
|
33
|
+
payload[:stranded] += 1 if fail_stranded_turn(failed)
|
|
28
34
|
end
|
|
29
35
|
end
|
|
36
|
+
payload[:rescued] = rescued
|
|
30
37
|
rescued
|
|
31
38
|
end
|
|
32
39
|
|
|
@@ -40,13 +47,14 @@ module Silas
|
|
|
40
47
|
# loudly with its approvals expired.
|
|
41
48
|
def fail_stranded_turn(failed)
|
|
42
49
|
turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
|
|
43
|
-
return unless turn&.active?
|
|
50
|
+
return false unless turn&.active?
|
|
44
51
|
|
|
45
52
|
exception = failed.error&.dig("exception_class")
|
|
46
53
|
turn.expire_pending_approvals!("turn failed: #{exception}")
|
|
47
54
|
turn.finish!(:failed, reason: "job_failed")
|
|
48
55
|
Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
|
|
49
56
|
"#{exception} — #{failed.error&.dig('message')}")
|
|
57
|
+
true
|
|
50
58
|
end
|
|
51
59
|
end
|
|
52
60
|
end
|
|
@@ -37,6 +37,8 @@ module Silas
|
|
|
37
37
|
assert_parked!
|
|
38
38
|
assert_turn_resumable!
|
|
39
39
|
update!(status: "pending", approval_state: "approved", approved_by: by)
|
|
40
|
+
Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
|
|
41
|
+
invocation_id: id, turn_id: turn_id)
|
|
40
42
|
resume_turn!
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -49,6 +51,8 @@ module Silas
|
|
|
49
51
|
assert_turn_resumable!
|
|
50
52
|
update!(status: "failed", approval_state: "declined", approved_by: by,
|
|
51
53
|
decline_reason: reason, result: { "denied" => reason })
|
|
54
|
+
Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
|
|
55
|
+
invocation_id: id, turn_id: turn_id)
|
|
52
56
|
resume_turn!
|
|
53
57
|
end
|
|
54
58
|
|
|
@@ -58,6 +62,8 @@ module Silas
|
|
|
58
62
|
where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv|
|
|
59
63
|
inv.update!(approval_state: "expired", status: "failed",
|
|
60
64
|
result: { "denied" => "approval expired" })
|
|
65
|
+
Silas.instrument(:approval, action: "expired", tool: inv.tool_name,
|
|
66
|
+
invocation_id: inv.id, turn_id: inv.turn_id)
|
|
61
67
|
inv.turn.finish!(:failed, reason: "approval_expired")
|
|
62
68
|
end
|
|
63
69
|
end
|
|
@@ -84,7 +90,15 @@ module Silas
|
|
|
84
90
|
return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
|
|
85
91
|
return if turn.tool_invocations.where(approval_state: "required").exists?
|
|
86
92
|
|
|
87
|
-
|
|
93
|
+
# Restart the wall clock: `limits.timeout` bounds ACTIVE stretches (hung
|
|
94
|
+
# providers, runaway loops), not human deliberation. Without this, any
|
|
95
|
+
# approval that took longer than the timeout made the approved resume
|
|
96
|
+
# instantly re-park on "timeout" — pathological for a gate whose whole
|
|
97
|
+
# point is waiting for a person. Cost/token budgets stay cumulative;
|
|
98
|
+
# they measure real spend.
|
|
99
|
+
parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
|
|
100
|
+
turn.update!(status: "queued", started_at: Time.current)
|
|
101
|
+
Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
|
|
88
102
|
AgentLoopJob.perform_later(turn.id)
|
|
89
103
|
end
|
|
90
104
|
end
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -21,8 +21,14 @@ module Silas
|
|
|
21
21
|
def active? = ACTIVE_STATUSES.include?(status)
|
|
22
22
|
def parked? = status == "waiting" || status == "in_doubt"
|
|
23
23
|
|
|
24
|
+
# Duration on this event spans the WHOLE turn, parked time included — it
|
|
25
|
+
# answers "how long did the customer wait", not "how much compute".
|
|
24
26
|
def finish!(new_status, reason: nil)
|
|
25
|
-
|
|
27
|
+
Silas.instrument(:turn, status: new_status.to_s, reason: reason, turn_id: id,
|
|
28
|
+
session_id: session_id, agent: session.agent_name,
|
|
29
|
+
steps: steps.count) do
|
|
30
|
+
update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
|
|
31
|
+
end
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
def canceled? = status == "canceled"
|
|
@@ -3,7 +3,7 @@ Your agent needs approval to run: <%= @invocation.tool_name %>
|
|
|
3
3
|
Arguments:
|
|
4
4
|
<%= JSON.pretty_generate(@invocation.arguments) %>
|
|
5
5
|
|
|
6
|
-
Approve: <%= silas.
|
|
7
|
-
Decline: <%= silas.
|
|
6
|
+
Approve: <%= silas.channels_approval_url(token: @approve_token) %>
|
|
7
|
+
Decline: <%= silas.channels_approval_url(token: @decline_token) %>
|
|
8
8
|
|
|
9
9
|
These links expire; do not forward them.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<% if @invocation&.awaiting_approval? %>
|
|
2
2
|
<h1>Approval needed: <%= @invocation.tool_name %></h1>
|
|
3
3
|
<pre><%= JSON.pretty_generate(@invocation.arguments) %></pre>
|
|
4
|
-
<%= form_with url: silas.
|
|
4
|
+
<%= form_with url: silas.channels_approval_path(token: params[:token]), method: :post do %>
|
|
5
5
|
<button type="submit">Confirm <%= @action %></button>
|
|
6
6
|
<% end %>
|
|
7
7
|
<% else %>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
<div class="approval">
|
|
2
2
|
<h3>Approval needed — <%= invocation.tool_name %></h3>
|
|
3
3
|
<pre><%= pretty_args(invocation.arguments) %></pre>
|
|
4
|
-
|
|
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 %>
|
|
5
8
|
<button class="btn approve">Approve</button>
|
|
6
9
|
<% end %>
|
|
7
|
-
<%= form_with url: decline_inbox_invocation_path
|
|
10
|
+
<%= form_with url: silas_engine_path(:decline_inbox_invocation_path, invocation), method: :post, class: "decline-form" do %>
|
|
8
11
|
<textarea name="reason" rows="2" placeholder="Reason (optional — sent back to the agent as the tool result)"></textarea>
|
|
9
12
|
<button class="btn decline">Decline</button>
|
|
10
13
|
<% end %>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<span class="name"><%= @session.agent_name %></span>
|
|
12
12
|
<span class="muted">#<%= @session.id %></span>
|
|
13
13
|
</div>
|
|
14
|
-
<div class="muted"><%= @session.channel.presence || "direct" %> · started <%=
|
|
14
|
+
<div class="muted"><%= @session.channel.presence || "direct" %> · started <%= silas_relative_time(@session.created_at) %></div>
|
|
15
15
|
<div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
<span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
|
|
3
3
|
<%= status_pill(turn.status) %>
|
|
4
4
|
<% if turn.active? && !turn.cancel_requested_at %>
|
|
5
|
-
|
|
5
|
+
<%# silas_engine_path — this partial is broadcast-rendered through the
|
|
6
|
+
host renderer on every turn status change; bare engine helpers (and
|
|
7
|
+
the scope-dependent mounted proxy) 500 there. %>
|
|
8
|
+
<%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
|
|
6
9
|
<button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
|
|
7
10
|
<% end %>
|
|
8
11
|
<% elsif turn.cancel_requested_at && turn.active? %>
|
|
@@ -14,7 +17,7 @@
|
|
|
14
17
|
<h3>Budget reached — <%= turn.failure_reason %></h3>
|
|
15
18
|
<div class="muted">Parked at zero compute. Raise the limit and the turn resumes
|
|
16
19
|
by replaying completed work from rows — no re-calls, no re-effects.</div>
|
|
17
|
-
<%= form_with url: raise_budget_inbox_turn_path
|
|
20
|
+
<%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
|
|
18
21
|
<input name="value" type="number" step="any" min="0" required class="topup-input"
|
|
19
22
|
placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
|
|
20
23
|
<button class="btn approve">Raise & resume</button>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
5
|
+
"warning_code": 7,
|
|
6
|
+
"fingerprint": "64dd0725204c2cd5d6b2dba2fac6c278d20cebd07aaed38493f229193f44ed79",
|
|
7
|
+
"file": "app/controllers/silas/channels/base_controller.rb",
|
|
8
|
+
"note": "Deliberate: Silas::Channels::BaseController serves inbound WEBHOOKS, which no browser session originates, so a CSRF token cannot exist. Each route authenticates the request itself instead: Slack routes verify an HMAC-SHA256 request signature with a 300s replay window (Silas::Slack.verify_signature, covered by spec/silas/channels/slack_controller_spec.rb), and the email approve/decline routes require a purpose-scoped, expiring MessageVerifier token whose possession IS the credential — CSRF would add nothing an attacker holding the token could not already do (covered by spec/silas/channels/approvals_controller_spec.rb, including tampered/expired/wrong-purpose/replayed tokens and the GET-never-mutates property). The inbox controllers keep protect_from_forgery; the JSON API is ActionController::API and is token-authenticated."
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Silas.configure do |config|
|
|
2
2
|
# Inference engine: :ruby_llm (API key, any provider RubyLLM supports), or
|
|
3
3
|
# any object responding to #execute_step. See silas/README.
|
|
4
|
-
config.
|
|
4
|
+
config.adapter = :ruby_llm
|
|
5
5
|
|
|
6
6
|
# Any model your installed ruby_llm's registry resolves (newer models may
|
|
7
7
|
# need `RubyLLM.models.refresh!` first). "claude-sonnet-4-5" is the balanced
|
|
@@ -3,11 +3,11 @@ module Silas
|
|
|
3
3
|
# set — consumers must ignore unknown types. Emitted today by Engines::RubyLLM:
|
|
4
4
|
# :message_start — once per model call (before_message)
|
|
5
5
|
# :text_delta — { text: } chunks as the response streams
|
|
6
|
-
# StepRunner coalesces :text_delta into "silas
|
|
6
|
+
# StepRunner coalesces :text_delta into "delta.silas" notifications (see
|
|
7
7
|
# DeltaBuffer); everything else is available to custom engines/hooks.
|
|
8
8
|
Event = Data.define(:type, :payload)
|
|
9
9
|
|
|
10
|
-
module
|
|
10
|
+
module Adapters
|
|
11
11
|
# The inference seam. An engine executes exactly ONE model call for a step
|
|
12
12
|
# and reports what came back; the framework owns the loop, the ledger owns
|
|
13
13
|
# tool execution.
|
|
@@ -25,4 +25,14 @@ module Silas
|
|
|
25
25
|
|
|
26
26
|
ToolCall = Data.define(:id, :name, :arguments)
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Renamed Engines:: -> Adapters:: in 0.4, removed in 2.0. Host apps subclass
|
|
30
|
+
# Adapters::Base for custom inference backends, so the old constant keeps
|
|
31
|
+
# resolving (with a warning) rather than blowing up on upgrade.
|
|
32
|
+
module Engines
|
|
33
|
+
def self.const_missing(name)
|
|
34
|
+
Silas.deprecator.warn("Silas::Engines::#{name} is deprecated; use Silas::Adapters::#{name}")
|
|
35
|
+
Silas::Adapters.const_get(name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
28
38
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Silas
|
|
2
|
-
module
|
|
2
|
+
module Adapters
|
|
3
3
|
# The :ruby_llm adapter: ONE model call per step, streamed, with tool
|
|
4
4
|
# interception. Silas's Ledger owns tool execution, so tools are registered
|
|
5
5
|
# as halt-proxies — RubyLLM sees the schemas, but the moment the model
|
|
@@ -11,7 +11,7 @@ module Silas
|
|
|
11
11
|
def execute_step(context, &on_event)
|
|
12
12
|
chat = build_chat(context, &on_event)
|
|
13
13
|
|
|
14
|
-
response =
|
|
14
|
+
response = Silas.instrument(:step,
|
|
15
15
|
turn_id: context[:turn]&.id,
|
|
16
16
|
index: context[:index],
|
|
17
17
|
model: context[:model]) do
|
data/lib/silas/budget.rb
CHANGED
|
@@ -7,9 +7,11 @@ module Silas
|
|
|
7
7
|
#
|
|
8
8
|
# Token/cost checks are deterministic (persisted step data). The timeout check
|
|
9
9
|
# reads the wall clock — benign non-determinism: a cap firing later on resume
|
|
10
|
-
# is correct (the turn genuinely ran too long across the crash).
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# is correct (the turn genuinely ran too long across the crash). The clock
|
|
11
|
+
# RESTARTS when an approval resumes the turn (resume_turn! resets
|
|
12
|
+
# started_at): timeout bounds active stretches, never the hours a human
|
|
13
|
+
# spends deciding. Crash-rescue resumes keep the original clock — the turn
|
|
14
|
+
# was live the whole time.
|
|
13
15
|
module Budget
|
|
14
16
|
REASONS = %w[max_input_tokens max_cost timeout].freeze
|
|
15
17
|
|
data/lib/silas/chat.rb
CHANGED
|
@@ -59,11 +59,11 @@ module Silas
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# The REPL runs inline, in the same process as the loop — so it hears the
|
|
62
|
-
# "silas
|
|
62
|
+
# "delta.silas" notifications and prints tokens as they arrive. Filtered by
|
|
63
63
|
# session id: notifications are process-global.
|
|
64
64
|
def with_delta_stream
|
|
65
65
|
@live = {}
|
|
66
|
-
subscription = ActiveSupport::Notifications.subscribe("silas
|
|
66
|
+
subscription = ActiveSupport::Notifications.subscribe("delta.silas") do |*args|
|
|
67
67
|
payload = args.last
|
|
68
68
|
print_delta(payload) if @session && payload[:session_id] == @session.id
|
|
69
69
|
end
|
data/lib/silas/configuration.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
class Configuration
|
|
3
|
-
# Inference
|
|
4
|
-
|
|
3
|
+
# Inference adapter seam: :ruby_llm, or any object responding to
|
|
4
|
+
# #execute_step. (Named `engine` before 0.4 — see the deprecated alias
|
|
5
|
+
# below; "engine" already meant the Rails engine at Silas::Engine.)
|
|
6
|
+
attr_accessor :adapter
|
|
5
7
|
# Default model when agent.yml doesn't specify one.
|
|
6
8
|
attr_accessor :default_model
|
|
7
9
|
# Active Job queue for agent turns.
|
|
@@ -39,8 +41,21 @@ module Silas
|
|
|
39
41
|
# tools as MCP" seam).
|
|
40
42
|
attr_accessor :mcp_server_host
|
|
41
43
|
|
|
44
|
+
# Renamed in 0.4, removed in 2.0. "engine" meant two unrelated things —
|
|
45
|
+
# the Rails engine (Silas::Engine) and the inference backend — the exact
|
|
46
|
+
# collision ActiveJob avoids by calling its seam QueueAdapters.
|
|
47
|
+
def engine
|
|
48
|
+
Silas.deprecator.warn("config.engine is deprecated; use config.adapter")
|
|
49
|
+
adapter
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def engine=(value)
|
|
53
|
+
Silas.deprecator.warn("config.engine= is deprecated; use config.adapter=")
|
|
54
|
+
self.adapter = value
|
|
55
|
+
end
|
|
56
|
+
|
|
42
57
|
# config.auth and the agent_sdk_* options were removed with the :agent_sdk
|
|
43
|
-
#
|
|
58
|
+
# adapter in 0.2 (warning no-ops for one release) and hard-removed in 0.3 —
|
|
44
59
|
# a leftover write now raises NoMethodError. Delete them from your
|
|
45
60
|
# initializer.
|
|
46
61
|
# JSON API (mounted under /silas/api/v1).
|
|
@@ -83,7 +98,7 @@ module Silas
|
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
def initialize
|
|
86
|
-
@
|
|
101
|
+
@adapter = :ruby_llm
|
|
87
102
|
# Must be resolvable by the installed ruby_llm's model registry — newer
|
|
88
103
|
# Claude models may need `RubyLLM.models.refresh!` before they resolve.
|
|
89
104
|
# (Sonnet 4.5 ships in every supported registry; never default a first
|
|
@@ -142,11 +157,11 @@ module Silas
|
|
|
142
157
|
|
|
143
158
|
# Fail-loud misconfiguration checks, run from Silas.configure and at boot.
|
|
144
159
|
def boot_guard!
|
|
145
|
-
if
|
|
160
|
+
if adapter == :agent_sdk
|
|
146
161
|
raise BootGuardError,
|
|
147
|
-
"the :agent_sdk
|
|
162
|
+
"the :agent_sdk adapter was removed in Silas 0.2 — the claude -p subprocess " \
|
|
148
163
|
"integration is gone (its subscription-auth rationale was unreachable). " \
|
|
149
|
-
"Use
|
|
164
|
+
"Use adapter :ruby_llm, the production path."
|
|
150
165
|
end
|
|
151
166
|
|
|
152
167
|
check_provider_credentials!
|
|
@@ -159,7 +174,7 @@ module Silas
|
|
|
159
174
|
# always a misconfiguration); warns in development so a fresh app can
|
|
160
175
|
# still boot and browse the inbox before a key exists.
|
|
161
176
|
def check_provider_credentials!
|
|
162
|
-
return unless
|
|
177
|
+
return unless adapter == :ruby_llm && defined?(::RubyLLM)
|
|
163
178
|
|
|
164
179
|
providers = ::RubyLLM::Provider.providers.values
|
|
165
180
|
configured = providers.any? do |provider|
|
|
@@ -168,7 +183,7 @@ module Silas
|
|
|
168
183
|
end
|
|
169
184
|
return if configured
|
|
170
185
|
|
|
171
|
-
message = "[Silas]
|
|
186
|
+
message = "[Silas] adapter :ruby_llm has no configured provider — no API key is set on " \
|
|
172
187
|
"RubyLLM.config. Set one in config/initializers/ruby_llm.rb, e.g. " \
|
|
173
188
|
"RubyLLM.configure { |c| c.anthropic_api_key = ENV[\"ANTHROPIC_API_KEY\"] } " \
|
|
174
189
|
"— the first agent turn will fail without it."
|
data/lib/silas/delta_buffer.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Silas
|
|
2
|
-
# Coalesces model text deltas into ~10Hz "silas
|
|
2
|
+
# Coalesces model text deltas into ~10Hz "delta.silas" notifications carrying
|
|
3
3
|
# the ACCUMULATED text so far — subscribers replace rather than append, which
|
|
4
4
|
# is idempotent under a crash-restream (same step id, fresh stream overwrites
|
|
5
5
|
# itself) and ordering-safe under Turbo. Deltas are decoration over the
|
|
@@ -38,8 +38,8 @@ module Silas
|
|
|
38
38
|
|
|
39
39
|
@published = @text.length
|
|
40
40
|
@last_publish = clock
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
Silas.instrument(
|
|
42
|
+
:delta,
|
|
43
43
|
session_id: @turn.session_id, turn_id: @turn.id,
|
|
44
44
|
step_id: @step.id, step_index: @step.index, text: @text.dup
|
|
45
45
|
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One deprecator for the whole gem, so hosts can control the noise the way
|
|
3
|
+
# they control Rails' own:
|
|
4
|
+
#
|
|
5
|
+
# Silas.deprecator.behavior = :raise # or :warn (default), :silence
|
|
6
|
+
#
|
|
7
|
+
# Rails registers it in application.deprecators (see Silas::Engine), which
|
|
8
|
+
# means `config.active_support.report_deprecations = false` silences Silas
|
|
9
|
+
# along with everything else, and a host can opt into raising in CI.
|
|
10
|
+
#
|
|
11
|
+
# Every deprecation message names BOTH the replacement and the version it
|
|
12
|
+
# disappears in — a warning you can't act on is just noise.
|
|
13
|
+
def self.deprecator
|
|
14
|
+
@deprecator ||= ActiveSupport::Deprecation.new("2.0", "Silas")
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/silas/engine.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
# The Rails engine (not to be confused with inference adapters under
|
|
3
|
-
# Silas::
|
|
3
|
+
# Silas::Adapters::*). Full engine: Silas is Rails-native by thesis.
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace Silas
|
|
6
6
|
|
|
@@ -33,16 +33,40 @@ module Silas
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Register with Rails so hosts control Silas's deprecations exactly as they
|
|
37
|
+
# control everyone else's: config.active_support.report_deprecations,
|
|
38
|
+
# or `config.silas.deprecator.behavior = :raise` in CI.
|
|
39
|
+
initializer "silas.deprecator" do |app|
|
|
40
|
+
app.deprecators[:silas] = Silas.deprecator if app.respond_to?(:deprecators)
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
initializer "silas.boot_guard", after: :load_config_initializers do
|
|
37
44
|
Silas.config.boot_guard!
|
|
38
45
|
end
|
|
39
46
|
|
|
47
|
+
# Turn the loop's notifications into log lines. Attaching here (rather than
|
|
48
|
+
# at require time) means a host that never boots Rails pays nothing.
|
|
49
|
+
initializer "silas.log_subscriber" do
|
|
50
|
+
Silas::LogSubscriber.attach_to :silas if defined?(Silas::LogSubscriber)
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
# Live token streaming into the inbox trace: one process-wide subscriber on
|
|
41
|
-
# "silas
|
|
54
|
+
# "delta.silas"; a no-op unless turbo-rails is present and streaming is on.
|
|
42
55
|
initializer "silas.delta_broadcaster" do
|
|
43
56
|
Silas::Inbox::DeltaBroadcaster.subscribe!
|
|
44
57
|
end
|
|
45
58
|
|
|
59
|
+
# The trace partials render in TWO contexts: the inbox controllers (which
|
|
60
|
+
# declare this helper) and Turbo's broadcast jobs, which render through the
|
|
61
|
+
# HOST's default renderer — where an engine-scoped helper doesn't exist and
|
|
62
|
+
# every broadcast died silently inside Turbo's job. Register it host-wide
|
|
63
|
+
# so broadcast renders — and host apps embedding the trace partials in
|
|
64
|
+
# their own pages — resolve it. (Routes inside those partials go through
|
|
65
|
+
# TraceHelper#silas_engine_path, which needs no routing scope at all.)
|
|
66
|
+
initializer "silas.trace_helper" do
|
|
67
|
+
ActiveSupport.on_load(:action_controller_base) { helper Silas::Inbox::TraceHelper }
|
|
68
|
+
end
|
|
69
|
+
|
|
46
70
|
# Registry rebuilds on every code reload in development, once in production.
|
|
47
71
|
initializer "silas.registry" do |app|
|
|
48
72
|
next unless app.root.join("app/agent").exist? || app.root.join("app/agents").exist?
|
data/lib/silas/eval/driver.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Silas
|
|
|
34
34
|
engine = scenario.real? ? nil : ScriptedEngine.new(scenario.steps)
|
|
35
35
|
base_resolver = Silas.config.tool_resolver
|
|
36
36
|
Silas.configure do |c|
|
|
37
|
-
c.
|
|
37
|
+
c.adapter = engine if engine
|
|
38
38
|
c.isolate_steps = false
|
|
39
39
|
c.max_steps = scenario.max_steps if scenario.max_steps
|
|
40
40
|
if scenario.stubs.any?
|
data/lib/silas/eval/dsl.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Silas
|
|
|
22
22
|
# on_step(0, text:, call: {name:, arguments:}, calls: [ {…}, … ])
|
|
23
23
|
def on_step(index, text: nil, call: nil, calls: [])
|
|
24
24
|
tcs = (calls + [ call ].compact).each_with_index.map do |c, n|
|
|
25
|
-
Silas::
|
|
25
|
+
Silas::Adapters::ToolCall.new(id: "eval_s#{index}_#{n}", name: c[:name].to_s,
|
|
26
26
|
arguments: (c[:arguments] || {}).stringify_keys)
|
|
27
27
|
end
|
|
28
28
|
blocks = []
|
|
@@ -3,7 +3,7 @@ module Silas
|
|
|
3
3
|
# The productized FakeEngine: a pure function of context[:index] that lets an
|
|
4
4
|
# eval script the MODEL's decisions while the REAL Ledger runs the REAL tools —
|
|
5
5
|
# so assertions see a genuine transcript.
|
|
6
|
-
class ScriptedEngine < Silas::
|
|
6
|
+
class ScriptedEngine < Silas::Adapters::Base
|
|
7
7
|
attr_reader :calls
|
|
8
8
|
|
|
9
9
|
def initialize(steps)
|
|
@@ -17,7 +17,7 @@ module Silas
|
|
|
17
17
|
spec = @steps[i]
|
|
18
18
|
return terminal("OK.") unless spec
|
|
19
19
|
|
|
20
|
-
Silas::
|
|
20
|
+
Silas::Adapters::Result.new(
|
|
21
21
|
blocks: spec[:blocks],
|
|
22
22
|
tool_calls: spec[:tool_calls],
|
|
23
23
|
stop_reason: spec[:tool_calls].empty? ? "end_turn" : "tool_use",
|
|
@@ -28,7 +28,7 @@ module Silas
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def terminal(text)
|
|
31
|
-
Silas::
|
|
31
|
+
Silas::Adapters::Result.new(blocks: [ { "type" => "text", "text" => text } ],
|
|
32
32
|
tool_calls: [], stop_reason: "end_turn",
|
|
33
33
|
usage: { input_tokens: 1, output_tokens: 1 })
|
|
34
34
|
end
|
data/lib/silas/inbox.rb
CHANGED
|
@@ -19,5 +19,20 @@ module Silas
|
|
|
19
19
|
def stream_name(session_id)
|
|
20
20
|
"silas:inbox:session:#{session_id}"
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Where the host mounted the engine ("/silas" by the installer's
|
|
24
|
+
# convention), discovered from the app's route set. Used to build paths
|
|
25
|
+
# that work in EVERY render context — including Turbo's bare broadcast
|
|
26
|
+
# renderer, which has no routing scope for the mounted proxy to lean on.
|
|
27
|
+
def mount_path
|
|
28
|
+
@mount_path ||= begin
|
|
29
|
+
route = Rails.application.routes.routes.find do |r|
|
|
30
|
+
r.app.respond_to?(:app) && r.app.app == Silas::Engine
|
|
31
|
+
end
|
|
32
|
+
route&.path&.spec.to_s.sub(/\(.*\z/, "").presence || "/silas"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset_mount_path! = (@mount_path = nil) # specs remount
|
|
22
37
|
end
|
|
23
38
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# ActiveSupport::Notifications for the durable loop.
|
|
3
|
+
#
|
|
4
|
+
# Until now the loop was silent: a turn could start, park for a human, get
|
|
5
|
+
# rescued after a kill -9, breach a budget, and finish — without emitting a
|
|
6
|
+
# single line. This is the seam for logs, APM spans, and metrics.
|
|
7
|
+
#
|
|
8
|
+
# Names follow the Rails convention `<event>.silas` (like `sql.active_record`),
|
|
9
|
+
# so `ActiveSupport::Notifications.subscribe(/\.silas\z/)` gets everything.
|
|
10
|
+
#
|
|
11
|
+
# ActiveSupport::Notifications.subscribe("tool.silas") do |event|
|
|
12
|
+
# StatsD.timing("agent.tool", event.duration, tags: ["tool:#{event.payload[:tool]}"])
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# ## Events and payloads
|
|
16
|
+
#
|
|
17
|
+
# Every payload carries `turn_id` and `session_id` where they exist, so any
|
|
18
|
+
# subscriber can correlate without joining.
|
|
19
|
+
#
|
|
20
|
+
# turn.silas status:, reason:, steps:, session_id:, turn_id:, agent:
|
|
21
|
+
# Duration = the whole turn INCLUDING parked time.
|
|
22
|
+
# step.silas index:, model:, turn_id: (one model call)
|
|
23
|
+
# tool.silas tool:, effect_mode:, status:, approval_state:,
|
|
24
|
+
# invocation_id:, turn_id:
|
|
25
|
+
# Duration = the tool's own execution. The single most
|
|
26
|
+
# useful span in the system.
|
|
27
|
+
# delta.silas session_id:, turn_id:, step_id:, step_index:, text:
|
|
28
|
+
# Streamed text so far (see DeltaBuffer). High frequency.
|
|
29
|
+
# park.silas reason: (approval | in_doubt | budget), turn_id:, detail:
|
|
30
|
+
# resume.silas turn_id:, parked_for: (seconds a human took)
|
|
31
|
+
# approval.silas action: (approved | declined | expired), tool:, by:,
|
|
32
|
+
# invocation_id:, turn_id:
|
|
33
|
+
# budget.silas reason: (max_cost | max_input_tokens | timeout), turn_id:
|
|
34
|
+
# rescue.silas rescued: (jobs retried), stranded: (turns failed)
|
|
35
|
+
# nondeterminism.silas turn_id:, was:, now: (digest changed mid-turn)
|
|
36
|
+
module Instrumentation
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def instrument(event, **payload, &block)
|
|
40
|
+
ActiveSupport::Notifications.instrument("#{event}.silas", **payload, &block)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Silas.instrument(:tool, tool: "issue_refund") { ... }
|
|
45
|
+
def self.instrument(event, **payload, &block)
|
|
46
|
+
Instrumentation.instrument(event, **payload, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The logger Silas writes through; hosts can point it elsewhere.
|
|
50
|
+
mattr_accessor :logger, default: nil
|
|
51
|
+
|
|
52
|
+
def self.logger
|
|
53
|
+
@@logger ||= (defined?(Rails) && Rails.logger) || ActiveSupport::Logger.new($stdout)
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/silas/ledger.rb
CHANGED
|
@@ -88,6 +88,8 @@ module Silas
|
|
|
88
88
|
when :user_approval
|
|
89
89
|
invocation.update!(approval_state: "required",
|
|
90
90
|
approval_expires_at: Silas.config.approval_ttl.from_now)
|
|
91
|
+
Silas.instrument(:park, reason: "approval", turn_id: invocation.turn_id,
|
|
92
|
+
detail: invocation.tool_name)
|
|
91
93
|
return :parked
|
|
92
94
|
when Hash # {denied: "reason"} — eve's shape
|
|
93
95
|
invocation.update!(status: "failed", result: { "denied" => verdict[:denied] })
|
|
@@ -107,6 +109,16 @@ module Silas
|
|
|
107
109
|
end
|
|
108
110
|
|
|
109
111
|
def execute!(invocation, tool)
|
|
112
|
+
Silas.instrument(:tool, tool: invocation.tool_name, effect_mode: invocation.effect_mode,
|
|
113
|
+
invocation_id: invocation.id, turn_id: invocation.turn_id) do |payload|
|
|
114
|
+
run_tool!(invocation, tool).tap do
|
|
115
|
+
payload[:status] = invocation.reload.status
|
|
116
|
+
payload[:approval_state] = invocation.approval_state
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_tool!(invocation, tool)
|
|
110
122
|
tool.session = invocation.turn.session if tool.respond_to?(:session=)
|
|
111
123
|
args = invocation.arguments.symbolize_keys
|
|
112
124
|
|
|
@@ -149,6 +161,8 @@ module Silas
|
|
|
149
161
|
# decline! = "it ran / abandon", operator supplies the outcome.
|
|
150
162
|
invocation.update!(status: "in_doubt", approval_state: "required",
|
|
151
163
|
approval_expires_at: Silas.config.approval_ttl.from_now)
|
|
164
|
+
Silas.instrument(:park, reason: "in_doubt", turn_id: invocation.turn_id,
|
|
165
|
+
detail: invocation.tool_name)
|
|
152
166
|
:parked
|
|
153
167
|
end
|
|
154
168
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "active_support/log_subscriber"
|
|
2
|
+
|
|
3
|
+
# Turns the loop's notifications into log lines, at levels that match what an
|
|
4
|
+
# operator actually wants paged about: parks and rescues are INFO (a human is
|
|
5
|
+
# now in the loop, or a crash was recovered), budget breaches and
|
|
6
|
+
# nondeterminism are WARN, failed turns are ERROR, and the per-step/per-token
|
|
7
|
+
# chatter stays DEBUG.
|
|
8
|
+
#
|
|
9
|
+
# Attach is automatic (see Silas::Engine). To silence just Silas:
|
|
10
|
+
# Silas.logger = Logger.new(IO::NULL)
|
|
11
|
+
class Silas::LogSubscriber < ActiveSupport::LogSubscriber
|
|
12
|
+
def turn(event)
|
|
13
|
+
status = event.payload[:status]
|
|
14
|
+
line = formatted_event(event, action: "Turn #{status}",
|
|
15
|
+
**event.payload.slice(:turn_id, :session_id, :agent, :steps, :reason).compact)
|
|
16
|
+
status.to_s == "failed" ? error(line) : info(line)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def step(event)
|
|
20
|
+
debug formatted_event(event, action: "Model call",
|
|
21
|
+
**event.payload.slice(:turn_id, :index, :model).compact)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tool(event)
|
|
25
|
+
line = formatted_event(event, action: "Tool #{event.payload[:tool]}",
|
|
26
|
+
**event.payload.slice(:effect_mode, :status, :approval_state, :turn_id).compact)
|
|
27
|
+
event.payload[:status].to_s == "failed" ? warn(line) : info(line)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def park(event)
|
|
31
|
+
info formatted_event(event, action: "Turn parked (#{event.payload[:reason]})",
|
|
32
|
+
**event.payload.slice(:turn_id, :detail).compact)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resume(event)
|
|
36
|
+
parked_for = event.payload[:parked_for]
|
|
37
|
+
info formatted_event(event, action: "Turn resumed after #{parked_for&.round(1)}s parked",
|
|
38
|
+
**event.payload.slice(:turn_id).compact)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def approval(event)
|
|
42
|
+
info formatted_event(event, action: "Approval #{event.payload[:action]}",
|
|
43
|
+
**event.payload.slice(:tool, :by, :turn_id).compact)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def budget(event)
|
|
47
|
+
warn formatted_event(event, action: "Budget reached (#{event.payload[:reason]})",
|
|
48
|
+
**event.payload.slice(:turn_id).compact)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def nondeterminism(event)
|
|
52
|
+
error formatted_event(event, action: "Definitions changed mid-turn",
|
|
53
|
+
**event.payload.slice(:turn_id, :was, :now).compact)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rescue(event)
|
|
57
|
+
payload = event.payload
|
|
58
|
+
return if payload[:rescued].to_i.zero? && payload[:stranded].to_i.zero?
|
|
59
|
+
|
|
60
|
+
info formatted_event(event, action: "Rescuer", **payload.slice(:rescued, :stranded))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# delta.silas is deliberately NOT logged — it fires many times per second
|
|
64
|
+
# per running turn. Subscribe to it directly if you want the firehose.
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
def formatted_event(event, action:, **attributes)
|
|
68
|
+
"Silas-#{Silas::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def formatted_attributes(**attributes)
|
|
72
|
+
attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def logger
|
|
76
|
+
Silas.logger
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/silas/slack.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Silas
|
|
|
43
43
|
|
|
44
44
|
# Slack signs each request (v0 scheme) — HMAC-SHA256 over "v0:ts:body" plus a
|
|
45
45
|
# 5-minute replay window. Returns true only for a genuine, fresh request.
|
|
46
|
-
def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.
|
|
46
|
+
def verify_signature(signing_secret:, timestamp:, body:, signature:, now: Time.current.to_i)
|
|
47
47
|
return false if signing_secret.blank? || signature.blank? || timestamp.blank?
|
|
48
48
|
return false if (now - timestamp.to_i).abs > REPLAY_WINDOW
|
|
49
49
|
|
data/lib/silas/step_runner.rb
CHANGED
|
@@ -51,7 +51,7 @@ module Silas
|
|
|
51
51
|
|
|
52
52
|
def execute_model_call(turn, index, step)
|
|
53
53
|
assert_definitions_unchanged!(turn)
|
|
54
|
-
engine = Silas.
|
|
54
|
+
engine = Silas.resolved_adapter
|
|
55
55
|
context = {
|
|
56
56
|
turn: turn,
|
|
57
57
|
index: index,
|
|
@@ -64,7 +64,7 @@ module Silas
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
# Live deltas: the engine yields Events, the buffer coalesces them into
|
|
67
|
-
# "silas
|
|
67
|
+
# "delta.silas" notifications. A replayed step never reaches this method
|
|
68
68
|
# (the completed? guard above), so replay emits nothing. The emitter is
|
|
69
69
|
# created HERE and closed over by the inner block, so around_model_call
|
|
70
70
|
# hooks keep their existing one-argument contract and can't swallow it.
|
|
@@ -93,6 +93,9 @@ module Silas
|
|
|
93
93
|
return if live == turn.definitions_digest
|
|
94
94
|
|
|
95
95
|
turn.finish!(:failed, reason: "definitions_changed")
|
|
96
|
+
Silas.instrument(:nondeterminism, turn_id: turn.id,
|
|
97
|
+
was: turn.definitions_digest.to_s[0, 12],
|
|
98
|
+
now: live[0, 12])
|
|
96
99
|
raise NondeterminismError,
|
|
97
100
|
"Tool/skill definitions changed mid-turn (digest #{turn.definitions_digest[0, 12]}… → " \
|
|
98
101
|
"#{live[0, 12]}…). The turn was failed rather than resumed against a different agent."
|
data/lib/silas/version.rb
CHANGED
data/lib/silas.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "active_job/railtie" if defined?(::Rails::Railtie)
|
|
|
4
4
|
require "active_record/railtie" if defined?(::Rails::Railtie)
|
|
5
5
|
|
|
6
6
|
require "silas/version"
|
|
7
|
+
require "silas/deprecator"
|
|
8
|
+
require "silas/instrumentation"
|
|
7
9
|
require "silas/errors"
|
|
8
10
|
require "silas/configuration"
|
|
9
11
|
require "silas/ledger"
|
|
@@ -35,15 +37,16 @@ require "silas/tools/recall"
|
|
|
35
37
|
require "silas/tools/handoff"
|
|
36
38
|
require "silas/mcp/handler"
|
|
37
39
|
require "silas/mcp/server"
|
|
38
|
-
require "silas/
|
|
40
|
+
require "silas/adapters/base"
|
|
39
41
|
require "ruby_llm"
|
|
40
|
-
require "silas/
|
|
42
|
+
require "silas/adapters/ruby_llm"
|
|
41
43
|
require "silas/message_builder"
|
|
42
44
|
require "silas/instructions"
|
|
43
45
|
require "silas/step_runner"
|
|
44
|
-
require "silas/eval" # after
|
|
46
|
+
require "silas/eval" # after adapters (ScriptedEngine < Adapters::Base)
|
|
45
47
|
require "silas/chat"
|
|
46
48
|
require "silas/doctor"
|
|
49
|
+
require "silas/log_subscriber" if defined?(::ActiveSupport::LogSubscriber)
|
|
47
50
|
|
|
48
51
|
module Silas
|
|
49
52
|
class << self
|
|
@@ -60,14 +63,14 @@ module Silas
|
|
|
60
63
|
def configure
|
|
61
64
|
yield config
|
|
62
65
|
config.validate!
|
|
63
|
-
@
|
|
66
|
+
@resolved_adapter = nil
|
|
64
67
|
@resolved_sandbox = nil
|
|
65
68
|
config
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
def reset_configuration! # for specs
|
|
69
72
|
@config = nil
|
|
70
|
-
@
|
|
73
|
+
@resolved_adapter = nil
|
|
71
74
|
@resolved_sandbox = nil
|
|
72
75
|
@agent = nil
|
|
73
76
|
end
|
|
@@ -103,21 +106,29 @@ module Silas
|
|
|
103
106
|
|
|
104
107
|
def reset_agent_memo! = (@agent = nil) # after Registry.install! swaps dirs
|
|
105
108
|
|
|
106
|
-
# The inference adapter instance. config.
|
|
109
|
+
# The inference adapter instance. config.adapter may be :ruby_llm or any
|
|
107
110
|
# object responding to #execute_step (specs, custom).
|
|
108
|
-
def
|
|
109
|
-
@
|
|
110
|
-
case config.
|
|
111
|
-
when :ruby_llm then
|
|
111
|
+
def resolved_adapter
|
|
112
|
+
@resolved_adapter ||=
|
|
113
|
+
case config.adapter
|
|
114
|
+
when :ruby_llm then Adapters::RubyLLM.new
|
|
112
115
|
when :agent_sdk
|
|
113
|
-
raise Error, "the :agent_sdk
|
|
116
|
+
raise Error, "the :agent_sdk adapter was removed in Silas 0.2 — the claude -p " \
|
|
114
117
|
"subprocess integration is gone (its subscription-auth rationale was " \
|
|
115
|
-
"unreachable). Use
|
|
116
|
-
when Symbol then raise Error, "unknown
|
|
117
|
-
else config.
|
|
118
|
+
"unreachable). Use adapter :ruby_llm, the production path."
|
|
119
|
+
when Symbol then raise Error, "unknown adapter #{config.adapter.inspect}"
|
|
120
|
+
else config.adapter
|
|
118
121
|
end
|
|
119
122
|
end
|
|
120
123
|
|
|
124
|
+
# Renamed in 0.4: "engine" meant two unrelated things (the Rails engine at
|
|
125
|
+
# Silas::Engine, and the inference backend), which is exactly the collision
|
|
126
|
+
# ActiveJob avoids with QueueAdapters. Removed in 2.0.
|
|
127
|
+
def resolved_engine
|
|
128
|
+
Silas.deprecator.warn("Silas.resolved_engine is deprecated; use Silas.resolved_adapter")
|
|
129
|
+
resolved_adapter
|
|
130
|
+
end
|
|
131
|
+
|
|
121
132
|
# ---- scope-aware readers -------------------------------------------------
|
|
122
133
|
# Every reader consults the active AgentScope first (named agent or
|
|
123
134
|
# subagent), falling back to the boot-time config the Registry installed.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: silas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -151,6 +151,7 @@ files:
|
|
|
151
151
|
- app/views/silas/inbox/steps/_step.html.erb
|
|
152
152
|
- app/views/silas/inbox/turns/_header.html.erb
|
|
153
153
|
- app/views/silas/inbox/turns/_turn.html.erb
|
|
154
|
+
- config/brakeman.ignore
|
|
154
155
|
- config/routes.rb
|
|
155
156
|
- db/migrate/20260714000001_create_silas_tables.rb
|
|
156
157
|
- db/migrate/20260715000001_add_channel_outbound_markers.rb
|
|
@@ -174,6 +175,8 @@ files:
|
|
|
174
175
|
- lib/generators/silas/install/templates/instructions.md
|
|
175
176
|
- lib/generators/silas/install/templates/ruby_llm.rb
|
|
176
177
|
- lib/silas.rb
|
|
178
|
+
- lib/silas/adapters/base.rb
|
|
179
|
+
- lib/silas/adapters/ruby_llm.rb
|
|
177
180
|
- lib/silas/agent.rb
|
|
178
181
|
- lib/silas/agent_scope.rb
|
|
179
182
|
- lib/silas/budget.rb
|
|
@@ -183,10 +186,9 @@ files:
|
|
|
183
186
|
- lib/silas/connection.rb
|
|
184
187
|
- lib/silas/connections.rb
|
|
185
188
|
- lib/silas/delta_buffer.rb
|
|
189
|
+
- lib/silas/deprecator.rb
|
|
186
190
|
- lib/silas/doctor.rb
|
|
187
191
|
- lib/silas/engine.rb
|
|
188
|
-
- lib/silas/engines/base.rb
|
|
189
|
-
- lib/silas/engines/ruby_llm.rb
|
|
190
192
|
- lib/silas/errors.rb
|
|
191
193
|
- lib/silas/eval.rb
|
|
192
194
|
- lib/silas/eval/assertions.rb
|
|
@@ -201,7 +203,9 @@ files:
|
|
|
201
203
|
- lib/silas/inbox/cost.rb
|
|
202
204
|
- lib/silas/inbox/delta_broadcaster.rb
|
|
203
205
|
- lib/silas/instructions.rb
|
|
206
|
+
- lib/silas/instrumentation.rb
|
|
204
207
|
- lib/silas/ledger.rb
|
|
208
|
+
- lib/silas/log_subscriber.rb
|
|
205
209
|
- lib/silas/mcp/client.rb
|
|
206
210
|
- lib/silas/mcp/handler.rb
|
|
207
211
|
- lib/silas/mcp/server.rb
|