@abloatai/ablo 0.22.1 → 0.24.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 +62 -0
- package/dist/BaseSyncedStore.js +15 -11
- package/dist/Database.js +3 -3
- package/dist/ObjectPool.js +14 -4
- package/dist/SyncClient.js +18 -4
- package/dist/agent/Agent.js +5 -3
- package/dist/agent/session.js +9 -6
- 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 +687 -498
- package/dist/client/Ablo.d.ts +14 -2
- package/dist/client/Ablo.js +23 -9
- package/dist/client/ApiClient.js +28 -6
- package/dist/client/createModelProxy.js +6 -1
- package/dist/client/httpClient.d.ts +6 -1
- package/dist/client/identity.js +1 -1
- package/dist/core/DatabaseManager.js +1 -1
- package/dist/core/StoreManager.js +5 -2
- package/dist/interfaces/index.d.ts +8 -0
- package/dist/mutators/UndoManager.js +7 -6
- package/dist/query/client.js +19 -5
- package/dist/react/useMutators.js +6 -2
- package/dist/sync/BootstrapHelper.d.ts +16 -0
- package/dist/sync/BootstrapHelper.js +29 -2
- package/dist/sync/ConnectionManager.js +7 -7
- package/dist/sync/NetworkProbe.js +2 -2
- package/dist/sync/SyncWebSocket.js +2 -2
- package/dist/sync/schemas.d.ts +1 -0
- package/dist/sync/schemas.js +3 -0
- package/dist/transactions/TransactionQueue.js +37 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Multi-agent coordination for AI SDK tools + safer `ablo push`.
|
|
8
|
+
- **`coordinatedTool` (`@abloatai/ablo/ai-sdk`)** — one call turns an Ablo model
|
|
9
|
+
write into a Vercel AI SDK `tool()` with concurrency coordination handled, so an
|
|
10
|
+
AI agent can contribute to shared state without clobbering concurrent writers.
|
|
11
|
+
Three strategies: `merge` (default — delegates to the functional update's
|
|
12
|
+
compare-and-swap + backoff, self-healing accumulate), `claim` (mutual exclusion,
|
|
13
|
+
returns a `claimed` signal the model retries on), and `queue` (SQS-style
|
|
14
|
+
poll-acquire over HTTP). The `ai-sdk` entry now also documents the canonical
|
|
15
|
+
multi-agent coordination model (surface-the-signal + back-off laws, strategy
|
|
16
|
+
table).
|
|
17
|
+
- **`ablo push` guards** — `--dry-run`/`--plan` prints the deploy target, a
|
|
18
|
+
model-level diff vs the deployed schema, and git state, then exits without
|
|
19
|
+
applying. Production deploys now require a typed confirmation (and refuse an
|
|
20
|
+
uncommitted schema unless `--allow-dirty`); sandbox confirms interactively.
|
|
21
|
+
`--yes`/`-y` skips confirmation for CI.
|
|
22
|
+
|
|
23
|
+
## 0.23.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- 2807efb: `create` now returns the created row, not a `CommitReceipt`.
|
|
28
|
+
|
|
29
|
+
The WebSocket client's `create` already returned the row (`T`); the HTTP client
|
|
30
|
+
and the `.model(name)` accessor returned a `CommitReceipt`, so "create returns
|
|
31
|
+
the thing I created" only held on one transport. Both now return the confirmed,
|
|
32
|
+
authoritative server row (framework defaults like `createdAt`/`createdBy`
|
|
33
|
+
included). For an idempotent re-create of an existing caller-supplied id, the
|
|
34
|
+
EXISTING row is returned (not the input).
|
|
35
|
+
|
|
36
|
+
BREAKING (HTTP / `.model()` callers only): `await ablo.<model>.create(...)` now
|
|
37
|
+
resolves to the row instead of `{ status, lastSyncId, ... }`. Code that ignored
|
|
38
|
+
the return value, or that read `.id` (the row carries `id` too), is unaffected;
|
|
39
|
+
code that read `lastSyncId` / `serverTxId` / `status` off a typed model create
|
|
40
|
+
should use the raw `commits.create(...)` resource, which still returns a
|
|
41
|
+
`CommitReceipt`. WebSocket-client callers are unaffected (already returned `T`).
|
|
42
|
+
|
|
43
|
+
- 2807efb: `delete` is idempotent — deleting an already-absent row is a no-op success, not
|
|
44
|
+
an error.
|
|
45
|
+
|
|
46
|
+
The WebSocket client's `delete` threw `entity_not_found` when the row wasn't in
|
|
47
|
+
the local pool, while the HTTP client returned without error — so "delete this"
|
|
48
|
+
was a hard edge on one transport. Both now agree: a row that isn't present is
|
|
49
|
+
already gone, so the delete succeeds with no effect. This is AIP-135's
|
|
50
|
+
recommended behavior for client-assigned-id / declarative APIs (Ablo is exactly
|
|
51
|
+
that), and it makes delete safe to retry and to race (two actors deleting the
|
|
52
|
+
same row). The deliberate "loud 0-row" assertion in `@ablo/slides-sdk` is
|
|
53
|
+
unchanged (it keeps its own `allowMissing` opt-out).
|
|
54
|
+
|
|
55
|
+
- 2807efb: `retrieve` reports a missing row as `data: undefined` instead of throwing.
|
|
56
|
+
|
|
57
|
+
The HTTP client previously threw `model_not_found` for a missing row while the
|
|
58
|
+
WebSocket client returned `T | undefined` — so the obvious read ("does this row
|
|
59
|
+
exist?") was a hard edge an agent had to wrap in `try/catch` on one transport
|
|
60
|
+
only. Both transports now agree: an absent row is data-absence, not an error.
|
|
61
|
+
`ModelRead.data` is now `T | undefined` (matching the documented `.data?.x`
|
|
62
|
+
usage). Taking a `claim` on a row that doesn't exist still throws
|
|
63
|
+
`AbloNotFoundError` — a claim has nothing to hold.
|
|
64
|
+
|
|
3
65
|
## 0.22.1
|
|
4
66
|
|
|
5
67
|
### Patch Changes
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -215,7 +215,7 @@ export class BaseSyncedStore {
|
|
|
215
215
|
this.hydratedGroups.add(g);
|
|
216
216
|
}
|
|
217
217
|
catch (err) {
|
|
218
|
-
getContext().logger.
|
|
218
|
+
getContext().logger.debug('[BaseSyncedStore] scoped hydrate failed', {
|
|
219
219
|
syncGroups: need,
|
|
220
220
|
error: err instanceof Error ? err.message : String(err),
|
|
221
221
|
});
|
|
@@ -684,7 +684,7 @@ export class BaseSyncedStore {
|
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
686
|
catch (fallbackError) {
|
|
687
|
-
getContext().logger.
|
|
687
|
+
getContext().logger.debug('[BaseSyncedStore] Local fallback failed', {
|
|
688
688
|
error: fallbackError.message,
|
|
689
689
|
});
|
|
690
690
|
}
|
|
@@ -752,7 +752,7 @@ export class BaseSyncedStore {
|
|
|
752
752
|
"(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
|
|
753
753
|
}
|
|
754
754
|
else {
|
|
755
|
-
getContext().logger.
|
|
755
|
+
getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
|
|
756
756
|
}
|
|
757
757
|
return 'network_error';
|
|
758
758
|
}
|
|
@@ -951,7 +951,7 @@ export class BaseSyncedStore {
|
|
|
951
951
|
const rawObj = (raw ?? {});
|
|
952
952
|
const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
|
|
953
953
|
if (!groupKey) {
|
|
954
|
-
getContext().logger.
|
|
954
|
+
getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
|
|
955
955
|
syncId: delta.id,
|
|
956
956
|
});
|
|
957
957
|
return;
|
|
@@ -1040,7 +1040,7 @@ export class BaseSyncedStore {
|
|
|
1040
1040
|
});
|
|
1041
1041
|
}
|
|
1042
1042
|
catch (error) {
|
|
1043
|
-
getContext().logger.
|
|
1043
|
+
getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
|
|
1044
1044
|
error: error instanceof Error ? error.message : String(error),
|
|
1045
1045
|
});
|
|
1046
1046
|
}
|
|
@@ -1102,7 +1102,7 @@ export class BaseSyncedStore {
|
|
|
1102
1102
|
hasLocalData = this.objectPool.size > 0;
|
|
1103
1103
|
}
|
|
1104
1104
|
catch (hydrateError) {
|
|
1105
|
-
getContext().logger.
|
|
1105
|
+
getContext().logger.debug('[sync-engine] IDB hydration failed', { error: hydrateError });
|
|
1106
1106
|
getContext().observability.captureBootstrapFailure(hydrateError, { type: 'hydration-from-idb' });
|
|
1107
1107
|
}
|
|
1108
1108
|
// Get sync baseline for WebSocket
|
|
@@ -1221,7 +1221,7 @@ export class BaseSyncedStore {
|
|
|
1221
1221
|
this.updateSyncStatus({ state: 'idle', progress: 100 });
|
|
1222
1222
|
}
|
|
1223
1223
|
catch (error) {
|
|
1224
|
-
getContext().logger.
|
|
1224
|
+
getContext().logger.debug('[sync-engine] Background bootstrap failed', {
|
|
1225
1225
|
error: error instanceof Error ? error.message : String(error),
|
|
1226
1226
|
cause: error,
|
|
1227
1227
|
});
|
|
@@ -1418,7 +1418,7 @@ export class BaseSyncedStore {
|
|
|
1418
1418
|
return;
|
|
1419
1419
|
resolved = true;
|
|
1420
1420
|
unsubscribe();
|
|
1421
|
-
getContext().logger.
|
|
1421
|
+
getContext().logger.debug(`[BaseSyncedStore] waitForWebSocketConnected timed out after ${timeoutMs}ms — initialize() will return but the next mutation may race the upgrade.`);
|
|
1422
1422
|
resolve(false);
|
|
1423
1423
|
}, timeoutMs);
|
|
1424
1424
|
});
|
|
@@ -1519,7 +1519,9 @@ export class BaseSyncedStore {
|
|
|
1519
1519
|
// SECURITY: Clear IndexedDB data on session expiry.
|
|
1520
1520
|
// When auth is revoked, locally cached data must not persist on disk.
|
|
1521
1521
|
this.database.clear().catch((clearErr) => {
|
|
1522
|
-
|
|
1522
|
+
// consumer register: session ended, but cached data may remain on disk
|
|
1523
|
+
getContext().logger.error('Your session ended, but some locally cached data could not be cleared from this device.');
|
|
1524
|
+
getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
|
|
1523
1525
|
});
|
|
1524
1526
|
this.objectPool.clear();
|
|
1525
1527
|
});
|
|
@@ -1533,7 +1535,9 @@ export class BaseSyncedStore {
|
|
|
1533
1535
|
this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
|
|
1534
1536
|
});
|
|
1535
1537
|
const onReconnectFailed = this.syncWebSocket.subscribe('reconnect_failed', ({ attempts }) => {
|
|
1536
|
-
|
|
1538
|
+
// consumer register: reconnection exhausted — the app is now offline
|
|
1539
|
+
getContext().logger.warn('Lost connection to the sync service and could not reconnect. Your app is now offline; changes will sync once the connection is restored.');
|
|
1540
|
+
getContext().logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
|
|
1537
1541
|
this.updateSyncStatus({ state: 'reconnecting' });
|
|
1538
1542
|
});
|
|
1539
1543
|
this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { this.areaOfInterest?.dispose(); this.areaOfInterest = null; });
|
|
@@ -2126,7 +2130,7 @@ export class BaseSyncedStore {
|
|
|
2126
2130
|
modelId: 'batch',
|
|
2127
2131
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
2128
2132
|
});
|
|
2129
|
-
getContext().logger.
|
|
2133
|
+
getContext().logger.debug('[BaseSyncedStore] Delta flush error', {
|
|
2130
2134
|
error: error instanceof Error ? error.message : String(error),
|
|
2131
2135
|
});
|
|
2132
2136
|
};
|
package/dist/Database.js
CHANGED
|
@@ -436,7 +436,7 @@ export class Database {
|
|
|
436
436
|
}
|
|
437
437
|
const store = this.getStore(modelName, 'bootstrap');
|
|
438
438
|
if (!store) {
|
|
439
|
-
getContext().logger.
|
|
439
|
+
getContext().logger.debug(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
|
|
440
440
|
continue;
|
|
441
441
|
}
|
|
442
442
|
let writeErrors = 0;
|
|
@@ -1023,7 +1023,7 @@ export class Database {
|
|
|
1023
1023
|
// `DataError`, `AbortError`) so we can find what's wrong with
|
|
1024
1024
|
// the `compacted` payload shape or store schema.
|
|
1025
1025
|
const idbErr = err instanceof Error ? err : new Error(String(err));
|
|
1026
|
-
getContext().logger.
|
|
1026
|
+
getContext().logger.debug('[Database.processDeltaBatch] store tx FAILED', {
|
|
1027
1027
|
modelName,
|
|
1028
1028
|
storeDeltasCount: storeDeltas.length,
|
|
1029
1029
|
errorName: idbErr.name,
|
|
@@ -1071,7 +1071,7 @@ export class Database {
|
|
|
1071
1071
|
// persisted" signal loud when it actually happens. If this fires
|
|
1072
1072
|
// repeatedly on the same sync IDs, a specific row is un-writable
|
|
1073
1073
|
// (validation? compact issue?) and needs fixing at that layer.
|
|
1074
|
-
getContext().logger.
|
|
1074
|
+
getContext().logger.debug('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
|
|
1075
1075
|
seen: highestSyncId,
|
|
1076
1076
|
persisted: highestPersistedSyncId,
|
|
1077
1077
|
gap: highestSyncId - highestPersistedSyncId,
|
package/dist/ObjectPool.js
CHANGED
|
@@ -232,7 +232,9 @@ export class ObjectPool {
|
|
|
232
232
|
// Warn about suspicious patterns
|
|
233
233
|
if (deltaInfo.action === 'I' &&
|
|
234
234
|
(history.lastAction === 'U' || history.lastAction === 'D')) {
|
|
235
|
-
|
|
235
|
+
// Internal delta-ordering anomaly that reconciles on the next
|
|
236
|
+
// catch-up — forensic, not consumer-actionable → debug.
|
|
237
|
+
getContext().logger.debug(`ObjectPool.add() SUSPICIOUS: INSERT after ${history.lastAction}`, { modelType, id, syncId: deltaInfo.syncId });
|
|
236
238
|
}
|
|
237
239
|
}
|
|
238
240
|
// Update delta history
|
|
@@ -609,7 +611,9 @@ export class ObjectPool {
|
|
|
609
611
|
const Constructor = ModelClass || this.registry.getModelByName(modelName);
|
|
610
612
|
if (!Constructor) {
|
|
611
613
|
if (!ModelClass && modelName === 'Unknown') {
|
|
612
|
-
|
|
614
|
+
// Malformed row with no type marker — dropped, but nothing the consumer
|
|
615
|
+
// can act on (the actionable schema-drift case is handled below) → debug.
|
|
616
|
+
getContext().logger.debug('ObjectPool.createFromData: No model identifier found', { data });
|
|
613
617
|
getContext().modelDebugLogger?.logError('Unknown', 'CREATE', 'No model identifier found', data);
|
|
614
618
|
return null;
|
|
615
619
|
}
|
|
@@ -620,7 +624,11 @@ export class ObjectPool {
|
|
|
620
624
|
`. The schema pushed to this org may differ from your local ` +
|
|
621
625
|
`schema — run \`ablo status\` to compare.`, { code: 'model_not_registered' });
|
|
622
626
|
}
|
|
623
|
-
|
|
627
|
+
// Genuinely actionable and NOT self-healing: a model the server is sending
|
|
628
|
+
// isn't in your schema, so these rows are silently skipped. Keep at warn,
|
|
629
|
+
// consumer register (their model name + the `ablo status` fix); forensics ride debug.
|
|
630
|
+
getContext().logger.warn(`Received data for "${modelName}", which isn't in your schema — these rows will be skipped. Run \`ablo status\` to compare your local schema with the server.`);
|
|
631
|
+
getContext().logger.debug(`ObjectPool.createFromData: No constructor found for model "${modelName}"`, { data });
|
|
624
632
|
getContext().modelDebugLogger?.logError(modelName, 'CREATE', `No constructor found for model "${modelName}"`, data);
|
|
625
633
|
return null;
|
|
626
634
|
}
|
|
@@ -646,7 +654,9 @@ export class ObjectPool {
|
|
|
646
654
|
}
|
|
647
655
|
catch (error) {
|
|
648
656
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
649
|
-
|
|
657
|
+
// Internal construction failure — captured via observability below and
|
|
658
|
+
// re-fetched on resync; the stack is forensic → debug.
|
|
659
|
+
getContext().logger.debug(`[ObjectPool.createFromData] FAILED ${modelName}`, { errorMessage, stack: error instanceof Error ? error.stack : undefined });
|
|
650
660
|
getContext().observability.captureTransactionFailure({
|
|
651
661
|
context: 'createFromData',
|
|
652
662
|
modelName,
|
package/dist/SyncClient.js
CHANGED
|
@@ -210,7 +210,13 @@ export class SyncClient extends EventEmitter {
|
|
|
210
210
|
// cascade), don't re-add it — Object.assign cannot restore the private
|
|
211
211
|
// isDisposed flag, so the model would be added in a broken state.
|
|
212
212
|
if (model.disposed) {
|
|
213
|
-
|
|
213
|
+
// Follow-on of an already-logged permanent error, not its own
|
|
214
|
+
// problem: the tx that failed has already surfaced the cause in
|
|
215
|
+
// TransactionQueue. Restoring a disposed model is a no-op by
|
|
216
|
+
// design (can't revive the private isDisposed flag), so keep this
|
|
217
|
+
// at `debug` instead of emitting a second `warn` that reads as a
|
|
218
|
+
// distinct failure in the console.
|
|
219
|
+
getContext().logger.debug('[SyncClient] Rollback skipped restore (model already disposed)', {
|
|
214
220
|
modelId: transaction.modelId,
|
|
215
221
|
modelName: transaction.modelName,
|
|
216
222
|
reason,
|
|
@@ -1007,8 +1013,14 @@ export class SyncClient extends EventEmitter {
|
|
|
1007
1013
|
// this class of misconfiguration surfaces in dev instead of
|
|
1008
1014
|
// manifesting as "my drag doesn't save."
|
|
1009
1015
|
if (!this.userId || !this.organizationId) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1016
|
+
// Internal invariant, not a consumer-actionable error: identity (user +
|
|
1017
|
+
// org) hasn't arrived yet. The mutations stay queued and retry once it
|
|
1018
|
+
// does, so this is `debug` — a transient startup race is normal. If it
|
|
1019
|
+
// never clears it means the host app finished sign-in without seeding
|
|
1020
|
+
// identity, which surfaces downstream as "writes never confirm"; we do
|
|
1021
|
+
// NOT name internal wiring (`SyncClient.initialize`) here because that
|
|
1022
|
+
// method isn't part of the @abloatai/ablo surface a reader could act on.
|
|
1023
|
+
getContext().logger.debug('[sync] writes waiting for identity (user/org not set yet) — queued, will retry', {
|
|
1012
1024
|
pending: this.pendingMutations.length,
|
|
1013
1025
|
userId: this.userId,
|
|
1014
1026
|
organizationId: this.organizationId,
|
|
@@ -1601,7 +1613,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1601
1613
|
// re-fetch and re-apply. Logged here so silent IDB failures
|
|
1602
1614
|
// are observable instead of disappearing into a default switch
|
|
1603
1615
|
// fall-through.
|
|
1604
|
-
|
|
1616
|
+
// Self-healing: the next catch-up poll / reconnect re-fetches and
|
|
1617
|
+
// re-applies this delta, so it's forensic, not consumer-actionable → debug.
|
|
1618
|
+
getContext().logger.debug('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
|
|
1605
1619
|
modelName,
|
|
1606
1620
|
modelId: modelId.slice(0, 12),
|
|
1607
1621
|
});
|
package/dist/agent/Agent.js
CHANGED
|
@@ -145,7 +145,9 @@ export class Agent {
|
|
|
145
145
|
await this.opts.announcer.announce(status, activity);
|
|
146
146
|
}
|
|
147
147
|
catch (err) {
|
|
148
|
-
|
|
148
|
+
// Best-effort presence; failing only means peers don't see this
|
|
149
|
+
// agent's status. Not consumer-actionable → maintainer register.
|
|
150
|
+
this.opts.logger.debug('[perception] announcer error', {
|
|
149
151
|
error: err.message,
|
|
150
152
|
});
|
|
151
153
|
}
|
|
@@ -160,11 +162,11 @@ export class Agent {
|
|
|
160
162
|
syncGroups: this.opts.syncGroups,
|
|
161
163
|
});
|
|
162
164
|
if (!res.ok) {
|
|
163
|
-
this.opts.logger.
|
|
165
|
+
this.opts.logger.debug(`[perception] announce failed: ${res.status} ${res.statusText}`);
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
catch (err) {
|
|
167
|
-
this.opts.logger.
|
|
169
|
+
this.opts.logger.debug('[perception] announce error', {
|
|
168
170
|
error: err.message,
|
|
169
171
|
});
|
|
170
172
|
}
|
package/dist/agent/session.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { Ablo } from '../client/Ablo.js';
|
|
23
23
|
import { AbloConnectionError } from '../errors.js';
|
|
24
|
+
import { getContext } from '../context.js';
|
|
24
25
|
/**
|
|
25
26
|
* Returns a session whose `getAgent` method handles cache, mint,
|
|
26
27
|
* sync_groups alignment, and lifecycle. Call `disposeAll()` from
|
|
@@ -100,12 +101,14 @@ export function createAgentSession(options) {
|
|
|
100
101
|
await agent.dispose();
|
|
101
102
|
}
|
|
102
103
|
catch { /* ignore */ }
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// `connectAgent.ts` so a
|
|
107
|
-
//
|
|
108
|
-
|
|
104
|
+
// Route through the gated logger so this obeys ABLO_LOG_LEVEL like every
|
|
105
|
+
// other line: a plain consumer-register `error` headline (the unreachable
|
|
106
|
+
// URL + code), with the structured fields on a `debug` companion. The
|
|
107
|
+
// companion's shape matches the cap-mint logger in `connectAgent.ts` so a
|
|
108
|
+
// single search picks both up.
|
|
109
|
+
const log = getContext().logger;
|
|
110
|
+
log.error(`Agent could not connect to the sync server at ${wsUrl}${code ? ` (${code})` : ''}.`);
|
|
111
|
+
log.debug('[Agent.session] ws bootstrap failed', {
|
|
109
112
|
url: wsUrl,
|
|
110
113
|
surfaceClass: identity.surfaceClass,
|
|
111
114
|
orgId: identity.organizationId,
|
|
@@ -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';
|