durable_agent 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/DESIGN.md +614 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +23 -0
  6. data/Rakefile +46 -0
  7. data/app/controllers/durable_agent/application_controller.rb +15 -0
  8. data/app/controllers/durable_agent/events_controller.rb +28 -0
  9. data/app/controllers/durable_agent/tools_controller.rb +33 -0
  10. data/app/models/concerns/durable_agent/harness_record.rb +11 -0
  11. data/app/models/durable_agent/application_record.rb +5 -0
  12. data/app/models/durable_agent/connection.rb +66 -0
  13. data/app/models/durable_agent/conversation.rb +107 -0
  14. data/app/models/durable_agent/document/revision.rb +16 -0
  15. data/app/models/durable_agent/document.rb +27 -0
  16. data/app/models/durable_agent/entry.rb +56 -0
  17. data/app/models/durable_agent/report.rb +14 -0
  18. data/app/models/durable_agent/submission.rb +57 -0
  19. data/app/models/durable_agent/task.rb +17 -0
  20. data/config/routes.rb +5 -0
  21. data/docs/guide.md +421 -0
  22. data/lib/durable_agent/account.rb +29 -0
  23. data/lib/durable_agent/client.rb +121 -0
  24. data/lib/durable_agent/configuration.rb +81 -0
  25. data/lib/durable_agent/current.rb +6 -0
  26. data/lib/durable_agent/engine.rb +7 -0
  27. data/lib/durable_agent/indexed_string.rb +14 -0
  28. data/lib/durable_agent/message_text.rb +23 -0
  29. data/lib/durable_agent/sidecar.rb +97 -0
  30. data/lib/durable_agent/test_helper.rb +110 -0
  31. data/lib/durable_agent/tool.rb +91 -0
  32. data/lib/durable_agent/version.rb +3 -0
  33. data/lib/durable_agent.rb +67 -0
  34. data/lib/generators/durable_agent/install/install_generator.rb +75 -0
  35. data/lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt +120 -0
  36. data/lib/generators/durable_agent/install/templates/durable_agent.rb.tt +36 -0
  37. data/lib/tasks/durable_agent_tasks.rake +6 -0
  38. data/sidecar/dist/sidecar.js +76417 -0
  39. data/sidecar/package.json +32 -0
  40. data/sidecar/sql/schema.postgres.sql +120 -0
  41. data/sidecar/sql/schema.sqlite.sql +120 -0
  42. metadata +115 -0
