@abloatai/ablo 0.23.0 → 0.25.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca30064: Logical replication is now the documented default storage path, with self-service data-source registration from the CLI.
8
+ - **`ablo connect --register`** — registers your database as an Ablo data source over logical replication in one step: it runs the same pre-flight replication probe `ablo connect` uses (server reachable, `REPLICATION` privilege, `wal_level=logical`, publication/slot creatable), and on success `POST`s the connection to the engine's `/v1/datasources`. The `ek_`-authed call scopes the source to your org automatically; the password is stored decomposed as a secret, never echoed back. This is the "registration is the enable" path — there is no separate tier or flag to pick.
9
+ - **`ablo init` leads with logical replication** — the default storage mode is now `replication` (was `endpoint`); the generated env + next-steps point at `ablo connect` / `ablo connect --register`. The signed-endpoint and direct modes remain as the explicit fallback / legacy options.
10
+ - **`ablo status`** — a data-plane diagnostic that probes whether your registered source is reachable and replicating (failure-only reporting, so it never falsely reports healthy).
11
+ - **`ablo push`** — minor guard/UX refinements (deploy-target clarity).
12
+ - **Docs (README, `docs/data-sources.md`, `llms.txt`)** rewritten to the one-path model: Ablo consumes your database's logical-replication stream and your application owns the write path. The security wording is precise — a logical-replication connection requires the `REPLICATION` privilege (it is **not** a read-only SQL account), so reviews are not misled by a "read-only" claim.
13
+
14
+ No breaking changes to the SDK runtime API. The server-side WAL read cutover these CLI changes support ships by deploying the engine, not this package.
15
+
16
+ ## 0.24.0
17
+
18
+ ### Minor Changes
19
+
20
+ - Multi-agent coordination for AI SDK tools + safer `ablo push`.
21
+ - **`coordinatedTool` (`@abloatai/ablo/ai-sdk`)** — one call turns an Ablo model
22
+ write into a Vercel AI SDK `tool()` with concurrency coordination handled, so an
23
+ AI agent can contribute to shared state without clobbering concurrent writers.
24
+ Three strategies: `merge` (default — delegates to the functional update's
25
+ compare-and-swap + backoff, self-healing accumulate), `claim` (mutual exclusion,
26
+ returns a `claimed` signal the model retries on), and `queue` (SQS-style
27
+ poll-acquire over HTTP). The `ai-sdk` entry now also documents the canonical
28
+ multi-agent coordination model (surface-the-signal + back-off laws, strategy
29
+ table).
30
+ - **`ablo push` guards** — `--dry-run`/`--plan` prints the deploy target, a
31
+ model-level diff vs the deployed schema, and git state, then exits without
32
+ applying. Production deploys now require a typed confirmation (and refuse an
33
+ uncommitted schema unless `--allow-dirty`); sandbox confirms interactively.
34
+ `--yes`/`-y` skips confirmation for CI.
35
+
3
36
  ## 0.23.0
4
37
 
5
38
  ### Minor Changes
package/README.md CHANGED
@@ -55,11 +55,13 @@ claims are visible while the work is still in progress.
55
55
  [Version History & Migration Guide](./docs/migration.md)
56
56
 
57
57
  It works with the auth and database you already have. **In production, your
58
- database is the system of record.** Ablo is the transaction layer on top of it:
59
- realtime data is scoped to *sync groups* from your own identity, and every
60
- committed row lives in your Postgres. (Trying Ablo with no database yet? The
61
- hosted **sandbox** can host rows in Ablo's test plane apiKey only, like
62
- Stripe test mode so you can explore before pointing it at your Postgres.)
58
+ database is the system of record.** Ablo is the sync + coordination layer on top
59
+ of it: it consumes your Postgres' logical-replication stream, scopes realtime
60
+ data to *sync groups* from your own identity, and your application keeps owning
61
+ the write path every row lives in your Postgres. (Trying Ablo with no database
62
+ yet? A **sandbox** `sk_test` key holds throwaway **test data** like Stripe test
63
+ mode — so you can explore before pointing it at your Postgres. Test-mode only; in
64
+ production every row lives in your database.)
63
65
 
64
66
  **Built for** collaborative editors, AI agent workflows, and internal tools —
65
67
  anywhere people and agents change shared state and everyone has to see it live.
