@nuanu-ai/agentify 0.2.4
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/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +135 -0
- package/dist/backoff.d.ts +55 -0
- package/dist/backoff.js +64 -0
- package/dist/check-card.d.ts +33 -0
- package/dist/check-card.js +29 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +24 -0
- package/dist/client.d.ts +377 -0
- package/dist/client.js +520 -0
- package/dist/contract.d.ts +17 -0
- package/dist/contract.js +20 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +39 -0
- package/dist/schema.d.ts +55 -0
- package/dist/schema.js +48 -0
- package/dist/transport.d.ts +200 -0
- package/dist/transport.js +271 -0
- package/dist/verify.d.ts +75 -0
- package/dist/verify.js +232 -0
- package/dist/worker.d.ts +347 -0
- package/dist/worker.js +614 -0
- package/package.json +52 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one loop that carries everything the merchant's process receives.
|
|
3
|
+
*
|
|
4
|
+
* The transport is HTTP long polling against the gateway (ADR-0004): the
|
|
5
|
+
* worker asks for the next batch, the gateway holds the request until
|
|
6
|
+
* something arrives or its own window closes, and the answer is a batch of
|
|
7
|
+
* envelopes or an empty one. An empty batch is the ordinary answer to a quiet
|
|
8
|
+
* window and not a failure. There is one loop and not three, because the
|
|
9
|
+
* stream carries three kinds on one connection and the portal promises the
|
|
10
|
+
* merchant one subscription.
|
|
11
|
+
*
|
|
12
|
+
* How each kind is answered is the part worth reading before changing
|
|
13
|
+
* anything, because the three are answered in three different places and none
|
|
14
|
+
* of them is an acknowledgement of the envelope itself.
|
|
15
|
+
*
|
|
16
|
+
* An order is given to the merchant's handler, and whatever the handler
|
|
17
|
+
* returns is posted to the order's answer route — in every mode, without the
|
|
18
|
+
* merchant asking. In the synchronous mode that return is the whole of the
|
|
19
|
+
* merchant's answer; in the asynchronous one it is usually an acceptance, and
|
|
20
|
+
* the goods follow later through the explicit `deliver` call, which is the
|
|
21
|
+
* merchant's to make and not this loop's.
|
|
22
|
+
*
|
|
23
|
+
* A price question is given to the price handler and the answer goes to the
|
|
24
|
+
* quote route against the `price_id` the question carried.
|
|
25
|
+
*
|
|
26
|
+
* An event is handed over and nothing is sent back. An event notifies, it does
|
|
27
|
+
* not ask for work.
|
|
28
|
+
*
|
|
29
|
+
* What happens when a handler throws is the design decision the rest follows
|
|
30
|
+
* from: nothing is sent. An order nobody answered comes back on its own,
|
|
31
|
+
* because the gateway's own order machine is watching for that answer and
|
|
32
|
+
* decides there should be another attempt — that is the machine's meaning of
|
|
33
|
+
* "not delivered". A retry written on this side would be a second machine with
|
|
34
|
+
* a second opinion about how many times a merchant's handler may run, and the
|
|
35
|
+
* two would disagree the first time one of them restarted. The same holds for a
|
|
36
|
+
* handler that answers with something the contract refuses, and for an envelope
|
|
37
|
+
* that arrives with no handler registered for its kind: the merchant is told
|
|
38
|
+
* through the problem channel, and an order or a price question comes back on
|
|
39
|
+
* its own.
|
|
40
|
+
*
|
|
41
|
+
* An event is the exception and it is worth knowing about. It carries no
|
|
42
|
+
* acknowledgement, so nothing here can ask for one again and nothing on the
|
|
43
|
+
* other side is waiting for a reply to notice that it went unanswered. The
|
|
44
|
+
* contract makes no promise that one is ever sent a second time, and whether a
|
|
45
|
+
* particular gateway sends one anyway is that gateway's business. A handler
|
|
46
|
+
* that throws on an event has lost what it carried.
|
|
47
|
+
*
|
|
48
|
+
* The transport failures are the one thing this loop does retry, because there
|
|
49
|
+
* is nothing else to do with them: a poll that did not reach the gateway
|
|
50
|
+
* delivered no envelope to anybody. That retry is the backoff in
|
|
51
|
+
* `backoff.ts` and nothing more.
|
|
52
|
+
*/
|
|
53
|
+
import { HandlerAnswerSchema, PROTOTYPE_KEY_IS_DROPPED, QuoteResponseSchema, } from "@nuanu-ai/agentify-contracts";
|
|
54
|
+
import { retryDelayMs } from "./backoff.js";
|
|
55
|
+
import { contractVersion, speaksContract } from "./contract.js";
|
|
56
|
+
import { describeProblems, problemsOf } from "./schema.js";
|
|
57
|
+
import { callRoute, REACH, whatIsKnown } from "./transport.js";
|
|
58
|
+
/**
|
|
59
|
+
* What follows from a failed answer for the order it was about.
|
|
60
|
+
*
|
|
61
|
+
* The three cases are three different things to tell a merchant, and only one
|
|
62
|
+
* of them is a promise. An answer that never left certainly leaves the order
|
|
63
|
+
* unanswered, so it comes back. An answer that reached us, or that vanished
|
|
64
|
+
* into silence, may have closed the order already — saying it will be
|
|
65
|
+
* delivered again would be promising a redelivery we have no grounds to
|
|
66
|
+
* expect, and a merchant who acted on that promise would be waiting for an
|
|
67
|
+
* order that never comes.
|
|
68
|
+
*/
|
|
69
|
+
const andSoTheOrder = (failure) => failure.reach === REACH.NOT_RECEIVED
|
|
70
|
+
? ", so the order will be delivered again"
|
|
71
|
+
: ", so whether the order is delivered again is not something this side can say";
|
|
72
|
+
/**
|
|
73
|
+
* The things the worker has to tell the merchant about, under the names their
|
|
74
|
+
* code can branch on.
|
|
75
|
+
*
|
|
76
|
+
* They are one vocabulary rather than several channels because every one of
|
|
77
|
+
* them has the same shape of consequence — something did not get through — and
|
|
78
|
+
* a merchant who wants to count them wants to count them together.
|
|
79
|
+
*/
|
|
80
|
+
export const WORKER_PROBLEM_KINDS = Object.freeze({
|
|
81
|
+
/** The gateway speaks another dialect of the contract. The loop stops. */
|
|
82
|
+
CONTRACT_VERSION_MISMATCH: "contract_version_mismatch",
|
|
83
|
+
/** A poll did not reach the gateway or did not come back as an answer. */
|
|
84
|
+
POLL_FAILED: "poll_failed",
|
|
85
|
+
/** The merchant's handler threw. Nothing was answered and the work returns. */
|
|
86
|
+
HANDLER_FAILED: "handler_failed",
|
|
87
|
+
/** The handler answered with something the contract does not accept. */
|
|
88
|
+
HANDLER_ANSWER_REFUSED: "handler_answer_refused",
|
|
89
|
+
/** Something arrived that nobody had registered a handler for. */
|
|
90
|
+
NO_HANDLER: "no_handler",
|
|
91
|
+
/** The answer was produced and could not be delivered to the gateway. */
|
|
92
|
+
ANSWER_FAILED: "answer_failed",
|
|
93
|
+
/** The gateway would not take the answer: it names an order it cannot close. */
|
|
94
|
+
ANSWER_REFUSED: "answer_refused",
|
|
95
|
+
/** A price answer arrived too late to price the purchase it was asked for. */
|
|
96
|
+
QUOTE_ANSWER_UNUSED: "quote_answer_unused",
|
|
97
|
+
/** The worker stopped part-way through a batch and the rest went unread. */
|
|
98
|
+
BATCH_ABANDONED: "batch_abandoned",
|
|
99
|
+
/** The loop itself failed in a way nothing here anticipated. A defect in this SDK. */
|
|
100
|
+
WORKER_FAILED: "worker_failed",
|
|
101
|
+
/** A delivery carried the one field name this contract removes in silence. */
|
|
102
|
+
DELIVERY_FIELD_DROPPED: "delivery_field_dropped",
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Calls a merchant's reporter and lets nothing it does escape.
|
|
106
|
+
*
|
|
107
|
+
* Reporting must not be able to end the loop that reports. A merchant's
|
|
108
|
+
* reporter is their code — a logger over a stream that closed during shutdown,
|
|
109
|
+
* a client that was never configured — and an exception out of it would
|
|
110
|
+
* otherwise unwind the worker and, from the worker's own last-resort handler,
|
|
111
|
+
* escape as an unhandled rejection and take the host process down with it.
|
|
112
|
+
* There is nowhere left to report a reporter that throws, so it is swallowed.
|
|
113
|
+
*
|
|
114
|
+
* Both ways of throwing, and the second is the one that hid. A reporter
|
|
115
|
+
* declared `void` may still be an `async` function — TypeScript allows it, and
|
|
116
|
+
* a merchant shipping problems to an HTTP logger will write one — and such a
|
|
117
|
+
* reporter does not throw, it returns a promise that rejects a turn later, past
|
|
118
|
+
* this `catch`, with nothing holding it. That is the unhandled rejection this
|
|
119
|
+
* comment claims not to allow, arriving by the door the `try` does not cover.
|
|
120
|
+
* So what comes back is settled too, and its failure dropped in the same
|
|
121
|
+
* silence and for the same reason.
|
|
122
|
+
*
|
|
123
|
+
* It is at the top of the file rather than inside the loop because the loop is
|
|
124
|
+
* not the only thing that reports: a merchant calling `deliver` on an order
|
|
125
|
+
* directly has a reporter too, and the same reasoning covers it exactly.
|
|
126
|
+
*/
|
|
127
|
+
export const reportSafely = (reporter, problem) => {
|
|
128
|
+
try {
|
|
129
|
+
// Anything with a `then` and not only a real Promise: a reporter built on
|
|
130
|
+
// another library's promise, or one from another realm, fails in exactly
|
|
131
|
+
// the same way and must be settled in exactly the same silence.
|
|
132
|
+
const reporting = reporter(problem);
|
|
133
|
+
if (typeof reporting?.then === "function") {
|
|
134
|
+
void Promise.resolve(reporting).catch(() => { });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Nowhere to say it. See above.
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The one loss in this contract that nobody is told about, said out loud here
|
|
143
|
+
* because this is the place it would reach the agent.
|
|
144
|
+
*
|
|
145
|
+
* A field named `__proto__` is removed while a delivery is parsed, before any
|
|
146
|
+
* check runs — it is neither carried nor refused, and the merchant's handler
|
|
147
|
+
* has no way to know that what it returned is not what went out. The contracts
|
|
148
|
+
* package names the delivery as the place the loss lands in what the agent is
|
|
149
|
+
* handed — `DeliverySchema` in its `handler.ts` — and a delivery is written
|
|
150
|
+
* here, so this file says it too.
|
|
151
|
+
*
|
|
152
|
+
* No card can declare such a field, so nothing legitimate reaches here; a
|
|
153
|
+
* merchant only meets it by delivering a name nobody asked for.
|
|
154
|
+
*
|
|
155
|
+
* It is one function because there are two roads to the same gateway and the
|
|
156
|
+
* loss is the same on both: the worker answering with what a handler returned,
|
|
157
|
+
* and a merchant calling `deliver` on an order himself. Written twice, one of
|
|
158
|
+
* them would drift or, as it did, never be written at all.
|
|
159
|
+
*/
|
|
160
|
+
export const droppedFieldWarning = (orderId, delivered) => {
|
|
161
|
+
if (delivered === undefined || !Object.hasOwn(delivered, PROTOTYPE_KEY_IS_DROPPED)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
kind: WORKER_PROBLEM_KINDS.DELIVERY_FIELD_DROPPED,
|
|
166
|
+
fatal: false,
|
|
167
|
+
subject: orderId,
|
|
168
|
+
message: `the delivery for order ${orderId} carried a field named ${PROTOTYPE_KEY_IS_DROPPED}, which is removed before anything can check it: the agent will not receive it, and no card can declare it`,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Which registration each kind on the stream is answered by, in the word a
|
|
173
|
+
* merchant passes to `on`.
|
|
174
|
+
*
|
|
175
|
+
* This table is the vocabulary, and everything that has to know the stream's
|
|
176
|
+
* kinds is derived from it rather than written out again: what a merchant may
|
|
177
|
+
* register, what the registry holds, what `start` counts as an answer, and the
|
|
178
|
+
* sentence that names a registration a merchant is missing. It is written over
|
|
179
|
+
* the contract's own kinds, so a fourth kind added to the envelope — the
|
|
180
|
+
* confirmation request, whose shape the contract does not carry yet — stops
|
|
181
|
+
* this file compiling until it has a word of its own here. The `switch` in
|
|
182
|
+
* `dispatch` below is the second half of that guard, and it is the one that
|
|
183
|
+
* demands the behaviour.
|
|
184
|
+
*/
|
|
185
|
+
export const REGISTERED_AS = Object.freeze({
|
|
186
|
+
order: "order",
|
|
187
|
+
quote_request: "quote",
|
|
188
|
+
order_event: "event",
|
|
189
|
+
});
|
|
190
|
+
export const systemClock = {
|
|
191
|
+
// Monotonic rather than the wall clock. The only thing this clock is asked
|
|
192
|
+
// is how long something took, and a wall clock stepped backwards by an hour
|
|
193
|
+
// — which is what a machine does when it corrects its time — would turn that
|
|
194
|
+
// question into an hour-long sleep.
|
|
195
|
+
now: () => performance.now(),
|
|
196
|
+
random: () => Math.random(),
|
|
197
|
+
sleep: (ms, signal) => new Promise((resolve) => {
|
|
198
|
+
if (signal.aborted) {
|
|
199
|
+
resolve();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
signal.removeEventListener("abort", onAbort);
|
|
204
|
+
resolve();
|
|
205
|
+
}, ms);
|
|
206
|
+
const onAbort = () => {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
resolve();
|
|
209
|
+
};
|
|
210
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* The shortest a quiet poll may take before the next one.
|
|
215
|
+
*
|
|
216
|
+
* The gateway holds a poll open for its own window, so in ordinary work this
|
|
217
|
+
* costs nothing: by the time an empty batch comes back, the floor has long
|
|
218
|
+
* passed and the next poll goes out at once — which is what keeps a waiting
|
|
219
|
+
* agent's price question free of any polling lag (ADR-0004 §4). It exists for
|
|
220
|
+
* the gateway that is not holding requests at all, misconfigured or half
|
|
221
|
+
* broken, where a loop with no floor turns into as many requests a second as
|
|
222
|
+
* the network allows. One second is the whole of the claim: slow enough that
|
|
223
|
+
* nothing is hammered, fast enough that it is invisible against a window
|
|
224
|
+
* measured in tens of seconds.
|
|
225
|
+
*/
|
|
226
|
+
export const QUIET_POLL_FLOOR_MS = 1_000;
|
|
227
|
+
/**
|
|
228
|
+
* The contract version named inside something the SDK could not parse.
|
|
229
|
+
*
|
|
230
|
+
* A gateway that added an envelope kind, or a field, answers with a document
|
|
231
|
+
* this SDK refuses, and the version it names is the one thing still readable
|
|
232
|
+
* in what it sent. It goes into the sentence the merchant reads and no
|
|
233
|
+
* further: this body was held to no schema of ours, so a proxy's error
|
|
234
|
+
* envelope that happens to carry a field of that name must not be able to stop
|
|
235
|
+
* a worker. One field is read, and only when it is a string; anything else is
|
|
236
|
+
* a body with nothing to say about versions.
|
|
237
|
+
*/
|
|
238
|
+
const versionSpokenIn = (body) => {
|
|
239
|
+
if (typeof body !== "object" || body === null)
|
|
240
|
+
return undefined;
|
|
241
|
+
const named = body.contract_version;
|
|
242
|
+
return typeof named === "string" && named !== "" ? named : undefined;
|
|
243
|
+
};
|
|
244
|
+
/** Refuses to compile if a kind is added to the contract and not handled here. */
|
|
245
|
+
const assertEveryKindIsHandled = (envelope) => {
|
|
246
|
+
throw new TypeError(`the worker was handed an envelope of a kind it does not know: ${JSON.stringify(envelope)}`);
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* How long the worker asks the gateway to hold a poll open, per ADR-0004 §1.
|
|
250
|
+
*
|
|
251
|
+
* It is a request and not an expectation. The gateway holds it for its own
|
|
252
|
+
* window at most, and the contract says as much, so a shorter answer is
|
|
253
|
+
* ordinary rather than a fault. Asking is what makes the number visible on the
|
|
254
|
+
* wire, where a gateway that wanted to answer sooner can see it.
|
|
255
|
+
*/
|
|
256
|
+
export const POLL_WAIT_SECONDS = 25;
|
|
257
|
+
/**
|
|
258
|
+
* How long the worker will wait for a poll before giving up on it.
|
|
259
|
+
*
|
|
260
|
+
* A window asked for is not a promise kept: a connection dropped silently by
|
|
261
|
+
* something in the middle — a load balancer that recycled it, a firewall that
|
|
262
|
+
* forgot it — leaves this side waiting on an answer that will never come, with
|
|
263
|
+
* nothing reported and nothing retried. Without a deadline of our own the only
|
|
264
|
+
* thing that eventually breaks that is whatever timeout the runtime happens to
|
|
265
|
+
* default to, which is a number nobody here chose and which is measured in
|
|
266
|
+
* minutes against a window measured in seconds.
|
|
267
|
+
*
|
|
268
|
+
* Twice the window, so a gateway that holds the full window and answers late is
|
|
269
|
+
* never cut off, and a connection that has gone quiet costs one window of
|
|
270
|
+
* silence rather than several minutes of it.
|
|
271
|
+
*
|
|
272
|
+
* This worker never comes near it against the gateway in this repository, and
|
|
273
|
+
* that is not luck: every poll names the window it wants, and that gateway
|
|
274
|
+
* holds a poll for the smaller of the window asked for and its own, so the wait
|
|
275
|
+
* is bounded by the twenty-five seconds above however the gateway is
|
|
276
|
+
* configured. Naming the window is this side's guarantee. The deadline is for
|
|
277
|
+
* the other case — a connection that died without saying so.
|
|
278
|
+
*
|
|
279
|
+
* A second belt exists on the gateway's side, and it is not for this worker.
|
|
280
|
+
* The window is optional on the wire, and a poll that names none is held for
|
|
281
|
+
* the gateway's whole configured window instead; that window is refused above a
|
|
282
|
+
* ceiling set under this number (`WORKER_POLL_WAIT_CEILING_MS` in
|
|
283
|
+
* `apps/gateway/src/config.ts`), which is where this figure is copied. The
|
|
284
|
+
* gateway depends on the contracts and the core machine rather than on the SDK,
|
|
285
|
+
* so it cannot read it, and nothing checks that the copy still matches:
|
|
286
|
+
* changing what is written here means changing it there in the same breath.
|
|
287
|
+
*
|
|
288
|
+
* It is also the reason two poll failures can be a minute and a half apart,
|
|
289
|
+
* which matters to anybody counting them from outside. A gateway that refuses
|
|
290
|
+
* connections fails a poll at once and the backoff is the whole gap; one that
|
|
291
|
+
* accepts and never answers fails only when this deadline runs out, so the gap
|
|
292
|
+
* is this plus the backoff. `DOUBT_MS` in packages/slice/src/subscription.ts is
|
|
293
|
+
* that sum, and it is named here because raising this number silently narrows
|
|
294
|
+
* it — and a window too narrow reads the quiet between two failures as a
|
|
295
|
+
* recovery.
|
|
296
|
+
*/
|
|
297
|
+
export const POLL_DEADLINE_MS = POLL_WAIT_SECONDS * 2 * 1_000;
|
|
298
|
+
export const startWorker = (gateway, handlers, clock = systemClock,
|
|
299
|
+
/**
|
|
300
|
+
* The deadline is a parameter for the same reason the clock is: a test
|
|
301
|
+
* cannot wait out fifty seconds to find out whether the worker gives up on a
|
|
302
|
+
* poll, and a deadline nothing exercises is a deadline nobody has seen work.
|
|
303
|
+
* It is not part of anything a merchant passes.
|
|
304
|
+
*/
|
|
305
|
+
deadlineMs = POLL_DEADLINE_MS) => {
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
let stopping = false;
|
|
308
|
+
let ended = false;
|
|
309
|
+
const report = (problem) => reportSafely(handlers.problem, problem);
|
|
310
|
+
const rest = async (ms) => {
|
|
311
|
+
if (ms > 0)
|
|
312
|
+
await clock.sleep(ms, controller.signal);
|
|
313
|
+
};
|
|
314
|
+
const reportTheMismatch = (spoken) => {
|
|
315
|
+
report({
|
|
316
|
+
kind: WORKER_PROBLEM_KINDS.CONTRACT_VERSION_MISMATCH,
|
|
317
|
+
fatal: true,
|
|
318
|
+
// Both numbers are in the sentence: a merchant reading only one of them
|
|
319
|
+
// cannot tell which of the two ends needs upgrading.
|
|
320
|
+
message: `the gateway speaks contract version ${spoken} and this SDK speaks ${contractVersion}; the worker stopped rather than handle orders in a dialect it may be reading wrong`,
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const answerOrder = async (order, answer) => {
|
|
324
|
+
const sent = await callRoute(gateway, "answer_order", {
|
|
325
|
+
path: { order_id: order.id },
|
|
326
|
+
body: answer,
|
|
327
|
+
signal: controller.signal,
|
|
328
|
+
});
|
|
329
|
+
if (!sent.ok) {
|
|
330
|
+
// Reported even when the worker is stopping, and especially then. The
|
|
331
|
+
// handler has already run: goods may have been issued, and a merchant
|
|
332
|
+
// reconciling a shutdown is entitled to know that an answer for this
|
|
333
|
+
// order went nowhere.
|
|
334
|
+
report({
|
|
335
|
+
kind: WORKER_PROBLEM_KINDS.ANSWER_FAILED,
|
|
336
|
+
fatal: false,
|
|
337
|
+
subject: order.id,
|
|
338
|
+
message: `the answer for order ${order.id} was produced and ${whatIsKnown(sent.failure)}${andSoTheOrder(sent.failure)}: ${sent.failure.reason}`,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!sent.document.ok) {
|
|
343
|
+
report({
|
|
344
|
+
kind: WORKER_PROBLEM_KINDS.ANSWER_REFUSED,
|
|
345
|
+
fatal: false,
|
|
346
|
+
subject: order.id,
|
|
347
|
+
message: `the answer for order ${order.id} was not accepted (${sent.document.error.code}): ${sent.document.error.message}`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
/** See `droppedFieldWarning`: the same loss, on the handler's road. */
|
|
352
|
+
const warnAboutTheDroppedKey = (order, answer) => {
|
|
353
|
+
const dropped = droppedFieldWarning(order.id, "delivered" in answer ? answer.delivered : undefined);
|
|
354
|
+
if (dropped !== null)
|
|
355
|
+
report(dropped);
|
|
356
|
+
};
|
|
357
|
+
const handleOrder = async (order) => {
|
|
358
|
+
const handler = handlers.order;
|
|
359
|
+
if (handler === undefined) {
|
|
360
|
+
report({
|
|
361
|
+
kind: WORKER_PROBLEM_KINDS.NO_HANDLER,
|
|
362
|
+
fatal: false,
|
|
363
|
+
subject: order.id,
|
|
364
|
+
message: `order ${order.id} arrived and no handler was registered with on('${REGISTERED_AS.order}'), so it was left unanswered and will be delivered again`,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let answer;
|
|
369
|
+
try {
|
|
370
|
+
answer = await handler(order);
|
|
371
|
+
}
|
|
372
|
+
catch (cause) {
|
|
373
|
+
report({
|
|
374
|
+
kind: WORKER_PROBLEM_KINDS.HANDLER_FAILED,
|
|
375
|
+
fatal: false,
|
|
376
|
+
cause,
|
|
377
|
+
subject: order.id,
|
|
378
|
+
message: `the handler threw on order ${order.id}, so nothing was answered and the order will be delivered again: ${String(cause)}`,
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const checked = HandlerAnswerSchema.safeParse(answer);
|
|
383
|
+
if (!checked.success) {
|
|
384
|
+
report({
|
|
385
|
+
kind: WORKER_PROBLEM_KINDS.HANDLER_ANSWER_REFUSED,
|
|
386
|
+
fatal: false,
|
|
387
|
+
subject: order.id,
|
|
388
|
+
message: `the handler's answer for order ${order.id} is not one the contract carries, so nothing was sent and the order will be delivered again:\n${describeProblems(problemsOf(checked.error.issues))}`,
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
warnAboutTheDroppedKey(order, answer);
|
|
393
|
+
await answerOrder(order, checked.data);
|
|
394
|
+
};
|
|
395
|
+
const handleQuote = async (question) => {
|
|
396
|
+
const handler = handlers.quote;
|
|
397
|
+
if (handler === undefined) {
|
|
398
|
+
report({
|
|
399
|
+
kind: WORKER_PROBLEM_KINDS.NO_HANDLER,
|
|
400
|
+
fatal: false,
|
|
401
|
+
subject: question.price_id,
|
|
402
|
+
message: `a price question for ${question.merchant_item_id} arrived and no handler was registered with on('${REGISTERED_AS.quote_request}'), so it was left unanswered`,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
let answer;
|
|
407
|
+
try {
|
|
408
|
+
answer = await handler(question);
|
|
409
|
+
}
|
|
410
|
+
catch (cause) {
|
|
411
|
+
report({
|
|
412
|
+
kind: WORKER_PROBLEM_KINDS.HANDLER_FAILED,
|
|
413
|
+
fatal: false,
|
|
414
|
+
cause,
|
|
415
|
+
subject: question.price_id,
|
|
416
|
+
message: `the price handler threw on question ${question.price_id}, so no price was sent and the sale goes on the card's own price or does not go on at all: ${String(cause)}`,
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Held to the contract before it is sent, for the same reason an order's
|
|
421
|
+
// answer is: an amount written as a number instead of text is the easiest
|
|
422
|
+
// mistake there is to make here, and sent out it would come back as the
|
|
423
|
+
// gateway's complaint about a document, which reads as our fault and
|
|
424
|
+
// names the merchant's field only inside a quoted blob.
|
|
425
|
+
const checked = QuoteResponseSchema.safeParse(answer);
|
|
426
|
+
if (!checked.success) {
|
|
427
|
+
report({
|
|
428
|
+
kind: WORKER_PROBLEM_KINDS.HANDLER_ANSWER_REFUSED,
|
|
429
|
+
fatal: false,
|
|
430
|
+
subject: question.price_id,
|
|
431
|
+
message: `the price handler's answer for question ${question.price_id} is not one the contract carries, so no price was sent:\n${describeProblems(problemsOf(checked.error.issues))}`,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const sent = await callRoute(gateway, "answer_quote", {
|
|
436
|
+
path: { price_id: question.price_id },
|
|
437
|
+
body: checked.data,
|
|
438
|
+
signal: controller.signal,
|
|
439
|
+
});
|
|
440
|
+
if (!sent.ok) {
|
|
441
|
+
report({
|
|
442
|
+
kind: WORKER_PROBLEM_KINDS.ANSWER_FAILED,
|
|
443
|
+
fatal: false,
|
|
444
|
+
subject: question.price_id,
|
|
445
|
+
// The advice about stock holds whichever of the three this was, which
|
|
446
|
+
// is why it is not branched the way an order's consequence is. A price
|
|
447
|
+
// that did arrive and was used produces an order before the question
|
|
448
|
+
// expires, and a merchant releasing stock at that moment is releasing
|
|
449
|
+
// stock they have already committed to an order they hold.
|
|
450
|
+
message: `the price for question ${question.price_id} was answered and ${whatIsKnown(sent.failure)}; stock set aside under that identifier can be released once the question expires, whichever of those it was: ${sent.failure.reason}`,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!sent.document.used) {
|
|
455
|
+
report({
|
|
456
|
+
kind: WORKER_PROBLEM_KINDS.QUOTE_ANSWER_UNUSED,
|
|
457
|
+
fatal: false,
|
|
458
|
+
subject: question.price_id,
|
|
459
|
+
message: `the price for question ${question.price_id} arrived too late to be used, or the question is no longer held; stock set aside under that identifier can be released`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const handleEvent = async (event, delivered) => {
|
|
464
|
+
const handler = handlers.event;
|
|
465
|
+
if (handler === undefined) {
|
|
466
|
+
report({
|
|
467
|
+
kind: WORKER_PROBLEM_KINDS.NO_HANDLER,
|
|
468
|
+
fatal: false,
|
|
469
|
+
subject: event.order_id,
|
|
470
|
+
message: `${event.type} arrived for order ${event.order_id} and nothing is listening for events; register one with on('${REGISTERED_AS.order_event}') to receive them`,
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
await handler(event, delivered);
|
|
476
|
+
}
|
|
477
|
+
catch (cause) {
|
|
478
|
+
report({
|
|
479
|
+
kind: WORKER_PROBLEM_KINDS.HANDLER_FAILED,
|
|
480
|
+
fatal: false,
|
|
481
|
+
cause,
|
|
482
|
+
subject: event.order_id,
|
|
483
|
+
// Nothing here can ask for the event again — an event carries no
|
|
484
|
+
// acknowledgement, so there is no call that says "send that one
|
|
485
|
+
// once more". Whether the gateway sends it again anyway is the
|
|
486
|
+
// gateway's, and this SDK does not know: the contract gives every
|
|
487
|
+
// envelope an identifier and a delivery time precisely so that a
|
|
488
|
+
// repeat can be recognised, which only means something if repeats
|
|
489
|
+
// happen. So the honest thing to tell a merchant is that we cannot
|
|
490
|
+
// get it back, not that it is gone.
|
|
491
|
+
message: `the event handler threw on ${event.type} for order ${event.order_id}; nothing here can ask for that event again, and whether it is sent again is not something this SDK knows: ${String(cause)}`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const dispatch = async (envelope) => {
|
|
496
|
+
switch (envelope.kind) {
|
|
497
|
+
case "order":
|
|
498
|
+
await handleOrder(envelope.payload);
|
|
499
|
+
return;
|
|
500
|
+
case "quote_request":
|
|
501
|
+
await handleQuote(envelope.payload);
|
|
502
|
+
return;
|
|
503
|
+
case "order_event":
|
|
504
|
+
await handleEvent(envelope.payload, { id: envelope.id, sent_at: envelope.sent_at });
|
|
505
|
+
return;
|
|
506
|
+
default:
|
|
507
|
+
// Unreachable while the contract carries three kinds, and here so
|
|
508
|
+
// that a fourth added there stops this file compiling rather than
|
|
509
|
+
// being dropped in silence by a switch with nothing to say about it.
|
|
510
|
+
return assertEveryKindIsHandled(envelope);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const run = async () => {
|
|
514
|
+
// One turn before the first poll goes out, so that a process which calls
|
|
515
|
+
// start() without awaiting it and registers on the next line has that
|
|
516
|
+
// registration in place before anything is asked of the gateway. A
|
|
517
|
+
// handler registered later than that is in place too — the registry is
|
|
518
|
+
// read when an envelope is dispatched, not when the loop begins — so this
|
|
519
|
+
// buys only the synchronous lines immediately after the call, which is
|
|
520
|
+
// exactly where a merchant writes them.
|
|
521
|
+
await Promise.resolve();
|
|
522
|
+
let failures = 0;
|
|
523
|
+
while (!stopping) {
|
|
524
|
+
const startedAt = clock.now();
|
|
525
|
+
const answer = await callRoute(gateway, "poll_worker", {
|
|
526
|
+
body: { wait_seconds: POLL_WAIT_SECONDS },
|
|
527
|
+
// Two ways this call can end early, and they are different: the worker
|
|
528
|
+
// was stopped, or the answer took longer than any answer should.
|
|
529
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(deadlineMs)]),
|
|
530
|
+
});
|
|
531
|
+
if (stopping)
|
|
532
|
+
break;
|
|
533
|
+
if (!answer.ok) {
|
|
534
|
+
// A gateway of another dialect answers with a document this SDK cannot
|
|
535
|
+
// parse, so the version it names is worth passing on — but only as a
|
|
536
|
+
// remark, never as a verdict. Whatever this field was read out of was
|
|
537
|
+
// not held to any schema of ours: a proxy's error envelope carrying a
|
|
538
|
+
// field of that name would otherwise stop a merchant's worker for
|
|
539
|
+
// good over something that needed waiting out. The one place a
|
|
540
|
+
// mismatch is decided is a poll answer that parsed.
|
|
541
|
+
const spoken = versionSpokenIn(answer.failure.body);
|
|
542
|
+
const remark = spoken !== undefined && !speaksContract(spoken)
|
|
543
|
+
? ` — what answered names contract version ${spoken} while this SDK speaks ${contractVersion}, which may mean the gateway is of another version`
|
|
544
|
+
: "";
|
|
545
|
+
failures += 1;
|
|
546
|
+
report({
|
|
547
|
+
kind: WORKER_PROBLEM_KINDS.POLL_FAILED,
|
|
548
|
+
fatal: false,
|
|
549
|
+
message: `${answer.failure.reason}${remark} — asking again after a wait`,
|
|
550
|
+
});
|
|
551
|
+
await rest(retryDelayMs(failures, clock.random()));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
failures = 0;
|
|
555
|
+
if (!speaksContract(answer.document.contract_version)) {
|
|
556
|
+
reportTheMismatch(answer.document.contract_version);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const batch = answer.document.envelopes;
|
|
560
|
+
let handled = 0;
|
|
561
|
+
for (const envelope of batch) {
|
|
562
|
+
if (stopping)
|
|
563
|
+
break;
|
|
564
|
+
await dispatch(envelope);
|
|
565
|
+
handled += 1;
|
|
566
|
+
}
|
|
567
|
+
if (handled < batch.length) {
|
|
568
|
+
// Said rather than left as a silence. An order or a price question
|
|
569
|
+
// left in the batch comes back on its own; an event carries no
|
|
570
|
+
// acknowledgement, so nothing here can ask for that one again, and a
|
|
571
|
+
// merchant who stopped a worker mid-batch is entitled to know which
|
|
572
|
+
// kinds went unread.
|
|
573
|
+
report({
|
|
574
|
+
kind: WORKER_PROBLEM_KINDS.BATCH_ABANDONED,
|
|
575
|
+
fatal: false,
|
|
576
|
+
message: `the worker stopped with ${batch.length - handled} of this batch's ${batch.length} messages unread (${batch
|
|
577
|
+
.slice(handled)
|
|
578
|
+
.map((envelope) => envelope.kind)
|
|
579
|
+
.join(", ")}); orders and price questions come back on their own, and nothing here can ask for an event again`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (batch.length === 0) {
|
|
583
|
+
await rest(QUIET_POLL_FLOOR_MS - (clock.now() - startedAt));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const finished = run()
|
|
588
|
+
.catch((cause) => {
|
|
589
|
+
// Under its own name rather than as a failed poll, because nothing about
|
|
590
|
+
// a poll failed: everything the loop expects to go wrong is handled
|
|
591
|
+
// inside it, so reaching here means this SDK has a defect and the
|
|
592
|
+
// merchant's worker is down until their process is restarted.
|
|
593
|
+
report({
|
|
594
|
+
kind: WORKER_PROBLEM_KINDS.WORKER_FAILED,
|
|
595
|
+
fatal: true,
|
|
596
|
+
cause,
|
|
597
|
+
message: `the worker stopped on an error it did not expect, which is a defect in the Agentify SDK rather than something the gateway did: ${String(cause)}`,
|
|
598
|
+
});
|
|
599
|
+
})
|
|
600
|
+
.finally(() => {
|
|
601
|
+
// However the loop ended — stopped, or fatally — it is over, and both
|
|
602
|
+
// this worker and whoever is holding it should agree about that.
|
|
603
|
+
ended = true;
|
|
604
|
+
stopping = true;
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
running: () => !ended,
|
|
608
|
+
stop: async () => {
|
|
609
|
+
stopping = true;
|
|
610
|
+
controller.abort();
|
|
611
|
+
await finished;
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuanu-ai/agentify",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Sell your existing products to AI agents: publish product cards to an x402 storefront and fill the paid orders from your own service.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"x402",
|
|
8
|
+
"agent-commerce",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"merchant-sdk",
|
|
11
|
+
"stablecoin-payments",
|
|
12
|
+
"usdc"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://app.agentify.ad/docs/",
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/nuanu-ai/agentify.git",
|
|
19
|
+
"directory": "packages/sdk"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@nuanu-ai/agentify-contracts": "0.3.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"vitest": "^4.1.11",
|
|
43
|
+
"@agentify/commerce-core": "0.0.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
48
|
+
},
|
|
49
|
+
"bin": {
|
|
50
|
+
"agentify": "./dist/cli.js"
|
|
51
|
+
}
|
|
52
|
+
}
|