silas 0.1.1
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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/app/controllers/silas/channels/approvals_controller.rb +34 -0
- data/app/controllers/silas/channels/base_controller.rb +22 -0
- data/app/controllers/silas/channels/slack_controller.rb +58 -0
- data/app/controllers/silas/inbox/base_controller.rb +37 -0
- data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
- data/app/helpers/silas/inbox/trace_helper.rb +42 -0
- data/app/jobs/silas/agent_loop_job.rb +95 -0
- data/app/jobs/silas/channel_delivery_job.rb +62 -0
- data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
- data/app/jobs/silas/schedule_job.rb +22 -0
- data/app/mailboxes/silas/agent_mailbox.rb +35 -0
- data/app/mailers/silas/channel_mailer.rb +17 -0
- data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
- data/app/models/silas/application_record.rb +5 -0
- data/app/models/silas/session.rb +36 -0
- data/app/models/silas/step.rb +33 -0
- data/app/models/silas/tool_invocation.rb +78 -0
- data/app/models/silas/turn.rb +58 -0
- data/app/views/layouts/silas/inbox.html.erb +91 -0
- data/app/views/silas/channel_mailer/approval.text.erb +9 -0
- data/app/views/silas/channels/approvals/done.html.erb +2 -0
- data/app/views/silas/channels/approvals/error.html.erb +2 -0
- data/app/views/silas/channels/approvals/show.html.erb +9 -0
- data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
- data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
- data/app/views/silas/inbox/sessions/index.html.erb +23 -0
- data/app/views/silas/inbox/sessions/show.html.erb +20 -0
- data/app/views/silas/inbox/steps/_step.html.erb +8 -0
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
- data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
- data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
- data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
- data/lib/generators/silas/install/install_generator.rb +81 -0
- data/lib/generators/silas/install/templates/agent.yml +9 -0
- data/lib/generators/silas/install/templates/bin_ci +7 -0
- data/lib/generators/silas/install/templates/channel_email.rb +18 -0
- data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
- data/lib/generators/silas/install/templates/example_eval.rb +22 -0
- data/lib/generators/silas/install/templates/example_schedule.md +11 -0
- data/lib/generators/silas/install/templates/example_skill.md +8 -0
- data/lib/generators/silas/install/templates/example_tool.rb +12 -0
- data/lib/generators/silas/install/templates/initializer.rb +16 -0
- data/lib/generators/silas/install/templates/instructions.md +4 -0
- data/lib/silas/agent.rb +33 -0
- data/lib/silas/agent_scope.rb +6 -0
- data/lib/silas/agent_sdk/cli.rb +59 -0
- data/lib/silas/agent_sdk/stream_parser.rb +86 -0
- data/lib/silas/agent_sdk/version_guard.rb +26 -0
- data/lib/silas/budget.rb +42 -0
- data/lib/silas/channel.rb +69 -0
- data/lib/silas/configuration.rb +164 -0
- data/lib/silas/connection.rb +77 -0
- data/lib/silas/connections.rb +52 -0
- data/lib/silas/engine.rb +36 -0
- data/lib/silas/engines/agent_sdk.rb +75 -0
- data/lib/silas/engines/base.rb +30 -0
- data/lib/silas/engines/ruby_llm.rb +136 -0
- data/lib/silas/errors.rb +26 -0
- data/lib/silas/eval/assertions.rb +107 -0
- data/lib/silas/eval/driver.rb +47 -0
- data/lib/silas/eval/dsl.rb +55 -0
- data/lib/silas/eval/grader.rb +37 -0
- data/lib/silas/eval/result.rb +28 -0
- data/lib/silas/eval/runner.rb +38 -0
- data/lib/silas/eval/scripted_engine.rb +39 -0
- data/lib/silas/eval/transcript.rb +22 -0
- data/lib/silas/eval.rb +35 -0
- data/lib/silas/inbox/cost.rb +58 -0
- data/lib/silas/inbox.rb +23 -0
- data/lib/silas/instructions.rb +55 -0
- data/lib/silas/ledger.rb +178 -0
- data/lib/silas/mcp/client.rb +86 -0
- data/lib/silas/mcp/handler.rb +89 -0
- data/lib/silas/mcp/server.rb +134 -0
- data/lib/silas/message_builder.rb +63 -0
- data/lib/silas/nested_runner.rb +41 -0
- data/lib/silas/registry.rb +155 -0
- data/lib/silas/sandbox/docker.rb +67 -0
- data/lib/silas/sandbox/null.rb +17 -0
- data/lib/silas/sandbox.rb +15 -0
- data/lib/silas/schedule/compiler.rb +52 -0
- data/lib/silas/schedule.rb +109 -0
- data/lib/silas/skill.rb +21 -0
- data/lib/silas/slack.rb +55 -0
- data/lib/silas/step_runner.rb +98 -0
- data/lib/silas/subprocess_runner.rb +41 -0
- data/lib/silas/tool.rb +93 -0
- data/lib/silas/tools/delegate.rb +42 -0
- data/lib/silas/tools/load_skill.rb +25 -0
- data/lib/silas/tools/run_code.rb +21 -0
- data/lib/silas/version.rb +3 -0
- data/lib/silas.rb +144 -0
- data/lib/tasks/silas_eval.rake +20 -0
- data/lib/tasks/silas_schedules.rake +33 -0
- metadata +232 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '08d5115f914d3ff251c01f57b506951e3367717d777bfdd3d175a165169552f7'
|
|
4
|
+
data.tar.gz: 04e26fde565b7cf2d2a402316e095d6b0cf9f6a753b2d2444682890ec0ebe66f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48d66589890f1c88e9ac8e1d476b131821907aea8c6e60d87300a42970e3e6a7bf52673020a94961f9bc32d2b3409cd6f735b6dfbb3d973ccaa86087298cb939
|
|
7
|
+
data.tar.gz: 9ddc0ddbba4ad607ecba6443b85492e94d72a058aab78f13ad256d62c2d0631f0a8d61cc0f37cd902d2701e821b4e5248aa7a1076321a2fe25f34fbf13c5a6c9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- **Fix: parallel tool calls.** When the model emitted several tool_use blocks
|
|
6
|
+
in one turn, replay could send a tool_result with no matching tool_use (the
|
|
7
|
+
provider rejects it) and, under a non-serializing queue adapter, double-execute
|
|
8
|
+
the step. The replayed assistant message's tool_use blocks are now
|
|
9
|
+
reconstructed from the settled ledger invocations — every tool_result has a
|
|
10
|
+
matching tool_use by construction — and consecutive tool results are batched
|
|
11
|
+
into a single provider message. Regression test:
|
|
12
|
+
`spec/silas/parallel_tool_calls_spec.rb`.
|
|
13
|
+
- **Boot guard: unsafe queue adapter.** Silas now warns at boot if ActiveJob is
|
|
14
|
+
using the in-process Async adapter, which runs continuation retries
|
|
15
|
+
concurrently and breaks exactly-once. Use Solid Queue (production) or `:inline`
|
|
16
|
+
(scripts/demos). See DEPLOY.md.
|
|
17
|
+
|
|
18
|
+
## 0.1.0
|
|
19
|
+
|
|
20
|
+
First release. A durable AI agent framework for Rails ("eve without the bill").
|
|
21
|
+
|
|
22
|
+
- **Durable loop** on Active Job Continuations + Solid Queue — a turn survives
|
|
23
|
+
crash / deploy / `kill -9` and resumes from the last completed step.
|
|
24
|
+
Chaos-gated: 100/100 runs, zero duplicate side effects, byte-identical
|
|
25
|
+
transcripts, on SQLite and Postgres.
|
|
26
|
+
- **Exactly-once tool execution** via a transactional ledger (at-most-once with
|
|
27
|
+
in-doubt→human resolution for external effects).
|
|
28
|
+
- **Human-in-the-loop approvals** that park at zero compute, resumable from
|
|
29
|
+
Slack buttons, signed email links, or the inbox.
|
|
30
|
+
- **`app/agent/` directory convention**: tools (signature = schema), skills
|
|
31
|
+
(progressive disclosure), instructions, schedules (cron → Solid Queue),
|
|
32
|
+
channels (Action Mailbox + Slack), subagents (isolated delegation),
|
|
33
|
+
connections (external MCP servers as tools).
|
|
34
|
+
- **Two engines**: `:ruby_llm` (any provider) and `:agent_sdk` (a `claude -p`
|
|
35
|
+
subprocess; API-key auth).
|
|
36
|
+
- **Mountable inbox** at `/silas/inbox`: live trace over Turbo Streams, approval
|
|
37
|
+
cards, per-session/agent cost. Deny-by-default.
|
|
38
|
+
- **Evals as a deploy gate**: transcript assertions + opt-in LLM rubric, `bin/ci`.
|
|
39
|
+
- **Budgets**: cost / token / time caps per turn.
|
|
40
|
+
- **Sandbox seam** with a Docker adapter (interim) for untrusted/model code.
|
|
41
|
+
- Deploys self-hosted with Kamal to one cheap VPS (see DEPLOY.md).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel St Paul
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Your Rails app is already an agent runtime.
|
|
2
|
+
|
|
3
|
+
**Silas** turns the Rails app you already run into a durable AI-agent runtime.
|
|
4
|
+
Active Job Continuations, Solid Queue, and one Postgres ledger table make every
|
|
5
|
+
turn survive `kill -9` and resume from the last completed step — with a live
|
|
6
|
+
operator inbox at `/silas/inbox` and park-at-zero human-in-the-loop approvals
|
|
7
|
+
holding the big levers. No new service, no managed platform, no per-run meter:
|
|
8
|
+
the durable stack is already booted inside your app. The only new surface is the
|
|
9
|
+
`app/agent/` directory below.
|
|
10
|
+
|
|
11
|
+
Honestly early and honestly narrow: **v0.1, one maintainer, zero external
|
|
12
|
+
users**, durability proven by an in-repo `kill -9` chaos harness (100/100, zero
|
|
13
|
+
duplicate effects, byte-identical replay), and scoped to **trusted code you write
|
|
14
|
+
yourself** (the sandbox is an interim Docker seam, not a microVM). The full pitch
|
|
15
|
+
and the honest caveats: [Why Silas](docs/why-silas.md) ·
|
|
16
|
+
[Silas vs eve](docs/vs-eve.md).
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
app/agent/
|
|
20
|
+
instructions.md # the persona (ERB, snapshotted once per turn)
|
|
21
|
+
agent.yml # data-only config: model, limits
|
|
22
|
+
tools/ # one file per tool; identity = filename
|
|
23
|
+
issue_refund.rb # keyword signature = the schema the model sees
|
|
24
|
+
skills/ # markdown playbooks, loaded on demand
|
|
25
|
+
triage.md # description: frontmatter is the routing hint
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bundle add silas
|
|
32
|
+
bin/rails generate silas:install
|
|
33
|
+
bin/rails db:migrate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class Agent::Tools::IssueRefund < Silas::Tool
|
|
38
|
+
description "Refund an order."
|
|
39
|
+
param :amount, :integer, desc: "Pence"
|
|
40
|
+
approval :always # parks the run; a human approves from your app
|
|
41
|
+
transactional! # DB-only side effects -> exactly-once, guaranteed
|
|
42
|
+
|
|
43
|
+
def call(order_id:, amount:)
|
|
44
|
+
Refund.create!(order_id:, amount:)
|
|
45
|
+
{ refunded: order_id }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
session = Silas.agent.start(input: "Refund order 42, £12.50")
|
|
52
|
+
session.pending_approvals.first.approve!(by: "daniel")
|
|
53
|
+
session.continue(input: "Now email the customer.")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## The durability contract (what's actually guaranteed)
|
|
57
|
+
|
|
58
|
+
Verified by `chaos_host/bin/chaos` — the harness that kill -9s a live agent
|
|
59
|
+
hundreds of times per release (results in `chaos_host/results/`):
|
|
60
|
+
|
|
61
|
+
- **A turn survives hard process death** (worker kill -9, whole-tree kill -9,
|
|
62
|
+
SIGTERM deploys) and resumes from the last completed step: 100% completion,
|
|
63
|
+
byte-identical transcripts, on SQLite and Postgres.
|
|
64
|
+
- **`transactional!` tools execute exactly once.** The tool's DB writes and the
|
|
65
|
+
ledger row commit or roll back together. Zero duplicates across every chaos run.
|
|
66
|
+
- **Other tools are at-least-once within one step** — and when a crash makes an
|
|
67
|
+
execution ambiguous, the default `at_most_once!` policy **parks the run for a
|
|
68
|
+
human verdict** instead of guessing (`idempotent!` opts into automatic re-runs).
|
|
69
|
+
- **Approvals park at zero compute** — the job exits; approving enqueues a fresh
|
|
70
|
+
one that replays completed work from rows, never re-calling the model or
|
|
71
|
+
re-running tools. Parks expire (default 7 days) rather than ghosting forever.
|
|
72
|
+
- **The rescuer is part of the contract.** Solid Queue marks a dead worker's
|
|
73
|
+
jobs failed and nothing retries them; the installer wires
|
|
74
|
+
`Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
|
|
75
|
+
`SolidQueue.process_alive_threshold` + that cadence. Do not remove it.
|
|
76
|
+
- **Deploys can't corrupt a run**: instructions are snapshotted per turn, and a
|
|
77
|
+
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
78
|
+
(`NondeterminismError`) instead of resuming into a different agent.
|
|
79
|
+
|
|
80
|
+
## Engines
|
|
81
|
+
|
|
82
|
+
Inference is one pluggable seam (`config.engine`):
|
|
83
|
+
|
|
84
|
+
- `:ruby_llm` — API-key auth via [RubyLLM](https://rubyllm.com); any provider it
|
|
85
|
+
supports. Canonical, production mode. Compose resilience via
|
|
86
|
+
`config.around_model_call`.
|
|
87
|
+
- `:agent_sdk` — a `claude -p` subprocess runs the whole turn, calling back into
|
|
88
|
+
your tools over an in-worker HTTP MCP endpoint whose `tools/call` goes through
|
|
89
|
+
the same Ledger. Always `--bare` (API-key auth only; the boot guard raises if
|
|
90
|
+
OAuth is configured with `ANTHROPIC_API_KEY` present, and if the key is missing
|
|
91
|
+
in api_key mode). v1 is honestly weaker than `:ruby_llm`: exactly-once *within*
|
|
92
|
+
a run, `approval :never` tools only, and fail-closed on a mid-subprocess kill.
|
|
93
|
+
|
|
94
|
+
## Triggers
|
|
95
|
+
|
|
96
|
+
An agent is reached by more than a method call:
|
|
97
|
+
|
|
98
|
+
- **`schedules/`** — `app/agent/schedules/*.md` (cron frontmatter, body = the turn
|
|
99
|
+
input) or `*.rb` handlers. `bin/rails silas:schedules` compiles them into
|
|
100
|
+
Solid Queue recurring tasks. A scheduled run is a normal durable turn.
|
|
101
|
+
- **`channels/`** — `app/agent/channels/*.rb` bind email (Action Mailbox) and
|
|
102
|
+
Slack to the loop. A new thread starts a session, a reply continues it, and
|
|
103
|
+
approvals render as Slack buttons / signed email links that call the same
|
|
104
|
+
`approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
|
|
105
|
+
|
|
106
|
+
## The inbox
|
|
107
|
+
|
|
108
|
+
Mount the engine (the generator does this) and a live inbox appears at
|
|
109
|
+
`/silas/inbox`: a session list, a live step-trace that streams over Turbo
|
|
110
|
+
Streams as the agent runs, approval cards whose Approve/Decline buttons call the
|
|
111
|
+
exact same `approve!`/`decline!` as Slack and email, and per-session token/cost
|
|
112
|
+
accounting. It's **deny-by-default** — invisible until you wire auth:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Silas.configure do |c|
|
|
116
|
+
# Devise-compatible: the lambda DENIES by rendering; passes by not rendering.
|
|
117
|
+
c.inbox_auth = ->(controller) { controller.head :not_found unless controller.current_user&.admin? }
|
|
118
|
+
# c.inbox_public_read = true # public read-only demo; approve/decline stay gated
|
|
119
|
+
# c.model_prices["your-model"] = { in: 300, out: 1500 } # microcents / 1k tokens
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Turbo streaming activates automatically when the host has `turbo-rails` (every
|
|
124
|
+
default Rails app does); without it the trace falls back to a polling refresh.
|
|
125
|
+
The gem itself takes no turbo dependency.
|
|
126
|
+
|
|
127
|
+
## Requirements
|
|
128
|
+
|
|
129
|
+
Rails >= 8.1 (Active Job Continuations) and Solid Queue >= 1.2 for the
|
|
130
|
+
durability contract. macOS dev note: Solid Queue forks + pg need
|
|
131
|
+
`PGGSSENCMODE=disable OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Channels
|
|
3
|
+
# Email approve/decline links land here. The signed token carries the
|
|
4
|
+
# invocation id + action (no session state in the URL); a GET shows a
|
|
5
|
+
# confirm page, a POST performs the action via the existing approve!/decline!.
|
|
6
|
+
class ApprovalsController < BaseController
|
|
7
|
+
def show
|
|
8
|
+
@claim = Silas::Channel.verify_token(params[:token])
|
|
9
|
+
return render(:error, status: :unprocessable_entity) unless @claim
|
|
10
|
+
|
|
11
|
+
@invocation = Silas::ToolInvocation.find_by(id: @claim["id"])
|
|
12
|
+
@action = @claim["action"]
|
|
13
|
+
render :show
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def update
|
|
17
|
+
claim = Silas::Channel.verify_token(params[:token])
|
|
18
|
+
return render(:error, status: :unprocessable_entity) unless claim
|
|
19
|
+
|
|
20
|
+
invocation = Silas::ToolInvocation.find(claim["id"])
|
|
21
|
+
if claim["action"] == "approve"
|
|
22
|
+
invocation.approve!(by: "email")
|
|
23
|
+
else
|
|
24
|
+
invocation.decline!(reason: "declined by email", by: "email")
|
|
25
|
+
end
|
|
26
|
+
@action = claim["action"]
|
|
27
|
+
render :done
|
|
28
|
+
rescue Silas::Error => e
|
|
29
|
+
@message = e.message
|
|
30
|
+
render :error, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Channels
|
|
3
|
+
class BaseController < ActionController::Base
|
|
4
|
+
skip_forgery_protection # webhooks are authenticated by signature/token, not CSRF
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Reject anything not genuinely signed by Slack (or with a stale timestamp).
|
|
9
|
+
def verify_slack!
|
|
10
|
+
secret = Silas.config.slack_signing_secret
|
|
11
|
+
ok = Silas::Slack.verify_signature(
|
|
12
|
+
signing_secret: secret,
|
|
13
|
+
timestamp: request.headers["X-Slack-Request-Timestamp"],
|
|
14
|
+
body: request.raw_post,
|
|
15
|
+
signature: request.headers["X-Slack-Signature"]
|
|
16
|
+
)
|
|
17
|
+
head(:unauthorized) unless ok
|
|
18
|
+
ok
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Channels
|
|
3
|
+
# Slack transport webhooks. Inbound messages start/continue a session;
|
|
4
|
+
# interactive button clicks map to the existing invocation.approve!/decline!.
|
|
5
|
+
# Requires app/agent/channels/slack.rb (Agent::Channels::Slack) to exist.
|
|
6
|
+
class SlackController < BaseController
|
|
7
|
+
# POST /channels/slack/events
|
|
8
|
+
def events
|
|
9
|
+
return unless verify_slack!
|
|
10
|
+
return render(json: { challenge: params[:challenge] }) if params[:type] == "url_verification"
|
|
11
|
+
# Slack retries deliver at-least-once; ignore retries (continuation-token
|
|
12
|
+
# uniqueness + single-active-turn are the real dedup).
|
|
13
|
+
return head(:ok) if request.headers["X-Slack-Retry-Num"].present?
|
|
14
|
+
|
|
15
|
+
event = params[:event]
|
|
16
|
+
if event && event[:type] == "message" && event[:bot_id].blank? && event[:subtype].blank?
|
|
17
|
+
thread_key = "#{params[:team_id]}:#{event[:channel]}:#{event[:thread_ts] || event[:ts]}"
|
|
18
|
+
channel_class.dispatch(
|
|
19
|
+
thread_key: thread_key, input: event[:text].to_s,
|
|
20
|
+
metadata: { "slack" => { "channel" => event[:channel],
|
|
21
|
+
"thread_ts" => event[:thread_ts] || event[:ts],
|
|
22
|
+
"user" => event[:user] } }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
head :ok
|
|
26
|
+
rescue Silas::TurnInProgressError
|
|
27
|
+
head :ok # single-active-turn: a reply mid-turn is dropped (v1 policy)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST /channels/slack/actions
|
|
31
|
+
def actions
|
|
32
|
+
return unless verify_slack!
|
|
33
|
+
|
|
34
|
+
payload = JSON.parse(params[:payload])
|
|
35
|
+
action = payload.fetch("actions").first
|
|
36
|
+
invocation = Silas::ToolInvocation.find(action["value"])
|
|
37
|
+
who = "slack:#{payload.dig('user', 'username') || payload.dig('user', 'id')}"
|
|
38
|
+
|
|
39
|
+
if action["action_id"] == "silas_approve"
|
|
40
|
+
invocation.approve!(by: who)
|
|
41
|
+
render json: { replace_original: true, text: ":white_check_mark: Approved by @#{who}" }
|
|
42
|
+
else
|
|
43
|
+
invocation.decline!(reason: "declined in Slack", by: who)
|
|
44
|
+
render json: { replace_original: true, text: ":x: Declined by @#{who}" }
|
|
45
|
+
end
|
|
46
|
+
rescue Silas::Error => e
|
|
47
|
+
render json: { replace_original: false, text: "Could not record: #{e.message}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def channel_class
|
|
53
|
+
Silas.config.channel_resolver&.call("slack") or
|
|
54
|
+
raise Silas::Error, "no app/agent/channels/slack.rb (Agent::Channels::Slack) defined"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# Deny-by-default (lifted from ruby_llm-resilience's dashboard_auth): the
|
|
4
|
+
# host's inbox_auth lambda renders/head-404s to DENY and passes by NOT
|
|
5
|
+
# rendering. A fresh mount is invisible until the host opts in.
|
|
6
|
+
class BaseController < ActionController::Base
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
layout "silas/inbox"
|
|
9
|
+
|
|
10
|
+
before_action :authenticate_read!
|
|
11
|
+
|
|
12
|
+
helper Silas::Inbox::TraceHelper
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Reads: allowed in public-read mode, else the host lambda must pass.
|
|
17
|
+
def authenticate_read!
|
|
18
|
+
return if Silas.config.inbox_public_read
|
|
19
|
+
|
|
20
|
+
deny_unless_authed!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes (approve/decline): ALWAYS the host lambda, even in public-read.
|
|
24
|
+
def authenticate_write!
|
|
25
|
+
deny_unless_authed!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deny_unless_authed!
|
|
29
|
+
Silas.config.inbox_auth.call(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_actor
|
|
33
|
+
Silas.config.inbox_actor.call(self)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# Approve/decline from the UI — the SAME code path as the Slack buttons and
|
|
4
|
+
# email links (invocation.approve!/decline!). Writes always require real
|
|
5
|
+
# auth, even when reads are public.
|
|
6
|
+
class InvocationsController < BaseController
|
|
7
|
+
before_action :authenticate_write!
|
|
8
|
+
before_action :set_invocation
|
|
9
|
+
|
|
10
|
+
def approve
|
|
11
|
+
@invocation.approve!(by: current_actor)
|
|
12
|
+
respond_resolved
|
|
13
|
+
rescue Silas::Error => e
|
|
14
|
+
respond_error(e)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def decline
|
|
18
|
+
# decline-with-note: the textarea's reason becomes {denied: reason},
|
|
19
|
+
# which the model sees as the tool result.
|
|
20
|
+
reason = params[:reason].presence || "declined from inbox"
|
|
21
|
+
@invocation.decline!(reason: reason, by: current_actor)
|
|
22
|
+
respond_resolved
|
|
23
|
+
rescue Silas::Error => e
|
|
24
|
+
respond_error(e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_invocation
|
|
30
|
+
@invocation = Silas::ToolInvocation.find(params[:id])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def respond_resolved
|
|
34
|
+
# The model's own after_commit broadcast fans the update to every open
|
|
35
|
+
# trace; this response just returns the caller to the session.
|
|
36
|
+
redirect_to inbox_session_path(@invocation.turn.session_id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def respond_error(error)
|
|
40
|
+
redirect_to inbox_session_path(@invocation.turn.session_id), alert: error.message
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
class SessionsController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@sessions = Silas::Session.order(created_at: :desc).limit(100)
|
|
6
|
+
@pending_total = Silas::ToolInvocation.where(approval_state: "required").count
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@session = Silas::Session.find(params[:id])
|
|
11
|
+
@turns = @session.turns.includes(steps: :tool_invocations)
|
|
12
|
+
@cost = Silas::Inbox::Cost.for_session(@session)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
module TraceHelper
|
|
4
|
+
STATUS_CLASS = {
|
|
5
|
+
"queued" => "pill-grey", "running" => "pill-blue pill-pulse",
|
|
6
|
+
"waiting" => "pill-amber", "in_doubt" => "pill-amber",
|
|
7
|
+
"completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-red",
|
|
8
|
+
# tool-invocation statuses
|
|
9
|
+
"pending" => "pill-grey", "started" => "pill-blue", "declined" => "pill-red",
|
|
10
|
+
"approved" => "pill-green", "required" => "pill-amber", "expired" => "pill-red"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def status_pill(status)
|
|
14
|
+
tag.span(status.to_s.tr("_", " "), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def step_text(step)
|
|
18
|
+
Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join.presence
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pretty_args(hash)
|
|
22
|
+
JSON.pretty_generate(hash || {})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cost_label(cost)
|
|
26
|
+
tokens = "#{number_with_delimiter(cost[:input_tokens])} in / #{number_with_delimiter(cost[:output_tokens])} out"
|
|
27
|
+
money = Silas::Inbox::Cost.format(cost[:microcents])
|
|
28
|
+
if cost[:unpriced] || money.nil?
|
|
29
|
+
"#{tokens} · cost unavailable"
|
|
30
|
+
else
|
|
31
|
+
"#{tokens} · #{money}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def relative_time(time)
|
|
36
|
+
return "" unless time
|
|
37
|
+
|
|
38
|
+
"#{time_ago_in_words(time)} ago"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One durable turn. Step-name sequence: :prepare, :step_0, :step_1, …,
|
|
3
|
+
# :finalize. Every between-step loop-control read hits write-once persisted
|
|
4
|
+
# state (Step#terminal, invocation approval state at-or-after the cursor), so
|
|
5
|
+
# a resumed continuation regenerates the IDENTICAL sequence — the spike's
|
|
6
|
+
# hard-won determinism constraint, owned by the framework so users can't
|
|
7
|
+
# violate it.
|
|
8
|
+
#
|
|
9
|
+
# Parking (approval / in-doubt) is a NORMAL job exit at zero compute; resume
|
|
10
|
+
# is a fresh job enqueued by approve!/decline!, replaying completed steps
|
|
11
|
+
# from rows (no model calls, no re-effects — StepRunner's replay path).
|
|
12
|
+
class AgentLoopJob < ActiveJob::Base
|
|
13
|
+
include ActiveJob::Continuable
|
|
14
|
+
|
|
15
|
+
self.resume_options = { wait: 0 } # spike: default 5s wait makes turns crawl
|
|
16
|
+
|
|
17
|
+
queue_as { Silas.config.queue_name }
|
|
18
|
+
|
|
19
|
+
def perform(turn_id)
|
|
20
|
+
turn = Turn.find(turn_id)
|
|
21
|
+
return if turn.completed? || %w[failed canceled].include?(turn.status)
|
|
22
|
+
|
|
23
|
+
if Silas.resolved_engine.class.loop_ownership == :engine
|
|
24
|
+
perform_engine_owned(turn)
|
|
25
|
+
else
|
|
26
|
+
perform_framework_owned(turn)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# :ruby_llm — the framework drives the loop, one model call per step, tools
|
|
33
|
+
# executed through the Ledger. The determinism constraints live here.
|
|
34
|
+
def perform_framework_owned(turn)
|
|
35
|
+
step :prepare, isolated: isolate? do
|
|
36
|
+
Ledger.assert_no_checkpoint!
|
|
37
|
+
turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
|
|
38
|
+
Instructions.snapshot!(turn)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
index = 0
|
|
42
|
+
loop do
|
|
43
|
+
step :"step_#{index}", isolated: isolate? do
|
|
44
|
+
Ledger.assert_no_checkpoint!
|
|
45
|
+
StepRunner.call(turn, index)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Loop control reads persisted, effectively-immutable state only.
|
|
49
|
+
row = Step.find_by!(turn: turn, index: index)
|
|
50
|
+
return if row.parked?
|
|
51
|
+
break if row.terminal?
|
|
52
|
+
|
|
53
|
+
# Budget caps (cost/tokens/time) — checked between steps, never inside one.
|
|
54
|
+
if (reason = Budget.exceeded_reason(turn))
|
|
55
|
+
turn.finish!(:failed, reason: reason)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
index += 1
|
|
60
|
+
if index >= Silas.agent.max_steps
|
|
61
|
+
turn.finish!(:failed, reason: "max_steps")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
step :finalize do
|
|
67
|
+
turn.finish!(:completed)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# :agent_sdk — Claude Code owns the loop; one isolated :run step wraps the
|
|
72
|
+
# whole subprocess (one Continuation checkpoint per invocation). Same
|
|
73
|
+
# durable shell, same queue/rescuer/single-active-turn invariants.
|
|
74
|
+
def perform_engine_owned(turn)
|
|
75
|
+
step :prepare, isolated: isolate? do
|
|
76
|
+
Ledger.assert_no_checkpoint!
|
|
77
|
+
turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
|
|
78
|
+
Instructions.snapshot!(turn)
|
|
79
|
+
Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
outcome = nil
|
|
83
|
+
step :run, isolated: isolate? do
|
|
84
|
+
Ledger.assert_no_checkpoint!
|
|
85
|
+
outcome = SubprocessRunner.call(turn)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
step :finalize do
|
|
89
|
+
turn.finish!(:completed) if outcome == :terminal
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def isolate? = Silas.config.isolate_steps
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Outbound delivery, decoupled from the durable loop. Triggered by after_commit
|
|
3
|
+
# callbacks on Turn/ToolInvocation; idempotent via a CAS claim on the marker
|
|
4
|
+
# column (notified_at/answered_at), released if the send raises so a retry can
|
|
5
|
+
# re-attempt. Duplicate pings are the worst failure — never a ledger violation
|
|
6
|
+
# (approve!/decline! are idempotent).
|
|
7
|
+
class ChannelDeliveryJob < ActiveJob::Base
|
|
8
|
+
queue_as { Silas.config.queue_name }
|
|
9
|
+
|
|
10
|
+
def perform(kind, id)
|
|
11
|
+
case kind
|
|
12
|
+
when "approval" then deliver_approval(id)
|
|
13
|
+
when "answer" then deliver_answer(id)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def deliver_approval(id)
|
|
20
|
+
invocation = ToolInvocation.find_by(id: id)
|
|
21
|
+
return unless invocation&.approval_state == "required"
|
|
22
|
+
return unless claim!(ToolInvocation, invocation.id, :notified_at)
|
|
23
|
+
|
|
24
|
+
channel = Channel.for_session(invocation.turn.session)
|
|
25
|
+
return release!(ToolInvocation, invocation.id, :notified_at) unless channel
|
|
26
|
+
|
|
27
|
+
with_release(ToolInvocation, invocation.id, :notified_at) do
|
|
28
|
+
channel.deliver_approval(session: invocation.turn.session, invocation: invocation)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def deliver_answer(id)
|
|
33
|
+
turn = Turn.find_by(id: id)
|
|
34
|
+
return unless turn && (turn.completed? || turn.status == "failed")
|
|
35
|
+
return unless claim!(Turn, turn.id, :answered_at)
|
|
36
|
+
|
|
37
|
+
channel = Channel.for_session(turn.session)
|
|
38
|
+
return release!(Turn, turn.id, :answered_at) unless channel
|
|
39
|
+
|
|
40
|
+
with_release(Turn, turn.id, :answered_at) do
|
|
41
|
+
channel.deliver_answer(session: turn.session, text: turn.answer_text)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Compare-and-swap: only the execution that flips NULL -> now proceeds.
|
|
46
|
+
def claim!(model, id, column)
|
|
47
|
+
model.where(id: id, column => nil).update_all(column => Time.current) == 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def release!(model, id, column)
|
|
51
|
+
model.where(id: id).update_all(column => nil)
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with_release(model, id, column)
|
|
56
|
+
yield
|
|
57
|
+
rescue StandardError
|
|
58
|
+
release!(model, id, column) # send failed — free the claim for a retry
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# Mandatory durability infrastructure (proven in the Phase 0 spike): Solid
|
|
3
|
+
# Queue FAILS a dead worker's claimed jobs (ProcessExitError on supervisor
|
|
4
|
+
# reap, ProcessPrunedError on heartbeat prune) and nothing retries them —
|
|
5
|
+
# retry_on can't see queue-level errors. This job, run as a recurring task,
|
|
6
|
+
# is the recovery path; recovery latency ≈ alive_threshold + its cadence.
|
|
7
|
+
#
|
|
8
|
+
# It also sweeps expired approvals while it's here.
|
|
9
|
+
class DeadJobRescuerJob < ActiveJob::Base
|
|
10
|
+
DEAD_PROCESS_ERRORS = %w[
|
|
11
|
+
SolidQueue::Processes::ProcessExitError
|
|
12
|
+
SolidQueue::Processes::ProcessPrunedError
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
queue_as { Silas.config.queue_name }
|
|
16
|
+
|
|
17
|
+
def perform
|
|
18
|
+
ToolInvocation.expire_stale!
|
|
19
|
+
return 0 unless defined?(SolidQueue)
|
|
20
|
+
|
|
21
|
+
rescued = 0
|
|
22
|
+
SolidQueue::FailedExecution.includes(:job).find_each do |failed|
|
|
23
|
+
next unless DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
|
|
24
|
+
|
|
25
|
+
failed.retry
|
|
26
|
+
rescued += 1
|
|
27
|
+
end
|
|
28
|
+
rescued
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|