data/docs/guide.md ADDED
@@ -0,0 +1,421 @@
1
+ # durable_agent
2
+
3
+ Crash-safe, resumable AI agent conversations for Rails.
4
+
5
+ durable_agent packages [pi-durable](https://github.com/earendil-works/pi), a durable agent runtime written in
6
+ TypeScript, as one gem: a Rails engine plus the sidecar process that runs the agents. pi-durable keeps its state
7
+ in your app's own database, Postgres or SQLite, in tables your migration creates. The sidecar is their only
8
+ writer; your app reads conversations with plain Active Record and gets live updates over Turbo. Tools are Ruby
9
+ classes.
10
+
11
+ ```
12
+ browser ◀─Turbo── Puma ──HTTP commands─────────────────▶ sidecar (node)
13
+ │ ◀──GET/POST /durable_agent/tools── │
14
+ │ ◀──POST /durable_agent/events───── │
15
+ │ reads │ writes
16
+ ▼ ▼
17
+ your database (Postgres or SQLite), durable_agent_* tables
18
+ ```
19
+
20
+ Status: v1. `DESIGN.md` is the contract both halves are built against.
21
+
22
+ ## Requirements
23
+
24
+ - Rails 8, with Postgres or SQLite (the app brings its own `pg` or `sqlite3` gem; durable_agent depends on neither)
25
+ - Node 22.19 or newer on the machine that runs the sidecar
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ bundle add durable_agent
31
+ bin/rails generate durable_agent:install
32
+ bin/rails db:migrate
33
+ ```
34
+
35
+ The generator:
36
+
37
+ - copies `db/migrate/*_create_durable_agent_tables.rb` (twelve `durable_agent_*` tables, the same on either database),
38
+ - writes `config/initializers/durable_agent.rb`,
39
+ - mounts the engine: `mount DurableAgent::Engine => "/durable_agent"`,
40
+ - adds a `sidecar:` line to `Procfile.dev` (creating it with `web: bin/rails server -p 3000` and `sidecar:` if
41
+ needed; the sidecar calls Rails at port 3000 by default, and foreman would otherwise run Puma on 5000),
42
+ - prints how to set a production token.
43
+
44
+ ## Use
45
+
46
+ ```ruby
47
+ conversation = DurableAgent.start # or start(model: "anthropic/claude-x", instructions: "Be brief")
48
+ chat.update!(durable_agent_conversation_id: conversation.id)
49
+
50
+ conversation.say("Summarize invoice INV-1042", request_id: message.id)
51
+ conversation.say("Actually, only the total", request_id: other.id, when_busy: :steer) # :followUp, :steer, :reject
52
+ conversation.abort
53
+
54
+ conversation.transcript # visible entries, oldest first (an Array)
55
+ conversation.busy? # a run is in progress
56
+ conversation.streaming_message
57
+ conversation.running_tools
58
+ conversation.inbox
59
+ conversation.last_failure # the newest unanswered input, with #explanation
60
+ conversation.model # "openai/gpt-6.1-sol", pinned when the conversation started
61
+ conversation.total_tokens / total_cost / usage_by_model # from pi.usage, over the whole conversation
62
+
63
+ entry.text / tool_calls / tool_name / error?
64
+ entry.model_ref / stop_reason / total_tokens / cost / duration_ms / timestamp # what pi-ai recorded on the message
65
+ entry.failed? # an assistant entry that ended in an error or abort: no answer, and error_message
66
+ ```
67
+
68
+ `say` is idempotent on `request_id`: retrying with the same id returns the same submission. It returns the
69
+ `DurableAgent::Submission` row, which the sidecar commits before it answers. Find it later with
70
+ `conversation.submissions.for_request(id)`. `start`, `say` and `abort` clear the query cache, so `busy?`,
71
+ `transcript` and friends read the sidecar's commit within the same request.
72
+
73
+ `DurableAgent.start` cannot take part in your transaction: the sidecar commits the conversation on its own
74
+ connection before it answers. If saving your own record fails afterwards, the conversation stays behind,
75
+ unreferenced. It is harmless, and you can find such leftovers by comparing `DurableAgent::Conversation.top_level`
76
+ with your own references.
77
+
78
+ `DurableAgent::Conversation.top_level` lists conversations no task owns, without the root conversation the sidecar
79
+ creates at boot (id 1, `DurableAgent::Conversation.root`). Conversations, entries and submissions carry
80
+ `created_at`; `conversation.last_activity_at` is the newest entry's, or the conversation's own.
81
+
82
+ Every entry has `kind` (`pi.user`, `pi.assistant`, `pi.tool-result`, ...), `text`, `tool_calls`, `tool_name` and
83
+ `error?`. In a view, subscribe to the conversation and the default `on_change` callback refreshes the page
84
+ whenever the sidecar commits a change to it:
85
+
86
+ ```erb
87
+ <%= turbo_stream_from @chat.conversation %>
88
+ <% @chat.conversation.transcript.each do |entry| %>
89
+ <p class="<%= entry.kind %>"><%= entry.text %></p>
90
+ <% end %>
91
+ ```
92
+
93
+ Add `<meta name="turbo-refresh-method" content="morph">` for smooth refreshes.
94
+
95
+ ### Errors
96
+
97
+ All inherit from `DurableAgent::Error` (`status`, `body`):
98
+
99
+ - `DurableAgent::Busy`: 409, the conversation is running and the submission used `when_busy: :reject`.
100
+ - `DurableAgent::UnknownModel`: the model id is not one pi knows; `suggestions` lists close matches.
101
+ - `DurableAgent::Unavailable`: the sidecar is not running, timed out, went away mid-response, or is still starting.
102
+
103
+ ### Provider sign-in
104
+
105
+ Sign-ins (including OAuth refresh tokens) are stored in your app's database, in
106
+ `durable_agent_credentials`, encrypted by the sidecar with AES-256-GCM under a key derived from the shared
107
+ `DURABLE_AGENT_TOKEN`. Database dumps, replicas and read-only database users see ciphertext. Rails has no model for
108
+ the table and never decrypts it. This is not a vault: the sidecar process holds the key, and anyone with both the
109
+ database and the token can decrypt. API keys work through `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`, read from the
110
+ environment, or through `connection.set_api_key`, which stores them in the same table; subscriptions sign in with
111
+ OAuth:
112
+
113
+ ```ruby
114
+ DurableAgent.auth
115
+ # => {"openai" => {"type" => "oauth", "source" => "OAuth"},
116
+ # "anthropic" => {"type" => "api_key", "source" => "ANTHROPIC_API_KEY"},
117
+ # "google" => nil, ...}
118
+
119
+ connection = DurableAgent::Connection.new("openai")
120
+ connection.connected? / connection.subscription? / connection.unreachable?
121
+ login = connection.start_login # => {"id" => ..., "status" => "awaiting_browser", "url" => ...}
122
+ connection.complete_login(login["id"], url_the_browser_ended_on)
123
+ connection.login(login["id"]) # poll status
124
+ connection.cancel_login(login["id"])
125
+ connection.disconnect
126
+ ```
127
+
128
+ `DurableAgent.auth` lists every provider the sidecar's pi-ai knows, signed in or not (`nil`), plus `faux`, the
129
+ offline model. `type` is `"oauth"` or `"api_key"`; `source` says where pi-ai found it (`"OAuth"`,
130
+ `"stored credential"`, or the environment variable). `Connection` covers OpenAI and Anthropic, and tolerates an
131
+ unreachable sidecar: it reads as not connected with `error` set.
132
+
133
+ When a sign-in completes or a provider signs out, the sidecar posts an auth event and `config.on_auth` runs. Its
134
+ default refreshes pages subscribed to `"durable_agent:auth"`, so a settings page updates by itself:
135
+
136
+ ```erb
137
+ <%= turbo_stream_from "durable_agent:auth" %>
138
+ ```
139
+
140
+ ### Accounts
141
+
142
+ Who pays for a conversation is the account it runs on. An account is a string you choose, typically a user id
143
+ (`[A-Za-z0-9_.-]{1,64}`); without one, a conversation runs on the app's own credentials.
144
+
145
+ | Who pays | Credential | Conversation |
146
+ |---|---|---|
147
+ | The app, one API key or sign-in for everyone | `OPENAI_API_KEY`, `Connection.new("openai").set_api_key(key)`, or Sign in with ChatGPT | `DurableAgent.start` |
148
+ | Each user, with an API key | `Connection.new("openai", account: user.id).set_api_key(key)` | `DurableAgent.start(account: user.id)` |
149
+ | Each user, with their ChatGPT plan | `Connection.new("openai", account: user.id).start_login`, then the user approves in the browser | `DurableAgent.start(account: user.id)` |
150
+
151
+ ```ruby
152
+ DurableAgent.auth(account: user.id) # that account's providers, under their base names
153
+ connection = DurableAgent::Connection.new("openai", account: user.id)
154
+ connection.set_api_key(params[:key]) # => {"type" => "api_key", "source" => "stored credential"}
155
+ connection.api_key? / connection.source / connection.scoped_provider # => true, "stored credential", "openai@42"
156
+ connection.disconnect # their running conversations settle model_error until they connect again
157
+
158
+ conversation = DurableAgent.start(account: user.id)
159
+ conversation.model # => "openai@42/gpt-6.1-sol"
160
+ conversation.provider / conversation.account # => "openai", "42"
161
+ ```
162
+
163
+ Your app decides which account a conversation runs on and what to do when that account has no credential;
164
+ `DurableAgent.auth(account:)` says which providers it has. `config.on_auth` receives scoped provider ids
165
+ (`"openai@42"`), and its default also refreshes each named account's stream, which a user's settings page
166
+ subscribes to:
167
+
168
+ ```erb
169
+ <%= turbo_stream_from DurableAgent.auth_stream(Current.user.id) %>
170
+ ```
171
+
172
+ Usage per account is the sum of `conversation.usage` over that account's conversations.
173
+
174
+ ## Tools
175
+
176
+ ```ruby
177
+ # app/tools/lookup_invoice.rb
178
+ class LookupInvoice < DurableAgent::Tool
179
+ description "Fetch one invoice by number"
180
+ param :number, :string, "Invoice number like INV-1042" # required by default
181
+ param :include_lines, :boolean, "Include line items", required: false
182
+ param :currency, :string, required: false, enum: %w[ EUR USD ] # extra JSON Schema keywords pass through
183
+ replay :safe # default :unsafe
184
+
185
+ def call(number:, include_lines: false)
186
+ invoice = Invoice.find_by!(number:)
187
+ invoice.as_json(include: include_lines ? :lines : nil)
188
+ end
189
+ end
190
+ ```
191
+
192
+ ```ruby
193
+ DurableAgent.configure { |config| config.tools = %w[ LookupInvoice ] }
194
+ ```
195
+
196
+ Give tool names as strings (classes work too); names are resolved on every request so code reloading keeps
197
+ working. Param types are `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`. The tool name is the
198
+ underscored class name without its namespace (`lookup_invoice`); override with `tool_name "other"`.
199
+
200
+ `replay :safe` lets pi run the tool again if the sidecar crashed mid-call. Leave tools that change the world
201
+ `:unsafe`.
202
+
203
+ At boot (and on `DurableAgent.client.reload_tools`) the sidecar fetches `GET /durable_agent/tools`. Each call is a
204
+ `POST /durable_agent/tools/:name`, authenticated with the shared token. Inside `call`,
205
+ `DurableAgent::Current.conversation_id` and `.call_id` say who asked. Return a string or anything JSON
206
+ serialisable.
207
+
208
+ Errors become an error result the model sees. Unknown or missing arguments (`ArgumentError`),
209
+ `ActiveRecord::RecordNotFound` and `DurableAgent::Tool::Error` carry their message; raise the last to tell the model
210
+ what went wrong:
211
+
212
+ ```ruby
213
+ raise DurableAgent::Tool::Error, "Invoices before 2020 are archived; ask for a newer one"
214
+ ```
215
+
216
+ Any other exception reaches the model only as `"<ErrorClass> while running <tool>"` and is reported to
217
+ `Rails.error` in full, so messages carrying SQL, paths or secrets stay in your app.
218
+
219
+ ## Configuration
220
+
221
+ ```ruby
222
+ DurableAgent.configure do |config|
223
+ config.model = "openai/gpt-6.1-sol" # "provider/modelId"; "faux/faux-1" is a scripted offline model
224
+ config.tools = %w[ LookupInvoice ]
225
+ config.host = "127.0.0.1" # sidecar bind address
226
+ config.port = 4010
227
+ config.url = "http://127.0.0.1:4010" # where Rails reaches the sidecar; defaults to host and port
228
+ config.rails_url = "http://127.0.0.1:3000" # where the sidecar reaches Rails for tools and change events
229
+ config.token = "..."
230
+ config.workspace_root = "storage/durable_agent"
231
+ config.agent_name = "MyApp" # shown on provider consent screens
232
+ config.parent_record_class = "ApplicationRecord" # default "ActiveRecord::Base"
233
+ config.on_change = ->(conversation) { ... }
234
+ config.on_report = ->(report) { ... }
235
+ config.on_auth = ->(providers) { ... }
236
+ end
237
+ ```
238
+
239
+ Every setting the sidecar also reads defaults to its `DURABLE_AGENT_*` variable (`MODEL`, `HOST`, `PORT`,
240
+ `RAILS_URL`, `TOKEN`, `WORKSPACE_ROOT`, `NAME`), so `DURABLE_AGENT_MODEL=faux/faux-1 bin/dev` moves both
241
+ processes. A value set in the initializer wins over the variable, which is why the generated initializer leaves
242
+ `config.model` commented out.
243
+
244
+ A conversation pins the model it was started with (`DurableAgent.start(model:)`, else the default) and keeps it
245
+ when the default changes; `conversation.model` reads it. The faux model is registered on every sidecar, so
246
+ conversations from a `faux/faux-1` session still answer after the switch to a real provider.
247
+
248
+ If your `ApplicationRecord` uses `connects_to` (roles, shards), set `config.parent_record_class =
249
+ "ApplicationRecord"` in the initializer, so the durable_agent models inherit it and follow role switching.
250
+
251
+ The token is read from `credentials.durable_agent.token`, then `ENV["DURABLE_AGENT_TOKEN"]`. In development and
252
+ test it falls back to a value derived from `secret_key_base`, so both processes agree with no setup. In
253
+ production a missing or blank token raises where it is used (starting the sidecar, the engine's endpoints, the
254
+ client), not at boot, so `db:migrate` and `console` work without one.
255
+
256
+ The token is also the key for stored provider sign-ins (see [Provider sign-in](#provider-sign-in)). Rotating it, or
257
+ moving from the development fallback to a real token, makes the stored sign-ins unreadable: the sidecar logs a
258
+ warning per provider, treats them as signed out, and users must sign in again.
259
+
260
+ ```sh
261
+ bin/rails credentials:edit
262
+ # durable_agent:
263
+ # token: <output of `bin/rails secret`>
264
+ ```
265
+
266
+ ## Running the processes
267
+
268
+ ```
269
+ web: bin/rails server -p 3000
270
+ sidecar: bin/rails durable_agent:sidecar
271
+ ```
272
+
273
+ `durable_agent:sidecar` derives the sidecar's whole environment from Rails (`DurableAgent::Sidecar.env`):
274
+ `DATABASE_URL` from the primary database in `database.yml` (`postgres://…` with `sslmode`, `sslrootcert`,
275
+ `sslcert`, `sslkey` and `schema_search_path` carried over, or `sqlite:///absolute/path`), the token, bind address, Rails URL, model and agent name, and passes
276
+ `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` through. It then replaces itself with `node sidecar/dist/sidecar.js` (or
277
+ the TypeScript source when no bundle is built). Run exactly one sidecar per app.
278
+
279
+ The sidecar refuses to boot until the tables exist ("run bin/rails db:migrate"). An unknown default model is
280
+ logged and the sidecar keeps running. Missing provider credentials are not an error: inputs settle as
281
+ unanswered with reason `no_model` until someone signs in.
282
+
283
+ SQLite suits development, test and single-host production: both processes open the same file (WAL, 5 s busy
284
+ timeout, as Rails 8 configures it). Use Postgres when the web and sidecar processes run on different hosts.
285
+
286
+ `sslmode=require` means "encrypt, but do not verify the certificate" to libpq (and Rails), while node-postgres
287
+ reads it as full verification and would refuse the self-signed certificates many hosted databases use. The
288
+ sidecar is therefore given `sslmode=no-verify`, node-postgres' name for the same thing, whether `require` comes
289
+ from `database.yml` or from `DATABASE_URL`. `verify-ca` and `verify-full` pass through and verify, against
290
+ `sslrootcert` when you set it.
291
+
292
+ ### Production
293
+
294
+ The sidecar calls Rails over plain HTTP at `rails_url` (loopback by default). If you use `config.force_ssl`,
295
+ keep `/durable_agent` out of the HTTPS redirect; if you set `config.hosts`, allow the sidecar's host. In
296
+ `config/environments/production.rb`:
297
+
298
+ ```ruby
299
+ config.ssl_options = { redirect: { exclude: ->(request) { request.path.start_with?("/durable_agent") } } }
300
+ config.hosts << "127.0.0.1"
301
+ ```
302
+
303
+ ## How it works
304
+
305
+ pi-durable's storage lives in your database. The sidecar runs pi-durable's own storage core over these tables,
306
+ so there is one copy of the state and nothing to keep in sync:
307
+
308
+ | Table | Holds | Model |
309
+ |---|---|---|
310
+ | `durable_agent_conversations` | one row per conversation, including ones tools created (`owner_conversation_id`) | `Conversation` |
311
+ | `durable_agent_entries` | the immutable transcript | `Entry` |
312
+ | `durable_agent_tasks` | durable work: runs, tool calls, compactions | `Task` |
313
+ | `durable_agent_submissions` | every input Rails sent, from queued to done or unanswered | `Submission` |
314
+ | `durable_agent_documents`, `_document_revisions` | conversation documents (`pi.live`, `pi.inbox`, `pi.agent`, ...) | `Document`, `Document::Revision` |
315
+ | `durable_agent_record_ids`, `_durable_schema`, `_durable_metadata` | pi-durable's bookkeeping | |
316
+ | `durable_agent_reports` | faults the harness reported | `Report` |
317
+ | `durable_agent_credentials` | provider sign-ins, encrypted; Rails has no model for it | |
318
+ | `durable_agent_settings` | `device_id`, `schema_version` | |
319
+
320
+ Rails owns the schema: the migration creates every table and the sidecar never runs DDL. The sidecar owns the
321
+ rows: every model is read-only, and a write from Rails would bypass the harness. Ids are pi's own. Records keep
322
+ pi-durable's shapes as JSON text (`record`, `content`, `info`), and a few indexed columns are JSON-encoded
323
+ strings (`kind`, `request_id`, `key_value`); the models decode them, and scopes such as `for_request` and
324
+ `of_kind` encode their arguments. Kind filtering on entries (`visible`, `of_kind`) happens in Ruby and returns
325
+ arrays. On boot the sidecar re-attaches every conversation and re-awaits unsettled submissions, so a crash loses
326
+ nothing that was committed.
327
+
328
+ pi-durable stores a document as a base revision plus deltas. The sidecar folds every delta into a new base
329
+ before it commits, so **a document's current value is the content of its newest base revision**, which is what
330
+ `Document#value` and `conversation.document(kind)` read. A document whose newest revision is a delta (a
331
+ rewindable document that does not checkpoint) raises `DurableAgent::Document::HistoryNotSupported`; none of the
332
+ `pi.*` documents Rails reads do.
333
+
334
+ After each commit the sidecar posts the ids it touched to `POST /durable_agent/events`
335
+ (`{"conversations": [7], "reports": [3], "auth": ["openai"]}`), authenticated with `Authorization: Bearer
336
+ <token>`. Puma calls `config.on_change(conversation)` and `config.on_report(report)` for each id that exists, and
337
+ `config.on_auth(providers)` when sign-ins changed. The defaults broadcast Turbo refreshes: `on_change` to the
338
+ conversation; `on_report` to the report's conversation, or to the `"durable_agent:reports"` stream when it has
339
+ none (an operator page can `turbo_stream_from "durable_agent:reports"`); `on_auth` to `"durable_agent:auth"`.
340
+ Replace them to broadcast your own partials or to notify other systems. While a model streams, change events
341
+ arrive up to every 100 ms, and a page refresh is a full GET each time; an app that cares sends one Turbo Stream
342
+ message replacing the regions that change (the transcript, the streaming answer, the composer) with
343
+ `method: :morph`, so the browser keeps focus, open details and the draft message:
344
+
345
+ ```ruby
346
+ config.on_change = ->(conversation) { Chat.changed(conversation) } # see the slopdep example app's Chat#broadcast_changes
347
+ ```
348
+
349
+ Callback errors go to `Rails.error` and never fail the request. Because the broadcast
350
+ happens inside Puma, the default `async` cable adapter works in development. Events are a hint, debounced and
351
+ retried once: the state is already in the database, and the next event or page load shows anything a dropped
352
+ event missed.
353
+
354
+ ## Testing your app
355
+
356
+ durable_agent ships test support. In `test/test_helper.rb`:
357
+
358
+ ```ruby
359
+ require "durable_agent/test_helper"
360
+
361
+ class ActiveSupport::TestCase
362
+ include DurableAgent::TestHelper
363
+ end
364
+ ```
365
+
366
+ `fake_sidecar` answers `create_conversation`, `submit` and `abort` the way the sidecar does: it commits the
367
+ conversation or the queued submission row first, then answers with its id, so `DurableAgent.start` and
368
+ `conversation.say` find their rows inside the test's transaction. Submissions are idempotent on `request_id`, and
369
+ an unknown conversation raises the sidecar's 404. `stub_sidecar` replaces any client call with a value, a
370
+ callable or an exception; both record calls in `sidecar_calls`. Once stubbed, the client is sealed: a call the
371
+ test did not stub raises `DurableAgent::Unavailable` instead of reaching a sidecar that happens to be running.
372
+
373
+ ```ruby
374
+ test "sending a message" do
375
+ fake_sidecar(auth: CONNECTED) # CONNECTED / DISCONNECTED are sample DurableAgent.auth answers
376
+
377
+ chat = Chat.create! # your code calls DurableAgent.start
378
+ chat.conversation.say("Hi", request_id: 1)
379
+
380
+ assert_equal 1, sidecar_calls(:submit).size
381
+ assert chat.conversation.submissions.for_request(1).sole.pending?
382
+ end
383
+
384
+ test "the sidecar is down" do
385
+ stub_sidecar(submit: DurableAgent::Unavailable)
386
+ # ...
387
+ end
388
+ ```
389
+
390
+ Rows for the harness tables can also come from fixtures under `test/fixtures/durable_agent/`. Use
391
+ `DurableAgent::IndexedString.encode` for the JSON-encoded columns (`kind`, `key_value`, `request_id`).
392
+
393
+ ## Limits of v1
394
+
395
+ - One sidecar per app, one set of provider credentials for the whole app (no per-user credentials).
396
+ - No compaction, reset or task-graph routes; no SSE event stream; no usage endpoint.
397
+ - A forked conversation's inherited entries are not followed: `entries` are its own.
398
+ - No UI: the engine only serves the tool and event endpoints the sidecar calls.
399
+ - No MCP. Tools are Ruby classes only.
400
+ - The workspace root is reserved for built-in tools and unused.
401
+
402
+ ## Development
403
+
404
+ ```sh
405
+ (cd sidecar && npm install) # enables the round-trip test
406
+ rake test # SQLite (test/dummy/storage/test.sqlite3); builds the dummy app's CSS first
407
+ DURABLE_AGENT_DB=postgres bin/rails db:create
408
+ DURABLE_AGENT_DB=postgres bin/rails test # Postgres: durable_agent_test
409
+ bundle exec rake test:all # both
410
+ ```
411
+
412
+ The round-trip test boots the real sidecar on `faux/faux-1` against the test database; it skips when
413
+ `sidecar/node_modules` is missing or `node` is not on the PATH.
414
+
415
+ Package the gem with `bundle exec rake build` (or `rake release`): it bundles the sidecar into
416
+ `sidecar/dist/sidecar.js`, which is not checked in, before packaging. A bare `gem build` refuses to run without
417
+ the bundle. Changes go in `CHANGELOG.md` under Unreleased.
418
+
419
+ ## License
420
+
421
+ MIT
@@ -0,0 +1,29 @@
1
+ module DurableAgent
2
+ # Whose provider credentials a conversation runs on: an app-chosen string, typically a user id. On the sidecar an
3
+ # account scopes the provider id, so "openai@u42" is account u42's OpenAI and "openai@u42/gpt-6.1-sol" its model
4
+ # ref. A provider id without "@" is the app's own.
5
+ module Account
6
+ PATTERN = /\A[A-Za-z0-9_.-]{1,64}\z/
7
+
8
+ class << self
9
+ # nil for the app's own account; raises ArgumentError for a value outside PATTERN.
10
+ def normalize(value)
11
+ return if value.blank?
12
+
13
+ value.to_s.tap do |account|
14
+ raise ArgumentError, "invalid account #{account.inspect}" unless PATTERN.match?(account)
15
+ end
16
+ end
17
+
18
+ def scoped(provider, account)
19
+ account ? "#{provider}@#{account}" : provider.to_s
20
+ end
21
+
22
+ # "openai@u42" => ["openai", "u42"]; "openai" => ["openai", nil].
23
+ def parse(scoped_provider)
24
+ provider, account = scoped_provider.to_s.split("@", 2)
25
+ [ provider, account.presence ]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,121 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module DurableAgent
5
+ class Error < StandardError
6
+ attr_reader :status, :body
7
+
8
+ def initialize(status = nil, body = nil, message = nil)
9
+ @status, @body = status, body
10
+ super(message || "sidecar returned #{status}: #{body.is_a?(Hash) ? body["error"] || body.to_json : body}")
11
+ end
12
+ end
13
+
14
+ # 409: the conversation is running and the submission asked to be rejected rather than queued.
15
+ class Busy < Error; end
16
+
17
+ # 422 on conversation creation: the model id is not one pi knows.
18
+ class UnknownModel < Error
19
+ def suggestions = body.is_a?(Hash) ? Array(body["suggestions"]) : []
20
+ end
21
+
22
+ # The sidecar is not running, did not answer in time, or went away mid-response.
23
+ class Unavailable < Error; end
24
+
25
+ # The sidecar's HTTP API v1. Rails sends commands here and reads the results from the harness tables.
26
+ class Client
27
+ UNREACHABLE = [ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::EPIPE, Net::OpenTimeout, Net::ReadTimeout,
28
+ SocketError, EOFError, IOError ]
29
+
30
+ def initialize(url: nil, token: nil)
31
+ @url, @token = url, token
32
+ end
33
+
34
+ def url = @url || DurableAgent.config.url
35
+ def token = @token || DurableAgent.config.token
36
+
37
+ def health = get("/health")
38
+
39
+ # `model` is "provider/modelId" or {provider:, modelId:}. With `account` the conversation runs on that account's
40
+ # credentials: its model's provider becomes "provider@account".
41
+ def create_conversation(model: nil, instructions: nil, account: nil)
42
+ post("/conversations", { model: model_payload(model), instructions: instructions, account: account&.to_s }.compact)
43
+ rescue Error => error
44
+ raise UnknownModel.new(error.status, error.body) if error.status == 422
45
+ raise
46
+ end
47
+
48
+ def submit(conversation_id, content, request_id:, when_busy: nil)
49
+ post("/conversations/#{conversation_id}/submissions",
50
+ { content: content, request_id: request_id.to_s, when_busy: when_busy&.to_s }.compact)
51
+ end
52
+
53
+ def abort(conversation_id) = post("/conversations/#{conversation_id}/abort")
54
+
55
+ # `provider` is the base id ("openai"); `account` scopes it. A login's id names its account, so the calls that
56
+ # take one need none.
57
+ def auth(account: nil) = get(scoped("/auth", account))
58
+ def set_api_key(provider, key, account: nil) = put(scoped("/auth/#{provider}/api_key", account), { key: key })
59
+ def start_login(provider, account: nil) = post(scoped("/auth/#{provider}/logins", account))
60
+ def login(provider, id) = get("/auth/#{provider}/logins/#{id}")
61
+ def complete_login(provider, id, url) = post("/auth/#{provider}/logins/#{id}/callback", { url: url })
62
+ def cancel_login(provider, id) = delete("/auth/#{provider}/logins/#{id}")
63
+ def logout(provider, account: nil) = delete(scoped("/auth/#{provider}", account))
64
+
65
+ def reload_tools = post("/tools/reload")
66
+
67
+ private
68
+ def get(path) = request(Net::HTTP::Get.new(path))
69
+ def delete(path) = request(Net::HTTP::Delete.new(path))
70
+ def post(path, body = {}) = request(Net::HTTP::Post.new(path), body)
71
+ def put(path, body = {}) = request(Net::HTTP::Put.new(path), body)
72
+
73
+ def scoped(path, account)
74
+ account.nil? ? path : "#{path}?#{URI.encode_www_form(account: account)}"
75
+ end
76
+
77
+ def request(http_request, body = nil)
78
+ http_request["Authorization"] = "Bearer #{token}"
79
+ http_request["Accept"] = "application/json"
80
+ if body
81
+ http_request["Content-Type"] = "application/json"
82
+ http_request.body = body.to_json
83
+ end
84
+
85
+ uri = URI(url)
86
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 15) do |http|
87
+ http.request(http_request)
88
+ end
89
+
90
+ body = parse(response.body)
91
+ error = body["error"] if body.is_a?(Hash)
92
+ # Until the harness has resumed (after tools load) the sidecar answers commands with 503 "starting".
93
+ raise Unavailable.new(503, body, "sidecar is starting") if response.code == "503" && error == "starting"
94
+ # Ruby validates accounts before sending them, so this is a programming error rather than a sidecar failure.
95
+ raise ArgumentError, "invalid account" if response.code == "422" && error == "invalid_account"
96
+
97
+ case response
98
+ when Net::HTTPSuccess then body || {}
99
+ when Net::HTTPConflict then raise Busy.new(response.code.to_i, body)
100
+ else raise Error.new(response.code.to_i, body)
101
+ end
102
+ rescue *UNREACHABLE => error
103
+ raise Unavailable.new(nil, nil, "sidecar unreachable at #{url}: #{error.message}")
104
+ end
105
+
106
+ def parse(body)
107
+ JSON.parse(body) if body.present?
108
+ rescue JSON::ParserError
109
+ body
110
+ end
111
+
112
+ def model_payload(model)
113
+ case model
114
+ when nil, Hash then model
115
+ else
116
+ provider, model_id = model.to_s.split("/", 2)
117
+ { provider: provider, modelId: model_id }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,81 @@
1
+ module DurableAgent
2
+ class Configuration
3
+ class MissingToken < StandardError; end
4
+
5
+ attr_writer :model, :tools, :url, :rails_url, :token, :host, :port, :workspace_root, :agent_name,
6
+ :parent_record_class, :on_change, :on_report, :on_auth
7
+
8
+ # Defaults come from the same DURABLE_AGENT_* variables the sidecar reads, so `DURABLE_AGENT_PORT=4020 bin/dev`
9
+ # moves both halves at once.
10
+ def model = @model || env("MODEL", "openai/gpt-6.1-sol")
11
+ def host = @host || env("HOST", "127.0.0.1")
12
+ def port = @port || env("PORT", 4010).to_i
13
+ def url = @url || "http://#{host}:#{port}"
14
+ def rails_url = @rails_url || env("RAILS_URL", "http://127.0.0.1:3000")
15
+ def workspace_root = @workspace_root || env("WORKSPACE_ROOT", Rails.root.join("storage/durable_agent").to_s)
16
+ def agent_name = @agent_name || env("NAME", Rails.application.class.module_parent_name)
17
+
18
+ # The class DurableAgent::ApplicationRecord inherits from. An app whose ApplicationRecord uses connects_to sets
19
+ # it to "ApplicationRecord", so the harness models follow its roles and shards. Read when the models load, so
20
+ # set it in an initializer.
21
+ def parent_record_class = @parent_record_class || "ActiveRecord::Base"
22
+
23
+ # Entries may be classes or class names. Names are resolved on every call so they survive code reloading.
24
+ def tools
25
+ Array(@tools).map { |tool| tool.is_a?(String) ? tool.constantize : tool }
26
+ end
27
+
28
+ def tool(name)
29
+ tools.find { |tool| tool.tool_name == name.to_s }
30
+ end
31
+
32
+ # Both halves read this: Rails to authenticate the sidecar's calls and talk to it, the rake task to start it.
33
+ # Development and test derive one from secret_key_base so nothing needs setting up. A blank token would let
34
+ # an empty Authorization header through, so it is refused rather than compared.
35
+ def token
36
+ @token = default_token if @token.nil?
37
+ raise MissingToken, "DurableAgent.config.token is blank" if @token.blank?
38
+ @token
39
+ end
40
+
41
+ # Called from Puma when the sidecar posts a change event for the conversation.
42
+ def on_change
43
+ @on_change || ->(conversation) { Turbo::StreamsChannel.broadcast_refresh_to(conversation) }
44
+ end
45
+
46
+ # A report without a conversation (a failed session, most reports on pi-durable 1.3.0) refreshes the pages
47
+ # subscribed with `turbo_stream_from "durable_agent:reports"`.
48
+ def on_report
49
+ @on_report || ->(report) { Turbo::StreamsChannel.broadcast_refresh_to(report.conversation || "durable_agent:reports") }
50
+ end
51
+
52
+ # Called with the provider ids when a sign-in completes or a provider signs out, scoped for accounts
53
+ # ("openai", "openai@u42"). The default refreshes the pages subscribed with `turbo_stream_from
54
+ # DurableAgent.auth_stream`, such as a settings page, and with `turbo_stream_from
55
+ # DurableAgent.auth_stream(account)` for each account named.
56
+ def on_auth
57
+ @on_auth || ->(providers) do
58
+ accounts = providers.filter_map { |provider| DurableAgent::Account.parse(provider).second }.uniq
59
+ [ nil, *accounts ].each { |account| Turbo::StreamsChannel.broadcast_refresh_to(DurableAgent.auth_stream(account)) }
60
+ end
61
+ end
62
+
63
+ private
64
+ def env(name, default)
65
+ ENV["DURABLE_AGENT_#{name}"].presence || default
66
+ end
67
+
68
+ def default_token
69
+ Rails.application.credentials.dig(:durable_agent, :token).presence ||
70
+ ENV["DURABLE_AGENT_TOKEN"].presence ||
71
+ derived_token ||
72
+ raise(MissingToken, "Set credentials.durable_agent.token or DURABLE_AGENT_TOKEN, or assign DurableAgent.config.token")
73
+ end
74
+
75
+ def derived_token
76
+ if Rails.env.local?
77
+ Rails.application.key_generator.generate_key("durable_agent/token", 32).unpack1("H*")
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,6 @@
1
+ module DurableAgent
2
+ # Set while a tool runs, so the tool (and anything it calls) knows which conversation asked.
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ attribute :conversation_id, :call_id
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ require "turbo-rails"
2
+
3
+ module DurableAgent
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace DurableAgent
6
+ end
7
+ end