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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/DESIGN.md +614 -0
- data/MIT-LICENSE +20 -0
- data/README.md +23 -0
- data/Rakefile +46 -0
- data/app/controllers/durable_agent/application_controller.rb +15 -0
- data/app/controllers/durable_agent/events_controller.rb +28 -0
- data/app/controllers/durable_agent/tools_controller.rb +33 -0
- data/app/models/concerns/durable_agent/harness_record.rb +11 -0
- data/app/models/durable_agent/application_record.rb +5 -0
- data/app/models/durable_agent/connection.rb +66 -0
- data/app/models/durable_agent/conversation.rb +107 -0
- data/app/models/durable_agent/document/revision.rb +16 -0
- data/app/models/durable_agent/document.rb +27 -0
- data/app/models/durable_agent/entry.rb +56 -0
- data/app/models/durable_agent/report.rb +14 -0
- data/app/models/durable_agent/submission.rb +57 -0
- data/app/models/durable_agent/task.rb +17 -0
- data/config/routes.rb +5 -0
- data/docs/guide.md +421 -0
- data/lib/durable_agent/account.rb +29 -0
- data/lib/durable_agent/client.rb +121 -0
- data/lib/durable_agent/configuration.rb +81 -0
- data/lib/durable_agent/current.rb +6 -0
- data/lib/durable_agent/engine.rb +7 -0
- data/lib/durable_agent/indexed_string.rb +14 -0
- data/lib/durable_agent/message_text.rb +23 -0
- data/lib/durable_agent/sidecar.rb +97 -0
- data/lib/durable_agent/test_helper.rb +110 -0
- data/lib/durable_agent/tool.rb +91 -0
- data/lib/durable_agent/version.rb +3 -0
- data/lib/durable_agent.rb +67 -0
- data/lib/generators/durable_agent/install/install_generator.rb +75 -0
- data/lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt +120 -0
- data/lib/generators/durable_agent/install/templates/durable_agent.rb.tt +36 -0
- data/lib/tasks/durable_agent_tasks.rake +6 -0
- data/sidecar/dist/sidecar.js +76417 -0
- data/sidecar/package.json +32 -0
- data/sidecar/sql/schema.postgres.sql +120 -0
- data/sidecar/sql/schema.sqlite.sql +120 -0
- 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
|