@@ -92,11 +94,11 @@ what's configured at any time.
92
94
 
93
95
  **Keys & runtime.** Ablo needs Node 24+ and TypeScript 5+. Keys come in two of
94
96
  *your* environments — `sk_test_` and `sk_live_`, like Stripe — and `ablo login`
95
- mints both. Keep the key and the database URL in trusted server runtimes only.
96
- In the browser, `<AbloProvider>` authenticates with the signed-in user's
97
- session never the raw key, never the database URL. Prefer the connection
98
- string never leaving your infrastructure? Expose a signed
99
- [Data Source endpoint](./docs/data-sources.md) instead and omit `databaseUrl`.
97
+ mints both. Keep the key in trusted server runtimes only. In the browser,
98
+ `<AbloProvider>` authenticates with the signed-in user's session — never the raw
99
+ key. Your database is connected once, out of band, via `npx ablo connect`
100
+ (logical replication); if it can't grant a replication role, expose a signed
101
+ [Data Source endpoint](./docs/data-sources.md) instead.
100
102
 
101
103
  For production (React, an existing backend, Data Source, agents), the
102
104
  [Integration Guide](./docs/integration-guide.md) is the deeper map.
@@ -175,8 +177,9 @@ const schema = defineSchema({
175
177
  const ablo = Ablo({
176
178
  schema,
177
179
  apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo push`
178
- databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
179
180
  });
181
+ // Your Postgres is connected once via `npx ablo connect` (logical replication) —
182
+ // not passed here. Ablo tails its WAL; your rows never leave your database.
180
183
 
181
184
  await ablo.ready();
182
185
 
@@ -403,10 +406,12 @@ each other's changes in real time — that's the default, not a feature you turn
403
406
  - `useAblo(...)` gives React clients the live row, kept current automatically.
404
407
  - `ablo.<model>.claim({ id })` / `claim.state({ id })` / `claim.queue({ id })` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
405
408
 
406
- Always write through Ablo either the SDK model methods
407
- (`ablo.<model>.create/update/delete`) or the HTTP write endpoint below. If you
408
- write straight to your own database instead, those changes won't reach connected
409
- clients.
409
+ Your application owns the write path: writes that land in your database — through
410
+ your own backend, your existing API, any path — are tailed off the WAL and fanned
411
+ out to connected clients automatically (the Electric model). The SDK model
412
+ methods (`ablo.<model>.create/update/delete`) and the HTTP write endpoint are the
413
+ *JavaScript/agent* ergonomic surface — they participate in claims and confirmation
414
+ — but they are not a requirement for your normal writes to show up.
410
415
 
411
416
  ## HTTP Writes
412
417
 
@@ -429,35 +434,39 @@ curl https://api.abloatai.com/v1/commits \
429
434
 
430
435
  ## Your Database
431
436
 
432
- In production, every schema model is backed by **your own database** — Ablo is
433
- the transaction layer on top of it. Two ways to connect it:
437
+ In production, every schema model is backed by **your own database** — Ablo
438
+ **consumes** it, never owns it. The model is Electric's (and PowerSync's, and
439
+ Zero's): Ablo consumes your Postgres' logical-replication stream and fans the
440
+ changes out; **your application continues to own the write path.** Two ways Ablo
441
+ connects:
434
442
 
435
- | | How Ablo reaches your Postgres | Use when |
443
+ | | How Ablo connects to your Postgres | Use when |
436
444
  | --- | --- | --- |
437
- | **Connection string** (primary) | `databaseUrl` at init passed explicitly, never auto-read from the environment. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
438
- | **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
445
+ | **Logical replication** (primary) | `npx ablo connect` prints the setup SQL (`wal_level=logical`, a publication, a `REPLICATION` role); `npx ablo connect --register` tells Ablo to replicate it. Ablo tails the WAL it never runs DDL on, writes to, owns, or migrates your database. | Your database can grant a `REPLICATION` role (most can). |
446
+ | **Signed endpoint** (fallback) | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed requests and your app touches its own database. Needs no database configuration. | Your database **can't** grant a replication role (a locked-down managed DB). |
439
447
 
440
- (No database yet? The hosted **sandbox** can host rows in Ablo's test plane
441
- omit `databaseUrl` and pass an `apiKey` only, like Stripe test mode — so you can
442
- try Ablo before connecting your Postgres.)
448
+ (No database yet? A **sandbox** `sk_test` key lets you try Ablo's full API and
449
+ coordination with throwaway **test data** like Stripe test mode — before you
450
+ connect your own Postgres. Test-mode only: in production your rows always live in
451
+ your database and Ablo holds just the transaction log, never your data.)
443
452
 
444
- Same product, same truth either way: in production your database is the system of
445
- record. See
446
- [Connect Your Database](./docs/data-sources.md) for both shapes.
453
+ Your database is the system of record. The old `databaseUrl` dial-in (Ablo
454
+ holding a read/write connection) is **deprecated and being removed** — connect
455
+ via logical replication instead. See
456
+ [Connect Your Database](./docs/data-sources.md).
447
457
 
448
458
  ## Configuration
449
459
 
450
- `Ablo({ ... })` takes your schema, your key, and in production — your database,
451
- either as an explicit `databaseUrl` here or as a signed
452
- [Data Source endpoint](./docs/data-sources.md) in your app. (`databaseUrl` is
453
- never auto-read from the environment; omit it to try Ablo against the hosted
454
- sandbox.) Every other option has correct defaults:
460
+ `Ablo({ ... })` takes your schema and your key. Your database is connected
461
+ **out of band** once, via `npx ablo connect` (logical replication) or a signed
462
+ [Data Source endpoint](./docs/data-sources.md) not through the constructor.
463
+ Every other option has correct defaults:
455
464
 
456
465
  | Option | Type | Default | Purpose |
457
466
  | --- | --- | --- | --- |
458
467
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
459
468
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
460
- | `databaseUrl` | `string \| null` | `—` | Your Postgres, registered as the data plane. **Must be passed explicitly it is not auto-read from the environment.** If you have a `DATABASE_URL` set for another tool (Prisma, Drizzle, docker-compose), `Ablo()` ignores it unless you pass `databaseUrl` explicitly. Server runtimes only the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead, or when trying Ablo against the hosted sandbox. |
469
+ | `databaseUrl` | `string \| null` | `—` | **Deprecated being removed.** The old dial-in (Ablo holding a read/write connection to your DB). Connect via `npx ablo connect` (logical replication) instead; Ablo tails your WAL rather than dialing in. See [Connect Your Database](./docs/data-sources.md). |
461
470
 
462
471
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
463
472
  authenticates with the signed-in user's session; the raw-key path is gated
@@ -512,7 +521,7 @@ contract; there are no retry or timeout knobs to tune.
512
521
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
513
522
  - [Coordination](./docs/coordination.md) — `claim` / `claim.state` / `claim.queue` / `claim.release` reference: hold a row across slow agent work, and observe the line waiting behind it.
514
523
  - [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
515
- - [Connect Your Database](./docs/data-sources.md) — connect your Postgres by connection string (`databaseUrl`) or signed endpoint; your database is the system of record either way.
524
+ - [Connect Your Database](./docs/data-sources.md) — connect your Postgres by logical replication (`npx ablo connect`) or, as a fallback, a signed endpoint; your database is the system of record either way.
516
525
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
517
526
  - [AI SDK Tool](./docs/examples/ai-sdk-tool.md) — use Ablo inside an AI SDK tool call.
518
527
  - [Server Agent](./docs/examples/server-agent.md) — schema-backed worker.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `coordinatedTool` — the one-liner that turns an Ablo model write into a Vercel
3
+ * AI SDK tool with multi-agent coordination already handled, so an AI agent can
4
+ * contribute to shared state without ever silently clobbering a concurrent
5
+ * writer.
6
+ *
7
+ * The base `./ai-sdk` pattern (see index.ts) is "write your own `tool()` and
8
+ * call `ablo.<model>.update({ id, data, claim })` inside `execute`". That's the
9
+ * right amount of control when a tool does something bespoke. But the *common*
10
+ * case — "the agent produced some content; save it into the shared row" — should
11
+ * not require every integration to re-derive optimistic concurrency by hand. This
12
+ * collapses it to a declaration:
13
+ *
14
+ * ```ts
15
+ * import { coordinatedTool } from '@abloatai/ablo/ai-sdk';
16
+ * import { z } from 'zod';
17
+ *
18
+ * const saveSection = coordinatedTool(ablo.documents, {
19
+ * description: 'Save your section into the shared document.',
20
+ * inputSchema: z.object({ text: z.string() }),
21
+ * id: () => DOC_ID,
22
+ * apply: (current, { text }) => ({ content: appendBlock(current.content, text) }),
23
+ * // strategy: 'merge' ← the default
24
+ * });
25
+ *
26
+ * await streamText({ model, messages, tools: { saveSection } });
27
+ * ```
28
+ *
29
+ * `apply` is the whole API: a pure function of `(freshest row, tool input) →
30
+ * patch`, exactly like React's `setState(prev => next)`. Everything underneath —
31
+ * reading the latest row, the compare-and-swap, the jittered backoff between
32
+ * reconcile rounds, releasing claims — is the runtime's job, not yours.
33
+ *
34
+ * ## Strategies (pick by how writers should relate; all verified to converge
35
+ * under N-way agent contention)
36
+ *
37
+ * - `'merge'` *(default)* — delegates straight to the functional update
38
+ * `ablo.<model>.update(id, current => apply(current, input))`. The SDK re-reads
39
+ * and re-applies `apply` on top of every concurrent write and backs off between
40
+ * rounds, so N agents *accumulate* into one row and the model never sees a
41
+ * conflict. **Requires the model's agent conflict policy to be `reject`** (the
42
+ * default, or `agentsReject()`); a model declaring `agentsNotify()` HOLDS the
43
+ * losing write instead of rejecting it, which defeats the reconcile — use
44
+ * `claim`/`queue` there, or switch the policy.
45
+ *
46
+ * - `'claim'` — mutual exclusion. Takes a fail-fast claim; if another participant
47
+ * holds the row it returns `{ status: 'claimed' }` so the *model* decides to
48
+ * retry (a legible signal beats a hidden wait when the agent might do something
49
+ * better with its turn). Works regardless of conflict policy.
50
+ *
51
+ * - `'queue'` — fair-ish serialization over stateless HTTP, the SQS shape: a
52
+ * client poll-acquire loop (true FIFO needs a socket) until the claim is granted
53
+ * or `poll.timeoutMs` elapses. The model calls once and the tool waits its turn.
54
+ */
55
+ import type { z } from 'zod';
56
+ import type { ModelOperations } from '../client/createModelProxy.js';
57
+ export type CoordinationStrategy = 'merge' | 'claim' | 'queue';
58
+ /** The structured result the tool hands back to the model (or the caller). */
59
+ export interface CoordinatedWriteResult<T> {
60
+ /**
61
+ * `'written'` — saved. `'claimed'` — another participant holds the row; NOT
62
+ * saved, the model should try again. `'timeout'` — the queue strategy could not
63
+ * acquire the row within `poll.timeoutMs`.
64
+ */
65
+ status: 'written' | 'claimed' | 'timeout';
66
+ /** The reconciled row, on `'written'`. */
67
+ row?: T;
68
+ message?: string;
69
+ /** On `'written'` via the `queue` strategy, how long the tool waited in line. */
70
+ waitedMs?: number;
71
+ }
72
+ export interface CoordinatedToolOptions<TInput, T> {
73
+ /** Tool description shown to the model. */
74
+ description: string;
75
+ /** What the model may send — a normal AI SDK / zod input schema. */
76
+ inputSchema: z.ZodType<TInput>;
77
+ /** Which row this write targets, derived from the tool input. */
78
+ id: (input: TInput) => string;
79
+ /**
80
+ * Produce the write patch from the freshest current row + the tool input — a
81
+ * pure `(prev, input) => next`. Under `merge` it re-runs on every concurrent
82
+ * write, so it must be idempotent w.r.t. its own contribution (e.g. skip if its
83
+ * marker is already present) to be safe across reconcile rounds.
84
+ */
85
+ apply: (current: T, input: TInput) => Partial<T>;
86
+ /** How concurrent writers relate. Defaults to `'merge'`. */
87
+ strategy?: CoordinationStrategy;
88
+ /** Human-legible coordination metadata attached to the claim (`claim`/`queue`). */
89
+ claim?: {
90
+ reason?: string;
91
+ description?: string;
92
+ };
93
+ /** Reconcile budget for `merge` (rounds before `AbloContentionError`). */
94
+ retries?: number;
95
+ /** Poll cadence / ceiling for `queue` (defaults 250ms / 30s). */
96
+ poll?: {
97
+ intervalMs?: number;
98
+ timeoutMs?: number;
99
+ };
100
+ }
101
+ export declare function coordinatedTool<TInput, T = Record<string, unknown>, CreateInput = Partial<T>>(model: ModelOperations<T, CreateInput>, options: CoordinatedToolOptions<TInput, T>): import("ai").Tool<TInput, CoordinatedWriteResult<T>>;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `coordinatedTool` — the one-liner that turns an Ablo model write into a Vercel
3
+ * AI SDK tool with multi-agent coordination already handled, so an AI agent can
4
+ * contribute to shared state without ever silently clobbering a concurrent
5
+ * writer.
6
+ *
7
+ * The base `./ai-sdk` pattern (see index.ts) is "write your own `tool()` and
8
+ * call `ablo.<model>.update({ id, data, claim })` inside `execute`". That's the
9
+ * right amount of control when a tool does something bespoke. But the *common*
10
+ * case — "the agent produced some content; save it into the shared row" — should
11
+ * not require every integration to re-derive optimistic concurrency by hand. This
12
+ * collapses it to a declaration:
13
+ *
14
+ * ```ts
15
+ * import { coordinatedTool } from '@abloatai/ablo/ai-sdk';
16
+ * import { z } from 'zod';
17
+ *
18
+ * const saveSection = coordinatedTool(ablo.documents, {
19
+ * description: 'Save your section into the shared document.',
20
+ * inputSchema: z.object({ text: z.string() }),
21
+ * id: () => DOC_ID,
22
+ * apply: (current, { text }) => ({ content: appendBlock(current.content, text) }),
23
+ * // strategy: 'merge' ← the default
24
+ * });
25
+ *
26
+ * await streamText({ model, messages, tools: { saveSection } });
27
+ * ```
28
+ *
29
+ * `apply` is the whole API: a pure function of `(freshest row, tool input) →
30
+ * patch`, exactly like React's `setState(prev => next)`. Everything underneath —
31
+ * reading the latest row, the compare-and-swap, the jittered backoff between
32
+ * reconcile rounds, releasing claims — is the runtime's job, not yours.
33
+ *
34
+ * ## Strategies (pick by how writers should relate; all verified to converge
35
+ * under N-way agent contention)
36
+ *
37
+ * - `'merge'` *(default)* — delegates straight to the functional update
38
+ * `ablo.<model>.update(id, current => apply(current, input))`. The SDK re-reads
39
+ * and re-applies `apply` on top of every concurrent write and backs off between
40
+ * rounds, so N agents *accumulate* into one row and the model never sees a
41
+ * conflict. **Requires the model's agent conflict policy to be `reject`** (the
42
+ * default, or `agentsReject()`); a model declaring `agentsNotify()` HOLDS the
43
+ * losing write instead of rejecting it, which defeats the reconcile — use
44
+ * `claim`/`queue` there, or switch the policy.
45
+ *
46
+ * - `'claim'` — mutual exclusion. Takes a fail-fast claim; if another participant
47
+ * holds the row it returns `{ status: 'claimed' }` so the *model* decides to
48
+ * retry (a legible signal beats a hidden wait when the agent might do something
49
+ * better with its turn). Works regardless of conflict policy.
50
+ *
51
+ * - `'queue'` — fair-ish serialization over stateless HTTP, the SQS shape: a
52
+ * client poll-acquire loop (true FIFO needs a socket) until the claim is granted
53
+ * or `poll.timeoutMs` elapses. The model calls once and the tool waits its turn.
54
+ */
55
+ import { tool } from 'ai';
56
+ import { AbloClaimedError, AbloNotFoundError } from '../errors.js';
57
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
58
+ export function coordinatedTool(model, options) {
59
+ const strategy = options.strategy ?? 'merge';
60
+ return tool({
61
+ description: options.description,
62
+ inputSchema: options.inputSchema,
63
+ execute: async (input) => {
64
+ const id = options.id(input);
65
+ if (strategy === 'merge') {
66
+ // The setState of the data layer: read fresh → apply → CAS → re-read +
67
+ // re-apply with backoff on any concurrent write. Self-healing; the model
68
+ // never sees a conflict. (Backoff lives in the shared reconcile loop.)
69
+ const row = await model.update(id, (current) => options.apply(current, input), {
70
+ retries: options.retries,
71
+ });
72
+ return { status: 'written', row: row ?? undefined };
73
+ }
74
+ // claim / queue both take a claim, write under it, and release. The only
75
+ // difference is what they do when the row is already held: claim returns the
76
+ // signal to the model; queue waits and retries (SQS-style poll-acquire).
77
+ const acquireWriteRelease = async () => {
78
+ const claim = await model.claim({
79
+ id,
80
+ queue: false,
81
+ reason: options.claim?.reason,
82
+ description: options.claim?.description,
83
+ });
84
+ try {
85
+ const current = await model.retrieve({ id });
86
+ if (current === undefined) {
87
+ throw new AbloNotFoundError(`Cannot write ${id}: it does not exist (or is outside this credential's scope).`, [id]);
88
+ }
89
+ const row = await model.update({ id, data: options.apply(current, input), claim, wait: 'confirmed' });
90
+ return { status: 'written', row };
91
+ }
92
+ finally {
93
+ await claim.release();
94
+ }
95
+ };
96
+ if (strategy === 'claim') {
97
+ try {
98
+ return await acquireWriteRelease();
99
+ }
100
+ catch (e) {
101
+ if (e instanceof AbloClaimedError) {
102
+ return { status: 'claimed', message: 'Another participant holds this row right now — it was NOT saved. Wait briefly and try again.' };
103
+ }
104
+ throw e;
105
+ }
106
+ }
107
+ // strategy === 'queue': SQS-style poll-acquire over stateless HTTP.
108
+ const interval = options.poll?.intervalMs ?? 250;
109
+ const timeout = options.poll?.timeoutMs ?? 30_000;
110
+ const start = Date.now();
111
+ for (;;) {
112
+ try {
113
+ const result = await acquireWriteRelease();
114
+ return { ...result, waitedMs: Date.now() - start };
115
+ }
116
+ catch (e) {
117
+ if (e instanceof AbloClaimedError) {
118
+ if (Date.now() - start >= timeout) {
119
+ return { status: 'timeout', message: `Could not acquire the row within ${timeout}ms.` };
120
+ }
121
+ await sleep(interval);
122
+ continue;
123
+ }
124
+ throw e;
125
+ }
126
+ }
127
+ },
128
+ });
129
+ }
@@ -71,6 +71,48 @@
71
71
  * `wrapWithMultiplayer` is optional. Use it when the whole model call is scoped
