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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/README.md +81 -14
  4. data/app/controllers/concerns/silas/api/serialization.rb +63 -0
  5. data/app/controllers/silas/api/base_controller.rb +24 -0
  6. data/app/controllers/silas/api/v1/approvals_controller.rb +35 -0
  7. data/app/controllers/silas/api/v1/sessions_controller.rb +39 -0
  8. data/app/controllers/silas/api/v1/streams_controller.rb +117 -0
  9. data/app/controllers/silas/api/v1/turns_controller.rb +32 -0
  10. data/app/controllers/silas/inbox/sessions_controller.rb +39 -2
  11. data/app/controllers/silas/inbox/turns_controller.rb +51 -0
  12. data/app/helpers/silas/inbox/trace_helper.rb +5 -0
  13. data/app/jobs/silas/agent_loop_job.rb +46 -43
  14. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  15. data/app/models/silas/session.rb +6 -0
  16. data/app/models/silas/tool_invocation.rb +13 -1
  17. data/app/models/silas/turn.rb +10 -0
  18. data/app/views/layouts/silas/inbox.html.erb +20 -0
  19. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  20. data/app/views/silas/inbox/sessions/index.html.erb +31 -5
  21. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  22. data/app/views/silas/inbox/steps/_step.html.erb +9 -1
  23. data/app/views/silas/inbox/turns/_header.html.erb +19 -1
  24. data/config/routes.rb +28 -1
  25. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  26. data/db/migrate/20260725000001_add_provider_to_silas_steps.rb +13 -0
  27. data/lib/generators/silas/install/install_generator.rb +34 -14
  28. data/lib/generators/silas/install/templates/agent.yml +10 -1
  29. data/lib/generators/silas/install/templates/bin_ci +2 -2
  30. data/lib/generators/silas/install/templates/initializer.rb +30 -5
  31. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  32. data/lib/silas/agent.rb +4 -0
  33. data/lib/silas/chat.rb +47 -13
  34. data/lib/silas/configuration.rb +77 -37
  35. data/lib/silas/delta_buffer.rb +50 -0
  36. data/lib/silas/doctor.rb +155 -0
  37. data/lib/silas/engine.rb +6 -0
  38. data/lib/silas/engines/base.rb +6 -8
  39. data/lib/silas/engines/ruby_llm.rb +28 -6
  40. data/lib/silas/errors.rb +3 -3
  41. data/lib/silas/eval/assertions.rb +18 -0
  42. data/lib/silas/eval/scripted_engine.rb +0 -2
  43. data/lib/silas/eval/transcript.rb +1 -0
  44. data/lib/silas/inbox/cost.rb +37 -13
  45. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  46. data/lib/silas/ledger.rb +26 -10
  47. data/lib/silas/mcp/handler.rb +6 -5
  48. data/lib/silas/mcp/server.rb +9 -9
  49. data/lib/silas/message_builder.rb +9 -2
  50. data/lib/silas/named_agent.rb +1 -0
  51. data/lib/silas/registry.rb +18 -8
  52. data/lib/silas/step_runner.rb +33 -6
  53. data/lib/silas/tool.rb +5 -0
  54. data/lib/silas/version.rb +1 -1
  55. data/lib/silas.rb +10 -9
  56. data/lib/tasks/silas_doctor.rake +17 -0
  57. metadata +14 -6
  58. data/lib/silas/agent_sdk/cli.rb +0 -59
  59. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  60. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  61. data/lib/silas/engines/agent_sdk.rb +0 -75
  62. data/lib/silas/subprocess_runner.rb +0 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad5ce26e72a73ea83df134c187f8c8dedcfcc35a65f20493f3095abfbfaa7ed8
4
- data.tar.gz: ab1989850b2b8c881da660824dcad3f71159cfffcd8648901ec2a06385d8c294
3
+ metadata.gz: 187a0730a1a84463f6207060d446c6960946513c0884d708fc0d16c011493a26
4
+ data.tar.gz: ed9a09b617c7dde80deaf6fe2c460e254cc96487b43eac52f56c6aa7dedecbaa
5
5
  SHA512:
6
- metadata.gz: fb5da2cdb854533bf68a761582063e8c5b22c2e40fc02cab05240d4ea199554da317bb0509398c81923a312c7b43773834d354ffb6391be549a6254329e3b995
7
- data.tar.gz: 57e53d9a3c5b438b941cd599ba805c2c210e303d6362599d757b58b907ea10b9401cbf918cd909d1c53590d8fee4cc9e0fef8a8fb5ccb422c26db5e0abaaf43a
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
- ## Engines
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
- - `:ruby_llm` API-key auth via [RubyLLM](https://rubyllm.com); any provider it
104
- supports. Canonical, production mode. Compose resilience via
105
- `config.around_model_call`.
106
- - `:agent_sdk` a `claude -p` subprocess runs the whole turn, calling back into
107
- your tools over an in-worker HTTP MCP endpoint whose `tools/call` goes through
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, a live step-trace that streams over Turbo
197
- Streams as the agent runs, approval cards whose Approve/Decline buttons call the
198
- exact same `approve!`/`decline!` as Slack and email, and per-session token/cost
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