silas 0.1.6 → 0.2.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 +118 -0
- data/README.md +55 -14
- data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
- data/app/controllers/silas/inbox/turns_controller.rb +32 -0
- data/app/jobs/silas/agent_loop_job.rb +46 -43
- data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
- data/app/models/silas/memory.rb +51 -0
- data/app/models/silas/session.rb +5 -3
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +1 -0
- data/app/views/layouts/silas/inbox.html.erb +14 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +20 -2
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +6 -1
- data/app/views/silas/inbox/turns/_header.html.erb +7 -0
- data/config/routes.rb +6 -1
- data/db/migrate/20260721000001_create_silas_memories.rb +22 -0
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/lib/generators/silas/install/install_generator.rb +33 -14
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +29 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/chat.rb +45 -13
- data/lib/silas/configuration.rb +75 -24
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +15 -4
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- data/lib/silas/instructions.rb +13 -1
- data/lib/silas/ledger.rb +26 -10
- data/lib/silas/mcp/handler.rb +6 -5
- data/lib/silas/mcp/server.rb +9 -9
- data/lib/silas/nested_runner.rb +2 -2
- data/lib/silas/registry.rb +12 -2
- data/lib/silas/step_runner.rb +21 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/tools/handoff.rb +68 -0
- data/lib/silas/tools/recall.rb +18 -0
- data/lib/silas/tools/remember.rb +32 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +20 -9
- metadata +10 -6
- data/lib/silas/agent_sdk/cli.rb +0 -59
- data/lib/silas/agent_sdk/stream_parser.rb +0 -86
- data/lib/silas/agent_sdk/version_guard.rb +0 -26
- data/lib/silas/engines/agent_sdk.rb +0 -75
- data/lib/silas/subprocess_runner.rb +0 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f804f46eccb34c27515be306e2697345f3be7e0ad3479aeafb05f3ca9e5e54b
|
|
4
|
+
data.tar.gz: 63a99caaeebb60133423d0d73669eff7074232262743764ec0d9d7d38f7b2012
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb45b5ca774d80cdd2c584851114204eb123dd714a94264a851699ea24955fa8eefba4b1897e1924a91a484f64c9746bc625e007652fdc8a71e096425e7a9882
|
|
7
|
+
data.tar.gz: 707d7857de1f2fba1e4e9327e9c32f71e7282c4dd2cfe739cb6aea98904cc805634f0ce70fd4353c5d580ea9f521d79e9581621d8f9b321216e9e00c058ba3b8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- **Token streaming, end to end.** The engine seam's `&on_event` block — dead
|
|
6
|
+
code since 0.1.0 — is live: the `:ruby_llm` engine streams the model
|
|
7
|
+
response (`chat.complete` with a block; the assembled message is identical
|
|
8
|
+
to the sync path, so durability semantics are untouched), `StepRunner`
|
|
9
|
+
coalesces text deltas into ~10Hz `"silas.delta"` notifications
|
|
10
|
+
(`Silas::DeltaBuffer`) carrying the accumulated text, and two subscribers
|
|
11
|
+
render them: the inbox trace (synchronous Turbo `broadcast_update_to` into a
|
|
12
|
+
stable per-step target — crash-restream overwrites, never duplicates) and
|
|
13
|
+
the `silas:chat` REPL (tokens print as they arrive). Deltas are decoration
|
|
14
|
+
over the authoritative rows: never persisted, never fed to the model, and a
|
|
15
|
+
replayed step emits none. `around_model_call` hooks keep their existing
|
|
16
|
+
contract and can no longer swallow the stream.
|
|
17
|
+
- **Onboarding fixes.** The generated `bin/ci` can now actually fail on app
|
|
18
|
+
tests (it silently swallowed them with `|| true`); the generated initializer
|
|
19
|
+
shows every option the next-steps mention (`inbox_auth`, `sandbox`,
|
|
20
|
+
`memory_approval`, `model_prices`, `eval_dir`, `approval_ttl`) and defaults
|
|
21
|
+
to `claude-sonnet-5` instead of handing a first run the most expensive model
|
|
22
|
+
with no budget set; the rescuer's `recurring.yml` entry is now **idempotent
|
|
23
|
+
and environment-aware** (injected under every deployable env block —
|
|
24
|
+
staging included — never blind-appended into whatever block ends the file,
|
|
25
|
+
never duplicated on a re-run); a missing provider API key is caught **at
|
|
26
|
+
boot** with the exact fix (warns in development, raises `BootGuardError` in
|
|
27
|
+
production); and the unsafe Async-adapter warning **raises in production**,
|
|
28
|
+
where running agents on it silently voids the durability contract.
|
|
29
|
+
- **Model-call resilience: transient provider errors retry from the
|
|
30
|
+
checkpoint; nothing ever strands in `running`.** Previously a single
|
|
31
|
+
429/529/timeout failed the loop job permanently and invisibly — the turn sat
|
|
32
|
+
in `running` forever with no retry and no signal. Now:
|
|
33
|
+
`resume_errors_after_advancing = false` on the loop job (Active Job
|
|
34
|
+
Continuations otherwise swallow errors raised after a checkpoint and
|
|
35
|
+
self-resume unboundedly, bypassing `retry_on` — verified against activejob
|
|
36
|
+
8.1); transient classes (`RateLimitError`, `OverloadedError`,
|
|
37
|
+
`ServiceUnavailableError`, `ServerError`, Faraday timeouts) retry with
|
|
38
|
+
polynomial backoff + jitter and **resume from the last completed step**;
|
|
39
|
+
exhaustion and permanent rejections (`UnauthorizedError`,
|
|
40
|
+
`PaymentRequiredError`, …) expire pending approvals and fail the turn
|
|
41
|
+
loudly. The rescuer now also sweeps **stranded turns** — a loop job that
|
|
42
|
+
died with an error outside the retry list fails its turn
|
|
43
|
+
(`reason: "job_failed"`) instead of leaving it running forever. Stale
|
|
44
|
+
approval cards can no longer zombie-resume a failed turn (`approve!` /
|
|
45
|
+
`decline!` refuse; `resume_turn!` guards).
|
|
46
|
+
- **The inbox now shows the audit trail it exists to provide.** Tool
|
|
47
|
+
`arguments` render for every settled invocation (not just parked ones), a
|
|
48
|
+
failed tool shows its recorded `error` instead of a bare red pill, and
|
|
49
|
+
`approved_by` / `decline_reason` render on settled approvals — who held the
|
|
50
|
+
lever, and why it moved. Active turns gain a **Cancel** button (running
|
|
51
|
+
turns flag for a step-boundary cancel, parked turns cancel immediately),
|
|
52
|
+
and the "N awaiting approval" badge is now a link filtering the session
|
|
53
|
+
list to what needs you (`?pending=1`).
|
|
54
|
+
- **Web chat in the inbox.** The session page gains a composer (`POST
|
|
55
|
+
.../sessions/:id/turns`) and the index a start-a-session form (with a named
|
|
56
|
+
agent picker) — the browser is now a first-class conversational surface, not
|
|
57
|
+
just approve/decline. Writes ride `authenticate_write!` exactly like
|
|
58
|
+
approvals; a turn-in-progress renders as an inline alert; web-chat sessions
|
|
59
|
+
stay `channel: nil` ("direct"), so no outbound delivery jobs are enqueued.
|
|
60
|
+
|
|
61
|
+
- **`approval :once` is now scoped to (tool, arguments), not tool name alone.**
|
|
62
|
+
Name-only matching was a footgun: approving a £5 refund silently
|
|
63
|
+
auto-approved a £5,000 refund later in the same session. Identical repeat
|
|
64
|
+
calls still skip re-approval; different arguments park again. Graded gates
|
|
65
|
+
belong in an approval lambda.
|
|
66
|
+
- **The ledger's checkpoint guard moved to `IsolatedExecutionState`** (from
|
|
67
|
+
`Thread.current[]`, which is fiber-local) — it now follows the app's
|
|
68
|
+
configured isolation level exactly like agent scopes, surviving into
|
|
69
|
+
internally-created fibers where the old flag silently vanished. Nested
|
|
70
|
+
ledger transactions now save/restore the guard instead of clearing it (the
|
|
71
|
+
old `ensure` opened a checkpoint-guard hole for the rest of the outer
|
|
72
|
+
transaction).
|
|
73
|
+
- **Removed the `:agent_sdk` engine** (the `claude -p` subprocess integration).
|
|
74
|
+
Its differentiating rationale — running on a Claude subscription plan instead
|
|
75
|
+
of API credits — was structurally unreachable: `--bare` was hardcoded and the
|
|
76
|
+
engine raised without `ANTHROPIC_API_KEY` regardless of `config.auth`, so the
|
|
77
|
+
OAuth path could never execute a turn. What remained was a second engine with
|
|
78
|
+
weaker guarantees on every axis (exactly-once only *within* a run,
|
|
79
|
+
`approval :never` tools only, fail-closed on any mid-subprocess kill) that
|
|
80
|
+
made the durability contract conditional. One production path now:
|
|
81
|
+
`:ruby_llm`.
|
|
82
|
+
- `config.engine = :agent_sdk` raises a clear `BootGuardError` at configure
|
|
83
|
+
time. `config.auth` and the `agent_sdk_*` options are warning no-ops for
|
|
84
|
+
this release (hard removal in 0.3) — an existing initializer won't crash.
|
|
85
|
+
- The in-process MCP server (`Silas::Mcp::Server`/`Handler`) **survives the
|
|
86
|
+
cut** — it is the seam for a planned "mount your agent's tools as an MCP
|
|
87
|
+
server" feature — with its own integration spec. Its bind host moved from
|
|
88
|
+
`config.agent_sdk_mcp_host` to `config.mcp_server_host`.
|
|
89
|
+
- New migration drops `silas_turns.cli_session_id` and
|
|
90
|
+
`silas_turns.mcp_token` (the latter was write-only; tokens are minted and
|
|
91
|
+
compared in memory). Run `bin/rails silas:install:migrations db:migrate`.
|
|
92
|
+
- `Silas::Engines::Base.loop_ownership` is gone — every engine executes one
|
|
93
|
+
model call per step under the framework-owned loop. Custom engines that
|
|
94
|
+
merely inherited it are unaffected.
|
|
95
|
+
|
|
96
|
+
## 0.1.7
|
|
97
|
+
|
|
98
|
+
- **Memory — graph-shaped, not a graph database.** New `silas_memories` table:
|
|
99
|
+
entity-attributed facts (`subject · attribute · content`) with provenance
|
|
100
|
+
(session/turn) and **supersession** — a new fact about the same
|
|
101
|
+
subject+attribute retires the old one. Two built-ins: `remember`
|
|
102
|
+
(`transactional!`, **approval-gated by default** — the memory card parks in
|
|
103
|
+
your inbox; `config.memory_approval = :never` opts out) and `recall`
|
|
104
|
+
(on-demand subject lookup). Recent memories inject into the instructions
|
|
105
|
+
snapshot (bounded by `config.memory_injection_limit`). Scopes: private
|
|
106
|
+
per-agent or `shared: true` app-wide. Domain memory stays where it belongs —
|
|
107
|
+
your own tables; this is for the fuzzy residue with no natural home. Edges
|
|
108
|
+
are a deliberate not-yet. Upgrade-safe: tools only advertise when the
|
|
109
|
+
migration has run.
|
|
110
|
+
- **Handoffs — staff composition without agent chatter.** New `handoff`
|
|
111
|
+
built-in (advertised when `app/agents/` exists): file a self-contained brief
|
|
112
|
+
that starts another named agent's linked session (`parent_session_id`),
|
|
113
|
+
async by default, `await: true` for run-now-and-return-answer.
|
|
114
|
+
`at_most_once!` through the ledger; refuses self-handoffs, unknown targets,
|
|
115
|
+
cycles, and chains deeper than 3. Free-form agent-to-agent conversation
|
|
116
|
+
remains deliberately unblessed.
|
|
117
|
+
- `Session#continue(enqueue: false)` for callers that drive the turn
|
|
118
|
+
themselves. New migration: run `bin/rails silas:install:migrations
|
|
119
|
+
db:migrate` on upgrade.
|
|
120
|
+
|
|
3
121
|
## 0.1.6
|
|
4
122
|
|
|
5
123
|
- **Named agents — the staff pattern.** An app can now employ several
|
data/README.md
CHANGED
|
@@ -88,6 +88,13 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
88
88
|
- **Approvals park at zero compute** — the job exits; approving enqueues a fresh
|
|
89
89
|
one that replays completed work from rows, never re-calling the model or
|
|
90
90
|
re-running tools. Parks expire (default 7 days) rather than ghosting forever.
|
|
91
|
+
- **Transient model errors retry from the checkpoint.** A rate limit,
|
|
92
|
+
overload, or timeout backs off and retries the job — and the continuation
|
|
93
|
+
resumes from the last completed step, never re-running completed work.
|
|
94
|
+
Exhausted retries and permanent rejections (bad key, bad request) expire
|
|
95
|
+
pending approvals and fail the turn loudly. **A turn can never sit in
|
|
96
|
+
`running` forever**: the rescuer also fails turns stranded by a loop job
|
|
97
|
+
that died outside the retry list.
|
|
91
98
|
- **The rescuer is part of the contract.** Solid Queue marks a dead worker's
|
|
92
99
|
jobs failed and nothing retries them; the installer wires
|
|
93
100
|
`Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
|
|
@@ -96,19 +103,19 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
96
103
|
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
97
104
|
(`NondeterminismError`) instead of resuming into a different agent.
|
|
98
105
|
|
|
99
|
-
##
|
|
106
|
+
## Engine
|
|
100
107
|
|
|
101
|
-
Inference is one pluggable seam (`config.engine`):
|
|
108
|
+
Inference is one pluggable seam (`config.engine`): `:ruby_llm` — API-key auth
|
|
109
|
+
via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
|
|
110
|
+
and the production path. Compose resilience via `config.around_model_call`, or
|
|
111
|
+
swap in any object responding to `#execute_step` (the eval harness and the
|
|
112
|
+
chaos tests do exactly that).
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the same Ledger. Always `--bare` (API-key auth only; the boot guard raises if
|
|
109
|
-
OAuth is configured with `ANTHROPIC_API_KEY` present, and if the key is missing
|
|
110
|
-
in api_key mode). v1 is honestly weaker than `:ruby_llm`: exactly-once *within*
|
|
111
|
-
a run, `approval :never` tools only, and fail-closed on a mid-subprocess kill.
|
|
114
|
+
> The experimental `:agent_sdk` engine (a `claude -p` subprocess) was removed
|
|
115
|
+
> in 0.2: its subscription-auth rationale was structurally unreachable, and it
|
|
116
|
+
> carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
|
|
117
|
+
> server survives and returns as a first-class *mount your tools as MCP*
|
|
118
|
+
> feature.
|
|
112
119
|
|
|
113
120
|
## Sandbox: run untrusted code with hermetic
|
|
114
121
|
|
|
@@ -160,6 +167,24 @@ default agent, unchanged. (Subagents stay a root-agent delegation feature;
|
|
|
160
167
|
scope switching is execution-isolated, so concurrent jobs running different
|
|
161
168
|
agents never cross wires.)
|
|
162
169
|
|
|
170
|
+
## Memory & handoffs
|
|
171
|
+
|
|
172
|
+
Silas memory is **graph-shaped, not a graph database**: facts as
|
|
173
|
+
`subject · attribute · content` triples with provenance and supersession
|
|
174
|
+
("author:jane · report_format: prefers CSV" — a new value retires the old).
|
|
175
|
+
The `remember` tool is **approval-gated by default** — the memory card parks
|
|
176
|
+
in your inbox before anything persists; `recall` digs deeper than the few
|
|
177
|
+
recent memories injected into each turn. Private per agent, or `shared: true`
|
|
178
|
+
for the whole staff. Your *domain* data does not belong here — it belongs in
|
|
179
|
+
your own tables, which your tools already read; memory is for the fuzzy
|
|
180
|
+
residue with no natural home.
|
|
181
|
+
|
|
182
|
+
Staff compose through **handoffs, not conversations**: `handoff` files a
|
|
183
|
+
self-contained brief that starts a linked session for another named agent
|
|
184
|
+
(async, or `await: true` for an answer), exactly-once-guarded, cycle-checked.
|
|
185
|
+
Two models chatting freely is a cost and audit hazard — deliberately
|
|
186
|
+
unblessed.
|
|
187
|
+
|
|
163
188
|
## Triggers
|
|
164
189
|
|
|
165
190
|
An agent is reached by more than a method call:
|
|
@@ -172,12 +197,28 @@ An agent is reached by more than a method call:
|
|
|
172
197
|
approvals render as Slack buttons / signed email links that call the same
|
|
173
198
|
`approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
|
|
174
199
|
|
|
200
|
+
## Streaming
|
|
201
|
+
|
|
202
|
+
Turns stream. The `:ruby_llm` engine emits text deltas as the model responds:
|
|
203
|
+
`bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
|
|
204
|
+
renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
|
|
205
|
+
the durable rows — never persisted, never fed back to the model, and a
|
|
206
|
+
replayed step renders from its row with no deltas at all, so streaming adds
|
|
207
|
+
zero risk to the durability contract. Custom sinks subscribe to the
|
|
208
|
+
`"silas.delta"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
|
|
209
|
+
text: }`, where `text` is the accumulated string so far — filter by ids;
|
|
210
|
+
notifications are process-global).
|
|
211
|
+
|
|
175
212
|
## The inbox
|
|
176
213
|
|
|
177
214
|
Mount the engine (the generator does this) and a live inbox appears at
|
|
178
|
-
`/silas/inbox`: a session list,
|
|
179
|
-
|
|
180
|
-
|
|
215
|
+
`/silas/inbox`: a session list, **web chat** (start a session or reply from
|
|
216
|
+
the browser — same durable loop, no separate surface), a live step-trace that
|
|
217
|
+
streams tokens over Turbo Streams as the agent runs, approval cards whose
|
|
218
|
+
Approve/Decline buttons call the exact same `approve!`/`decline!` as Slack and
|
|
219
|
+
email, a full **audit trail** (every tool call's arguments and its result or
|
|
220
|
+
recorded error; who approved; who declined and why), **cancel** on active
|
|
221
|
+
turns (honored at the next step boundary), and per-session token/cost
|
|
181
222
|
accounting. It's **deny-by-default** — invisible until you wire auth:
|
|
182
223
|
|
|
183
224
|
```ruby
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
module Silas
|
|
2
2
|
module Inbox
|
|
3
3
|
class SessionsController < BaseController
|
|
4
|
+
before_action :authenticate_write!, only: :create
|
|
5
|
+
|
|
4
6
|
def index
|
|
5
7
|
@sessions = Silas::Session.order(created_at: :desc).limit(100)
|
|
6
8
|
@sessions = @sessions.where(agent_name: params[:agent]) if params[:agent].present?
|
|
9
|
+
if params[:pending].present? # the "N awaiting approval" badge drills into this
|
|
10
|
+
# Subquery, not joins+distinct: DISTINCT over silas_sessions.* trips
|
|
11
|
+
# on the json metadata column (PG json has no equality operator).
|
|
12
|
+
@sessions = @sessions.where(
|
|
13
|
+
id: Silas::Turn.joins(:tool_invocations)
|
|
14
|
+
.where(silas_tool_invocations: { approval_state: "required" })
|
|
15
|
+
.select(:session_id)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
7
18
|
@agent_names = Silas::Session.distinct.pluck(:agent_name).sort
|
|
8
19
|
@pending_total = Silas::ToolInvocation.where(approval_state: "required").count
|
|
9
20
|
end
|
|
@@ -13,6 +24,19 @@ module Silas
|
|
|
13
24
|
@turns = @session.turns.includes(steps: :tool_invocations)
|
|
14
25
|
@cost = Silas::Inbox::Cost.for_session(@session)
|
|
15
26
|
end
|
|
27
|
+
|
|
28
|
+
# Start a session from the browser. channel stays nil ("direct") — web
|
|
29
|
+
# chat is read live on the session page, not delivered outbound.
|
|
30
|
+
def create
|
|
31
|
+
input = params[:input].to_s.strip
|
|
32
|
+
return redirect_to inbox_sessions_path, alert: "Type a message first." if input.empty?
|
|
33
|
+
|
|
34
|
+
handle = params[:agent].present? ? Silas.agent(params[:agent]) : Silas.agent
|
|
35
|
+
started = handle.start(input: input)
|
|
36
|
+
redirect_to inbox_session_path(started)
|
|
37
|
+
rescue Silas::Error => e
|
|
38
|
+
redirect_to inbox_sessions_path, alert: e.message
|
|
39
|
+
end
|
|
16
40
|
end
|
|
17
41
|
end
|
|
18
42
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Inbox
|
|
3
|
+
# The web composer: append a turn to an existing session. Same write-auth
|
|
4
|
+
# gate as approve/decline. Deliberately thin — the turn runs on the durable
|
|
5
|
+
# loop and the model's after_commit broadcasts render it live, so this
|
|
6
|
+
# controller only enqueues and redirects.
|
|
7
|
+
class TurnsController < BaseController
|
|
8
|
+
before_action :authenticate_write!
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
agent_session = Silas::Session.find(params[:session_id])
|
|
12
|
+
input = params[:input].to_s.strip
|
|
13
|
+
return redirect_to inbox_session_path(agent_session), alert: "Type a message first." if input.empty?
|
|
14
|
+
|
|
15
|
+
agent_session.continue(input: input)
|
|
16
|
+
redirect_to inbox_session_path(agent_session)
|
|
17
|
+
rescue Silas::TurnInProgressError
|
|
18
|
+
redirect_to inbox_session_path(agent_session), alert: "A turn is already running — wait for it to settle."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Cancel from the trace. Running turns are flagged and honored at the
|
|
22
|
+
# next step boundary (the same safe point as budgets); parked/queued
|
|
23
|
+
# turns cancel immediately.
|
|
24
|
+
def cancel
|
|
25
|
+
turn = Silas::Turn.find(params[:id])
|
|
26
|
+
outcome = turn.cancel!(reason: "canceled from inbox by #{current_actor}")
|
|
27
|
+
notice = outcome == :cancel_requested ? "Cancel requested — honored at the next step boundary." : "Turn canceled."
|
|
28
|
+
redirect_to inbox_session_path(turn.session_id), notice: notice
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -12,10 +12,51 @@ module Silas
|
|
|
12
12
|
class AgentLoopJob < ActiveJob::Base
|
|
13
13
|
include ActiveJob::Continuable
|
|
14
14
|
|
|
15
|
+
# Errors must reach retry_on. By default Continuable swallows any
|
|
16
|
+
# StandardError raised after a checkpoint and silently self-resumes —
|
|
17
|
+
# unbounded invisible retries that bypass attempts/wait/jitter entirely
|
|
18
|
+
# (verified against activejob 8.1: the around_perform rescue runs before
|
|
19
|
+
# rescue_with_handler). Checkpoints still survive retry_on's re-enqueues —
|
|
20
|
+
# continuation state rides the job payload — so a retried execution skips
|
|
21
|
+
# completed steps. Isolation interrupts are rescued separately and are
|
|
22
|
+
# unaffected by this flag. Do NOT reach for max_resumptions as an error
|
|
23
|
+
# bound: with isolate_steps on, every isolated step consumes one
|
|
24
|
+
# resumption by design.
|
|
25
|
+
self.resume_errors_after_advancing = false
|
|
26
|
+
|
|
15
27
|
self.resume_options = { wait: 0 } # spike: default 5s wait makes turns crawl
|
|
16
28
|
|
|
17
29
|
queue_as { Silas.config.queue_name }
|
|
18
30
|
|
|
31
|
+
# Transient provider trouble: back off and retry; the continuation resumes
|
|
32
|
+
# from the last completed step. Exhaustion fails the turn LOUDLY — a turn
|
|
33
|
+
# must never strand in "running". (Never retry_on StandardError: it would
|
|
34
|
+
# catch Continuation::Error subclasses and retry a structurally broken job
|
|
35
|
+
# forever.)
|
|
36
|
+
retry_on ::RubyLLM::RateLimitError, ::RubyLLM::OverloadedError,
|
|
37
|
+
::RubyLLM::ServiceUnavailableError, ::RubyLLM::ServerError,
|
|
38
|
+
::Faraday::TimeoutError, ::Faraday::ConnectionFailed,
|
|
39
|
+
wait: :polynomially_longer, attempts: 5, jitter: 0.15 do |job, error|
|
|
40
|
+
fail_turn(job, error)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Permanent provider rejections: retrying cannot help. Fail the turn now.
|
|
44
|
+
discard_on ::RubyLLM::UnauthorizedError, ::RubyLLM::PaymentRequiredError,
|
|
45
|
+
::RubyLLM::ForbiddenError, ::RubyLLM::BadRequestError do |job, error|
|
|
46
|
+
fail_turn(job, error)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The force-fail path: expire approvals FIRST so no stale card can
|
|
50
|
+
# zombie-resume the failed turn, then finish loudly.
|
|
51
|
+
def self.fail_turn(job, error)
|
|
52
|
+
turn = Turn.find_by(id: job.arguments.first)
|
|
53
|
+
return unless turn&.active?
|
|
54
|
+
|
|
55
|
+
turn.expire_pending_approvals!("turn failed: model error")
|
|
56
|
+
turn.finish!(:failed, reason: "model_error")
|
|
57
|
+
Rails.logger&.error("[silas] turn #{turn.id} failed on #{error.class}: #{error.message}")
|
|
58
|
+
end
|
|
59
|
+
|
|
19
60
|
def perform(turn_id)
|
|
20
61
|
turn = Turn.find(turn_id)
|
|
21
62
|
return if turn.completed? || %w[failed canceled].include?(turn.status)
|
|
@@ -26,26 +67,17 @@ module Silas
|
|
|
26
67
|
# staff member never wakes up holding the root agent's tools.
|
|
27
68
|
scope = Silas.scope_for_session(turn.session)
|
|
28
69
|
if scope
|
|
29
|
-
Silas.with_agent_scope(scope) {
|
|
70
|
+
Silas.with_agent_scope(scope) { run_turn(turn) }
|
|
30
71
|
else
|
|
31
|
-
|
|
72
|
+
run_turn(turn)
|
|
32
73
|
end
|
|
33
74
|
end
|
|
34
75
|
|
|
35
76
|
private
|
|
36
77
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
else
|
|
41
|
-
perform_framework_owned(turn)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# :ruby_llm — the framework drives the loop, one model call per step, tools
|
|
47
|
-
# executed through the Ledger. The determinism constraints live here.
|
|
48
|
-
def perform_framework_owned(turn)
|
|
78
|
+
# The framework drives the loop: one model call per step, tools executed
|
|
79
|
+
# through the Ledger. The determinism constraints live here.
|
|
80
|
+
def run_turn(turn)
|
|
49
81
|
step :prepare, isolated: isolate? do
|
|
50
82
|
Ledger.assert_no_checkpoint!
|
|
51
83
|
turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
|
|
@@ -95,35 +127,6 @@ module Silas
|
|
|
95
127
|
end
|
|
96
128
|
end
|
|
97
129
|
|
|
98
|
-
# :agent_sdk — Claude Code owns the loop; one isolated :run step wraps the
|
|
99
|
-
# whole subprocess (one Continuation checkpoint per invocation). Same
|
|
100
|
-
# durable shell, same queue/rescuer/single-active-turn invariants.
|
|
101
|
-
def perform_engine_owned(turn)
|
|
102
|
-
step :prepare, isolated: isolate? do
|
|
103
|
-
Ledger.assert_no_checkpoint!
|
|
104
|
-
turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
|
|
105
|
-
Instructions.snapshot!(turn)
|
|
106
|
-
Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Cancellation for engine-owned turns is honored only BEFORE the
|
|
110
|
-
# subprocess starts — a running claude -p is not aborted mid-flight (v1).
|
|
111
|
-
if turn.reload.cancel_requested_at
|
|
112
|
-
turn.finish!(:canceled, reason: "canceled")
|
|
113
|
-
return
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
outcome = nil
|
|
117
|
-
step :run, isolated: isolate? do
|
|
118
|
-
Ledger.assert_no_checkpoint!
|
|
119
|
-
outcome = SubprocessRunner.call(turn)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
step :finalize do
|
|
123
|
-
turn.finish!(:completed) if outcome == :terminal
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
130
|
def isolate? = Silas.config.isolate_steps
|
|
128
131
|
end
|
|
129
132
|
end
|
|
@@ -20,12 +20,33 @@ module Silas
|
|
|
20
20
|
|
|
21
21
|
rescued = 0
|
|
22
22
|
SolidQueue::FailedExecution.includes(:job).find_each do |failed|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
if DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
|
|
24
|
+
failed.retry
|
|
25
|
+
rescued += 1
|
|
26
|
+
elsif failed.job&.class_name == "Silas::AgentLoopJob"
|
|
27
|
+
fail_stranded_turn(failed)
|
|
28
|
+
end
|
|
27
29
|
end
|
|
28
30
|
rescued
|
|
29
31
|
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# A loop job that failed with a NON-dead-process error (something outside
|
|
36
|
+
# AgentLoopJob's retry list — a NoMethodError in a tool, an AR blip) will
|
|
37
|
+
# never be retried by anyone. Without this sweep its turn sits in
|
|
38
|
+
# "running" forever: not failed, not parked, invisible as broken. The
|
|
39
|
+
# failed execution stays in Solid Queue for forensics; the TURN is failed
|
|
40
|
+
# loudly with its approvals expired.
|
|
41
|
+
def fail_stranded_turn(failed)
|
|
42
|
+
turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
|
|
43
|
+
return unless turn&.active?
|
|
44
|
+
|
|
45
|
+
exception = failed.error&.dig("exception_class")
|
|
46
|
+
turn.expire_pending_approvals!("turn failed: #{exception}")
|
|
47
|
+
turn.finish!(:failed, reason: "job_failed")
|
|
48
|
+
Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
|
|
49
|
+
"#{exception} — #{failed.error&.dig('message')}")
|
|
50
|
+
end
|
|
30
51
|
end
|
|
31
52
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
# One remembered fact. Triple-ish (subject · attribute · content) with
|
|
3
|
+
# provenance (which turn wrote it) and supersession: a new fact about the
|
|
4
|
+
# same (agent, scope, subject, attribute) retires the old one — temporal
|
|
5
|
+
# versioning without a temporal store. Edges between memories are a
|
|
6
|
+
# deliberate not-yet: add them when a real agent needs multi-hop.
|
|
7
|
+
class Memory < ApplicationRecord
|
|
8
|
+
self.table_name = "silas_memories"
|
|
9
|
+
|
|
10
|
+
SCOPES = %w[agent app].freeze
|
|
11
|
+
validates :scope, inclusion: { in: SCOPES }
|
|
12
|
+
validates :agent_name, :subject, :content, presence: true
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(status: "active") }
|
|
15
|
+
|
|
16
|
+
# Write-through with supersession. attribute nil = free-form note about the
|
|
17
|
+
# subject (accumulates); attribute present = the triple's slot (supersedes).
|
|
18
|
+
def self.remember!(agent_name:, subject:, content:, attribute: nil, scope: "agent", turn: nil)
|
|
19
|
+
transaction do
|
|
20
|
+
record = create!(agent_name:, scope:, subject: subject.to_s.strip.downcase,
|
|
21
|
+
attribute_name: attribute.presence&.strip&.downcase, content:,
|
|
22
|
+
session_id: turn&.session_id, turn_id: turn&.id)
|
|
23
|
+
if record.attribute_name
|
|
24
|
+
active.where(agent_name:, scope:, subject: record.subject, attribute_name: record.attribute_name)
|
|
25
|
+
.where.not(id: record.id)
|
|
26
|
+
.update_all(status: "superseded", superseded_by_id: record.id, updated_at: Time.current)
|
|
27
|
+
end
|
|
28
|
+
record
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What an agent can see: its own memories + app-shared ones. Subject-matched
|
|
33
|
+
# first (when subjects given), then most recent.
|
|
34
|
+
def self.recall(agent_name:, subjects: [], limit: 10)
|
|
35
|
+
visible = active.where("agent_name = :a OR scope = 'app'", a: agent_name)
|
|
36
|
+
if subjects.any?
|
|
37
|
+
keys = subjects.map { |s| s.to_s.strip.downcase }
|
|
38
|
+
matched = visible.where(subject: keys).order(created_at: :desc).limit(limit).to_a
|
|
39
|
+
rest = visible.where.not(subject: keys).order(created_at: :desc).limit(limit - matched.size)
|
|
40
|
+
matched + rest
|
|
41
|
+
else
|
|
42
|
+
visible.order(created_at: :desc).limit(limit).to_a
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_line
|
|
47
|
+
head = attribute_name ? "#{subject} · #{attribute_name}: " : "#{subject}: "
|
|
48
|
+
head + content
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/app/models/silas/session.rb
CHANGED
|
@@ -19,15 +19,17 @@ module Silas
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Enqueue the next turn. One active turn per session — the partial unique
|
|
22
|
-
# index is the backstop; this is the friendly front door.
|
|
23
|
-
|
|
22
|
+
# index is the backstop; this is the friendly front door. enqueue: false
|
|
23
|
+
# creates the turn without scheduling it (callers that drive it themselves,
|
|
24
|
+
# e.g. an awaited handoff running the loop inline).
|
|
25
|
+
def continue(input:, enqueue: true)
|
|
24
26
|
if active_turn
|
|
25
27
|
raise TurnInProgressError, "session #{id} already has an active turn (##{active_turn.index})"
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
next_index = (turns.maximum(:index) || -1) + 1
|
|
29
31
|
turn = turns.create!(index: next_index, input: input)
|
|
30
|
-
AgentLoopJob.perform_later(turn.id)
|
|
32
|
+
AgentLoopJob.perform_later(turn.id) if enqueue
|
|
31
33
|
turn
|
|
32
34
|
rescue ActiveRecord::RecordNotUnique
|
|
33
35
|
raise TurnInProgressError, "session #{id} already has an active turn"
|
|
@@ -35,6 +35,7 @@ module Silas
|
|
|
35
35
|
# in-doubt invocation, approval means "it did not run — re-execute".
|
|
36
36
|
def approve!(by: nil)
|
|
37
37
|
assert_parked!
|
|
38
|
+
assert_turn_resumable!
|
|
38
39
|
update!(status: "pending", approval_state: "approved", approved_by: by)
|
|
39
40
|
resume_turn!
|
|
40
41
|
end
|
|
@@ -45,6 +46,7 @@ module Silas
|
|
|
45
46
|
# abandon" — the operator-supplied reason becomes the recorded outcome.
|
|
46
47
|
def decline!(reason:, by: nil)
|
|
47
48
|
assert_parked!
|
|
49
|
+
assert_turn_resumable!
|
|
48
50
|
update!(status: "failed", approval_state: "declined", approved_by: by,
|
|
49
51
|
decline_reason: reason, result: { "denied" => reason })
|
|
50
52
|
resume_turn!
|
|
@@ -68,8 +70,18 @@ module Silas
|
|
|
68
70
|
raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
# A failed turn must never be zombie-resumed by a stale approval card:
|
|
74
|
+
# force-fail paths expire approvals first, but a card already rendered in
|
|
75
|
+
# someone's browser can still POST — the verdict must land on a live turn.
|
|
76
|
+
def assert_turn_resumable!
|
|
77
|
+
return unless turn.reload.failed?
|
|
78
|
+
|
|
79
|
+
raise Error, "turn #{turn.id} already failed (#{turn.failure_reason}) — " \
|
|
80
|
+
"this approval can no longer resume it"
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
def resume_turn!
|
|
72
|
-
return if turn.reload.canceled? #
|
|
84
|
+
return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
|
|
73
85
|
return if turn.tool_invocations.where(approval_state: "required").exists?
|
|
74
86
|
|
|
75
87
|
turn.update!(status: "queued")
|
data/app/models/silas/turn.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Silas
|
|
|
17
17
|
|
|
18
18
|
ACTIVE_STATUSES.each { |s| define_method(:"#{s}?") { status == s } }
|
|
19
19
|
def completed? = status == "completed"
|
|
20
|
+
def failed? = status == "failed"
|
|
20
21
|
def active? = ACTIVE_STATUSES.include?(status)
|
|
21
22
|
def parked? = status == "waiting" || status == "in_doubt"
|
|
22
23
|
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
.step::before { content: ""; position: absolute; left: -5px; top: 6px; width: 8px; height: 8px;
|
|
59
59
|
border-radius: 50%; background: var(--accent); }
|
|
60
60
|
.step-text { margin: 2px 0; }
|
|
61
|
+
.step-live { white-space: pre-wrap; }
|
|
61
62
|
.tool { background: var(--grey-bg); border-radius: 10px; padding: 8px 10px; margin: 6px 0; font-size: 13px; }
|
|
62
63
|
.tool code { font-family: var(--mono); }
|
|
63
64
|
pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: 8px;
|
|
@@ -73,7 +74,19 @@
|
|
|
73
74
|
padding: 8px; margin: 8px 0; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
|
|
74
75
|
form.inline { display: inline; }
|
|
75
76
|
.cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
77
|
+
.composer textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px;
|
|
78
|
+
padding: 10px 12px; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
|
|
79
|
+
.composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
80
|
+
.composer-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
|
|
81
|
+
.btn.send { background: var(--accent); color: #fff; }
|
|
82
|
+
select.composer-agent { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
|
|
83
|
+
font: inherit; background: var(--panel); color: var(--ink); margin-bottom: 8px; }
|
|
76
84
|
.flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
|
|
85
|
+
.flash-notice { background: var(--green-bg); color: var(--green); }
|
|
86
|
+
.btn-cancel { border: 1px solid var(--red); background: transparent; color: var(--red);
|
|
87
|
+
border-radius: 8px; padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; margin-left: auto; }
|
|
88
|
+
pre.error { background: var(--red-bg); color: var(--red); }
|
|
89
|
+
pre.args { opacity: 0.85; }
|
|
77
90
|
.empty { text-align: center; color: var(--muted); padding: 48px 0; }
|
|
78
91
|
.agent-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
79
92
|
.agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid #d9dce1;
|
|
@@ -89,6 +102,7 @@
|
|
|
89
102
|
<% if content_for?(:header_extra) %><%= yield :header_extra %><% end %>
|
|
90
103
|
</header>
|
|
91
104
|
<% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
|
|
105
|
+
<% if flash[:notice] %><div class="flash flash-notice"><%= flash[:notice] %></div><% end %>
|
|
92
106
|
<%= yield %>
|
|
93
107
|
</div>
|
|
94
108
|
</body>
|
|
@@ -2,10 +2,26 @@
|
|
|
2
2
|
<div>
|
|
3
3
|
<code><%= invocation.tool_name %></code>
|
|
4
4
|
<%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
|
|
5
|
+
<%# The audit line — who held the lever, and why it moved. %>
|
|
6
|
+
<% if invocation.approval_state == "approved" && invocation.approved_by.present? %>
|
|
7
|
+
<span class="muted">approved by <%= invocation.approved_by %></span>
|
|
8
|
+
<% elsif invocation.approval_state == "declined" %>
|
|
9
|
+
<span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
|
|
10
|
+
<% elsif invocation.approval_state == "expired" %>
|
|
11
|
+
<span class="muted">approval expired unanswered</span>
|
|
12
|
+
<% end %>
|
|
5
13
|
</div>
|
|
6
14
|
<% if invocation.awaiting_approval? %>
|
|
7
15
|
<%= render "silas/inbox/invocations/approval_card", invocation: invocation %>
|
|
8
|
-
<%
|
|
9
|
-
|
|
16
|
+
<% else %>
|
|
17
|
+
<%# What the agent passed — the question an audit surface must answer. %>
|
|
18
|
+
<% if invocation.arguments.present? %>
|
|
19
|
+
<pre class="args"><%= pretty_args(invocation.arguments) %></pre>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if invocation.error.present? %>
|
|
22
|
+
<pre class="error"><%= invocation.error %></pre>
|
|
23
|
+
<% elsif invocation.result.present? %>
|
|
24
|
+
<pre><%= pretty_args(invocation.result) %></pre>
|
|
25
|
+
<% end %>
|
|
10
26
|
<% end %>
|
|
11
27
|
</div>
|