72
72
  * to one entity before any tool is chosen; tool implementations stay exactly
73
73
  * the same.
74
+ *
75
+ * ## Multi-agent coordination — the canonical way
76
+ *
77
+ * When several agents (or agents + humans) write the SAME row concurrently, the
78
+ * outcome is decided by **(write path) × (the model's conflict policy)**, NOT by
79
+ * how smart the model is. The same model silently loses 3 of 4 concurrent
80
+ * contributions through a blind whole-row write, and lands all 4 through a
81
+ * coordinated one — because the coordinated write returns a *signal* the model
82
+ * (or the runtime) acts on. Two empirical laws fall out:
83
+ *
84
+ * 1. **Surface the signal.** A write that swallows the conflict and reports
85
+ * success is the footgun. Every robust path returns a legible result
86
+ * (`reject` → re-read & retry; `claimed` → the model tries again) instead of
87
+ * clobbering. Reaching for a bigger model does not fix a silent write.
88
+ * 2. **Back off.** Under N-way contention, writers that retry in lock-step just
89
+ * re-collide. The shared reconcile loop already jitters its backoff; any
90
+ * hand-rolled retry must too, or it exhausts its budget and drops a writer.
91
+ *
92
+ * `coordinatedTool` (below) encodes both. Prefer it over a hand-written tool for
93
+ * "save the agent's contribution into the shared row":
94
+ *
95
+ * ```ts
96
+ * import { coordinatedTool } from '@abloatai/ablo/ai-sdk';
97
+ * const saveSection = coordinatedTool(ablo.documents, {
98
+ * description: 'Save your section into the shared document.',
99
+ * inputSchema: z.object({ text: z.string() }),
100
+ * id: () => DOC_ID,
101
+ * apply: (current, { text }) => ({ content: appendBlock(current.content, text) }),
102
+ * strategy: 'merge', // 'merge' (default, self-healing) | 'claim' | 'queue'
103
+ * });
104
+ * ```
105
+ *
106
+ * | strategy | writers relate by | on contention | model conflict policy |
107
+ * |----------|-------------------|---------------|------------------------|
108
+ * | `merge` | accumulate (CAS) | re-read + re-apply (silent, backed off) | must be `reject` (default) |
109
+ * | `claim` | mutual exclusion | returns `{status:'claimed'}` → model retries | any |
110
+ * | `queue` | FIFO-ish (SQS) | poll-acquire until granted / timeout | any |
111
+ *
112
+ * Note: a model declaring `agentsNotify()` HOLDS a losing write instead of
113
+ * rejecting it, which defeats `merge`'s reconcile (the loser is dropped, not
114
+ * retried). Use `agentsReject()` for accumulate semantics, or `claim`/`queue`.
74
115
  */
