@abloatai/ablo 0.37.1 → 0.38.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/AGENTS.md +3 -3
- package/CHANGELOG.md +100 -13
- package/README.md +65 -28
- package/dist/ai-sdk.d.ts +2 -0
- package/dist/ai-sdk.d.ts.map +1 -0
- package/dist/ai-sdk.js +2 -0
- package/dist/ai-sdk.js.map +1 -0
- package/docs/agents.md +59 -9
- package/docs/api-keys.md +51 -27
- package/docs/branch-development.md +392 -0
- package/docs/cli.md +50 -42
- package/docs/data-sources.md +11 -0
- package/docs/deployment.md +20 -19
- package/docs/examples/ai-sdk-tool.md +25 -52
- package/docs/how-it-works.md +3 -2
- package/docs/index.md +7 -3
- package/docs/integration-guide.md +6 -13
- package/docs/integrations/inngest.md +258 -0
- package/docs/integrations/temporal.md +187 -0
- package/docs/integrations.md +54 -0
- package/docs/migration.md +4 -4
- package/docs/projects.md +1 -1
- package/docs/quickstart.md +30 -22
- package/docs/webhooks.md +4 -4
- package/llms.txt +20 -18
- package/package.json +15 -3
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
# AI SDK
|
|
1
|
+
# AI SDK Tools
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Give an AI SDK agent safe access to the same typed Ablo resources as your
|
|
4
|
+
> backend.
|
|
4
5
|
|
|
5
6
|
Use AI SDK for the agent loop and Ablo for the state boundary inside the tool.
|
|
6
7
|
When an agent updates a shared record from inside a tool call you have a
|
|
7
|
-
concurrency problem: another agent may be editing the same row, and
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
13
|
-
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
14
|
-
of clobbering.
|
|
8
|
+
concurrency problem: another agent or a person may be editing the same row, and
|
|
9
|
+
a naive write can overwrite work the model never saw. Ablo's tool adapters put
|
|
10
|
+
the authoritative read, retry, claim, and confirmed-write behavior behind the
|
|
11
|
+
ordinary AI SDK tool contract.
|
|
15
12
|
|
|
16
13
|
```ts
|
|
17
14
|
// app/api/chat/route.ts
|
|
@@ -20,11 +17,11 @@ import { defineSchema, model, z as schemaZ } from '@abloatai/ablo/schema';
|
|
|
20
17
|
import { anthropic } from '@ai-sdk/anthropic';
|
|
21
18
|
import {
|
|
22
19
|
streamText,
|
|
23
|
-
tool,
|
|
24
20
|
convertToModelMessages,
|
|
25
21
|
stepCountIs,
|
|
26
22
|
type UIMessage,
|
|
27
23
|
} from 'ai';
|
|
24
|
+
import { updateTool } from '@abloatai/ablo/ai-sdk';
|
|
28
25
|
import { z } from 'zod';
|
|
29
26
|
|
|
30
27
|
export const runtime = 'nodejs';
|
|
@@ -43,42 +40,19 @@ const ablo = Ablo({
|
|
|
43
40
|
transport: 'http',
|
|
44
41
|
});
|
|
45
42
|
|
|
46
|
-
const updateTask =
|
|
47
|
-
|
|
43
|
+
const updateTask = updateTool(ablo.tasks, {
|
|
44
|
+
title: 'Update task',
|
|
45
|
+
description: 'Update a task without overwriting concurrent work.',
|
|
48
46
|
inputSchema: z.object({
|
|
49
47
|
taskId: z.string(),
|
|
50
48
|
status: z.enum(['todo', 'doing', 'done']).optional(),
|
|
51
49
|
summary: z.string().optional(),
|
|
52
50
|
}),
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!task) return { ok: false, reason: 'not_found' };
|
|
59
|
-
|
|
60
|
-
// If another agent already holds this row, claim waits for them to finish,
|
|
61
|
-
// re-reads the fresh row, then hands it back on `claim.data`. The claim is
|
|
62
|
-
// released automatically when it goes out of scope.
|
|
63
|
-
await using claim = await ablo.tasks.claim({
|
|
64
|
-
id: taskId,
|
|
65
|
-
description: 'editing',
|
|
66
|
-
ttl: '2m',
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// Because you hold the claim, this update is rejected if the row changed
|
|
70
|
-
// underneath you, instead of silently overwriting it.
|
|
71
|
-
const updated = await ablo.tasks.update({
|
|
72
|
-
id: claim.data.id,
|
|
73
|
-
data: {
|
|
74
|
-
status: status ?? claim.data.status,
|
|
75
|
-
summary: summary ?? claim.data.summary,
|
|
76
|
-
},
|
|
77
|
-
wait: 'confirmed',
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
return { ok: true, task: updated };
|
|
81
|
-
},
|
|
51
|
+
id: ({ taskId }) => taskId,
|
|
52
|
+
apply: (current, { status, summary }) => ({
|
|
53
|
+
status: status ?? current.status,
|
|
54
|
+
summary: summary ?? current.summary,
|
|
55
|
+
}),
|
|
82
56
|
});
|
|
83
57
|
|
|
84
58
|
export async function POST(req: Request) {
|
|
@@ -100,13 +74,12 @@ export async function POST(req: Request) {
|
|
|
100
74
|
|
|
101
75
|
The model provider is interchangeable — swap `anthropic(...)` for any
|
|
102
76
|
server-bound provider instance. What matters is that the route binds the model on
|
|
103
|
-
the server (never trusting one sent in the request body)
|
|
104
|
-
`UIMessage[]` with `convertToModelMessages
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
- and auto-releases the claim when the tool returns.
|
|
77
|
+
the server (never trusting one sent in the request body) and converts the
|
|
78
|
+
incoming `UIMessage[]` with `convertToModelMessages`.
|
|
79
|
+
|
|
80
|
+
`updateTool` defaults to a functional update: Ablo re-reads and reapplies the
|
|
81
|
+
patch if another participant writes first. Use `strategy: 'claim'` when the
|
|
82
|
+
model should skip work already owned by someone else, or `strategy: 'queue'`
|
|
83
|
+
when it should wait in Ablo's server-owned FIFO claim queue. The same entrypoint
|
|
84
|
+
also exports `readTool`, `createTool`, and `deleteTool`; deletes require AI SDK
|
|
85
|
+
approval unless the application explicitly disables it.
|
package/docs/how-it-works.md
CHANGED
|
@@ -69,8 +69,9 @@ database differs by environment — the code is identical.
|
|
|
69
69
|
- **Production:** your Postgres. `ablo connect` sets up a scoped writer role and
|
|
70
70
|
logical replication; your rows live in your database, and Ablo writes to them
|
|
71
71
|
through that role.
|
|
72
|
-
- **
|
|
73
|
-
models, same code, a different
|
|
72
|
+
- **Development branches and local dev:** a separate or local Postgres, or a
|
|
73
|
+
branch of the one you already run. Same models, same code, a different
|
|
74
|
+
database behind them.
|
|
74
75
|
- **Before you connect one.** Ablo keeps state in its own log, so you can build the
|
|
75
76
|
whole app today and point it at a real database when you're ready.
|
|
76
77
|
|
package/docs/index.md
CHANGED
|
@@ -60,11 +60,14 @@ based on a row that has since changed is turned away rather than applied.
|
|
|
60
60
|
stay in your own migrations.
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
npx ablo init
|
|
63
|
+
npx ablo init
|
|
64
|
+
npx ablo dev
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
`
|
|
67
|
-
|
|
67
|
+
`dev` gives the current Git branch an isolated Ablo branch, wires its temporary key,
|
|
68
|
+
pushes the schema, and watches for changes. Until the server has your schema, a write to
|
|
69
|
+
a new model fails with `server_execute_unknown_model`. See
|
|
70
|
+
[Branch-first development](./branch-development.md).
|
|
68
71
|
</Step>
|
|
69
72
|
|
|
70
73
|
<Step title="Connect the database the rows live in">
|
|
@@ -161,6 +164,7 @@ default caller, not a special one.
|
|
|
161
164
|
|
|
162
165
|
- [Quickstart](./quickstart.md) — make your first coordinated write.
|
|
163
166
|
- [Integration Guide](./integration-guide.md) — the canonical end-to-end integration.
|
|
167
|
+
- [Integrations](./integrations.md) — long-running tasks, ingestion, and other application-edge runtimes.
|
|
164
168
|
- [CLI & Migrations](./cli.md) — `init` / `connect` / `push` / `migrate` / `generate`.
|
|
165
169
|
- [Connect Your Database](./data-sources.md) — where rows land when your own database is canonical.
|
|
166
170
|
- [Deployment](./deployment.md) — the database, the keys, and the schema push that take an integration to production.
|
|
@@ -66,24 +66,17 @@ deterministic demo; it does not call your API key or mutate hosted Ablo data.
|
|
|
66
66
|
It is also built for coding agents: copy the sandbox prompt into Claude Code or
|
|
67
67
|
Codex and ask it to wire one real model through the schema model API.
|
|
68
68
|
|
|
69
|
-
Use
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- it mints `sk_test_*` keys,
|
|
75
|
-
- it can be reset without touching live state,
|
|
76
|
-
- additional sandboxes can start blank or from copied live configuration.
|
|
77
|
-
|
|
78
|
-
Live keys and sandbox keys are separate. Use `sk_test_*` while wiring your app,
|
|
79
|
-
agents, and Data Source endpoint; move to `sk_live_*` only when the same schema
|
|
80
|
-
and write path are ready for production.
|
|
69
|
+
Use `npx ablo dev` for real integration work. It derives an immutable branch
|
|
70
|
+
from Git, inherits the parent schema, and writes a temporary branch credential
|
|
71
|
+
to `.env.local`. Each developer or pull request gets independent schema, rows,
|
|
72
|
+
claims, and logs. Use an explicit `sk_live_*` root credential only in the
|
|
73
|
+
reviewed production deployment.
|
|
81
74
|
|
|
82
75
|
When handing this to a coding agent, give it a concrete target:
|
|
83
76
|
|
|
84
77
|
```txt
|
|
85
78
|
Add Ablo to this app for one model your agents edit.
|
|
86
|
-
|
|
79
|
+
Run npx ablo dev and use its branch-bound key. Declare schema, add the Ablo client, replace
|
|
87
80
|
one write with ablo.<model>.update(..., { readAt, onStale: 'reject',
|
|
88
81
|
wait: 'confirmed' }), and add a smoke test for two concurrent writers.
|
|
89
82
|
```
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Inngest for long-running tasks
|
|
2
|
+
|
|
3
|
+
> Run event-driven, retryable agent tasks with Inngest while Ablo makes each
|
|
4
|
+
> shared-state effect typed, idempotent, and authoritative.
|
|
5
|
+
|
|
6
|
+
Inngest and Ablo solve different parts of a durable agent system:
|
|
7
|
+
|
|
8
|
+
| Concern | Owner |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Events, step checkpoints, retries, sleeps, flow control, cancellation | Inngest |
|
|
11
|
+
| Provider calls, prompts, and model output | AI SDK |
|
|
12
|
+
| Typed reads and writes, mutation idempotency, claims, confirmation | Ablo |
|
|
13
|
+
| Event names, function definitions, effect identity, business behavior | Your application |
|
|
14
|
+
|
|
15
|
+
The short version is:
|
|
16
|
+
|
|
17
|
+
> Inngest makes sure the job resumes. Ablo makes sure its changes are safe.
|
|
18
|
+
|
|
19
|
+
## Keep Ablo calls inside steps
|
|
20
|
+
|
|
21
|
+
An Inngest function is ordinary application code, but external I/O belongs in
|
|
22
|
+
a retriable `step.run()`. A successful step result is checkpointed and reused;
|
|
23
|
+
a failed step is independently retried.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import Ablo from '@abloatai/ablo';
|
|
27
|
+
import { Inngest, eventType } from 'inngest';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
import { schema } from './schema.js';
|
|
30
|
+
|
|
31
|
+
const inngest = new Inngest({ id: 'orders' });
|
|
32
|
+
const ablo = Ablo({
|
|
33
|
+
schema,
|
|
34
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
35
|
+
transport: 'http',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const approvalRequested = eventType('orders/approval.requested', {
|
|
39
|
+
schema: z.object({
|
|
40
|
+
operationId: z.string(),
|
|
41
|
+
orderId: z.string(),
|
|
42
|
+
approvalNote: z.string(),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const approveOrder = inngest.createFunction(
|
|
47
|
+
{
|
|
48
|
+
id: 'approve-order',
|
|
49
|
+
triggers: [approvalRequested],
|
|
50
|
+
retries: 4,
|
|
51
|
+
idempotency: 'event.data.operationId',
|
|
52
|
+
},
|
|
53
|
+
async ({ event, step }) => {
|
|
54
|
+
const { operationId, orderId, approvalNote } = event.data;
|
|
55
|
+
|
|
56
|
+
return step.run('write-approved-order', () =>
|
|
57
|
+
ablo.orders.update({
|
|
58
|
+
id: orderId,
|
|
59
|
+
data: { status: 'approved', approvalNote },
|
|
60
|
+
idempotencyKey: `${operationId}:approve-order:${orderId}`,
|
|
61
|
+
wait: 'confirmed',
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
There is no Inngest-specific Ablo transport. The step calls the same branded
|
|
69
|
+
model API as a route handler, worker, or command-line process.
|
|
70
|
+
|
|
71
|
+
## Use both idempotency layers
|
|
72
|
+
|
|
73
|
+
Send a globally scoped event ID and carry a stable business operation ID in the
|
|
74
|
+
event:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
await inngest.send(
|
|
78
|
+
approvalRequested.create(
|
|
79
|
+
{
|
|
80
|
+
operationId,
|
|
81
|
+
orderId,
|
|
82
|
+
approvalNote: 'Approved by the durable function.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: `orders-approval-requested-${operationId}`,
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Inngest event IDs and function-level `idempotency` prevent duplicate function
|
|
92
|
+
runs for Inngest's documented 24-hour window. They do not replace the Ablo
|
|
93
|
+
mutation key. Ablo can confirm the write and the process can lose the response
|
|
94
|
+
before Inngest checkpoints the step, causing the step callback to execute
|
|
95
|
+
again.
|
|
96
|
+
|
|
97
|
+
For every mutating step:
|
|
98
|
+
|
|
99
|
+
1. Accept a stable business operation ID in the triggering event.
|
|
100
|
+
2. Derive a separate key for each logical effect.
|
|
101
|
+
3. Reuse the key and mutation body for every retry.
|
|
102
|
+
4. Use `wait: 'confirmed'` when later steps depend on the authoritative result.
|
|
103
|
+
5. Treat reuse of a key with another body as an application bug.
|
|
104
|
+
6. Keep the complete retry horizon within Ablo's documented
|
|
105
|
+
[idempotency retention window](../idempotency.md).
|
|
106
|
+
|
|
107
|
+
Never put Inngest's `attempt`, a timestamp, or a random value in the key.
|
|
108
|
+
`runId` is suitable only when a newly created function run should represent a
|
|
109
|
+
new effect.
|
|
110
|
+
|
|
111
|
+
## Compose AI SDK as durable steps
|
|
112
|
+
|
|
113
|
+
Inngest can checkpoint AI SDK calls with `step.ai.wrap()`. Keep the model call
|
|
114
|
+
and the Ablo mutation as separate steps:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { openai } from '@ai-sdk/openai';
|
|
118
|
+
import { generateText } from 'ai';
|
|
119
|
+
|
|
120
|
+
const { text: approvalNote } = await step.ai.wrap(
|
|
121
|
+
'review-order',
|
|
122
|
+
generateText,
|
|
123
|
+
{
|
|
124
|
+
model: openai('gpt-4o-mini'),
|
|
125
|
+
system:
|
|
126
|
+
'Return only a concise approval note suitable for an audit trail.',
|
|
127
|
+
prompt,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await step.run('apply-order-review', () =>
|
|
132
|
+
ablo.orders.update({
|
|
133
|
+
id: orderId,
|
|
134
|
+
data: { status: 'approved', approvalNote },
|
|
135
|
+
idempotencyKey: `${operationId}:apply-order-review:${orderId}`,
|
|
136
|
+
wait: 'confirmed',
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This separation means retrying a write does not spend model tokens again, and
|
|
142
|
+
retrying a model call cannot partially mutate shared state.
|
|
143
|
+
|
|
144
|
+
For a multi-turn agent loop, checkpoint every model invocation and wrap every
|
|
145
|
+
tool execution in its own `step.run()`. The generic
|
|
146
|
+
`@abloatai/ablo/ai-sdk` helpers may execute inside a step because Inngest does
|
|
147
|
+
not impose Temporal's deterministic Workflow sandbox. Explicit model calls are
|
|
148
|
+
still preferable in the first integration because they make the effect key and
|
|
149
|
+
step boundary visible.
|
|
150
|
+
|
|
151
|
+
## Claims and cancellation
|
|
152
|
+
|
|
153
|
+
A claim protects a short shared-state critical section, not an entire Inngest
|
|
154
|
+
function. Acquire, fresh-read, checked-write, and release inside one
|
|
155
|
+
`step.run()`:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
await step.run('apply-review-with-claim', async () => {
|
|
159
|
+
const claim = await ablo.orders.claim({
|
|
160
|
+
id: orderId,
|
|
161
|
+
description: 'applying an Inngest review',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
return await ablo.orders.update({
|
|
166
|
+
id: claim.data.id,
|
|
167
|
+
data: { status: 'approved', approvalNote },
|
|
168
|
+
claim,
|
|
169
|
+
idempotencyKey: `${operationId}:apply-review:${orderId}`,
|
|
170
|
+
wait: 'confirmed',
|
|
171
|
+
});
|
|
172
|
+
} finally {
|
|
173
|
+
await claim.release();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Do not hold a claim across `step.sleep()`, `step.waitForEvent()`, or an AI
|
|
179
|
+
inference step. Configure `cancelOn` for the function, but do not assume that
|
|
180
|
+
function cancellation aborts an already running Ablo network request. Keep
|
|
181
|
+
claims short, release in `finally`, and rely on bounded claim leases for
|
|
182
|
+
process-loss recovery.
|
|
183
|
+
|
|
184
|
+
## Expose the application endpoint
|
|
185
|
+
|
|
186
|
+
Mount Inngest's handler at the conventional `/api/inngest` path. For a plain
|
|
187
|
+
Node application:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { createServer } from 'node:http';
|
|
191
|
+
import { serve } from 'inngest/node';
|
|
192
|
+
|
|
193
|
+
const handler = serve({
|
|
194
|
+
client: inngest,
|
|
195
|
+
functions: [approveOrder],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
createServer((request, response) => {
|
|
199
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
200
|
+
if (path === '/api/inngest') return handler(request, response);
|
|
201
|
+
response.writeHead(404).end();
|
|
202
|
+
}).listen(3000);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Use the framework-specific `serve` adapter in an existing Next.js, Express,
|
|
206
|
+
Hono, or other supported application.
|
|
207
|
+
|
|
208
|
+
Start the local app and Inngest Dev Server:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
npm run dev
|
|
212
|
+
npx --ignore-scripts=false inngest-cli@latest dev \
|
|
213
|
+
--no-discovery \
|
|
214
|
+
-u http://localhost:3000/api/inngest
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Set `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` in production.
|
|
218
|
+
|
|
219
|
+
## Test the failure boundary
|
|
220
|
+
|
|
221
|
+
Use `@inngest/test`'s `InngestTestEngine` for function and step tests. A useful
|
|
222
|
+
integration suite proves:
|
|
223
|
+
|
|
224
|
+
- the Ablo write happens inside the expected named step;
|
|
225
|
+
- a confirmed write followed by simulated response loss retries with the same
|
|
226
|
+
key and body and commits once;
|
|
227
|
+
- conflicting reuse of a key fails;
|
|
228
|
+
- checkpointed step state bypasses the Ablo callback;
|
|
229
|
+
- claims release after write failure; and
|
|
230
|
+
- the `/api/inngest` endpoint exposes function metadata without eagerly
|
|
231
|
+
creating an Ablo connection.
|
|
232
|
+
|
|
233
|
+
The complete runnable suite lives in `examples/inngest-agent`.
|
|
234
|
+
|
|
235
|
+
## Package boundary
|
|
236
|
+
|
|
237
|
+
Inngest remains an application dependency:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
packages/
|
|
241
|
+
transaction/src/ai-sdk/ reusable model-backed AI SDK tools
|
|
242
|
+
ablo/src/ai-sdk.ts @abloatai/ablo/ai-sdk
|
|
243
|
+
|
|
244
|
+
examples/
|
|
245
|
+
inngest-agent/ events, functions, steps, endpoint, AI composition
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Do not add Inngest to `packages/agent`. A dedicated `@abloatai/inngest` package
|
|
249
|
+
is justified only after multiple real applications reveal substantial
|
|
250
|
+
reusable behavior beyond a small function or step wrapper.
|
|
251
|
+
|
|
252
|
+
## References
|
|
253
|
+
|
|
254
|
+
- [Inngest TypeScript SDK v4](https://www.inngest.com/docs/reference/typescript/v4/intro)
|
|
255
|
+
- [Inngest steps](https://www.inngest.com/docs/reference/typescript/functions/step-run)
|
|
256
|
+
- [Inngest idempotency](https://www.inngest.com/docs/guides/handling-idempotency)
|
|
257
|
+
- [Inngest AI inference steps](https://www.inngest.com/docs/features/inngest-functions/steps-workflows/step-ai-orchestration)
|
|
258
|
+
- [Testing Inngest functions](https://www.inngest.com/docs/reference/typescript/v4/testing)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Temporal for long-running tasks
|
|
2
|
+
|
|
3
|
+
> Run long-lived, retryable agent tasks with Temporal while Ablo makes each
|
|
4
|
+
> shared-state effect typed, idempotent, and authoritative.
|
|
5
|
+
|
|
6
|
+
Temporal and Ablo solve different parts of a durable agent system:
|
|
7
|
+
|
|
8
|
+
| Concern | Owner |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Workflow history, replay, timers, retries, cancellation | Temporal |
|
|
11
|
+
| Provider, messages, tools, approval, model loop | AI SDK |
|
|
12
|
+
| Typed reads and writes, idempotency, claims, confirmation | Ablo |
|
|
13
|
+
| Workflow names, task queues, retry policy, business behavior | Your application |
|
|
14
|
+
|
|
15
|
+
The short version is:
|
|
16
|
+
|
|
17
|
+
> Temporal makes sure the agent finishes. Ablo makes sure its changes are safe.
|
|
18
|
+
|
|
19
|
+
## Keep Ablo calls in Activities
|
|
20
|
+
|
|
21
|
+
Temporal Workflow code must be deterministic. An Ablo model operation performs
|
|
22
|
+
network I/O, so instantiate `Ablo()` and call model resources in an Activity:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// activities/orders.ts
|
|
26
|
+
import Ablo from '@abloatai/ablo';
|
|
27
|
+
import { schema } from '../schema.js';
|
|
28
|
+
|
|
29
|
+
const ablo = Ablo({
|
|
30
|
+
schema,
|
|
31
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
32
|
+
transport: 'http',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export interface ApproveOrderInput {
|
|
36
|
+
orderId: string;
|
|
37
|
+
approvalNote: string;
|
|
38
|
+
idempotencyKey: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function approveOrder(input: ApproveOrderInput) {
|
|
42
|
+
return ablo.orders.update({
|
|
43
|
+
id: input.orderId,
|
|
44
|
+
data: {
|
|
45
|
+
status: 'approved',
|
|
46
|
+
approvalNote: input.approvalNote,
|
|
47
|
+
},
|
|
48
|
+
idempotencyKey: input.idempotencyKey,
|
|
49
|
+
wait: 'confirmed',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The Activity uses the same branded model method as a route handler, job, or
|
|
55
|
+
command-line process. There is no Temporal-specific Ablo transport.
|
|
56
|
+
|
|
57
|
+
Workflow code creates the stable logical effect identity and schedules the
|
|
58
|
+
Activity:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// workflows/approve-order.ts
|
|
62
|
+
import { proxyActivities, workflowInfo } from '@temporalio/workflow';
|
|
63
|
+
import type * as activities from '../activities/orders.js';
|
|
64
|
+
|
|
65
|
+
const { approveOrder } = proxyActivities<typeof activities>({
|
|
66
|
+
startToCloseTimeout: '1 minute',
|
|
67
|
+
retry: { maximumAttempts: 5 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export async function approveOrderWorkflow(orderId: string) {
|
|
71
|
+
const { workflowId, runId } = workflowInfo();
|
|
72
|
+
|
|
73
|
+
return approveOrder({
|
|
74
|
+
orderId,
|
|
75
|
+
approvalNote: 'Approved by the durable workflow.',
|
|
76
|
+
idempotencyKey: `${workflowId}:${runId}:approve-order:${orderId}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Temporal may retry an Activity after the Ablo write succeeded but before the
|
|
82
|
+
result reached Temporal. Every attempt must therefore send the same
|
|
83
|
+
`idempotencyKey` and the same mutation body. Do not put an Activity attempt
|
|
84
|
+
number, timestamp, or random value in the key.
|
|
85
|
+
|
|
86
|
+
Using the Workflow Run ID is appropriate when a new run should represent a new
|
|
87
|
+
effect. If an effect must survive Continue-As-New or a newly started Workflow
|
|
88
|
+
run, accept a stable business operation ID as Workflow input instead.
|
|
89
|
+
|
|
90
|
+
## Compose AI SDK tools through Activities
|
|
91
|
+
|
|
92
|
+
Temporal's AI SDK integration can make the model interaction durable inside a
|
|
93
|
+
Workflow. Any tool that touches Ablo still delegates to an Activity:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { temporalProvider } from '@temporalio/ai-sdk/workflow';
|
|
97
|
+
import { generateText, stepCountIs, tool } from 'ai';
|
|
98
|
+
import { z } from 'zod';
|
|
99
|
+
|
|
100
|
+
const result = await generateText({
|
|
101
|
+
model: temporalProvider.languageModel('gpt-4o-mini'),
|
|
102
|
+
prompt,
|
|
103
|
+
tools: {
|
|
104
|
+
approveOrder: tool({
|
|
105
|
+
description: 'Approve the order after reviewing it.',
|
|
106
|
+
inputSchema: z.object({ approvalNote: z.string() }),
|
|
107
|
+
execute: ({ approvalNote }) =>
|
|
108
|
+
approveOrder({
|
|
109
|
+
orderId,
|
|
110
|
+
approvalNote,
|
|
111
|
+
idempotencyKey: `${effectPrefix}:approve-order:${orderId}`,
|
|
112
|
+
}),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
stopWhen: stepCountIs(5),
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Register Temporal's `AiSdkPlugin` on the Worker and pin all
|
|
120
|
+
`@temporalio/*` packages to one compatible version set. The integration is
|
|
121
|
+
experimental, so replay-test Workflow histories before upgrading it.
|
|
122
|
+
The import above matches the example's pinned `1.21.1` release. Follow the
|
|
123
|
+
matching Temporal upgrade guide when changing that version set.
|
|
124
|
+
|
|
125
|
+
Do not import `@abloatai/ablo/ai-sdk` into Workflow code. Its `readTool`,
|
|
126
|
+
`createTool`, `updateTool`, and `deleteTool` helpers execute Ablo model
|
|
127
|
+
operations directly and are intended for ordinary Node.js AI SDK loops. In a
|
|
128
|
+
Temporal Workflow, define the tool at the application edge and make its
|
|
129
|
+
`execute` function call a proxied Activity.
|
|
130
|
+
|
|
131
|
+
## Idempotency rules
|
|
132
|
+
|
|
133
|
+
For every mutating Activity:
|
|
134
|
+
|
|
135
|
+
1. Create the key in deterministic Workflow code or accept a stable operation
|
|
136
|
+
ID in the Workflow input.
|
|
137
|
+
2. Reuse the key and mutation body for every retry of the same logical effect.
|
|
138
|
+
3. Use `wait: 'confirmed'` when later Workflow steps depend on the
|
|
139
|
+
authoritative database result.
|
|
140
|
+
4. Treat reuse of a key with a different body as an application bug.
|
|
141
|
+
5. Keep the Temporal retry horizon within Ablo's documented
|
|
142
|
+
[idempotency retention window](../idempotency.md).
|
|
143
|
+
|
|
144
|
+
This gives effective-once composition for the advertised retention window. It
|
|
145
|
+
does not make an external side effect physically execute only once; Temporal
|
|
146
|
+
Activities are intentionally retryable.
|
|
147
|
+
|
|
148
|
+
## Claims and cancellation
|
|
149
|
+
|
|
150
|
+
A claim protects a short shared-state operation, not an entire Workflow
|
|
151
|
+
history. Keep claim acquisition, the fresh read, the checked write, and release
|
|
152
|
+
inside one Activity:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { Context } from '@temporalio/activity';
|
|
156
|
+
|
|
157
|
+
export async function applyReview(orderId: string, note: string) {
|
|
158
|
+
await using claim = await ablo.orders.claim({
|
|
159
|
+
id: orderId,
|
|
160
|
+
description: 'applying review',
|
|
161
|
+
signal: Context.current().cancellationSignal,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return ablo.orders.update({
|
|
165
|
+
id: claim.data.id,
|
|
166
|
+
data: { approvalNote: note },
|
|
167
|
+
claim,
|
|
168
|
+
wait: 'confirmed',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
For long model reasoning, reason in the Workflow and use a short Activity to
|
|
174
|
+
re-read and apply the result. Do not return from an Activity while assuming a
|
|
175
|
+
held lease will remain valid.
|
|
176
|
+
|
|
177
|
+
## Complete example
|
|
178
|
+
|
|
179
|
+
The repository's
|
|
180
|
+
[`examples/temporal-agent`](../../../../examples/temporal-agent/README.md)
|
|
181
|
+
contains a Workflow, Activities, Worker, client, durable AI SDK tool, and a
|
|
182
|
+
simulated lost-response retry. It is a standalone application on purpose:
|
|
183
|
+
Temporal stays out of Ablo's core packages and out of `packages/agent`.
|
|
184
|
+
|
|
185
|
+
A dedicated `@abloatai/temporal` package should be introduced only after
|
|
186
|
+
multiple production integrations reveal substantial, stable behavior that
|
|
187
|
+
cannot be expressed clearly at this application boundary.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Integrations
|
|
2
|
+
|
|
3
|
+
> Compose Ablo's authoritative shared-state operations with the runtimes that
|
|
4
|
+
> execute, schedule, or ingest work in your application.
|
|
5
|
+
|
|
6
|
+
Integrations live at the application edge. Ablo continues to own typed reads
|
|
7
|
+
and writes, idempotency, claims, and authoritative confirmation; the external
|
|
8
|
+
runtime keeps owning the job it was designed for.
|
|
9
|
+
|
|
10
|
+
| Category | Integration | Status | Use it for |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| Long-running tasks | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
|
|
13
|
+
| Long-running tasks | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
|
|
14
|
+
| Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority |
|
|
15
|
+
|
|
16
|
+
An integration gets its own guide when there is runnable application code and
|
|
17
|
+
the boundary has been tested. A dedicated package comes later still: only
|
|
18
|
+
repeated production integrations that reveal substantial reusable behavior
|
|
19
|
+
justify adding another public runtime dependency.
|
|
20
|
+
|
|
21
|
+
## Long-running tasks
|
|
22
|
+
|
|
23
|
+
Use [Temporal](./integrations/temporal.md) when work must survive process
|
|
24
|
+
failure, retry Activities, wait on timers, or preserve a durable model loop.
|
|
25
|
+
Temporal owns execution history. Each tool or Activity that touches shared
|
|
26
|
+
state calls the branded Ablo model API at the application edge.
|
|
27
|
+
|
|
28
|
+
The complete example lives in `examples/temporal-agent` and includes retry,
|
|
29
|
+
replay, cancellation, claim release, and Workflow-bundle tests.
|
|
30
|
+
|
|
31
|
+
Use [Inngest](./integrations/inngest.md) when durable work starts from events
|
|
32
|
+
and should be expressed as independently retriable steps behind an application
|
|
33
|
+
HTTP endpoint. Inngest owns event delivery, step checkpoints, and flow control.
|
|
34
|
+
Each `step.run()` that touches shared state calls the same branded Ablo model
|
|
35
|
+
API used elsewhere in the application.
|
|
36
|
+
|
|
37
|
+
The complete example lives in `examples/inngest-agent` and includes
|
|
38
|
+
confirmed-write response-loss replay, key/body conflict, checkpoint reuse,
|
|
39
|
+
claim cleanup, and endpoint-discovery tests.
|
|
40
|
+
|
|
41
|
+
## Data ingestion
|
|
42
|
+
|
|
43
|
+
Data ingestion is separate from the Inngest durable-execution integration. A
|
|
44
|
+
connector or ingestion runtime belongs in this section when its public contract
|
|
45
|
+
exists. The same ownership rule will apply:
|
|
46
|
+
|
|
47
|
+
- the ingestion runtime owns connectors, polling, checkpoints, parsing, and
|
|
48
|
+
backpressure;
|
|
49
|
+
- Ablo owns validated model writes, idempotency, coordination, and confirmed
|
|
50
|
+
outcomes;
|
|
51
|
+
- the consuming application owns field mapping and business policy.
|
|
52
|
+
|
|
53
|
+
Until that contract and a runnable example exist, this page records the
|
|
54
|
+
category without publishing placeholder imports or configuration.
|