silas 0.1.7 → 0.3.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 +153 -0
- data/README.md +81 -14
- data/app/controllers/concerns/silas/api/serialization.rb +63 -0
- data/app/controllers/silas/api/base_controller.rb +24 -0
- data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
- data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
- data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
- data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
- data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
- data/app/controllers/silas/inbox/turns_controller.rb +51 -0
- data/app/helpers/silas/inbox/trace_helper.rb +5 -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/session.rb +6 -0
- data/app/models/silas/tool_invocation.rb +13 -1
- data/app/models/silas/turn.rb +10 -0
- data/app/views/layouts/silas/inbox.html.erb +20 -0
- data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
- data/app/views/silas/inbox/sessions/index.html.erb +31 -5
- data/app/views/silas/inbox/sessions/show.html.erb +11 -0
- data/app/views/silas/inbox/steps/_step.html.erb +9 -1
- data/app/views/silas/inbox/turns/_header.html.erb +19 -1
- data/config/routes.rb +28 -1
- data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
- data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
- data/lib/generators/silas/install/install_generator.rb +34 -14
- data/lib/generators/silas/install/templates/agent.yml +10 -1
- data/lib/generators/silas/install/templates/bin_ci +2 -2
- data/lib/generators/silas/install/templates/initializer.rb +30 -5
- data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
- data/lib/silas/agent.rb +4 -0
- data/lib/silas/chat.rb +47 -13
- data/lib/silas/configuration.rb +77 -37
- data/lib/silas/delta_buffer.rb +50 -0
- data/lib/silas/doctor.rb +155 -0
- data/lib/silas/engine.rb +6 -0
- data/lib/silas/engines/base.rb +6 -8
- data/lib/silas/engines/ruby_llm.rb +28 -6
- data/lib/silas/errors.rb +3 -3
- data/lib/silas/eval/assertions.rb +18 -0
- data/lib/silas/eval/scripted_engine.rb +0 -2
- data/lib/silas/eval/transcript.rb +1 -0
- data/lib/silas/inbox/cost.rb +37 -13
- data/lib/silas/inbox/delta_broadcaster.rb +38 -0
- 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/message_builder.rb +9 -2
- data/lib/silas/named_agent.rb +1 -0
- data/lib/silas/registry.rb +18 -8
- data/lib/silas/step_runner.rb +33 -6
- data/lib/silas/tool.rb +5 -0
- data/lib/silas/version.rb +1 -1
- data/lib/silas.rb +10 -9
- data/lib/tasks/silas_doctor.rake +17 -0
- metadata +14 -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: 187a0730a1a84463f6207060d446c6960946513c0884d708fc0d16c011493a26
|
|
4
|
+
data.tar.gz: ed9a09b617c7dde80deaf6fe2c460e254cc96487b43eac52f56c6aa7dedecbaa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a38278a7020a9282b1ff9aaac7b18da60bd017a001bcc421c6e316d0033b1a4b468cd86c0d4bcdcd869940f7d9c4b953318e84dd626087bef4fee1ee8ea8b87c
|
|
7
|
+
data.tar.gz: 6f42e1a81bf935ebdbb39c678e46662ab85b9f45bd8293c0e83f4467e7db922e2102087ab62ea7eb8e8b1b3b1d323eba1a5772e997028b3e5838882bda9cfeb3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,158 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- **`bin/rails silas:doctor`** — every known first-run failure mode as one
|
|
6
|
+
command: provider key, queue adapter (async = red), model resolution with
|
|
7
|
+
prices, migrations, tool validation, the rescuer recurring entry, the cable
|
|
8
|
+
adapter for live streaming (async cable can't carry worker deltas), and
|
|
9
|
+
whether the deny-by-default inbox/API auth has been wired. Exits non-zero
|
|
10
|
+
on failures, so it slots into CI.
|
|
11
|
+
- **Hard-removed the 0.2 `:agent_sdk` config shims** — `config.auth` and
|
|
12
|
+
`config.agent_sdk_*` now raise `NoMethodError` (they were warning no-ops
|
|
13
|
+
for the 0.2 cycle). `config.engine = :agent_sdk` still raises the
|
|
14
|
+
explanatory `BootGuardError`. Workflows now use `actions/checkout@v5`
|
|
15
|
+
(Node 20 deprecation).
|
|
16
|
+
- **Inbox at scale + the promised top-up card.** Budget-parked turns now
|
|
17
|
+
render a **"Budget reached — raise & resume"** card (the UI the 0.1.5
|
|
18
|
+
changelog promised): one field, one click, `raise_budget!`, completed work
|
|
19
|
+
replays from rows. The session list pages by keyset (`?before=<id>`, 50 per
|
|
20
|
+
page — the old hard `limit(100)` made session 101 unreachable forever), and
|
|
21
|
+
the index renders in **2 queries** instead of ~4 per card.
|
|
22
|
+
- **Cost accounting prices itself from RubyLLM's model registry** (1,100+
|
|
23
|
+
models, refreshed upstream from models.dev) instead of a five-entry
|
|
24
|
+
hand-maintained map. Steps stamp the **provider** RubyLLM resolves at
|
|
25
|
+
persist time, and lookups use the two-arg `find(model, provider)` — 85/1081
|
|
26
|
+
registry ids exist under multiple providers at different prices, so the
|
|
27
|
+
bare lookup could silently price the wrong one. `config.model_prices` is
|
|
28
|
+
now an **override map** (default `{}`) for fine-tunes, custom deployments,
|
|
29
|
+
and models newer than the installed registry; unknown models stay
|
|
30
|
+
`unpriced`, never a lying $0.00. Dropped the write-only
|
|
31
|
+
`silas_turns.cost_microcents` column (declared since 0.1.0, never
|
|
32
|
+
populated). Default model is now `claude-sonnet-4-5` — in every supported
|
|
33
|
+
registry, unlike `claude-sonnet-5` (absent from ruby_llm 1.16's shipped
|
|
34
|
+
registry, which would have broken first runs), and never the priciest
|
|
35
|
+
model. New migration: `bin/rails silas:install:migrations db:migrate`.
|
|
36
|
+
- **Structured final answers.** Declare a JSON schema under `final_answer:` in
|
|
37
|
+
agent.yml and the turn's answer comes back as a parsed Hash —
|
|
38
|
+
`Turn#answer_data` (alongside `answer_text` for prose agents), in the API
|
|
39
|
+
(`answer_data` on turns, `structured` on steps), the inbox trace, the REPL,
|
|
40
|
+
and a new `assert_answer_data` eval assertion. Implemented on RubyLLM's
|
|
41
|
+
`with_schema` (each provider's native structured-output dialect). The schema
|
|
42
|
+
is model-visible state: it folds into the definitions digest **only when
|
|
43
|
+
present** — schema-less agents keep a byte-identical digest, so turns parked
|
|
44
|
+
across the upgrade never trip `NondeterminismError` (pinned by spec); a
|
|
45
|
+
mid-turn schema change fails loudly by design. Structured answers replay
|
|
46
|
+
into later turns' history as their JSON text (deterministic).
|
|
47
|
+
|
|
48
|
+
- **A public HTTP + SSE session API** at `/silas/api/v1` — sessions
|
|
49
|
+
(create/show, `?trace=1` for the full transcript), turns (create — **409**
|
|
50
|
+
while one is active — and cancel), and approvals (list / approve / decline,
|
|
51
|
+
the exact same `approve!`/`decline!` as every other surface, stamped with
|
|
52
|
+
`config.api_actor`). Deny-by-default via `config.api_auth`, the inbox's
|
|
53
|
+
contract. `GET .../sessions/:id/stream` is server-sent events at row
|
|
54
|
+
granularity (turn / completed-step / invocation changes) with
|
|
55
|
+
`Last-Event-ID` resume — at-least-once, epoch-ms watermark ids, `?poll=1`
|
|
56
|
+
for a curl-friendly backlog-and-close, self-closing after
|
|
57
|
+
`api_stream_max_duration` with a `timeout` reconnect event. The AR
|
|
58
|
+
connection is released between polls; per-token streaming remains the
|
|
59
|
+
Turbo/browser feature (deltas live in the worker process; the gem takes no
|
|
60
|
+
cross-process bus dependency). `Session` gains `parent_session` /
|
|
61
|
+
`child_sessions` associations, and session JSON carries the lineage.
|
|
62
|
+
|
|
63
|
+
## 0.2.0
|
|
64
|
+
|
|
65
|
+
- **Token streaming, end to end.** The engine seam's `&on_event` block — dead
|
|
66
|
+
code since 0.1.0 — is live: the `:ruby_llm` engine streams the model
|
|
67
|
+
response (`chat.complete` with a block; the assembled message is identical
|
|
68
|
+
to the sync path, so durability semantics are untouched), `StepRunner`
|
|
69
|
+
coalesces text deltas into ~10Hz `"silas.delta"` notifications
|
|
70
|
+
(`Silas::DeltaBuffer`) carrying the accumulated text, and two subscribers
|
|
71
|
+
render them: the inbox trace (synchronous Turbo `broadcast_update_to` into a
|
|
72
|
+
stable per-step target — crash-restream overwrites, never duplicates) and
|
|
73
|
+
the `silas:chat` REPL (tokens print as they arrive). Deltas are decoration
|
|
74
|
+
over the authoritative rows: never persisted, never fed to the model, and a
|
|
75
|
+
replayed step emits none. `around_model_call` hooks keep their existing
|
|
76
|
+
contract and can no longer swallow the stream.
|
|
77
|
+
- **Onboarding fixes.** The generated `bin/ci` can now actually fail on app
|
|
78
|
+
tests (it silently swallowed them with `|| true`); the generated initializer
|
|
79
|
+
shows every option the next-steps mention (`inbox_auth`, `sandbox`,
|
|
80
|
+
`memory_approval`, `model_prices`, `eval_dir`, `approval_ttl`) and defaults
|
|
81
|
+
to `claude-sonnet-5` instead of handing a first run the most expensive model
|
|
82
|
+
with no budget set; the rescuer's `recurring.yml` entry is now **idempotent
|
|
83
|
+
and environment-aware** (injected under every deployable env block —
|
|
84
|
+
staging included — never blind-appended into whatever block ends the file,
|
|
85
|
+
never duplicated on a re-run); a missing provider API key is caught **at
|
|
86
|
+
boot** with the exact fix (warns in development, raises `BootGuardError` in
|
|
87
|
+
production); and the unsafe Async-adapter warning **raises in production**,
|
|
88
|
+
where running agents on it silently voids the durability contract.
|
|
89
|
+
- **Model-call resilience: transient provider errors retry from the
|
|
90
|
+
checkpoint; nothing ever strands in `running`.** Previously a single
|
|
91
|
+
429/529/timeout failed the loop job permanently and invisibly — the turn sat
|
|
92
|
+
in `running` forever with no retry and no signal. Now:
|
|
93
|
+
`resume_errors_after_advancing = false` on the loop job (Active Job
|
|
94
|
+
Continuations otherwise swallow errors raised after a checkpoint and
|
|
95
|
+
self-resume unboundedly, bypassing `retry_on` — verified against activejob
|
|
96
|
+
8.1); transient classes (`RateLimitError`, `OverloadedError`,
|
|
97
|
+
`ServiceUnavailableError`, `ServerError`, Faraday timeouts) retry with
|
|
98
|
+
polynomial backoff + jitter and **resume from the last completed step**;
|
|
99
|
+
exhaustion and permanent rejections (`UnauthorizedError`,
|
|
100
|
+
`PaymentRequiredError`, …) expire pending approvals and fail the turn
|
|
101
|
+
loudly. The rescuer now also sweeps **stranded turns** — a loop job that
|
|
102
|
+
died with an error outside the retry list fails its turn
|
|
103
|
+
(`reason: "job_failed"`) instead of leaving it running forever. Stale
|
|
104
|
+
approval cards can no longer zombie-resume a failed turn (`approve!` /
|
|
105
|
+
`decline!` refuse; `resume_turn!` guards).
|
|
106
|
+
- **The inbox now shows the audit trail it exists to provide.** Tool
|
|
107
|
+
`arguments` render for every settled invocation (not just parked ones), a
|
|
108
|
+
failed tool shows its recorded `error` instead of a bare red pill, and
|
|
109
|
+
`approved_by` / `decline_reason` render on settled approvals — who held the
|
|
110
|
+
lever, and why it moved. Active turns gain a **Cancel** button (running
|
|
111
|
+
turns flag for a step-boundary cancel, parked turns cancel immediately),
|
|
112
|
+
and the "N awaiting approval" badge is now a link filtering the session
|
|
113
|
+
list to what needs you (`?pending=1`).
|
|
114
|
+
- **Web chat in the inbox.** The session page gains a composer (`POST
|
|
115
|
+
.../sessions/:id/turns`) and the index a start-a-session form (with a named
|
|
116
|
+
agent picker) — the browser is now a first-class conversational surface, not
|
|
117
|
+
just approve/decline. Writes ride `authenticate_write!` exactly like
|
|
118
|
+
approvals; a turn-in-progress renders as an inline alert; web-chat sessions
|
|
119
|
+
stay `channel: nil` ("direct"), so no outbound delivery jobs are enqueued.
|
|
120
|
+
|
|
121
|
+
- **`approval :once` is now scoped to (tool, arguments), not tool name alone.**
|
|
122
|
+
Name-only matching was a footgun: approving a £5 refund silently
|
|
123
|
+
auto-approved a £5,000 refund later in the same session. Identical repeat
|
|
124
|
+
calls still skip re-approval; different arguments park again. Graded gates
|
|
125
|
+
belong in an approval lambda.
|
|
126
|
+
- **The ledger's checkpoint guard moved to `IsolatedExecutionState`** (from
|
|
127
|
+
`Thread.current[]`, which is fiber-local) — it now follows the app's
|
|
128
|
+
configured isolation level exactly like agent scopes, surviving into
|
|
129
|
+
internally-created fibers where the old flag silently vanished. Nested
|
|
130
|
+
ledger transactions now save/restore the guard instead of clearing it (the
|
|
131
|
+
old `ensure` opened a checkpoint-guard hole for the rest of the outer
|
|
132
|
+
transaction).
|
|
133
|
+
- **Removed the `:agent_sdk` engine** (the `claude -p` subprocess integration).
|
|
134
|
+
Its differentiating rationale — running on a Claude subscription plan instead
|
|
135
|
+
of API credits — was structurally unreachable: `--bare` was hardcoded and the
|
|
136
|
+
engine raised without `ANTHROPIC_API_KEY` regardless of `config.auth`, so the
|
|
137
|
+
OAuth path could never execute a turn. What remained was a second engine with
|
|
138
|
+
weaker guarantees on every axis (exactly-once only *within* a run,
|
|
139
|
+
`approval :never` tools only, fail-closed on any mid-subprocess kill) that
|
|
140
|
+
made the durability contract conditional. One production path now:
|
|
141
|
+
`:ruby_llm`.
|
|
142
|
+
- `config.engine = :agent_sdk` raises a clear `BootGuardError` at configure
|
|
143
|
+
time. `config.auth` and the `agent_sdk_*` options are warning no-ops for
|
|
144
|
+
this release (hard removal in 0.3) — an existing initializer won't crash.
|
|
145
|
+
- The in-process MCP server (`Silas::Mcp::Server`/`Handler`) **survives the
|
|
146
|
+
cut** — it is the seam for a planned "mount your agent's tools as an MCP
|
|
147
|
+
server" feature — with its own integration spec. Its bind host moved from
|
|
148
|
+
`config.agent_sdk_mcp_host` to `config.mcp_server_host`.
|
|
149
|
+
- New migration drops `silas_turns.cli_session_id` and
|
|
150
|
+
`silas_turns.mcp_token` (the latter was write-only; tokens are minted and
|
|
151
|
+
compared in memory). Run `bin/rails silas:install:migrations db:migrate`.
|
|
152
|
+
- `Silas::Engines::Base.loop_ownership` is gone — every engine executes one
|
|
153
|
+
model call per step under the framework-owned loop. Custom engines that
|
|
154
|
+
merely inherited it are unaffected.
|
|
155
|
+
|
|
3
156
|
## 0.1.7
|
|
4
157
|
|
|
5
158
|
- **Memory — graph-shaped, not a graph database.** New `silas_memories` table:
|
data/README.md
CHANGED
|
@@ -33,6 +33,7 @@ app/agent/
|
|
|
33
33
|
bundle add silas
|
|
34
34
|
bin/rails generate silas:install
|
|
35
35
|
bin/rails db:migrate
|
|
36
|
+
bin/rails silas:doctor # key · queue adapter · model · migrations · tools · rescuer · cable · auth
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
```ruby
|
|
@@ -88,6 +89,13 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
88
89
|
- **Approvals park at zero compute** — the job exits; approving enqueues a fresh
|
|
89
90
|
one that replays completed work from rows, never re-calling the model or
|
|
90
91
|
re-running tools. Parks expire (default 7 days) rather than ghosting forever.
|
|
92
|
+
- **Transient model errors retry from the checkpoint.** A rate limit,
|
|
93
|
+
overload, or timeout backs off and retries the job — and the continuation
|
|
94
|
+
resumes from the last completed step, never re-running completed work.
|
|
95
|
+
Exhausted retries and permanent rejections (bad key, bad request) expire
|
|
96
|
+
pending approvals and fail the turn loudly. **A turn can never sit in
|
|
97
|
+
`running` forever**: the rescuer also fails turns stranded by a loop job
|
|
98
|
+
that died outside the retry list.
|
|
91
99
|
- **The rescuer is part of the contract.** Solid Queue marks a dead worker's
|
|
92
100
|
jobs failed and nothing retries them; the installer wires
|
|
93
101
|
`Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
|
|
@@ -96,19 +104,19 @@ hundreds of times per release (results in `chaos_host/results/`):
|
|
|
96
104
|
deploy that changes tools/skills mid-turn fails the turn loudly
|
|
97
105
|
(`NondeterminismError`) instead of resuming into a different agent.
|
|
98
106
|
|
|
99
|
-
##
|
|
107
|
+
## Engine
|
|
100
108
|
|
|
101
|
-
Inference is one pluggable seam (`config.engine`):
|
|
109
|
+
Inference is one pluggable seam (`config.engine`): `:ruby_llm` — API-key auth
|
|
110
|
+
via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
|
|
111
|
+
and the production path. Compose resilience via `config.around_model_call`, or
|
|
112
|
+
swap in any object responding to `#execute_step` (the eval harness and the
|
|
113
|
+
chaos tests do exactly that).
|
|
102
114
|
|
|
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.
|
|
115
|
+
> The experimental `:agent_sdk` engine (a `claude -p` subprocess) was removed
|
|
116
|
+
> in 0.2: its subscription-auth rationale was structurally unreachable, and it
|
|
117
|
+
> carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
|
|
118
|
+
> server survives and returns as a first-class *mount your tools as MCP*
|
|
119
|
+
> feature.
|
|
112
120
|
|
|
113
121
|
## Sandbox: run untrusted code with hermetic
|
|
114
122
|
|
|
@@ -190,12 +198,71 @@ An agent is reached by more than a method call:
|
|
|
190
198
|
approvals render as Slack buttons / signed email links that call the same
|
|
191
199
|
`approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
|
|
192
200
|
|
|
201
|
+
## Streaming
|
|
202
|
+
|
|
203
|
+
Turns stream. The `:ruby_llm` engine emits text deltas as the model responds:
|
|
204
|
+
`bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
|
|
205
|
+
renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
|
|
206
|
+
the durable rows — never persisted, never fed back to the model, and a
|
|
207
|
+
replayed step renders from its row with no deltas at all, so streaming adds
|
|
208
|
+
zero risk to the durability contract. Custom sinks subscribe to the
|
|
209
|
+
`"silas.delta"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
|
|
210
|
+
text: }`, where `text` is the accumulated string so far — filter by ids;
|
|
211
|
+
notifications are process-global).
|
|
212
|
+
|
|
213
|
+
## Structured answers
|
|
214
|
+
|
|
215
|
+
Give the turn's final answer a schema in agent.yml and read it back as a Hash:
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
final_answer:
|
|
219
|
+
type: object
|
|
220
|
+
properties:
|
|
221
|
+
verdict: { type: string }
|
|
222
|
+
amount_pence: { type: integer }
|
|
223
|
+
required: [verdict]
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`Turn#answer_data` returns the parsed payload (`answer_text` stays for prose
|
|
227
|
+
agents); the HTTP API carries it as `answer_data`, evals get
|
|
228
|
+
`assert_answer_data(key: :verdict, value: "approve")`. Rendered through
|
|
229
|
+
RubyLLM's `with_schema`, so each provider's native structured-output mode is
|
|
230
|
+
used. The schema is model-visible state — changing it mid-turn fails the turn
|
|
231
|
+
loudly rather than resuming into a different contract.
|
|
232
|
+
|
|
233
|
+
## The HTTP API
|
|
234
|
+
|
|
235
|
+
Everything the operator surface can do, over JSON — mounted with the engine at
|
|
236
|
+
`/silas/api/v1`, **deny-by-default** like the inbox (wire `config.api_auth`;
|
|
237
|
+
`config.api_actor` names the identity recorded on approvals):
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
curl -X POST .../silas/api/v1/sessions -d "input=Refund order 42, £12.50"
|
|
241
|
+
curl .../silas/api/v1/sessions/1?trace=1 # turns + steps + tool calls
|
|
242
|
+
curl .../silas/api/v1/sessions/1/approvals # what's parked
|
|
243
|
+
curl -X POST .../silas/api/v1/approvals/7/approve # the same approve! as the inbox
|
|
244
|
+
curl -X POST .../silas/api/v1/sessions/1/turns -d "input=Now email them" # 409 if busy
|
|
245
|
+
curl -X POST .../silas/api/v1/turns/9/cancel
|
|
246
|
+
curl -N .../silas/api/v1/sessions/1/stream # server-sent events
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The stream is SSE at **row granularity** — turn / completed-step / invocation
|
|
250
|
+
changes, at-least-once with `Last-Event-ID` resume (ids are epoch-ms
|
|
251
|
+
watermarks; `?poll=1` returns the backlog and closes, curl-friendly; streams
|
|
252
|
+
close themselves after `api_stream_max_duration` and clients reconnect).
|
|
253
|
+
Per-token streaming is deliberately the browser/Turbo feature — deltas live in
|
|
254
|
+
the worker process, and the gem requires no cross-process bus.
|
|
255
|
+
|
|
193
256
|
## The inbox
|
|
194
257
|
|
|
195
258
|
Mount the engine (the generator does this) and a live inbox appears at
|
|
196
|
-
`/silas/inbox`: a session list,
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
`/silas/inbox`: a session list, **web chat** (start a session or reply from
|
|
260
|
+
the browser — same durable loop, no separate surface), a live step-trace that
|
|
261
|
+
streams tokens over Turbo Streams as the agent runs, approval cards whose
|
|
262
|
+
Approve/Decline buttons call the exact same `approve!`/`decline!` as Slack and
|
|
263
|
+
email, a full **audit trail** (every tool call's arguments and its result or
|
|
264
|
+
recorded error; who approved; who declined and why), **cancel** on active
|
|
265
|
+
turns (honored at the next step boundary), and per-session token/cost
|
|
199
266
|
accounting. It's **deny-by-default** — invisible until you wire auth:
|
|
200
267
|
|
|
201
268
|
```ruby
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Api
|
|
3
|
+
# Minimal, dependency-free JSON shapes. Deliberately explicit allowlists —
|
|
4
|
+
# never as_json a whole row (instruction snapshots and internal columns
|
|
5
|
+
# don't belong on the wire).
|
|
6
|
+
module Serialization
|
|
7
|
+
def session_json(session, include_turns: true, trace: false)
|
|
8
|
+
base = {
|
|
9
|
+
id: session.id, agent_name: session.agent_name,
|
|
10
|
+
channel: session.channel, status: session.status,
|
|
11
|
+
parent_session_id: session.parent_session_id,
|
|
12
|
+
child_session_ids: session.child_sessions.ids,
|
|
13
|
+
cost: Silas::Inbox::Cost.for_session(session),
|
|
14
|
+
created_at: session.created_at, updated_at: session.updated_at
|
|
15
|
+
}
|
|
16
|
+
return base unless include_turns
|
|
17
|
+
|
|
18
|
+
base.merge(turns: session.turns.map { |t| turn_json(t, trace: trace) })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def turn_json(turn, trace: false)
|
|
22
|
+
base = {
|
|
23
|
+
id: turn.id, session_id: turn.session_id, index: turn.index,
|
|
24
|
+
status: turn.status, input: turn.input,
|
|
25
|
+
failure_reason: turn.failure_reason,
|
|
26
|
+
answer_text: (turn.answer_text if turn.completed?),
|
|
27
|
+
answer_data: (turn.answer_data if turn.completed?),
|
|
28
|
+
created_at: turn.created_at, updated_at: turn.updated_at
|
|
29
|
+
}
|
|
30
|
+
return base unless trace
|
|
31
|
+
|
|
32
|
+
base.merge(steps: turn.steps.map do |step|
|
|
33
|
+
step_json(step).merge(invocations: step.tool_invocations.map { |i| invocation_json(i) })
|
|
34
|
+
end)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def step_json(step)
|
|
38
|
+
{
|
|
39
|
+
id: step.id, turn_id: step.turn_id, index: step.index,
|
|
40
|
+
status: step.status, terminal: step.terminal,
|
|
41
|
+
model: step.model, provider: step.provider,
|
|
42
|
+
text: Array(step.response_blocks).select { |b| b["type"] == "text" }
|
|
43
|
+
.map { |b| b["text"] }.join.presence,
|
|
44
|
+
structured: Array(step.response_blocks).reverse
|
|
45
|
+
.find { |b| b["type"] == "structured" }&.dig("data"),
|
|
46
|
+
updated_at: step.updated_at
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def invocation_json(invocation)
|
|
51
|
+
{
|
|
52
|
+
id: invocation.id, turn_id: invocation.turn_id,
|
|
53
|
+
tool_name: invocation.tool_name, status: invocation.status,
|
|
54
|
+
approval_state: invocation.approval_state,
|
|
55
|
+
arguments: invocation.arguments, result: invocation.result,
|
|
56
|
+
error: invocation.error, approved_by: invocation.approved_by,
|
|
57
|
+
decline_reason: invocation.decline_reason,
|
|
58
|
+
updated_at: invocation.updated_at
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Api
|
|
3
|
+
# JSON API base: ActionController::API (no CSRF, no layouts, no cookies),
|
|
4
|
+
# deny-by-default auth with the inbox's contract — the host lambda DENIES
|
|
5
|
+
# by rendering (or head-ing) and PASSES by not rendering.
|
|
6
|
+
class BaseController < ActionController::API
|
|
7
|
+
before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
10
|
+
render json: { error: "not found" }, status: :not_found
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def authenticate!
|
|
16
|
+
Silas.config.api_auth.call(self)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_actor
|
|
20
|
+
Silas.config.api_actor.call(self)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
# The headline feature over HTTP: list what's parked, approve or decline
|
|
5
|
+
# it — the exact same approve!/decline! as the inbox, Slack, and email.
|
|
6
|
+
class ApprovalsController < Silas::Api::BaseController
|
|
7
|
+
include Silas::Api::Serialization
|
|
8
|
+
|
|
9
|
+
# GET /silas/api/v1/sessions/:session_id/approvals
|
|
10
|
+
def index
|
|
11
|
+
session = Silas::Session.find(params[:session_id])
|
|
12
|
+
render json: { approvals: session.pending_approvals.order(:id).map { |i| invocation_json(i) } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# POST /silas/api/v1/approvals/:id/approve
|
|
16
|
+
def approve
|
|
17
|
+
invocation = Silas::ToolInvocation.find(params[:id])
|
|
18
|
+
invocation.approve!(by: current_actor)
|
|
19
|
+
render json: invocation_json(invocation.reload)
|
|
20
|
+
rescue Silas::Error => e
|
|
21
|
+
render json: { error: e.message }, status: :conflict
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# POST /silas/api/v1/approvals/:id/decline { reason: (optional) }
|
|
25
|
+
def decline
|
|
26
|
+
invocation = Silas::ToolInvocation.find(params[:id])
|
|
27
|
+
invocation.decline!(reason: params[:reason].presence || "declined via api", by: current_actor)
|
|
28
|
+
render json: invocation_json(invocation.reload)
|
|
29
|
+
rescue Silas::Error => e
|
|
30
|
+
render json: { error: e.message }, status: :conflict
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class SessionsController < Silas::Api::BaseController
|
|
5
|
+
include Silas::Api::Serialization
|
|
6
|
+
|
|
7
|
+
# POST /silas/api/v1/sessions { input:, agent: (optional), metadata: (optional) }
|
|
8
|
+
# channel stays nil ("direct") — API consumers read state via GET/stream;
|
|
9
|
+
# a non-nil channel would enqueue pointless outbound delivery jobs.
|
|
10
|
+
def create
|
|
11
|
+
input = params[:input].to_s.strip
|
|
12
|
+
return render json: { error: "input is required" }, status: :unprocessable_entity if input.empty?
|
|
13
|
+
|
|
14
|
+
handle = params[:agent].present? ? Silas.agent(params[:agent]) : Silas.agent
|
|
15
|
+
session = handle.start(input: input, metadata: metadata_param)
|
|
16
|
+
render json: session_json(session.reload), status: :created
|
|
17
|
+
rescue Silas::TurnInProgressError => e
|
|
18
|
+
render json: { error: e.message }, status: :conflict
|
|
19
|
+
rescue Silas::Error => e
|
|
20
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GET /silas/api/v1/sessions/:id — session + turns
|
|
24
|
+
# GET /silas/api/v1/sessions/:id?trace=1 — plus steps + invocations
|
|
25
|
+
def show
|
|
26
|
+
session = Silas::Session.includes(turns: { steps: :tool_invocations }).find(params[:id])
|
|
27
|
+
render json: session_json(session, trace: params[:trace].present?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def metadata_param
|
|
33
|
+
raw = params[:metadata]
|
|
34
|
+
raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : (raw.presence || {})
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Silas
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
# Server-sent events at ROW granularity: turn / completed-step /
|
|
7
|
+
# invocation changes polled from the durable rows — the same source of
|
|
8
|
+
# truth as everything else. Deliberately NOT per-token: deltas are
|
|
9
|
+
# emitted in the worker process and the gem requires no cross-process
|
|
10
|
+
# bus; per-token streaming is the Turbo/browser feature.
|
|
11
|
+
#
|
|
12
|
+
# Delivery is at-least-once with Last-Event-ID resume: event ids are
|
|
13
|
+
# epoch-millisecond watermarks, each poll re-reads a 1ms overlap, and
|
|
14
|
+
# clients dedup by (event type, id, status). `?poll=1` emits the backlog
|
|
15
|
+
# since Last-Event-ID and closes — curl-able, and how the request specs
|
|
16
|
+
# exercise the cursor without holding a live stream.
|
|
17
|
+
#
|
|
18
|
+
# Live actions run on their own thread and hold a Puma thread for the
|
|
19
|
+
# stream's lifetime — inherent to SSE. The AR connection is NOT held:
|
|
20
|
+
# each poll checks one out and returns it before sleeping, and the
|
|
21
|
+
# stream closes itself after api_stream_max_duration (clients reconnect
|
|
22
|
+
# with Last-Event-ID).
|
|
23
|
+
class StreamsController < Silas::Api::BaseController
|
|
24
|
+
include ActionController::Live
|
|
25
|
+
include Silas::Api::Serialization
|
|
26
|
+
|
|
27
|
+
HEARTBEAT_EVERY = 15 # seconds; comment-frames keep proxies alive
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
session_id = with_connection { Silas::Session.find(params[:id]).id }
|
|
31
|
+
|
|
32
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
33
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
34
|
+
response.headers["X-Accel-Buffering"] = "no" # nginx: never buffer SSE
|
|
35
|
+
# Whatever the router/auth checked out must not stay pinned for the
|
|
36
|
+
# stream's lifetime.
|
|
37
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
|
38
|
+
|
|
39
|
+
watermark = initial_watermark
|
|
40
|
+
deadline = clock + Silas.config.api_stream_max_duration
|
|
41
|
+
last_beat = clock
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
events = with_connection { collect_changes(session_id, watermark) }
|
|
45
|
+
events.each do |type, at, payload|
|
|
46
|
+
write_event(type, payload, watermark_ms(at))
|
|
47
|
+
watermark = at if at > watermark
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
break if params[:poll].present? # backlog served; close
|
|
51
|
+
|
|
52
|
+
if clock >= deadline
|
|
53
|
+
write_event("timeout", { reconnect: true }, watermark_ms(watermark))
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if clock - last_beat >= HEARTBEAT_EVERY
|
|
58
|
+
response.stream.write(": hb\n\n")
|
|
59
|
+
last_beat = clock
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sleep Silas.config.api_stream_poll_interval
|
|
63
|
+
end
|
|
64
|
+
rescue IOError, ActionController::Live::ClientDisconnected
|
|
65
|
+
# client went away — the normal end of an SSE stream
|
|
66
|
+
ensure
|
|
67
|
+
response.stream.close rescue nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Everything with updated_at at-or-after the watermark (1ms overlap —
|
|
73
|
+
# at-least-once beats a gap). Steps only when completed: their text is
|
|
74
|
+
# the payload, and incomplete steps have none.
|
|
75
|
+
def collect_changes(session_id, watermark)
|
|
76
|
+
overlap = watermark - 0.001
|
|
77
|
+
events = []
|
|
78
|
+
|
|
79
|
+
Silas::Turn.where(session_id: session_id).where(updated_at: overlap..)
|
|
80
|
+
.order(:updated_at, :id).each do |turn|
|
|
81
|
+
events << [ "turn", turn.updated_at, turn_json(turn) ]
|
|
82
|
+
end
|
|
83
|
+
Silas::Step.joins(:turn).where(silas_turns: { session_id: session_id })
|
|
84
|
+
.where(status: "completed").where(updated_at: overlap..)
|
|
85
|
+
.order(:updated_at, :id).each do |step|
|
|
86
|
+
events << [ "step", step.updated_at, step_json(step) ]
|
|
87
|
+
end
|
|
88
|
+
Silas::ToolInvocation.joins(:turn).where(silas_turns: { session_id: session_id })
|
|
89
|
+
.where(updated_at: overlap..)
|
|
90
|
+
.order(:updated_at, :id).each do |invocation|
|
|
91
|
+
events << [ "invocation", invocation.updated_at, invocation_json(invocation) ]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
events.sort_by { |(_type, at, _payload)| at }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write_event(type, payload, id_ms)
|
|
98
|
+
response.stream.write("id: #{id_ms}\nevent: #{type}\ndata: #{JSON.generate(payload)}\n\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Last-Event-ID (header or param) in epoch ms; absent -> "from now"
|
|
102
|
+
# (GET /sessions/:id carries current state; the stream carries what
|
|
103
|
+
# changes next). Last-Event-ID: 0 replays the whole session.
|
|
104
|
+
def initial_watermark
|
|
105
|
+
raw = request.headers["Last-Event-ID"].presence || params[:last_event_id].presence
|
|
106
|
+
raw ? Time.zone.at(raw.to_i / 1000.0) : Time.zone.now
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def watermark_ms(time) = (time.to_f * 1000).round
|
|
110
|
+
|
|
111
|
+
def with_connection(&) = ActiveRecord::Base.connection_pool.with_connection(&)
|
|
112
|
+
|
|
113
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Silas
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class TurnsController < Silas::Api::BaseController
|
|
5
|
+
include Silas::Api::Serialization
|
|
6
|
+
|
|
7
|
+
# POST /silas/api/v1/sessions/:session_id/turns { input: }
|
|
8
|
+
# 409 when a turn is already active — an API must surface it, not
|
|
9
|
+
# swallow it the way the webhook channels do.
|
|
10
|
+
def create
|
|
11
|
+
session = Silas::Session.find(params[:session_id])
|
|
12
|
+
input = params[:input].to_s.strip
|
|
13
|
+
return render json: { error: "input is required" }, status: :unprocessable_entity if input.empty?
|
|
14
|
+
|
|
15
|
+
turn = session.continue(input: input)
|
|
16
|
+
render json: turn_json(turn), status: :created
|
|
17
|
+
rescue Silas::TurnInProgressError => e
|
|
18
|
+
render json: { error: e.message }, status: :conflict
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST /silas/api/v1/turns/:id/cancel
|
|
22
|
+
# Running turns are flagged (honored at the next step boundary);
|
|
23
|
+
# queued/parked turns cancel immediately — cancel: reflects which.
|
|
24
|
+
def cancel
|
|
25
|
+
turn = Silas::Turn.find(params[:id])
|
|
26
|
+
outcome = turn.cancel!(reason: "canceled via api by #{current_actor}")
|
|
27
|
+
render json: turn_json(turn.reload).merge(cancel: outcome)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|