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/DESIGN.md
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# durable_agent — design and contract
|
|
2
|
+
|
|
3
|
+
pi-durable, a durable agent runtime written in TypeScript, packaged as one Rails gem. The gem contains a Rails
|
|
4
|
+
engine and the sidecar process that runs the agents. An app installs the gem, runs one migration, and starts one
|
|
5
|
+
extra process. State lives in the app's own database, Postgres or SQLite, and Rails reads it with ordinary
|
|
6
|
+
models. `docs/design.html` is the illustrated version of this document; this file is the normative contract both
|
|
7
|
+
halves are implemented against.
|
|
8
|
+
|
|
9
|
+
## Shape
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
browser ◀─Turbo─ Puma ◀─POST /durable_agent/events─ sidecar (node)
|
|
13
|
+
│ │
|
|
14
|
+
│ reads durable_agent_* tables │ pi-durable Harness over a storage adapter
|
|
15
|
+
▼ ▼
|
|
16
|
+
the app's database (Postgres or SQLite) ◀─┘
|
|
17
|
+
▲
|
|
18
|
+
└─ Rails ─HTTP commands─▶ sidecar; sidecar ─GET/POST /durable_agent/tools─▶ Rails
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- One sidecar per app. It owns a pi-durable Harness whose `Storage` is pi-durable's portable SQLite storage
|
|
22
|
+
core (`SqliteStorage` from `@earendil-works/pi-durable/storage/sqlite`) running over a database adapter:
|
|
23
|
+
pi-durable's own `openNodeSqliteDatabase` for SQLite, the gem's `PostgresDatabase` for Postgres. Both open
|
|
24
|
+
the tables under the prefix `durable_agent_`.
|
|
25
|
+
- Rails owns the schema. The install migration creates every table on either adapter; the sidecar never runs
|
|
26
|
+
DDL and refuses to boot when the tables are missing.
|
|
27
|
+
- The sidecar is the only writer of every `durable_agent_*` table. Rails models are read-only.
|
|
28
|
+
- Rails sends commands to the sidecar over HTTP. After each commit that touches a conversation the sidecar
|
|
29
|
+
posts a change event to Rails; Rails calls a configurable callback whose default broadcasts a Turbo refresh
|
|
30
|
+
from inside Puma, so the stock `async` cable adapter works in development.
|
|
31
|
+
- The sidecar calls into Rails for tool definitions, tool execution and change events. Nothing else.
|
|
32
|
+
- SQLite suits development, test and single-host production. Postgres suits everything else.
|
|
33
|
+
|
|
34
|
+
## Names
|
|
35
|
+
|
|
36
|
+
| Thing | Name |
|
|
37
|
+
|---|---|
|
|
38
|
+
| Gem / module | `durable_agent` / `DurableAgent`, isolated engine mounted at `/durable_agent` |
|
|
39
|
+
| Harness tables (pi-durable's) | `durable_agent_conversations`, `_entries`, `_tasks`, `_submissions`, `_documents`, `_document_revisions`, `_record_ids`, `_durable_schema`, `_durable_metadata` |
|
|
40
|
+
| Gem tables | `durable_agent_reports`, `_credentials`, `_settings` |
|
|
41
|
+
| Schema versions | `durable_agent_durable_schema.version` is pi-durable's (1 for pi-durable 1.3.0); `durable_agent_settings` row `schema_version` is the gem's (2); `GET /health` reports both |
|
|
42
|
+
| Sidecar | `sidecar/` (TypeScript), bundled to `sidecar/dist/sidecar.js` by `rake build`, shipped in the gem |
|
|
43
|
+
| Env prefix | `DURABLE_AGENT_*`, read by both halves |
|
|
44
|
+
| Account | an app-chosen string (`[A-Za-z0-9_.-]{1,64}`, typically a user id) naming whose provider credentials a conversation runs on; `openai@u42` is the account `u42`'s OpenAI provider id, `openai@u42/gpt-6.1-sol` its model ref; no `@` is the app's own account |
|
|
45
|
+
|
|
46
|
+
## Schema
|
|
47
|
+
|
|
48
|
+
Created by `lib/generators/durable_agent/install/templates/create_durable_agent_tables.rb.tt` with adapter-neutral
|
|
49
|
+
Rails DDL; `test/dummy/db/migrate` carries the same file. The first nine tables are pi-durable 1.3.0's SQLite
|
|
50
|
+
schema (`storage/sqlite/migrations.js`, version 1) rendered for Rails under the prefix: the same table names,
|
|
51
|
+
columns, indexes and partial index, so pi-durable's storage core runs over them unchanged. Deliberate
|
|
52
|
+
differences from pi-durable's own DDL: no `STRICT`, no `json_valid` checks, `record` and `content` are `text`
|
|
53
|
+
holding JSON (never `jsonb`, which rejects `\u0000` and would turn a model output into a fatal commit), and
|
|
54
|
+
`created_at` on conversations, entries and submissions. Ids and singletons use Rails' default primary key:
|
|
55
|
+
`integer PRIMARY KEY AUTOINCREMENT` on SQLite, a rowid alias as in pi-durable's own `INTEGER PRIMARY KEY`;
|
|
56
|
+
`bigserial` on Postgres, whose sequence goes unused because the sidecar supplies every id (and always writes
|
|
57
|
+
singleton `1`). `created_at` is `DEFAULT CURRENT_TIMESTAMP`: pi-durable's inserts name their columns and its
|
|
58
|
+
submission upsert updates named columns only, so the database fills it once, at insert (UTC on both: SQLite's
|
|
59
|
+
`CURRENT_TIMESTAMP` is UTC, and the sidecar's Postgres connections run with `TimeZone=UTC`). Because both
|
|
60
|
+
adapters dump these keys the same way, `schema.rb` loads to the migration's schema on either.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
create_table :durable_agent_durable_schema, primary_key: :singleton do |t|
|
|
64
|
+
t.integer :version, null: false
|
|
65
|
+
end
|
|
66
|
+
create_table :durable_agent_durable_metadata, primary_key: :singleton do |t|
|
|
67
|
+
t.text :next_id, null: false # pi stores it as text: ids may exceed 2^53 in principle
|
|
68
|
+
t.bigint :next_seq, null: false
|
|
69
|
+
end
|
|
70
|
+
create_table :durable_agent_record_ids do |t|
|
|
71
|
+
t.text :record_type, null: false # conversation | entry | task | submission | document
|
|
72
|
+
end
|
|
73
|
+
create_table :durable_agent_conversations do |t|
|
|
74
|
+
t.bigint :owner_conversation_id
|
|
75
|
+
t.bigint :owner_task_id
|
|
76
|
+
t.text :record, null: false # ConversationRecord JSON: {id, parent?: {conversationId, at}, owner?, ...}
|
|
77
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
78
|
+
t.index [ :owner_conversation_id, :id ], name: "durable_agent_conversations_by_owner_conversation"
|
|
79
|
+
t.index [ :owner_task_id, :id ], name: "durable_agent_conversations_by_owner_task"
|
|
80
|
+
end
|
|
81
|
+
create_table :durable_agent_entries do |t|
|
|
82
|
+
t.bigint :conversation_id, null: false
|
|
83
|
+
t.bigint :head
|
|
84
|
+
t.bigint :commit_seq, null: false
|
|
85
|
+
t.text :record, null: false # EntryRecord JSON: {id, conversationId, kind, data?, model?, head?, byTaskId?}
|
|
86
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
87
|
+
t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entries_by_conversation"
|
|
88
|
+
t.index [ :conversation_id, :id ], order: { id: :desc }, name: "durable_agent_entry_heads_by_conversation", where: "head IS NOT NULL"
|
|
89
|
+
end
|
|
90
|
+
create_table :durable_agent_tasks do |t|
|
|
91
|
+
t.bigint :conversation_id, null: false
|
|
92
|
+
t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.run\""
|
|
93
|
+
t.text :status, null: false # pending | running | waiting | completing | terminal
|
|
94
|
+
t.integer :abort_requested, null: false # 0 | 1
|
|
95
|
+
t.integer :background, null: false # 0 | 1
|
|
96
|
+
t.text :record, null: false
|
|
97
|
+
t.index [ :status, :id ], name: "durable_agent_tasks_by_status"
|
|
98
|
+
t.index [ :conversation_id, :id ], name: "durable_agent_tasks_by_conversation"
|
|
99
|
+
t.index [ :kind, :id ], name: "durable_agent_tasks_by_kind"
|
|
100
|
+
t.index [ :abort_requested, :id ], name: "durable_agent_tasks_by_abort_requested"
|
|
101
|
+
t.index [ :background, :id ], name: "durable_agent_tasks_by_background"
|
|
102
|
+
end
|
|
103
|
+
create_table :durable_agent_submissions do |t|
|
|
104
|
+
t.bigint :conversation_id, null: false
|
|
105
|
+
t.text :request_id # JSON-encoded string, e.g. "\"42\""; NULL when the harness made it
|
|
106
|
+
t.text :status, null: false # queued | placed | done | unanswered
|
|
107
|
+
t.text :record, null: false # SubmissionRecord JSON: {id, conversationId, requestId?, type, status, entry?, answer?, reason?, detail?}
|
|
108
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
109
|
+
t.index [ :conversation_id, :request_id ], name: "durable_agent_submissions_by_request"
|
|
110
|
+
t.index [ :conversation_id, :id ], name: "durable_agent_submissions_by_conversation"
|
|
111
|
+
t.index [ :status, :id ], name: "durable_agent_submissions_by_status"
|
|
112
|
+
end
|
|
113
|
+
create_table :durable_agent_documents do |t|
|
|
114
|
+
t.text :kind, null: false # JSON-encoded string, e.g. "\"pi.live\""
|
|
115
|
+
t.integer :family, null: false # 0 = unkeyed, 1 = keyed
|
|
116
|
+
t.text :key_value, null: false # JSON-encoded key, "\"\"" when unkeyed
|
|
117
|
+
t.text :scope_kind, null: false # session | conversation | task
|
|
118
|
+
t.bigint :owner_id, null: false # 0, the conversation id, or the task id
|
|
119
|
+
t.bigint :created_at, null: false # commit seq, not a time
|
|
120
|
+
t.bigint :retired_at # commit seq, not a time
|
|
121
|
+
t.text :record, null: false # DocumentRecord JSON: {id, kind, scope: {kind, conversationId?|taskId?}, key?, history, fork?, createdAt, retiredAt?}
|
|
122
|
+
t.index [ :kind, :scope_kind, :owner_id, :family, :key_value, :created_at, :retired_at ], order: { created_at: :desc }, name: "durable_agent_documents_by_address"
|
|
123
|
+
t.index [ :scope_kind, :owner_id, :id ], name: "durable_agent_documents_by_scope"
|
|
124
|
+
t.index [ :scope_kind, :owner_id, :kind, :id ], name: "durable_agent_documents_by_scope_kind"
|
|
125
|
+
end
|
|
126
|
+
create_table :durable_agent_document_revisions, primary_key: [ :document_id, :seq ] do |t|
|
|
127
|
+
t.bigint :document_id, null: false
|
|
128
|
+
t.bigint :seq, null: false
|
|
129
|
+
t.text :kind, null: false # base | delta
|
|
130
|
+
t.bigint :version, null: false
|
|
131
|
+
t.text :content, null: false # base: the value as JSON; delta: chord ops as JSON
|
|
132
|
+
t.index [ :document_id, :kind, :seq ], order: { seq: :desc }, name: "durable_agent_document_revisions_by_kind"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
create_table :durable_agent_reports do |t| # written by the sidecar through its database facade
|
|
136
|
+
t.text :type, null: false
|
|
137
|
+
t.bigint :conversation_id
|
|
138
|
+
t.bigint :task_id
|
|
139
|
+
t.text :task_kind
|
|
140
|
+
t.text :extension
|
|
141
|
+
t.text :message, null: false
|
|
142
|
+
t.text :info, null: false # JSON text
|
|
143
|
+
t.datetime :created_at, null: false
|
|
144
|
+
t.index [ :conversation_id, :id ]
|
|
145
|
+
end
|
|
146
|
+
create_table :durable_agent_credentials, id: false do |t|
|
|
147
|
+
t.text :provider_id, primary_key: true
|
|
148
|
+
t.text :credential, null: false # the encrypted envelope as JSON text
|
|
149
|
+
t.datetime :updated_at, null: false
|
|
150
|
+
end
|
|
151
|
+
create_table :durable_agent_settings, id: false do |t|
|
|
152
|
+
t.text :key, primary_key: true
|
|
153
|
+
t.text :value, null: false
|
|
154
|
+
t.datetime :updated_at, null: false
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
No foreign keys: pi-durable writes rows in its own order inside one transaction. The migration contains no
|
|
159
|
+
data; the sidecar seeds the two singleton rows at boot, so a database loaded from `schema.rb` works.
|
|
160
|
+
`sidecar/sql/schema.sqlite.sql` and `schema.postgres.sql` are SQL renderings of the same tables for the
|
|
161
|
+
sidecar's tests. A Ruby test runs the migration and the matching file against fresh databases of each adapter
|
|
162
|
+
and compares tables, primary keys, column types, nullability and defaults, and index definitions (name,
|
|
163
|
+
columns, order, predicate, uniqueness); it also checks that the test database loaded from `schema.rb` equals the
|
|
164
|
+
migration's.
|
|
165
|
+
|
|
166
|
+
Indexed strings (`tasks.kind`, `documents.kind`, `documents.key_value`, `submissions.request_id`) hold the
|
|
167
|
+
JSON encoding JavaScript's `JSON.stringify` produces, which escapes only `"`, `\` and control characters.
|
|
168
|
+
Rails encodes them with `DurableAgent::IndexedString` (`JSON.generate`), never `to_json`, which escapes `&`, `<`,
|
|
169
|
+
`>` and U+2028 and would find nothing.
|
|
170
|
+
|
|
171
|
+
## Sidecar
|
|
172
|
+
|
|
173
|
+
### Storage layer (`sidecar/src`)
|
|
174
|
+
|
|
175
|
+
- `db/index.ts`: `type Dialect = "postgres" | "sqlite"`; `interface Db extends SqliteDatabase { readonly dialect }`
|
|
176
|
+
(`SqliteDatabase` is pi-durable's facade: `exec`, `run`, `get`, `all`, `transaction`, `close`, `?`
|
|
177
|
+
placeholders); `openDatabase(url)` picks the adapter by scheme (`postgres:`/`postgresql:`, `sqlite:`/`sqlite3:`).
|
|
178
|
+
- `db/sqlite.ts`: pi-durable's `openNodeSqliteDatabase` (WAL, `synchronous = NORMAL`, `BEGIN IMMEDIATE`, the
|
|
179
|
+
same settings Rails 8 uses, so Puma and the sidecar share one file) behind a serial queue of its own.
|
|
180
|
+
`node:sqlite` waits for a lock synchronously, stalling the event loop, so its busy timeout is 100 ms
|
|
181
|
+
(`SQLITE_BUSY_TIMEOUT_MS`) and the real wait is asynchronous: a `transaction` whose `BEGIN IMMEDIATE` fails
|
|
182
|
+
with `SQLITE_BUSY` before the callback has run, and a `run`/`get`/`all` that fails busy (an autocommit statement
|
|
183
|
+
that has not run), are retried with backoff (25 ms doubling to 500 ms) for up to `SQLITE_BUSY_BUDGET_MS`
|
|
184
|
+
(30 s), then rejected with "SQLite database is locked: another writer held the write lock for more than …".
|
|
185
|
+
A busy error after the callback started, and `exec` (a script may have partly committed), are not retried.
|
|
186
|
+
Later operations wait behind the retrying one. Operational rule: no other writer (Puma, a console, a backup) may
|
|
187
|
+
hold the SQLite write lock longer than 30 s, or the session fails.
|
|
188
|
+
- `db/postgres.ts`: `PostgresDatabase` over a `pg.Pool` (max 2). `int8` is parsed to a JS number (ids and seqs
|
|
189
|
+
are safe integers; `next_id` is text). Every operation runs through one serial queue, as pi-durable's node
|
|
190
|
+
adapter does: a `transaction` checks out a client, `BEGIN`s, runs the callback on an executor bound to that
|
|
191
|
+
client, then `COMMIT`s or `ROLLBACK`s and rethrows; everything else waits. pg-pool drops its idle `error`
|
|
192
|
+
listener on checkout, so the checked-out client carries the adapter's own `error` listener from checkout to
|
|
193
|
+
release: a dropped connection rejects the pending query and every later one (the transaction rejects with the
|
|
194
|
+
connection error, without a `ROLLBACK`), and the client is released broken so the pool discards it; it is never
|
|
195
|
+
an uncaught exception. A transaction rejected this way may still have committed, when the connection died
|
|
196
|
+
between the server's `COMMIT` and its acknowledgement; the Harness treats the rejection as fatal, and the
|
|
197
|
+
restarted sidecar resumes from whatever the database holds, so both outcomes are consistent. Single statements go through `pool.query`, which guards its checkout the same way.
|
|
198
|
+
Retries, because pi-durable treats a storage error as fatal to the session: a checkout or `BEGIN` that fails
|
|
199
|
+
with a connection-class error (`ECONNRESET`, `57P01`, `08*`, "Connection terminated") is retried once, since
|
|
200
|
+
nothing has run; outside a transaction only a statement that starts with `SELECT` (after leading comments) is
|
|
201
|
+
sent once more, because a lost response may hide a committed write. `INSERT`/`UPDATE`/`DELETE`, CTEs and `exec`
|
|
202
|
+
are never repeated, and nothing inside a transaction is. Connections run with `TimeZone=UTC` so
|
|
203
|
+
`CURRENT_TIMESTAMP` matches Rails' timestamp columns. The pool logs idle-client errors.
|
|
204
|
+
- `db/translate.ts`: the storage core speaks SQLite; `toPostgres(sql)` turns `?` into `$1, $2, …` (never inside
|
|
205
|
+
a literal), `INSERT OR IGNORE INTO …` into `INSERT INTO … ON CONFLICT DO NOTHING` (before any `RETURNING`),
|
|
206
|
+
drops ` STRICT` and `CHECK (json_valid(x))`. `excluded.col`, `ON CONFLICT(id) DO UPDATE`, partial indexes and
|
|
207
|
+
`LIMIT ?` are already valid Postgres. Memoized by SQL text.
|
|
208
|
+
- `storage.ts`: `openHarnessStorage(db)` checks that `durable_agent_conversations` exists (else `MissingSchema`,
|
|
209
|
+
"run bin/rails db:migrate"), seeds `durable_agent_durable_schema (1, CURRENT_SQLITE_SCHEMA_VERSION)` and
|
|
210
|
+
`durable_agent_durable_metadata (1, '2', 1)` with `ON CONFLICT DO NOTHING`, refuses a stored version lower than
|
|
211
|
+
pi-durable's (a gem upgrade ships the Rails migration and bumps the row), and returns
|
|
212
|
+
`SqliteStorage.open(db, { prefix: "durable_agent_" })`, which finds the version current and runs no DDL. One
|
|
213
|
+
`CREATE TABLE IF NOT EXISTS` for the version table still reaches the database, so on Postgres the role needs
|
|
214
|
+
`CREATE` on the schema. Closing the storage closes the database handle.
|
|
215
|
+
- `settings.ts`: `installationDeviceId`, `recordSchemaVersion` (`"2"`, do nothing if present),
|
|
216
|
+
`readSchemaVersion`, `readHarnessSchemaVersion`, `SCHEMA_VERSION = 2`. `checkSchemaVersion` runs first at boot:
|
|
217
|
+
it records the version on a fresh database, refuses to boot when the stored version is greater than
|
|
218
|
+
`SCHEMA_VERSION` ("the database is newer than this gem"), and logs a warning ("run bin/rails db:migrate") but
|
|
219
|
+
boots when it is lower.
|
|
220
|
+
- `credentials.ts`: `DbCredentialStore(db, token)`, pi-ai's `CredentialStore` over the facade. Each credential is
|
|
221
|
+
stored as `{"enc": 1, "type", "iv", "tag", "data"}`: AES-256-GCM with the provider id as associated data,
|
|
222
|
+
keyed by HKDF-SHA256(token, salt `durable_agent_credentials`, info `v1`). `type` stays clear for `GET /auth`.
|
|
223
|
+
Dumps and replicas hold ciphertext; the sidecar holds the key, so anyone with the database and the token can
|
|
224
|
+
decrypt; Rails never decrypts. Rotating the token makes stored sign-ins unreadable: the sidecar logs a warning
|
|
225
|
+
per provider and treats them as absent until the user signs in again. Plaintext rows are rewritten encrypted on
|
|
226
|
+
the next modify. Modifications to one provider are serialized. Rows are keyed by provider id, scoped or not:
|
|
227
|
+
`openai` is the app's credential, `openai@u42` the account `u42`'s.
|
|
228
|
+
- `accounts.ts`: accounts as provider ids. `scopedProviderId("openai", "u42")` is `openai@u42`;
|
|
229
|
+
`parseProviderId` splits it back; `ensureAccountProvider(models, base, account)` registers a clone of the base
|
|
230
|
+
provider (the same factory, `openaiProvider()`, `anthropicProvider()` or faux, with the scoped id) when
|
|
231
|
+
`models.getProvider` does not know it, so the catalog, auth methods and request code are the base provider's and
|
|
232
|
+
only the credential row differs. At boot every scoped provider id in `durable_agent_credentials` is registered;
|
|
233
|
+
`POST /conversations` and the auth routes register on demand. pi-ai keys the credential store, `login`, `logout`
|
|
234
|
+
and `checkAuth` by provider id, and a conversation pins its provider in `pi.agent`, so a conversation started on
|
|
235
|
+
`openai@u42` resolves that account's credential on every request, including after a restart. The clone's models
|
|
236
|
+
carry the scoped id, since pi-ai routes a request and its auth by `model.provider`; the clone hands each
|
|
237
|
+
request to the base provider with the base id again, because the request code switches on it (a Sign in with
|
|
238
|
+
ChatGPT token is recognised for `provider === "openai"` only), so assistant entries record `provider: "openai"`
|
|
239
|
+
while `pi.agent` keeps `openai@u42`. An account provider resolves its own stored credential only: the
|
|
240
|
+
environment (`OPENAI_API_KEY`) and credential files are the app's, never an account's. An account whose
|
|
241
|
+
credential is gone settles its runs `unanswered` with reason `model_error` ("Provider is not configured").
|
|
242
|
+
- `reports.ts`: `createReporter(db, events)` stores Harness reports (faulted tasks, hook errors, a failed session)
|
|
243
|
+
with `RETURNING id` and `CURRENT_TIMESTAMP`, then `events.report(id)`. pi-durable 1.3.0 calls `onReport(error)`
|
|
244
|
+
with one argument; the `info` of newer releases is read when present. The reporter tracks its inserts in flight;
|
|
245
|
+
`reporter.settled()` waits for all of them and their `events.report`, and shutdown awaits it before closing the
|
|
246
|
+
Harness, so a report raised just before a signal is stored and delivered.
|
|
247
|
+
|
|
248
|
+
SQL in `settings.ts`, `credentials.ts` and `reports.ts` is written once in the SQLite dialect and translated on
|
|
249
|
+
Postgres like the core's.
|
|
250
|
+
|
|
251
|
+
### Documents are stored as bases (`documents.ts`)
|
|
252
|
+
|
|
253
|
+
pi-durable stores a document as a base revision plus chord deltas and checkpoints a new base only when the
|
|
254
|
+
document's definition says so (`pi.live`: when idle; `pi.inbox`: when empty). Rails would have to replay chord
|
|
255
|
+
deltas to read a streaming `pi.live`. `withBaseDocuments(inner)` wraps `commit(writes)`: for each
|
|
256
|
+
`document.change` carrying a delta whose document keeps only its current value (the storage core's own
|
|
257
|
+
`isCurrentOnly`: `scope.kind !== "conversation" || history === "latest"`, so every session and task document and
|
|
258
|
+
every `latest` conversation document, which covers every `pi.*` document Rails reads: live, inbox, usage, provider;
|
|
259
|
+
`pi.agent` is `rewindable` but always checkpoints) it reads `inner.document(id, "current")`, applies the ops with `apply` from `@earendil-works/chord/delta` on a structured
|
|
260
|
+
clone, and replaces the write with `{kind: "base", version, value}`. The storage core deletes the previous
|
|
261
|
+
revisions of a current-only document on a base, so such a document always has exactly one revision row. Commits
|
|
262
|
+
through the wrapper are serialized so the read and the commit cannot interleave. Deltas on `rewindable`
|
|
263
|
+
conversation documents pass through. The Harness sees the same value and version either way. A failed read of the
|
|
264
|
+
current value fails the commit: without it there is no base to write.
|
|
265
|
+
|
|
266
|
+
The wrapper also resolves, before the commit and inside the same serialized step, the conversation owning each
|
|
267
|
+
`document.change` and `document.retire` (`commitWithOwners`): from a cache filled by creates and copies, from the
|
|
268
|
+
rewrite's own read of the record, or else from `inner.document`. A failed owner read is logged at `warn` and that
|
|
269
|
+
document is left out of the change event; the commit goes ahead.
|
|
270
|
+
|
|
271
|
+
The rule Rails relies on: **the current value of a document is the content of its newest `base` revision, and no
|
|
272
|
+
`delta` follows it for any document Rails reads.** If one does, `DurableAgent::Document#value` raises
|
|
273
|
+
`DurableAgent::Document::HistoryNotSupported`.
|
|
274
|
+
|
|
275
|
+
### Change events (`events.ts`)
|
|
276
|
+
|
|
277
|
+
`ChangeTracker` wraps `commit(writes)` too, and the change path never decides a commit's fate: every owner is known
|
|
278
|
+
before `inner.commit` runs, and nothing after it can reject. It announces the conversation ids touched:
|
|
279
|
+
`conversation` writes, `entry` and `submission` writes (`value.conversationId`), `document.create`/`document.copy`
|
|
280
|
+
with a conversation scope, and the owners of `document.change`/`document.retire` that the base-document wrapper
|
|
281
|
+
resolved before the commit (see above). `task` writes are ignored. Queuing the ids runs after the commit inside a
|
|
282
|
+
`try` that logs at `warn` and returns the commit's seq regardless.
|
|
283
|
+
|
|
284
|
+
`Events` debounces for 100 ms (a steady stream still posts every 100 ms) and does
|
|
285
|
+
`POST {RAILS_URL}/durable_agent/events` with the bearer token and `{"conversations": [7, 9], "reports": [3],
|
|
286
|
+
"auth": ["openai"]}`. `auth` names the provider ids whose credential changed (a login reached `done`, an API key was stored, or
|
|
287
|
+
`DELETE /auth/:provider` signed one out), scoped when an account's: `["openai", "openai@u42"]`. It is left out
|
|
288
|
+
when there are none.
|
|
289
|
+
A non-2xx or network error is retried once after 1 s, then logged at `warn` and dropped: events are a hint, the
|
|
290
|
+
state is in the database, and the next event or page load shows it. Unset `DURABLE_AGENT_RAILS_URL` disables
|
|
291
|
+
events with one boot log line. `flush()` is awaited on shutdown, after the Harness has closed.
|
|
292
|
+
|
|
293
|
+
### Boot and shutdown (`main.ts`, `harness.ts`)
|
|
294
|
+
|
|
295
|
+
Open the database from `DATABASE_URL` → `openSidecar(db, config)` (`harness.ts`): check the gem schema version →
|
|
296
|
+
credential store and models → account providers from the credentials table → device id → logins → Harness over
|
|
297
|
+
`ChangeTracker(withBaseDocuments(openHarnessStorage(db)))` → ensure the root conversation; then `main.ts` serves →
|
|
298
|
+
load tools (awaited, with the bounded boot retry; a failure or an unset Rails URL falls through with no tools) →
|
|
299
|
+
log "resuming with N tools" → `resume()`. The sidecar's tests boot through the same `openSidecar`. Every step of
|
|
300
|
+
`openSidecar` runs inside one handler: a missing table at any of them (SQLite "no such table", Postgres `42P01`,
|
|
301
|
+
also as a cause) is reported as `MissingSchema` ("run bin/rails db:migrate") and the process exits 1; any other
|
|
302
|
+
boot error exits 1 too. Until
|
|
303
|
+
`resume()` has run, `/health` reports `resumed: false` and submissions and aborts answer `503 {error: "starting"}`,
|
|
304
|
+
because either would start pi-durable's scheduler before the tools are installed.
|
|
305
|
+
|
|
306
|
+
Supervision: main watches `harness.closed`. When the session ends with `{reason: "failed", error}` (pi-durable
|
|
307
|
+
closes it, database included, after the first failed storage call) it logs the error and exits 1 so a supervisor
|
|
308
|
+
restarts the process; a clean close outside our own shutdown exits 1 as well. An uncaught exception is logged and
|
|
309
|
+
exits 1. Unhandled rejections are logged, not fatal.
|
|
310
|
+
|
|
311
|
+
Shutdown on SIGTERM or SIGINT: stop the server and wait up to 10 s for requests underway (then close their
|
|
312
|
+
connections), then `sidecar.close()`: dispose logins, await `reporter.settled()`, close the Harness (which commits
|
|
313
|
+
nothing new and closes the database), flush events; exit 0. The whole shutdown has a 30 s deadline, after which the process logs and
|
|
314
|
+
exits 1; a second SIGTERM or SIGINT during shutdown exits 1 at once. A signal during the tool load stops it and
|
|
315
|
+
the Harness is not resumed.
|
|
316
|
+
|
|
317
|
+
### HTTP API v1 (`server.ts`)
|
|
318
|
+
|
|
319
|
+
JSON bodies, `Authorization: Bearer <token>` on everything except `/health`, compared in constant time. Unknown
|
|
320
|
+
routes 404. The login routes with an `:id` answer 404 unless that login belongs to `:provider`.
|
|
321
|
+
|
|
322
|
+
| Method | Path | Request | Response |
|
|
323
|
+
|---|---|---|---|
|
|
324
|
+
| GET | `/health` | | `200 {ok: true, schemaVersion: 2, harnessSchemaVersion: 1, database: "postgres" \| "sqlite", model: {provider, modelId}, tools: [names], resumed: boolean}`; `503 {ok: false, error}` once the Harness has closed |
|
|
325
|
+
| POST | `/conversations` | `{model?: {provider, modelId}, instructions?, account?}` | `201 {id}`; `422 {error, suggestions: [ids]}` for an unknown model; `422 {error: "invalid_account"}`. With `account`, the model's provider becomes `provider@account` and the conversation runs on that account's credential. |
|
|
326
|
+
| POST | `/conversations/:id/submissions` | `{content, request_id, when_busy?: "followUp" \| "steer" \| "reject"}` | `202 {id, status}`; `404`; `409 {error: "conversation_busy"}`; `503 {error: "starting"}` before resume |
|
|
327
|
+
| POST | `/conversations/:id/abort` | | `200 {ok: true}`; `503 {error: "starting"}` before resume |
|
|
328
|
+
| GET | `/auth?account=` | | `200 {"<provider>": {type: "oauth" \| "api_key", source} \| null, ...}` for the app's providers, or the account's, under their base names |
|
|
329
|
+
| PUT | `/auth/:provider/api_key?account=` | `{key}` | `200 {type: "api_key", source: "stored credential"}` stores the key, encrypted; `422 {error}` for a blank key or a provider without API-key auth |
|
|
330
|
+
| POST | `/auth/:provider/logins?account=` | | `201 <login>`; `422 <login>` when it failed at once |
|
|
331
|
+
| GET | `/auth/:provider/logins/:id` | | `200 <login>`; `404` |
|
|
332
|
+
| POST | `/auth/:provider/logins/:id/callback` | `{url}` (the redirect URL the browser ended on) | `200 <login>` |
|
|
333
|
+
| DELETE | `/auth/:provider/logins/:id` | | `200 <login>` (cancelled) |
|
|
334
|
+
| DELETE | `/auth/:provider?account=` | | `200 {ok: true}` (sign out) |
|
|
335
|
+
|
|
336
|
+
`:provider` is the base provider id; `account` scopes it; the login routes with an `:id` also accept the scoped
|
|
337
|
+
id. `<login>.provider` carries the scoped id. Every account route answers `422 {error: "invalid_account"}` for an
|
|
338
|
+
account outside `[A-Za-z0-9_.-]{1,64}`, and `422 {error: "unknown_provider"}` for a base provider the sidecar
|
|
339
|
+
cannot clone (`openai`, `anthropic` and `faux` it can). `PUT …/api_key` on a provider without API-key login
|
|
340
|
+
(faux) is `422 {error: "no_api_key_auth"}`. `POST /conversations` also accepts an already scoped model ref
|
|
341
|
+
(`{provider: "openai@u42", modelId}`) without `account`.
|
|
342
|
+
| POST | `/tools/reload` | | `200 {tools: [names]}` re-fetches tool definitions from Rails |
|
|
343
|
+
|
|
344
|
+
`<login>` = `{id, provider, status: "starting" | "awaiting_browser" | "exchanging" | "done" | "failed", url?, instructions?, deviceCode?: {userCode, verificationUri}, messages: [], error?}`.
|
|
345
|
+
Sign in with ChatGPT and the other OAuth flows also listen on `127.0.0.1:1455` for the browser redirect, which
|
|
346
|
+
completes the login by itself when the browser runs on the sidecar's host; otherwise the user pastes the final
|
|
347
|
+
URL and Rails posts it to the callback route.
|
|
348
|
+
|
|
349
|
+
Submissions are idempotent on `request_id`. The submission row is committed before the 202, so Rails can `find`
|
|
350
|
+
it at once. An unknown default model logs an error at boot and the sidecar keeps running (creation then returns
|
|
351
|
+
422). Missing provider credentials are not an error at boot; runs settle `unanswered` with reason `no_model`
|
|
352
|
+
until someone signs in.
|
|
353
|
+
|
|
354
|
+
### Environment
|
|
355
|
+
|
|
356
|
+
`bin/rails durable_agent:sidecar` derives every value from Rails configuration, which in turn defaults to the same
|
|
357
|
+
variables, so setting one of these in the shell moves both halves.
|
|
358
|
+
|
|
359
|
+
| Variable | Meaning | Default |
|
|
360
|
+
|---|---|---|
|
|
361
|
+
| `DATABASE_URL` | the app's primary database: `postgres://…` (plus `?host=` for a socket directory, and `sslmode`, `sslrootcert`, `sslcert`, `sslkey` and `options=-c search_path=…` when `database.yml` sets them; see `Sidecar.database_url`) or `sqlite:///absolute/path/to/development.sqlite3` | rendered from `database.yml` |
|
|
362
|
+
| `DURABLE_AGENT_TOKEN` | shared bearer secret; required, and a whitespace-only value counts as unset (both halves refuse it) | `DurableAgent.config.token` |
|
|
363
|
+
| `DURABLE_AGENT_HOST`, `DURABLE_AGENT_PORT` | bind address | `127.0.0.1`, `4010` |
|
|
364
|
+
| `DURABLE_AGENT_RAILS_URL` | where Rails answers `/durable_agent/tools` and `/durable_agent/events`; unset runs without tools or events | `http://127.0.0.1:3000` |
|
|
365
|
+
| `DURABLE_AGENT_MODEL` | default model as `provider/modelId`; `faux/faux-1` is the scripted offline model | `openai/gpt-6.1-sol` |
|
|
366
|
+
| `DURABLE_AGENT_WORKSPACE_ROOT` | cwd for built-in tools (unused today, keep the knob) | `storage/durable_agent` |
|
|
367
|
+
| `DURABLE_AGENT_NAME` | agent name shown on provider consent screens | the Rails application name |
|
|
368
|
+
| `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | pass-through | |
|
|
369
|
+
|
|
370
|
+
## Tools
|
|
371
|
+
|
|
372
|
+
Tools are Ruby classes; the sidecar is generic.
|
|
373
|
+
|
|
374
|
+
1. At boot and on `POST /tools/reload` the sidecar does `GET {RAILS_URL}/durable_agent/tools` with the bearer
|
|
375
|
+
token and expects `200 [{name, description, parameters: <JSON Schema object>, replay: "safe" | "unsafe"}]`.
|
|
376
|
+
At boot a failed fetch is retried with backoff (500 ms doubling to 10 s) for up to 60 s, because `bin/dev`
|
|
377
|
+
starts node before Puma listens; then it logs a warning and runs with no tools until the next reload. A
|
|
378
|
+
successful reload ends the retry early. The HTTP server answers meanwhile, but the Harness resumes only after
|
|
379
|
+
this load: pi-durable re-runs an interrupted `replay: "safe"` call only when the tool is registered at the
|
|
380
|
+
moment the run resumes, and otherwise settles it as interrupted for good.
|
|
381
|
+
2. Each definition becomes a pi-durable tool. Executing it does `POST {RAILS_URL}/durable_agent/tools/:name` with
|
|
382
|
+
`{arguments, conversation_id, call_id}`. `200 {result}` → the tool output is `result` when it is a string,
|
|
383
|
+
else `JSON.stringify(result)`. Any other status with `{error}` → an error result carrying the message.
|
|
384
|
+
Network failure → error result. Timeout 120 s.
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
class LookupInvoice < DurableAgent::Tool
|
|
388
|
+
description "Fetch one invoice by number"
|
|
389
|
+
param :number, :string, "Invoice number like INV-1042" # required by default
|
|
390
|
+
param :include_lines, :boolean, "Include line items", required: false
|
|
391
|
+
replay :safe # default :unsafe
|
|
392
|
+
|
|
393
|
+
def call(number:, include_lines: false)
|
|
394
|
+
Invoice.find_by!(number:).as_json
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
DurableAgent.configure { |config| config.tools = [ LookupInvoice ] }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`DurableAgent::Tool.tool_name` is the underscored class name. `to_definition` returns the hash above. Param
|
|
402
|
+
types: `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`. `DurableAgent::ToolsController`
|
|
403
|
+
(engine routes `GET /tools`, `POST /tools/:name`) authenticates the bearer token against
|
|
404
|
+
`DurableAgent.config.token`, returns 404 for an unknown tool, and sets `DurableAgent::Current.conversation_id` /
|
|
405
|
+
`call_id` during the call. Errors answer 422 `{error}`, and `error` is what the model sees: the message for
|
|
406
|
+
`ArgumentError` (bad arguments), `ActiveRecord::RecordNotFound` and `DurableAgent::Tool::Error` (which tools raise
|
|
407
|
+
with a message meant for the model); for any other `StandardError` only `"<ErrorClass> while running <tool>"`,
|
|
408
|
+
with the error itself sent to `Rails.error.report`, so messages carrying SQL, paths or secrets stay in the app.
|
|
409
|
+
|
|
410
|
+
## Events endpoint
|
|
411
|
+
|
|
412
|
+
`POST /durable_agent/events` (engine route `events#create`), same bearer check as tools. Body
|
|
413
|
+
`{"conversations": [ids], "reports": [ids], "auth": ["openai"]}`, every key optional. For each conversation that
|
|
414
|
+
exists, `config.on_change.call(conversation)`; for each report, `config.on_report.call(report)`; when `auth` names
|
|
415
|
+
provider ids, `config.on_auth.call(providers)` once with them as given (`"openai"`, `"openai@u42"`). Unknown ids
|
|
416
|
+
are skipped. Callback errors go to
|
|
417
|
+
`Rails.error.report` and never fail the request. Responds 204.
|
|
418
|
+
|
|
419
|
+
The bearer check (tools and events) wants exactly `Authorization: Bearer <token>`: the scheme is case-sensitive,
|
|
420
|
+
then one space, then the token, compared in constant time. A bare token or any other form is 401.
|
|
421
|
+
|
|
422
|
+
The default callbacks broadcast Turbo refreshes from Puma: `on_change` to the conversation (`turbo_stream_from
|
|
423
|
+
conversation`); `on_report` to `report.conversation`, or to the stream `"durable_agent:reports"` when the report
|
|
424
|
+
has none (every report on pi-durable 1.3.0); `on_auth` to the stream `"durable_agent:auth"` and, for each account named, `"durable_agent:auth:<account>"`;
|
|
425
|
+
a settings page subscribes with `turbo_stream_from DurableAgent.auth_stream(account)`.
|
|
426
|
+
|
|
427
|
+
## Who pays
|
|
428
|
+
|
|
429
|
+
Three arrangements. Each is a credential on an account plus a conversation started on that account; the app picks
|
|
430
|
+
per conversation, and can mix them (a user with a credential runs on their account, the rest on the app's).
|
|
431
|
+
|
|
432
|
+
- The app pays: one credential for everyone (`OPENAI_API_KEY`, a key stored with `connection.set_api_key`, or the
|
|
433
|
+
operator's Sign in with ChatGPT), conversations from `DurableAgent.start`. The operator's bill or plan carries
|
|
434
|
+
every user.
|
|
435
|
+
- Each user pays with an API key: `Connection.new("openai", account: user.id).set_api_key(key)`, conversations
|
|
436
|
+
from `DurableAgent.start(account: user.id)`. Usage lands on the user's own provider bill. No browser flow.
|
|
437
|
+
- Each user pays with their ChatGPT plan: `Connection.new("openai", account: user.id).start_login`, the user
|
|
438
|
+
approves in the browser, conversations from `DurableAgent.start(account: user.id)`. Usage is drawn from the plan,
|
|
439
|
+
under limits the user manages in ChatGPT settings; the app holds a refreshable token per user and never a key.
|
|
440
|
+
|
|
441
|
+
## Accounts
|
|
442
|
+
|
|
443
|
+
The app decides which account a conversation runs on and what to do when an account has no credential:
|
|
444
|
+
`DurableAgent.auth(account:)` says, and `start` without `account:` runs on the app's. A user's sign-out
|
|
445
|
+
(`connection.disconnect`) leaves their running conversations settling `model_error` until they connect again. Usage
|
|
446
|
+
per account is the sum of `pi.usage` over that account's conversations, which the app relates to its users.
|
|
447
|
+
|
|
448
|
+
Sign in with ChatGPT is pi-ai's OpenAI OAuth: a public client registered dynamically per login, scopes
|
|
449
|
+
`openid profile email offline_access resource.invoke chatgpt.tokens.use.direct`, the resulting token sent to the
|
|
450
|
+
Responses API, usage drawn from the signed-in user's plan. Its redirect is `http://127.0.0.1:1455/auth/callback`.
|
|
451
|
+
When the browser runs on the sidecar's host, the sidecar's listener completes the login; a user elsewhere lands
|
|
452
|
+
on their own machine's port 1455 and pastes the final URL into the app, which posts it to the callback route.
|
|
453
|
+
|
|
454
|
+
## Ruby API
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
DurableAgent.configure do |config|
|
|
458
|
+
config.model = "openai/gpt-6.1-sol" # "provider/modelId"; default DURABLE_AGENT_MODEL
|
|
459
|
+
config.tools = [ LookupInvoice ]
|
|
460
|
+
config.url = "http://127.0.0.1:4010" # the sidecar; built from host and port
|
|
461
|
+
config.host, config.port # defaults DURABLE_AGENT_HOST / DURABLE_AGENT_PORT, then 127.0.0.1:4010
|
|
462
|
+
config.rails_url = "http://127.0.0.1:3000" # default DURABLE_AGENT_RAILS_URL
|
|
463
|
+
config.token = "..." # default below
|
|
464
|
+
config.workspace_root, config.agent_name # defaults DURABLE_AGENT_WORKSPACE_ROOT / DURABLE_AGENT_NAME, then the table's
|
|
465
|
+
config.parent_record_class = "ApplicationRecord" # DurableAgent::ApplicationRecord's superclass; default "ActiveRecord::Base"
|
|
466
|
+
config.on_change = ->(conversation) { ... } # default: Turbo::StreamsChannel.broadcast_refresh_to(conversation)
|
|
467
|
+
config.on_report = ->(report) { ... } # default: refresh report.conversation, else "durable_agent:reports"
|
|
468
|
+
config.on_auth = ->(providers) { ... } # provider ids, scoped for accounts ("openai@u42"); default: refresh
|
|
469
|
+
# "durable_agent:auth" and "durable_agent:auth:<account>" for each account named
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
DurableAgent.start(model: nil, instructions: nil, account: nil) # => DurableAgent::Conversation (created on the sidecar, then found)
|
|
473
|
+
DurableAgent.conversation(id)
|
|
474
|
+
DurableAgent.auth(account: nil) # => {"openai" => {"type" => "oauth", "source" => "OAuth"},
|
|
475
|
+
# "anthropic" => {"type" => "api_key", "source" => "ANTHROPIC_API_KEY"}, "google" => nil, ...}
|
|
476
|
+
# the app's providers, or the account's under their base names; `source` is pi-ai's:
|
|
477
|
+
# "OAuth", "stored credential", or the environment variable it read
|
|
478
|
+
DurableAgent.auth_stream(account = nil) # "durable_agent:auth" or "durable_agent:auth:u42", for turbo_stream_from
|
|
479
|
+
|
|
480
|
+
conversation.say("Hello", request_id: message.id, when_busy: nil) # => DurableAgent::Submission (persisted)
|
|
481
|
+
conversation.abort
|
|
482
|
+
conversation.transcript # Array of visible entries, oldest first
|
|
483
|
+
conversation.entries / submissions / tasks / reports / documents
|
|
484
|
+
conversation.document("pi.live") / live / busy? / streaming_message / running_tools / inbox / last_failure
|
|
485
|
+
conversation.model / agent / usage / usage_by_model / total_tokens / total_cost # pi.agent and pi.usage
|
|
486
|
+
conversation.provider / account # "openai" and "u42" from model "openai@u42/gpt-6.1-sol"; account nil for the app's
|
|
487
|
+
entry.message / provider / model_id / model_ref / stop_reason / error_message / usage / total_tokens / cost / duration_ms / timestamp
|
|
488
|
+
entry.failed? / answer? # an assistant entry whose request ended in an error or abort is no answer
|
|
489
|
+
conversation.parent / parent_at # from record["parent"]: the parent Conversation and the Entry it forked at
|
|
490
|
+
conversation.created_at / last_activity_at # the row's insert time; the newest entry's created_at, else created_at
|
|
491
|
+
DurableAgent::Conversation.root / top_level # the root the sidecar creates at boot (id 1); the others no task owns
|
|
492
|
+
|
|
493
|
+
DurableAgent::Connection.new("openai", account: nil) # the app's OpenAI sign-in, or account u42's
|
|
494
|
+
connection.connected? / subscription? / api_key? / unreachable? / error / source
|
|
495
|
+
connection.set_api_key(key) / start_login / login(id) / complete_login(id, url) / cancel_login(id) / disconnect
|
|
496
|
+
connection.account / stream_name # DurableAgent.auth_stream(account)
|
|
497
|
+
DurableAgent::Connection.all(account: nil)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Models in `app/models/durable_agent/`, all `readonly?`, all over the tables above, inheriting
|
|
501
|
+
`DurableAgent::ApplicationRecord` and the `HarnessRecord` concern (table name prefix, read-only).
|
|
502
|
+
`DurableAgent::ApplicationRecord` inherits from `config.parent_record_class`, resolved when the models load; an
|
|
503
|
+
app whose `ApplicationRecord` uses `connects_to` sets it to `"ApplicationRecord"` so role and shard switching apply.
|
|
504
|
+
`DurableAgent.start`, `Conversation#say` and `#abort` clear the query cache once the sidecar has answered, so
|
|
505
|
+
`busy?`, `live` and `transcript` read its commit in the same request. `DurableAgent.start` cannot join the caller's
|
|
506
|
+
transaction (the sidecar commits on its own connection): when the app's own record fails to save afterwards, the
|
|
507
|
+
conversation remains, unreferenced.
|
|
508
|
+
|
|
509
|
+
- `Conversation`: `attribute :record, :json`; `top_level` scope is `owner_conversation_id: nil` without the root
|
|
510
|
+
(id 1, which the sidecar creates at boot; `Conversation.root` returns it); `created_at` is a column and
|
|
511
|
+
`last_activity_at` the newest entry's `created_at`, else the conversation's;
|
|
512
|
+
`has_many :entries / :submissions / :tasks / :reports` on `conversation_id` (own rows only; a forked
|
|
513
|
+
conversation's inherited entries are not followed); `documents` is `Document.current.where(scope_kind:
|
|
514
|
+
"conversation", owner_id: id)`; `document(kind)` returns that document's `value` or `{}`. `say` returns
|
|
515
|
+
`submissions.find(response["id"])`. `last_failure` is the newest `unanswered` submission whose `entry_id` is
|
|
516
|
+
greater than the id of the newest `pi.assistant` entry that is an answer (`stopReason` neither `error` nor
|
|
517
|
+
`aborted`), or any unanswered one when there is no answer yet. `model` is `"provider/modelId"` from the
|
|
518
|
+
`pi.agent` document; `usage`, `usage_by_model`, `total_tokens`, `total_cost` read `pi.usage`.
|
|
519
|
+
- `Entry`: `attribute :record, :json`; `kind`, `data`, `model`, `head`, `by_task_id` come from the record (there
|
|
520
|
+
are no such columns); `created_at` is a column. Scopes `chronologically` / `reverse_chronologically` on id. `HIDDEN_KINDS =
|
|
521
|
+
%w[pi.system pi.reset]`, `visible?`, `user?`, `assistant?`, `tool_result?`, `compaction?`, `messages`, `text`,
|
|
522
|
+
`tool_calls`, `thinking?`, `tool_name`, `error?`. Kind filtering happens in Ruby: `Entry.visible` and
|
|
523
|
+
`Entry.of_kind(*kinds)` return arrays and work on relations.
|
|
524
|
+
- `Submission`: `attribute :record, :json`; the `request_id` column is the JSON-encoded string, so
|
|
525
|
+
`Submission.for_request(id)` encodes with `IndexedString`; `created_at` is a column; `type`, `entry_id`, `answer_entry_id`, `reason`, `detail`,
|
|
526
|
+
`entry`, `answer` come from the record; `status` is a column with scopes `pending`, `settled`, `unanswered`;
|
|
527
|
+
`explanation` turns pi's reasons (`no_model`, `run_failed`, `aborted`, …) into a sentence.
|
|
528
|
+
- `Task`: `attribute :record, :json`; `kind` decodes the JSON column; `status`, `abort_requested?`,
|
|
529
|
+
`background?`, `terminal?`; scopes `of_kind`, `unfinished`.
|
|
530
|
+
- `Document`: `kind` and `key` decode their JSON columns; `current` scope (`retired_at: nil`); `of_kind(kind)`
|
|
531
|
+
encodes; `has_many :revisions` (`Document::Revision`, table `durable_agent_document_revisions`); `value` is the
|
|
532
|
+
parsed content of the newest `base` revision and raises `HistoryNotSupported` when a `delta` has a greater seq.
|
|
533
|
+
- `Report`: `attribute :info, :json`; `belongs_to :conversation, optional: true`; `latest` scope.
|
|
534
|
+
- Credentials and settings have no models.
|
|
535
|
+
|
|
536
|
+
Other Ruby:
|
|
537
|
+
|
|
538
|
+
- `Configuration#token`: `Rails.application.credentials.dig(:durable_agent, :token)`, then
|
|
539
|
+
`ENV["DURABLE_AGENT_TOKEN"]`, then in development and test a value derived from `secret_key_base` so both
|
|
540
|
+
halves agree with zero setup. Production with none of these raises `MissingToken` where the token is used (the
|
|
541
|
+
`durable_agent:sidecar` task, the engine's controllers, the client), not at boot, so `db:migrate`, `console`
|
|
542
|
+
and asset builds run without one. A blank token raises `MissingToken` rather than being compared.
|
|
543
|
+
- `DurableAgent::IndexedString.encode(value)` / `.decode(text)`: the indexed-string encoding (see Schema).
|
|
544
|
+
- `DurableAgent::MessageText` extracts text, tool calls and thinking from pi messages (string content or blocks
|
|
545
|
+
of `{type: "text" | "thinking" | "toolCall"}`).
|
|
546
|
+
- `DurableAgent::Client` wraps Net::HTTP. Errors: `DurableAgent::Error` (status, body), `Busy` (409),
|
|
547
|
+
`UnknownModel` (422 on conversations, carries `suggestions`), `Unavailable` (connection refused, reset or
|
|
548
|
+
timed out; `EOFError`, `IOError` or `Errno::EPIPE` from a sidecar that went away mid-response; 503 `{error:
|
|
549
|
+
"starting"}` before the sidecar has resumed, with the message "sidecar is starting").
|
|
550
|
+
- `DurableAgent::Sidecar.env` builds the environment table; `.database_url` renders the primary `database.yml`
|
|
551
|
+
entry (`sqlite3` → `sqlite:///<absolute path>`, relative paths resolved against `Rails.root`; `postgresql` →
|
|
552
|
+
`postgres://user:pass@host:port/db` plus `?host=`, `sslmode`, `sslrootcert`/`sslcert`/`sslkey` (relative paths
|
|
553
|
+
resolved against `Rails.root`) and `schema_search_path` as `options=-c search_path=<value>`; an explicit `url`
|
|
554
|
+
passes through; other adapters raise `ArgumentError`). `sslmode=require`, rendered or in an explicit URL,
|
|
555
|
+
reaches node as `sslmode=no-verify`: libpq's `require` encrypts without verifying the server certificate, while
|
|
556
|
+
pg-connection-string reads `require` as `verify-full` and would refuse the self-signed certificates many hosted
|
|
557
|
+
databases use; `no-verify` is its name for libpq's `require`. `verify-ca` and `verify-full` pass through.
|
|
558
|
+
`.command` returns `["node", <dist/sidecar.js, or src/main.ts from a
|
|
559
|
+
checkout>]`; `.exec` replaces the process.
|
|
560
|
+
- Rake task: `durable_agent:sidecar`. Package with `rake build`, which bundles the sidecar (not checked in) before
|
|
561
|
+
packaging; the gemspec aborts a bare `gem build` when `sidecar/dist/sidecar.js` is missing. `CHANGELOG.md` keeps
|
|
562
|
+
an Unreleased section.
|
|
563
|
+
- `DurableAgent::TestHelper` (`require "durable_agent/test_helper"`, include it in `ActiveSupport::TestCase`):
|
|
564
|
+
`stub_sidecar(**responses)` replaces client methods with values, callables or errors and records
|
|
565
|
+
`sidecar_calls`; `fake_sidecar(**responses)` answers `create_conversation`, `submit` and `abort` as the sidecar
|
|
566
|
+
does, committing the conversation or queued submission row first (ids from 950001, submissions idempotent on
|
|
567
|
+
`request_id`, 404 for an unknown conversation). The gem's own tests use it.
|
|
568
|
+
- Generator `durable_agent:install`: copies the migration, writes `config/initializers/durable_agent.rb` with
|
|
569
|
+
commented production notes for excluding `/durable_agent` from `force_ssl`'s redirect and from `config.hosts`
|
|
570
|
+
(the sidecar calls Rails over plain loopback HTTP), mounts the engine, adds `sidecar: bin/rails
|
|
571
|
+
durable_agent:sidecar` to `Procfile.dev` (creating it with `web: bin/rails server -p 3000` and `sidecar:`
|
|
572
|
+
otherwise; when an existing `web:` line sets no port it says that foreman runs it on 5000 while `rails_url`
|
|
573
|
+
defaults to 3000), and prints what it did and how to set a production token. The initializer leaves
|
|
574
|
+
`config.model` commented out, so `DURABLE_AGENT_MODEL` keeps working, and shows all three callbacks.
|
|
575
|
+
- The app brings its own database adapter gem (`pg` or `sqlite3`); the gem depends on `rails` and `turbo-rails`.
|
|
576
|
+
|
|
577
|
+
## Tests
|
|
578
|
+
|
|
579
|
+
Both suites run on both databases.
|
|
580
|
+
|
|
581
|
+
- Ruby: Minitest with the dummy app, which is the example app (slopdep: a Nitro Kit chat UI, three tools, a
|
|
582
|
+
settings page; its own tests are in the suite). `test/dummy/config/database.yml` selects the adapter from
|
|
583
|
+
`DURABLE_AGENT_DB` (`sqlite`, the default, files under `test/dummy/storage/`; `postgres` with
|
|
584
|
+
`durable_agent_development` / `durable_agent_test`). `rake test` runs on SQLite, `rake test:postgres` on
|
|
585
|
+
Postgres, `rake test:all` both. Fixtures use ids ≥ 900000 and `record` JSON in pi-durable's shapes. Covered:
|
|
586
|
+
the models (document values on a base and on a base followed by a delta, the JSON-encoded columns against
|
|
587
|
+
hand-written `JSON.stringify` output, transcript, `last_failure`, `busy?`, timestamps, the root), the query
|
|
588
|
+
cache after commands (a second connection commits as the sidecar would), client errors, the tools and events
|
|
589
|
+
controllers (Bearer scheme, error exposure, auth events), configuration (environment defaults, blank token),
|
|
590
|
+
`Sidecar.database_url` on both adapters, the generator, `DurableAgent::TestHelper`, the migration against the
|
|
591
|
+
sidecar's SQL files on both adapters in every run and against `schema.rb`, and one round trip that boots the
|
|
592
|
+
real sidecar on `faux/faux-1` against the test database when `node` is available, after `/health` says
|
|
593
|
+
`resumed`. Cross-process reads use `ActiveRecord::Base.uncached`.
|
|
594
|
+
- TypeScript: `node --test`. `test/helpers.ts` opens the database from `DURABLE_AGENT_TEST_DATABASE_URL`
|
|
595
|
+
(default a fresh SQLite file in a temp dir) and applies the matching schema file. `npm test` runs on SQLite,
|
|
596
|
+
`npm run test:postgres` on Postgres. Covered: pi-durable's storage conformance cases against
|
|
597
|
+
`openHarnessStorage`, the translator (including pi-durable's own migration DDL), the Postgres queue, SELECT-only
|
|
598
|
+
retry and backend termination inside transactions, the SQLite busy wait against a second connection holding the lock,
|
|
599
|
+
storage open (missing schema, seeding, version refusal, a Harness on faux), the base-document wrapper (unit
|
|
600
|
+
cases, a task-scoped document among them, and a streaming `pi.live` that is only ever one base row equal to `watch.value.docs["pi.live"]`), the
|
|
601
|
+
change tracker (failing reads before and after the commit) and events (auth included) against a fake Rails,
|
|
602
|
+
credentials, settings (schema version newer and older), reports (`settled()`), the model check, the HTTP API
|
|
603
|
+
(health, readiness, login provider check), the process itself (`test/sidecar.test.ts` spawns `src/main.ts`: resume
|
|
604
|
+
after tools, exit 1 on a failed storage call, a second signal, `MissingSchema` for any missing table, a newer
|
|
605
|
+
schema), a restart that re-runs an interrupted replay-safe tool call once Rails lists the tools late,
|
|
606
|
+
and the tools protocol with its boot retry.
|
|
607
|
+
|
|
608
|
+
## Conventions
|
|
609
|
+
|
|
610
|
+
- Ruby in 37signals style: fat models, thin controllers, concerns, no service objects, Minitest, fixtures.
|
|
611
|
+
- TypeScript: strict, ESM, `.ts` imports, Node 22.19+ (type stripping), runnable from source without a build.
|
|
612
|
+
- Do not commit from an agent; the orchestrator commits.
|
|
613
|
+
- Out of scope today: more than one sidecar, compaction/reset/task-graph routes, any UI, MCP, a Ruby port of
|
|
614
|
+
chord deltas, following forked conversations' inherited entries.
|