@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/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
+ }