@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 +33 -0
- package/README.md +42 -33
- package/dist/ai-sdk/coordinated-tool.d.ts +101 -0
- package/dist/ai-sdk/coordinated-tool.js +129 -0
- package/dist/ai-sdk/index.d.ts +42 -0
- package/dist/ai-sdk/index.js +42 -0
- package/dist/cli.cjs +948 -529
- package/dist/client/Ablo.js +4 -0
- package/dist/interfaces/index.d.ts +8 -0
- package/dist/sync/BootstrapHelper.d.ts +16 -0
- package/dist/sync/BootstrapHelper.js +27 -0
- package/dist/sync/schemas.d.ts +1 -0
- package/dist/sync/schemas.js +3 -0
- package/docs/data-sources.md +12 -5
- package/llms.txt +4 -2
- package/package.json +1 -1
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
[Data Source endpoint](./docs/data-sources.md) instead
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
433
|
-
|
|
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
|
|
443
|
+
| | How Ablo connects to your Postgres | Use when |
|
|
436
444
|
| --- | --- | --- |
|
|
437
|
-
| **
|
|
438
|
-
| **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed
|
|
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?
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
[Data Source endpoint](./docs/data-sources.md)
|
|
453
|
-
|
|
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` | `—` |
|
|
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
|
|
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
|
+
}
|
package/dist/ai-sdk/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/ai-sdk/index.js
CHANGED
|
@@ -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';
|