75
116
  export { coordinationContextMiddleware, type CoordinationContextMiddlewareOptions, type ClaimTarget, } from './coordination-context.js';
76
117
  export { wrapWithMultiplayer, type WrapWithMultiplayerOptions } from './wrap.js';
118
+ export { coordinatedTool, type CoordinationStrategy, type CoordinatedToolOptions, type CoordinatedWriteResult, } from './coordinated-tool.js';
@@ -71,6 +71,48 @@
71
71
  * `wrapWithMultiplayer` is optional. Use it when the whole model call is scoped
72
72
  * to one entity before any tool is chosen; tool implementations stay exactly
73
73
  * the same.
74
+ *
75
+ * ## Multi-agent coordination — the canonical way
76
+ *
77
+ * When several agents (or agents + humans) write the SAME row concurrently, the
78
+ * outcome is decided by **(write path) × (the model's conflict policy)**, NOT by
79
+ * how smart the model is. The same model silently loses 3 of 4 concurrent
80
+ * contributions through a blind whole-row write, and lands all 4 through a
81
+ * coordinated one — because the coordinated write returns a *signal* the model
82
+ * (or the runtime) acts on. Two empirical laws fall out:
83
+ *
84
+ * 1. **Surface the signal.** A write that swallows the conflict and reports
85
+ * success is the footgun. Every robust path returns a legible result
86
+ * (`reject` → re-read & retry; `claimed` → the model tries again) instead of
87
+ * clobbering. Reaching for a bigger model does not fix a silent write.
88
+ * 2. **Back off.** Under N-way contention, writers that retry in lock-step just
89
+ * re-collide. The shared reconcile loop already jitters its backoff; any
90
+ * hand-rolled retry must too, or it exhausts its budget and drops a writer.
91
+ *
92
+ * `coordinatedTool` (below) encodes both. Prefer it over a hand-written tool for
93
+ * "save the agent's contribution into the shared row":
94
+ *
95
+ * ```ts
96
+ * import { coordinatedTool } from '@abloatai/ablo/ai-sdk';
97
+ * const saveSection = coordinatedTool(ablo.documents, {
98
+ * description: 'Save your section into the shared document.',
99
+ * inputSchema: z.object({ text: z.string() }),
100
+ * id: () => DOC_ID,
101
+ * apply: (current, { text }) => ({ content: appendBlock(current.content, text) }),
102
+ * strategy: 'merge', // 'merge' (default, self-healing) | 'claim' | 'queue'
103
+ * });
104
+ * ```
105
+ *
106
+ * | strategy | writers relate by | on contention | model conflict policy |
107
+ * |----------|-------------------|---------------|------------------------|
108
+ * | `merge` | accumulate (CAS) | re-read + re-apply (silent, backed off) | must be `reject` (default) |
109
+ * | `claim` | mutual exclusion | returns `{status:'claimed'}` → model retries | any |
110
+ * | `queue` | FIFO-ish (SQS) | poll-acquire until granted / timeout | any |
111
+ *
112
+ * Note: a model declaring `agentsNotify()` HOLDS a losing write instead of
113
+ * rejecting it, which defeats `merge`'s reconcile (the loser is dropped, not
114
+ * retried). Use `agentsReject()` for accumulate semantics, or `claim`/`queue`.
74
115
  */
75
116
  export { coordinationContextMiddleware, } from './coordination-context.js';
76
117
  export { wrapWithMultiplayer } from './wrap.js';
118
+ export { coordinatedTool, } from './coordinated-tool.js';