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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +135 -0
  5. data/app/controllers/silas/channels/approvals_controller.rb +34 -0
  6. data/app/controllers/silas/channels/base_controller.rb +22 -0
  7. data/app/controllers/silas/channels/slack_controller.rb +58 -0
  8. data/app/controllers/silas/inbox/base_controller.rb +37 -0
  9. data/app/controllers/silas/inbox/invocations_controller.rb +44 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +16 -0
  11. data/app/helpers/silas/inbox/trace_helper.rb +42 -0
  12. data/app/jobs/silas/agent_loop_job.rb +95 -0
  13. data/app/jobs/silas/channel_delivery_job.rb +62 -0
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +31 -0
  15. data/app/jobs/silas/schedule_job.rb +22 -0
  16. data/app/mailboxes/silas/agent_mailbox.rb +35 -0
  17. data/app/mailers/silas/channel_mailer.rb +17 -0
  18. data/app/models/concerns/silas/inbox/broadcastable.rb +86 -0
  19. data/app/models/silas/application_record.rb +5 -0
  20. data/app/models/silas/session.rb +36 -0
  21. data/app/models/silas/step.rb +33 -0
  22. data/app/models/silas/tool_invocation.rb +78 -0
  23. data/app/models/silas/turn.rb +58 -0
  24. data/app/views/layouts/silas/inbox.html.erb +91 -0
  25. data/app/views/silas/channel_mailer/approval.text.erb +9 -0
  26. data/app/views/silas/channels/approvals/done.html.erb +2 -0
  27. data/app/views/silas/channels/approvals/error.html.erb +2 -0
  28. data/app/views/silas/channels/approvals/show.html.erb +9 -0
  29. data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -0
  30. data/app/views/silas/inbox/invocations/_invocation.html.erb +11 -0
  31. data/app/views/silas/inbox/sessions/_cost.html.erb +1 -0
  32. data/app/views/silas/inbox/sessions/index.html.erb +23 -0
  33. data/app/views/silas/inbox/sessions/show.html.erb +20 -0
  34. data/app/views/silas/inbox/steps/_step.html.erb +8 -0
  35. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  36. data/app/views/silas/inbox/turns/_turn.html.erb +8 -0
  37. data/config/routes.rb +19 -0
  38. data/db/migrate/20260714000001_create_silas_tables.rb +78 -0
  39. data/db/migrate/20260715000001_add_channel_outbound_markers.rb +8 -0
  40. data/db/migrate/20260715000002_add_agent_sdk_columns_to_turns.rb +9 -0
  41. data/db/migrate/20260715000003_add_parent_session_to_silas_sessions.rb +8 -0
  42. data/lib/generators/silas/install/install_generator.rb +81 -0
  43. data/lib/generators/silas/install/templates/agent.yml +9 -0
  44. data/lib/generators/silas/install/templates/bin_ci +7 -0
  45. data/lib/generators/silas/install/templates/channel_email.rb +18 -0
  46. data/lib/generators/silas/install/templates/channel_slack.rb +19 -0
  47. data/lib/generators/silas/install/templates/example_eval.rb +22 -0
  48. data/lib/generators/silas/install/templates/example_schedule.md +11 -0
  49. data/lib/generators/silas/install/templates/example_skill.md +8 -0
  50. data/lib/generators/silas/install/templates/example_tool.rb +12 -0
  51. data/lib/generators/silas/install/templates/initializer.rb +16 -0
  52. data/lib/generators/silas/install/templates/instructions.md +4 -0
  53. data/lib/silas/agent.rb +33 -0
  54. data/lib/silas/agent_scope.rb +6 -0
  55. data/lib/silas/agent_sdk/cli.rb +59 -0
  56. data/lib/silas/agent_sdk/stream_parser.rb +86 -0
  57. data/lib/silas/agent_sdk/version_guard.rb +26 -0
  58. data/lib/silas/budget.rb +42 -0
  59. data/lib/silas/channel.rb +69 -0
  60. data/lib/silas/configuration.rb +164 -0
  61. data/lib/silas/connection.rb +77 -0
  62. data/lib/silas/connections.rb +52 -0
  63. data/lib/silas/engine.rb +36 -0
  64. data/lib/silas/engines/agent_sdk.rb +75 -0
  65. data/lib/silas/engines/base.rb +30 -0
  66. data/lib/silas/engines/ruby_llm.rb +136 -0
  67. data/lib/silas/errors.rb +26 -0
  68. data/lib/silas/eval/assertions.rb +107 -0
  69. data/lib/silas/eval/driver.rb +47 -0
  70. data/lib/silas/eval/dsl.rb +55 -0
  71. data/lib/silas/eval/grader.rb +37 -0
  72. data/lib/silas/eval/result.rb +28 -0
  73. data/lib/silas/eval/runner.rb +38 -0
  74. data/lib/silas/eval/scripted_engine.rb +39 -0
  75. data/lib/silas/eval/transcript.rb +22 -0
  76. data/lib/silas/eval.rb +35 -0
  77. data/lib/silas/inbox/cost.rb +58 -0
  78. data/lib/silas/inbox.rb +23 -0
  79. data/lib/silas/instructions.rb +55 -0
  80. data/lib/silas/ledger.rb +178 -0
  81. data/lib/silas/mcp/client.rb +86 -0
  82. data/lib/silas/mcp/handler.rb +89 -0
  83. data/lib/silas/mcp/server.rb +134 -0
  84. data/lib/silas/message_builder.rb +63 -0
  85. data/lib/silas/nested_runner.rb +41 -0
  86. data/lib/silas/registry.rb +155 -0
  87. data/lib/silas/sandbox/docker.rb +67 -0
  88. data/lib/silas/sandbox/null.rb +17 -0
  89. data/lib/silas/sandbox.rb +15 -0
  90. data/lib/silas/schedule/compiler.rb +52 -0
  91. data/lib/silas/schedule.rb +109 -0
  92. data/lib/silas/skill.rb +21 -0
  93. data/lib/silas/slack.rb +55 -0
  94. data/lib/silas/step_runner.rb +98 -0
  95. data/lib/silas/subprocess_runner.rb +41 -0
  96. data/lib/silas/tool.rb +93 -0
  97. data/lib/silas/tools/delegate.rb +42 -0
  98. data/lib/silas/tools/load_skill.rb +25 -0
  99. data/lib/silas/tools/run_code.rb +21 -0
  100. data/lib/silas/version.rb +3 -0
  101. data/lib/silas.rb +144 -0
  102. data/lib/tasks/silas_eval.rake +20 -0
  103. data/lib/tasks/silas_schedules.rake +33 -0
  104. 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