@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/client.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client a merchant's engineer holds: a catalog, an order desk, and one
|
|
3
|
+
* place to say what this process answers.
|
|
4
|
+
*
|
|
5
|
+
* The shape of it is the portal's, not this file's. Every method here exists
|
|
6
|
+
* because a page of the documentation tells a merchant to call it, with the
|
|
7
|
+
* arguments that page passes and answering with what that page reads — and the
|
|
8
|
+
* examples on those pages are compiled against these types by a test, so the
|
|
9
|
+
* code a merchant copies out of the documentation is code that builds.
|
|
10
|
+
*
|
|
11
|
+
* Three kinds travel one stream, so there is one way to register a handler for
|
|
12
|
+
* one of them — `on(kind, handler)` — and one lifecycle, `start` and `stop`,
|
|
13
|
+
* for the loop that carries all three. What a handler receives carries the
|
|
14
|
+
* calls that answer it, and that is the part worth reading before changing
|
|
15
|
+
* anything here, because two things that look alike are deliberately not.
|
|
16
|
+
*
|
|
17
|
+
* `order.delivered(...)`, `order.refused(...)` and `order.accepted(...)`
|
|
18
|
+
* build the answer and send nothing; the handler still returns it. A bot
|
|
19
|
+
* library lets a handler reply and forget, and forgetting there costs a
|
|
20
|
+
* message; forgetting here is an order nobody answered — redelivered, a
|
|
21
|
+
* delivery attempt spent, and in the asynchronous mode a debt to a buyer at
|
|
22
|
+
* the end of it. Returning the answer is what makes forgetting impossible and
|
|
23
|
+
* answering twice impossible.
|
|
24
|
+
*
|
|
25
|
+
* `order.deliver(...)`, `order.refuse(...)` and `order.accept(...)` are the
|
|
26
|
+
* other thing: they send. They are the asynchronous merchant's closure verbs,
|
|
27
|
+
* made hours later and from anywhere in their code, and they exist on the
|
|
28
|
+
* order rather than beside it so that a merchant never holds an identifier of
|
|
29
|
+
* ours. The same calls are on every order this client hands back — off the
|
|
30
|
+
* stream, off `orders.get`, off `orders.list`, and off `orders.forId` for the
|
|
31
|
+
* process that kept nothing but the identifier — because a process that
|
|
32
|
+
* restarted has no object left to hold, and that is exactly the moment
|
|
33
|
+
* juggling identifiers would come back.
|
|
34
|
+
*
|
|
35
|
+
* One rule runs through the whole surface and it is worth stating once. What
|
|
36
|
+
* the contract gives a call a failure branch for is returned, never thrown,
|
|
37
|
+
* and every one of those answers is the same envelope: `ok` says which it was,
|
|
38
|
+
* a card that was not published comes back with its findings under `problems`,
|
|
39
|
+
* and an order call that did not go through comes back as an error with a flag
|
|
40
|
+
* saying whether repeating it could help. A merchant's integration code is
|
|
41
|
+
* expected to read those and branch on them.
|
|
42
|
+
*
|
|
43
|
+
* What is thrown is what has no branch to be read on, and it is two kinds. A
|
|
44
|
+
* client built wrong — no key, an address that is not one, a handler
|
|
45
|
+
* registered twice — is a `TypeError` at the line that is wrong, before
|
|
46
|
+
* anything leaves the process. A call that went out and produced no answer of
|
|
47
|
+
* the kind its route promises, where the route has nowhere to put one, is a
|
|
48
|
+
* `AgentifyError` carrying the same code vocabulary the returned failures use.
|
|
49
|
+
*
|
|
50
|
+
* The one place that rule needed a decision rather than a reading is a
|
|
51
|
+
* `deliver` or a `refuse` whose answer never arrived, or arrived in words this
|
|
52
|
+
* package cannot read. The contract has a branch for it — an error with a flag
|
|
53
|
+
* saying whether calling again could change the outcome is exactly the shape
|
|
54
|
+
* of "the connection dropped, call again" — so it comes back through that
|
|
55
|
+
* branch, under a code this package produces and the gateway never sends. The
|
|
56
|
+
* three codes and what separates them are written down beside them below.
|
|
57
|
+
*/
|
|
58
|
+
import { callRoute, REACH, whatIsKnown, } from "./transport.js";
|
|
59
|
+
import { droppedFieldWarning, REGISTERED_AS, reportSafely, startWorker, } from "./worker.js";
|
|
60
|
+
/** The word for the reporter, which nothing on the wire carries. */
|
|
61
|
+
const PROBLEM = "problem";
|
|
62
|
+
/**
|
|
63
|
+
* The words `on` accepts, as a map over the kinds themselves.
|
|
64
|
+
*
|
|
65
|
+
* A merchant working outside TypeScript, or one who wrote `orders`, would
|
|
66
|
+
* otherwise register a handler that is never called and hear nothing about it.
|
|
67
|
+
* Written as a mapped type so that a kind the stream grows and this file
|
|
68
|
+
* forgets stops it compiling.
|
|
69
|
+
*/
|
|
70
|
+
const HANDLER_KINDS = Object.freeze({
|
|
71
|
+
order: true,
|
|
72
|
+
quote: true,
|
|
73
|
+
event: true,
|
|
74
|
+
problem: true,
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* Every word `on` accepts, in the order this file lists them, for the sentence
|
|
78
|
+
* a merchant reads when they wrote one that is not on the list.
|
|
79
|
+
*/
|
|
80
|
+
const everyKind = () => Object.keys(HANDLER_KINDS).map((kind) => `'${kind}'`);
|
|
81
|
+
/** The words that answer something on the stream, which `problem` does not. */
|
|
82
|
+
const streamKinds = () => Object.values(REGISTERED_AS);
|
|
83
|
+
/**
|
|
84
|
+
* The three codes an order call comes back under when the gateway produced no
|
|
85
|
+
* answer this package can read.
|
|
86
|
+
*
|
|
87
|
+
* The contract's list of error codes is open exactly so that a case nobody
|
|
88
|
+
* anticipated reaches the merchant in its own words instead of being flattened
|
|
89
|
+
* into the nearest of three. These are three of those, and they are produced
|
|
90
|
+
* here rather than on the wire.
|
|
91
|
+
*
|
|
92
|
+
* Three and not one because the difference is the whole of what a merchant
|
|
93
|
+
* needs at that moment, and they are about their own books. A call that never
|
|
94
|
+
* arrived certainly changed nothing. A call answered in words we could not
|
|
95
|
+
* read reached us and may well have done its work; what is missing is what it
|
|
96
|
+
* said. And a call that was sent into silence — the connection broke, the
|
|
97
|
+
* worker was shut down mid-flight — is neither of those, and saying it did not
|
|
98
|
+
* arrive would be inventing the one fact the merchant came here for.
|
|
99
|
+
*/
|
|
100
|
+
export const CALL_DID_NOT_REACH_US = "call_did_not_reach_us";
|
|
101
|
+
export const ANSWER_NOT_UNDERSTOOD = "answer_not_understood";
|
|
102
|
+
export const OUTCOME_UNKNOWN = "outcome_unknown";
|
|
103
|
+
const CODE_FOR = {
|
|
104
|
+
[REACH.NOT_RECEIVED]: CALL_DID_NOT_REACH_US,
|
|
105
|
+
[REACH.ANSWERED]: ANSWER_NOT_UNDERSTOOD,
|
|
106
|
+
[REACH.UNKNOWN]: OUTCOME_UNKNOWN,
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* What is thrown where a route has no failure branch to hand a merchant back.
|
|
110
|
+
*
|
|
111
|
+
* Three of this client's calls answer with a document or with nothing at all —
|
|
112
|
+
* publishing a card, reading one order, listing them — so a call that produced
|
|
113
|
+
* no document has nowhere in the contract to be returned. It is thrown, and
|
|
114
|
+
* this is what it is thrown as.
|
|
115
|
+
*
|
|
116
|
+
* It carries what the returned failures carry, because the two are the same
|
|
117
|
+
* facts about the same calls and a merchant should not have to learn a second
|
|
118
|
+
* vocabulary for the half of them that throws. `code` is the gateway's own word
|
|
119
|
+
* where the gateway refused in words we recognise, and otherwise one of the
|
|
120
|
+
* three this package produces — `call_did_not_reach_us`, `answer_not_understood`,
|
|
121
|
+
* `outcome_unknown`. `failedCall` below makes that same choice for the order
|
|
122
|
+
* calls, so a refusal has one name on both roads. `route` is the call it
|
|
123
|
+
* happened on, under the name the
|
|
124
|
+
* contract's own table gives it, so a `catch` can say which of three it was
|
|
125
|
+
* without reading the sentence.
|
|
126
|
+
*
|
|
127
|
+
* What is not this: a client built wrong. A missing key, a `baseUrl` that is
|
|
128
|
+
* not an address, a handler registered twice and a kind nothing delivers are
|
|
129
|
+
* mistakes in the merchant's own code, made before anything left the process,
|
|
130
|
+
* and they stay `TypeError`. A merchant catching this class is catching calls
|
|
131
|
+
* that went out.
|
|
132
|
+
*/
|
|
133
|
+
export class AgentifyError extends Error {
|
|
134
|
+
/** Why the call did not go through, for the code that branches on it. */
|
|
135
|
+
code;
|
|
136
|
+
/** The call it happened on, named as the contract's route table names it. */
|
|
137
|
+
route;
|
|
138
|
+
/**
|
|
139
|
+
* Whether making the same call again could get past this.
|
|
140
|
+
*
|
|
141
|
+
* Here for the same reason the code is: the calls that throw and the calls
|
|
142
|
+
* that return are the same calls failing the same ways, and a merchant who
|
|
143
|
+
* had to work out from a `catch` what a returned error would have told them
|
|
144
|
+
* outright is being charged twice for one lesson. The gateway's own answer
|
|
145
|
+
* where the gateway refused in words, and true where nothing readable came
|
|
146
|
+
* back — which is a statement about what this package knows, not a promise
|
|
147
|
+
* that the call will work.
|
|
148
|
+
*/
|
|
149
|
+
retryable;
|
|
150
|
+
constructor(code, route, message, retryable) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = "AgentifyError";
|
|
153
|
+
this.code = code;
|
|
154
|
+
this.route = route;
|
|
155
|
+
this.retryable = retryable;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* What an order call hands back when it produced no answer of its own.
|
|
160
|
+
*
|
|
161
|
+
* `code` is the gateway's own word where the gateway refused in words we
|
|
162
|
+
* recognise, and otherwise one of the three this package produces for a
|
|
163
|
+
* silence. The two halves of the answer have to say the same thing: a refusal
|
|
164
|
+
* quoted in the message and filed under `answer_not_understood` is the message
|
|
165
|
+
* arguing with the code, and the code is the half a program reads. It is the
|
|
166
|
+
* same choice `document` makes for the calls that throw, so one refusal has one
|
|
167
|
+
* name whether the merchant catches it or branches on it.
|
|
168
|
+
*
|
|
169
|
+
* `retryable` says whether calling again could change the outcome, and it has
|
|
170
|
+
* two sources because there are two situations. Where the gateway refused in
|
|
171
|
+
* words, the answer is the gateway's own: it is the side that knows whether
|
|
172
|
+
* the door is shut or was merely shut a moment ago, and this package does not
|
|
173
|
+
* improve on what it was told. Where no answer arrived, or none that could be
|
|
174
|
+
* read, there is nothing to defer to and the answer is true — none of those
|
|
175
|
+
* three is a state of the order, and none will still be true in a minute if a
|
|
176
|
+
* network settled or a proxy went away.
|
|
177
|
+
*
|
|
178
|
+
* The fallback is `true` and not `false` on purpose. It is reached only when
|
|
179
|
+
* this package could not read an answer, which is a statement about our
|
|
180
|
+
* knowledge and not about the order; saying "do not call again" there would be
|
|
181
|
+
* this package inventing the one fact the merchant came for.
|
|
182
|
+
*
|
|
183
|
+
* The sentence about repeating safely is added where two things hold at once:
|
|
184
|
+
* the contract says the call may be repeated — delivering is idempotent by the
|
|
185
|
+
* order's identifier, and taking an order on happens again on every
|
|
186
|
+
* redelivery, while refusing is documented as neither — and calling again
|
|
187
|
+
* could get anywhere. Offering "you may safely repeat this" under a refusal
|
|
188
|
+
* that will answer the same way for ever is an invitation to do something
|
|
189
|
+
* pointless, and it reads as encouragement.
|
|
190
|
+
*/
|
|
191
|
+
const failedCall = (repeatIsSafe, failure) => {
|
|
192
|
+
const retryable = failure.refusal?.retryable ?? true;
|
|
193
|
+
return {
|
|
194
|
+
code: failure.refusal?.code ?? CODE_FOR[failure.reach],
|
|
195
|
+
message: repeatIsSafe && retryable
|
|
196
|
+
? `${whatIsKnown(failure)}: ${failure.reason} — this call may be made again without doing its work twice`
|
|
197
|
+
: `${whatIsKnown(failure)}: ${failure.reason}`,
|
|
198
|
+
retryable,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* The reporter a client gets when the merchant registered none.
|
|
203
|
+
*
|
|
204
|
+
* A library writing to the console is a small rudeness. A worker that stopped
|
|
205
|
+
* on a contract mismatch and said nothing to anybody is a merchant learning
|
|
206
|
+
* about it from a buyer, and between the two this is the cheaper one.
|
|
207
|
+
*/
|
|
208
|
+
const reportToConsole = (problem) => {
|
|
209
|
+
// The exception goes out beside the sentence rather than inside it. The
|
|
210
|
+
// message already carries what the exception says; what it cannot carry is
|
|
211
|
+
// the stack, and the stack is the whole of what a merchant debugging their
|
|
212
|
+
// own handler is looking for.
|
|
213
|
+
if (problem.cause === undefined) {
|
|
214
|
+
console.error(`[agentify] ${problem.kind}: ${problem.message}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.error(`[agentify] ${problem.kind}: ${problem.message}`, problem.cause);
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* What a key says about the world it was issued in, read from its own prefix.
|
|
221
|
+
*
|
|
222
|
+
* ADR-0020 is what makes reading it here legitimate rather than clever: every
|
|
223
|
+
* key carries the environment it was issued in as its prefix, and whoever holds
|
|
224
|
+
* a key can read that prefix. So a worker learns which deployment it joined
|
|
225
|
+
* without a field on the wire, a line in a response document or a step in a
|
|
226
|
+
* handshake — none of which this adds.
|
|
227
|
+
*
|
|
228
|
+
* The rule's home is `packages/core/src/deployment/environment.ts`, where
|
|
229
|
+
* `keyPrefixFor` writes these prefixes and `environmentOfKeyPrefix` reads them.
|
|
230
|
+
* The two strings below are a second copy of that rule and not a use of it,
|
|
231
|
+
* because this package cannot depend on that module at runtime: it is private
|
|
232
|
+
* to the workspace and unpublished, and the dependency tree a merchant installs
|
|
233
|
+
* is a recorded decision (ADR-0003 §8) that does not grow to reach it.
|
|
234
|
+
*
|
|
235
|
+
* A copy is a thing that drifts, and these two must not. What holds them
|
|
236
|
+
* together is `which-world.test.ts`: it builds its keys out of core's own
|
|
237
|
+
* `keyPrefixFor` and asserts the sentences written here, so a prefix changed on
|
|
238
|
+
* one side and not the other fails `pnpm test` instead of going quiet in
|
|
239
|
+
* somebody's log. That import is dev-time and reaches nothing published — the
|
|
240
|
+
* tarball ships `dist`, and the build leaves the tests out. Changing either
|
|
241
|
+
* side means changing both in the same commit.
|
|
242
|
+
*
|
|
243
|
+
* A key matching neither prefix says nothing at all, which is the third case
|
|
244
|
+
* ADR-0020 names: a key issued before the prefix existed, or one that is not
|
|
245
|
+
* ours. There is nothing to fall back to. Guessing is wrong in both directions
|
|
246
|
+
* — telling a merchant their money is play when it is real, or the other way
|
|
247
|
+
* round — and no line at all is better than a wrong claim about whose money is
|
|
248
|
+
* at stake.
|
|
249
|
+
*/
|
|
250
|
+
const WHAT_THE_KEY_SAYS = Object.freeze({
|
|
251
|
+
csk_test_: "with a test key: the money there is not real",
|
|
252
|
+
csk_live_: "with a live key: the money there is real",
|
|
253
|
+
});
|
|
254
|
+
const whatTheKeySays = (apiKey) => Object.entries(WHAT_THE_KEY_SAYS).find(([prefix]) => apiKey.startsWith(prefix))?.[1];
|
|
255
|
+
const keyOf = (apiKey) => {
|
|
256
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
257
|
+
throw new TypeError("createClient needs the merchant's API key, and was given none — check that the environment variable holding it is set where this process can read it");
|
|
258
|
+
}
|
|
259
|
+
return apiKey;
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* The address as given, checked as far as it can be checked without using it.
|
|
263
|
+
*
|
|
264
|
+
* A value that is not an address is refused at once: it will not become one
|
|
265
|
+
* later, and the line that produced it is the line to fix. An address that was
|
|
266
|
+
* not given at all is a different matter, and it is kept as the sentence that
|
|
267
|
+
* will be raised by the first call that needs it — see `baseUrl` above.
|
|
268
|
+
*/
|
|
269
|
+
const addressOf = (baseUrl) => {
|
|
270
|
+
if (baseUrl === undefined)
|
|
271
|
+
return "";
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = new URL(baseUrl);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new TypeError(`baseUrl is not an address: ${JSON.stringify(baseUrl)}`);
|
|
278
|
+
}
|
|
279
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
280
|
+
throw new TypeError(`baseUrl is not an http address: ${JSON.stringify(baseUrl)} — the gateway is reached over https, and over http only where the two run on one machine`);
|
|
281
|
+
}
|
|
282
|
+
return baseUrl;
|
|
283
|
+
};
|
|
284
|
+
/** Refuses to compile if a kind is added to `Handlers` and not registered here. */
|
|
285
|
+
const assertEveryKindIsRegistered = (kind) => {
|
|
286
|
+
throw new TypeError(`on() has no place to put a handler for ${JSON.stringify(kind)}`);
|
|
287
|
+
};
|
|
288
|
+
export const createClient = (options) => {
|
|
289
|
+
const gateway = {
|
|
290
|
+
apiKey: keyOf(options.apiKey),
|
|
291
|
+
baseUrl: addressOf(options.baseUrl),
|
|
292
|
+
};
|
|
293
|
+
const reachable = () => {
|
|
294
|
+
if (gateway.baseUrl === "") {
|
|
295
|
+
throw new TypeError("this client has no gateway address: pass baseUrl to createClient — https://test.agentify.ad while you are building, https://app.agentify.ad once you are live. There is no default on purpose: which of the two you talk to is the difference between a sale and a rehearsal, and an address this package chose for you would point somebody's production at the wrong one without a word");
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* The handlers, and the loop that dispatches to them.
|
|
300
|
+
*
|
|
301
|
+
* The registry belongs to the client and not to any one loop, which is what
|
|
302
|
+
* lets a merchant stop and start again on the handlers they already
|
|
303
|
+
* registered. `worker` is the loop currently running, if one is; it also
|
|
304
|
+
* ends on its own when the gateway turns out to speak another dialect, which
|
|
305
|
+
* is why every reader asks it whether it is still running rather than
|
|
306
|
+
* assuming that a worker that exists is a worker that polls.
|
|
307
|
+
*/
|
|
308
|
+
const registry = { problem: reportToConsole };
|
|
309
|
+
const registered = new Set();
|
|
310
|
+
let worker;
|
|
311
|
+
/** Set while a stop is in flight, and shared by every caller of stop(). */
|
|
312
|
+
let stopping;
|
|
313
|
+
/** Whether the world this client works in has already been said. */
|
|
314
|
+
let worldIsSaid = false;
|
|
315
|
+
/**
|
|
316
|
+
* The one line this package writes uninvited: where this worker is pointed
|
|
317
|
+
* and whether the money there is real.
|
|
318
|
+
*
|
|
319
|
+
* Once per client and not once per start. The key cannot change under a
|
|
320
|
+
* client and neither can the address, so a second line would carry no second
|
|
321
|
+
* fact — and a supervisor that stops and starts a worker on a schedule would
|
|
322
|
+
* turn it into the kind of line people learn to scroll past. The question is
|
|
323
|
+
* closed even where the answer is silence: a key naming no environment is
|
|
324
|
+
* asked about once and never again.
|
|
325
|
+
*
|
|
326
|
+
* It goes to the console because that is where this package already writes
|
|
327
|
+
* when nobody gave it anywhere else to write, and at the informational level
|
|
328
|
+
* because that is what it is. `console.error` would put it in front of every
|
|
329
|
+
* log collector as a fault on a worker that has just started correctly, which
|
|
330
|
+
* is a false alarm and the one thing this line must never look like.
|
|
331
|
+
*
|
|
332
|
+
* It is not a `WorkerProblem`: the merchant's reporter is for what went
|
|
333
|
+
* wrong, and a process counting problems should not have to filter out the
|
|
334
|
+
* one that means everything is fine.
|
|
335
|
+
*/
|
|
336
|
+
const sayWhichWorld = () => {
|
|
337
|
+
if (worldIsSaid)
|
|
338
|
+
return;
|
|
339
|
+
worldIsSaid = true;
|
|
340
|
+
const words = whatTheKeySays(gateway.apiKey);
|
|
341
|
+
if (words === undefined)
|
|
342
|
+
return;
|
|
343
|
+
console.info(`[agentify] worker started against ${gateway.baseUrl} ${words}`);
|
|
344
|
+
};
|
|
345
|
+
const orderCall = async (route, orderId, body) => {
|
|
346
|
+
reachable();
|
|
347
|
+
if (route === "deliver_order") {
|
|
348
|
+
// The road the worker's own warning does not cover, and the one a
|
|
349
|
+
// merchant delivering asynchronously takes. See `droppedFieldWarning`:
|
|
350
|
+
// the field goes no further than the gateway's parse, and without this
|
|
351
|
+
// the merchant's handler had no way to learn that what it sent is not
|
|
352
|
+
// what went out. A warning and not a refusal — the rest of the delivery
|
|
353
|
+
// is good, and the call is the merchant's to make.
|
|
354
|
+
const dropped = droppedFieldWarning(orderId, body);
|
|
355
|
+
if (dropped !== null)
|
|
356
|
+
reportSafely(registry.problem, dropped);
|
|
357
|
+
}
|
|
358
|
+
const answer = await callRoute(gateway, route, { path: { order_id: orderId }, body });
|
|
359
|
+
return answer.ok
|
|
360
|
+
? answer.document
|
|
361
|
+
: { ok: false, error: failedCall(route === "deliver_order", answer.failure) };
|
|
362
|
+
};
|
|
363
|
+
const acceptCall = async (orderId, acceptance) => {
|
|
364
|
+
reachable();
|
|
365
|
+
const answer = await callRoute(gateway, "accept_order", {
|
|
366
|
+
path: { order_id: orderId },
|
|
367
|
+
body: acceptance ?? {},
|
|
368
|
+
});
|
|
369
|
+
// Accepting the same order again is ordinary: an order is taken on afresh
|
|
370
|
+
// on every redelivery, so a repeat does no work twice.
|
|
371
|
+
return answer.ok ? answer.document : { ok: false, error: failedCall(true, answer.failure) };
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* The calls one order answers to, closed over that order's identifier and
|
|
375
|
+
* over this client.
|
|
376
|
+
*
|
|
377
|
+
* This closure is the whole mechanism. The identifier is captured here, once,
|
|
378
|
+
* at the moment the order was read — off the stream or out of our record —
|
|
379
|
+
* and never travels back through a merchant's hands, where it could be the
|
|
380
|
+
* wrong string. What holds the client is the same closure: `orderCall` and
|
|
381
|
+
* `acceptCall` above are this client's, so an order kept in a merchant's map
|
|
382
|
+
* for four hours still knows which gateway to call and with whose key.
|
|
383
|
+
*/
|
|
384
|
+
const callsFor = (orderId) => ({
|
|
385
|
+
delivered: (delivery) => ({ delivered: delivery }),
|
|
386
|
+
refused: (refusal) => ({ refused: refusal }),
|
|
387
|
+
accepted: (acceptance) => ({ accepted: acceptance ?? {} }),
|
|
388
|
+
deliver: (delivery) => orderCall("deliver_order", orderId, delivery),
|
|
389
|
+
refuse: (refusal) => orderCall("refuse_order", orderId, refusal),
|
|
390
|
+
accept: (acceptance) => acceptCall(orderId, acceptance),
|
|
391
|
+
});
|
|
392
|
+
const withCalls = (order) => Object.assign({}, order, callsFor(order.id));
|
|
393
|
+
const askedWithAnswers = (question) => Object.assign({}, question, {
|
|
394
|
+
available: (price, asOf) => ({
|
|
395
|
+
available: true,
|
|
396
|
+
price,
|
|
397
|
+
as_of: asOf ?? new Date().toISOString(),
|
|
398
|
+
}),
|
|
399
|
+
unavailable: (asOf) => ({
|
|
400
|
+
available: false,
|
|
401
|
+
as_of: asOf ?? new Date().toISOString(),
|
|
402
|
+
}),
|
|
403
|
+
});
|
|
404
|
+
const document = async (route, options_) => {
|
|
405
|
+
reachable();
|
|
406
|
+
const answer = await callRoute(gateway, route, options_);
|
|
407
|
+
if (!answer.ok) {
|
|
408
|
+
const { failure } = answer;
|
|
409
|
+
// The gateway's own word wherever it wrote one, and one of ours only
|
|
410
|
+
// where it did not. A refusal we recognise carries the reason a merchant
|
|
411
|
+
// can act on, and replacing it with "we could not read the answer" would
|
|
412
|
+
// throw away the one thing they came here for. The same holds for its
|
|
413
|
+
// answer about calling again — this is the rule `failedCall` applies for
|
|
414
|
+
// the calls that return, applied here so the two roads agree.
|
|
415
|
+
throw new AgentifyError(failure.refusal?.code ?? CODE_FOR[failure.reach], failure.route, failure.reason, failure.refusal?.retryable ?? true);
|
|
416
|
+
}
|
|
417
|
+
return answer.document;
|
|
418
|
+
};
|
|
419
|
+
return {
|
|
420
|
+
catalog: {
|
|
421
|
+
publish: (card) => document("publish_card", { body: card }),
|
|
422
|
+
},
|
|
423
|
+
orders: {
|
|
424
|
+
forId: (orderId) => ({ id: orderId, ...callsFor(orderId) }),
|
|
425
|
+
get: async (orderId) => withCalls(await document("get_order", { path: { order_id: orderId } })),
|
|
426
|
+
list: async (query) => {
|
|
427
|
+
const listed = await document("list_orders", {
|
|
428
|
+
// The wire carries the two words rather than a boolean, because a
|
|
429
|
+
// query string carries text. The merchant writes the boolean their
|
|
430
|
+
// language has and this is where the two meet.
|
|
431
|
+
query: query?.open === undefined ? {} : { open: query.open ? "true" : "false" },
|
|
432
|
+
});
|
|
433
|
+
return listed.orders.map((order) => withCalls(order));
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
on(kind, handler) {
|
|
437
|
+
if (!Object.hasOwn(HANDLER_KINDS, kind)) {
|
|
438
|
+
throw new TypeError(`on() was given ${JSON.stringify(kind)}, which is not a kind this stream carries: the kinds are ${everyKind().join(", ")}`);
|
|
439
|
+
}
|
|
440
|
+
if (registered.has(kind)) {
|
|
441
|
+
throw new TypeError(`on('${kind}') was called twice on one client: the second handler would silently replace the first, and one message goes to one handler`);
|
|
442
|
+
}
|
|
443
|
+
// Narrowed off the generic so that the switch below is exhaustive over
|
|
444
|
+
// the kinds rather than over whatever this call was instantiated with.
|
|
445
|
+
const which = kind;
|
|
446
|
+
switch (which) {
|
|
447
|
+
case "order": {
|
|
448
|
+
// The merchant's handler is wrapped rather than registered as it
|
|
449
|
+
// stands: the loop reads a plain order off the envelope, and the
|
|
450
|
+
// calls that close it are this client's to attach.
|
|
451
|
+
const answering = handler;
|
|
452
|
+
registry.order = (arrived) => answering(withCalls(arrived));
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case "quote": {
|
|
456
|
+
const answering = handler;
|
|
457
|
+
registry.quote = (asked) => answering(askedWithAnswers(asked));
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case "event": {
|
|
461
|
+
registry.event = handler;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "problem": {
|
|
465
|
+
registry.problem = handler;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
return assertEveryKindIsRegistered(which);
|
|
470
|
+
}
|
|
471
|
+
// Written down only once everything that could refuse this call has
|
|
472
|
+
// passed, so a call that threw leaves no trace and the same call made
|
|
473
|
+
// again succeeds.
|
|
474
|
+
registered.add(which);
|
|
475
|
+
},
|
|
476
|
+
start: async () => {
|
|
477
|
+
reachable();
|
|
478
|
+
if (stopping !== undefined) {
|
|
479
|
+
throw new TypeError("this client is being stopped: await that stop() before starting it again, or the loop being started would be racing the one going away");
|
|
480
|
+
}
|
|
481
|
+
if (worker?.running() === true) {
|
|
482
|
+
throw new TypeError("this client is already started: a second loop would open a second long poll for one merchant, and an envelope handed to one of them is not handed to the other");
|
|
483
|
+
}
|
|
484
|
+
// Read off the vocabulary rather than written out, so a kind the stream
|
|
485
|
+
// grows is counted as an answer here without anybody remembering to add
|
|
486
|
+
// it — a process that answered only the new kind would otherwise be
|
|
487
|
+
// refused a start on the grounds that it answers nothing.
|
|
488
|
+
if (!streamKinds().some((kind) => registry[kind] !== undefined)) {
|
|
489
|
+
throw new TypeError(`this client has nothing registered to answer with: register at least one of ${streamKinds()
|
|
490
|
+
.map((kind) => `on('${kind}')`)
|
|
491
|
+
.join(", ")} before start(), or the loop would take work off the queue that nobody answers`);
|
|
492
|
+
}
|
|
493
|
+
// After every refusal above and before the loop: a start that threw
|
|
494
|
+
// opened nothing, and a line saying otherwise would be the first thing a
|
|
495
|
+
// merchant read on the way to an exception.
|
|
496
|
+
sayWhichWorld();
|
|
497
|
+
worker = startWorker(gateway, registry);
|
|
498
|
+
},
|
|
499
|
+
stop: async () => {
|
|
500
|
+
const owned = worker;
|
|
501
|
+
// A client that was never started has nothing to stop, and saying so by
|
|
502
|
+
// returning is what lets a shutdown routine call this unconditionally.
|
|
503
|
+
if (owned === undefined)
|
|
504
|
+
return;
|
|
505
|
+
// One promise for every caller — a shutdown routine and a signal handler
|
|
506
|
+
// both calling stop() is ordinary — and what it buys is the bookkeeping
|
|
507
|
+
// rather than the waiting. Each of them would wait out the same loop
|
|
508
|
+
// either way, because the worker's own stop() awaits the one promise the
|
|
509
|
+
// loop ends on. What must happen once is the clearing below: two of them
|
|
510
|
+
// racing it is two of them deciding whether this client still has a
|
|
511
|
+
// worker, and the last one to answer wins.
|
|
512
|
+
stopping ??= owned.stop().finally(() => {
|
|
513
|
+
if (worker === owned)
|
|
514
|
+
worker = undefined;
|
|
515
|
+
stopping = undefined;
|
|
516
|
+
});
|
|
517
|
+
await stopping;
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which contract this package speaks, and whether it understands another.
|
|
3
|
+
*
|
|
4
|
+
* It sits in a file of its own because both ends of the package need it and
|
|
5
|
+
* neither should reach through the other: the worker checks the version on the
|
|
6
|
+
* answer to every poll, and `index.ts` publishes the same check to a merchant
|
|
7
|
+
* who wants to make it themselves.
|
|
8
|
+
*/
|
|
9
|
+
/** The contract version this SDK talks in. */
|
|
10
|
+
export declare const contractVersion = "2";
|
|
11
|
+
/**
|
|
12
|
+
* Whether the SDK understands a contract of the given version. The gateway
|
|
13
|
+
* names its own version in the response, the merchant's worker checks it with
|
|
14
|
+
* this function and fails at startup rather than on the first order, where a
|
|
15
|
+
* divergence of dialects costs the buyer money.
|
|
16
|
+
*/
|
|
17
|
+
export declare function speaksContract(version: string): boolean;
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which contract this package speaks, and whether it understands another.
|
|
3
|
+
*
|
|
4
|
+
* It sits in a file of its own because both ends of the package need it and
|
|
5
|
+
* neither should reach through the other: the worker checks the version on the
|
|
6
|
+
* answer to every poll, and `index.ts` publishes the same check to a merchant
|
|
7
|
+
* who wants to make it themselves.
|
|
8
|
+
*/
|
|
9
|
+
import { CONTRACT_VERSION } from "@nuanu-ai/agentify-contracts";
|
|
10
|
+
/** The contract version this SDK talks in. */
|
|
11
|
+
export const contractVersion = CONTRACT_VERSION;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the SDK understands a contract of the given version. The gateway
|
|
14
|
+
* names its own version in the response, the merchant's worker checks it with
|
|
15
|
+
* this function and fails at startup rather than on the first order, where a
|
|
16
|
+
* divergence of dialects costs the buyer money.
|
|
17
|
+
*/
|
|
18
|
+
export function speaksContract(version) {
|
|
19
|
+
return version === CONTRACT_VERSION;
|
|
20
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agentify merchant SDK: what someone else's engineer installs so that
|
|
3
|
+
* their catalog sells to agents.
|
|
4
|
+
*
|
|
5
|
+
* There are two things in here and they are one process. `createClient` builds
|
|
6
|
+
* a handle on the gateway from a key and an address; through it a merchant
|
|
7
|
+
* says what their process answers with `on`, opens one outgoing subscription
|
|
8
|
+
* that carries paid orders, price questions and order events together, and
|
|
9
|
+
* closes orders they took on earlier off the orders themselves. Beside it,
|
|
10
|
+
* `checkCard` is the check a merchant runs on their own cards before
|
|
11
|
+
* publishing them — the same check the documentation calls
|
|
12
|
+
* `agentify verify`, which the published package installs as a command of
|
|
13
|
+
* that name. The command's own insides are not exported: what a merchant calls
|
|
14
|
+
* from their code is `checkCard`, and what they run from a terminal is the
|
|
15
|
+
* command, and neither of those needs the other's exit codes.
|
|
16
|
+
*
|
|
17
|
+
* Every call that can fail answers in one envelope — `ok` says which, and a
|
|
18
|
+
* failure carries one `error` with a code, a sentence, whether repeating could
|
|
19
|
+
* help, and the findings under `problems` where the call is refusing what it
|
|
20
|
+
* was handed. What throws instead is a call with no such branch, as
|
|
21
|
+
* `AgentifyError`, under the same codes.
|
|
22
|
+
*
|
|
23
|
+
* The runtime dependency tree is minimal and listed in full: our own
|
|
24
|
+
* `@nuanu-ai/agentify-contracts`, and zod underneath it, and nothing else. A merchant
|
|
25
|
+
* installing the SDK into their production should know exactly what arrives
|
|
26
|
+
* with it, rather than inherit a foreign package tree they would then be
|
|
27
|
+
* maintaining themselves. Every new third-party package in this tree is a
|
|
28
|
+
* recorded decision (ADR-0003 §8), and a test in this package holds the line.
|
|
29
|
+
*
|
|
30
|
+
* The types a merchant needs to write their own functions against — what a
|
|
31
|
+
* card is, what an order carries, what a handler may answer — are re-exported
|
|
32
|
+
* here from the contracts package, so that integration code has one import and
|
|
33
|
+
* not two.
|
|
34
|
+
*/
|
|
35
|
+
export type { Acceptance, CallError, Card, CardInput, Delivery, Fulfillment, HandlerAnswer, Money, Order, OrderCallResponse, OrderCallResult, OrderEvent, OrderList, OrderStatus, OrderWithStatus, Problem, PublishResult, QuotePurpose, QuoteRequest, QuoteResponse, Refusal, RefusalCode, SalePrice, } from "@nuanu-ai/agentify-contracts";
|
|
36
|
+
export { CARD_REJECTED, ORDER_EVENT_TYPES, RECOMMENDED_REFUSAL_CODES, } from "@nuanu-ai/agentify-contracts";
|
|
37
|
+
export type { CardCheck } from "./check-card.js";
|
|
38
|
+
export { checkCard } from "./check-card.js";
|
|
39
|
+
export type { AgentifyClient, CatalogNamespace, ClientOptions, HandlerKind, Handlers, LiveOrder, LiveOrderWithStatus, LiveQuoteRequest, OrderCalls, OrderHandle, OrderHandler, OrdersNamespace, QuoteCalls, QuoteHandler, } from "./client.js";
|
|
40
|
+
export { AgentifyError, ANSWER_NOT_UNDERSTOOD, CALL_DID_NOT_REACH_US, createClient, OUTCOME_UNKNOWN, } from "./client.js";
|
|
41
|
+
export { contractVersion, speaksContract } from "./contract.js";
|
|
42
|
+
export type { Delivered, EventHandler, ProblemReporter, WorkerProblem, WorkerProblemKind, } from "./worker.js";
|
|
43
|
+
export { WORKER_PROBLEM_KINDS } from "./worker.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agentify merchant SDK: what someone else's engineer installs so that
|
|
3
|
+
* their catalog sells to agents.
|
|
4
|
+
*
|
|
5
|
+
* There are two things in here and they are one process. `createClient` builds
|
|
6
|
+
* a handle on the gateway from a key and an address; through it a merchant
|
|
7
|
+
* says what their process answers with `on`, opens one outgoing subscription
|
|
8
|
+
* that carries paid orders, price questions and order events together, and
|
|
9
|
+
* closes orders they took on earlier off the orders themselves. Beside it,
|
|
10
|
+
* `checkCard` is the check a merchant runs on their own cards before
|
|
11
|
+
* publishing them — the same check the documentation calls
|
|
12
|
+
* `agentify verify`, which the published package installs as a command of
|
|
13
|
+
* that name. The command's own insides are not exported: what a merchant calls
|
|
14
|
+
* from their code is `checkCard`, and what they run from a terminal is the
|
|
15
|
+
* command, and neither of those needs the other's exit codes.
|
|
16
|
+
*
|
|
17
|
+
* Every call that can fail answers in one envelope — `ok` says which, and a
|
|
18
|
+
* failure carries one `error` with a code, a sentence, whether repeating could
|
|
19
|
+
* help, and the findings under `problems` where the call is refusing what it
|
|
20
|
+
* was handed. What throws instead is a call with no such branch, as
|
|
21
|
+
* `AgentifyError`, under the same codes.
|
|
22
|
+
*
|
|
23
|
+
* The runtime dependency tree is minimal and listed in full: our own
|
|
24
|
+
* `@nuanu-ai/agentify-contracts`, and zod underneath it, and nothing else. A merchant
|
|
25
|
+
* installing the SDK into their production should know exactly what arrives
|
|
26
|
+
* with it, rather than inherit a foreign package tree they would then be
|
|
27
|
+
* maintaining themselves. Every new third-party package in this tree is a
|
|
28
|
+
* recorded decision (ADR-0003 §8), and a test in this package holds the line.
|
|
29
|
+
*
|
|
30
|
+
* The types a merchant needs to write their own functions against — what a
|
|
31
|
+
* card is, what an order carries, what a handler may answer — are re-exported
|
|
32
|
+
* here from the contracts package, so that integration code has one import and
|
|
33
|
+
* not two.
|
|
34
|
+
*/
|
|
35
|
+
export { CARD_REJECTED, ORDER_EVENT_TYPES, RECOMMENDED_REFUSAL_CODES, } from "@nuanu-ai/agentify-contracts";
|
|
36
|
+
export { checkCard } from "./check-card.js";
|
|
37
|
+
export { AgentifyError, ANSWER_NOT_UNDERSTOOD, CALL_DID_NOT_REACH_US, createClient, OUTCOME_UNKNOWN, } from "./client.js";
|
|
38
|
+
export { contractVersion, speaksContract } from "./contract.js";
|
|
39
|
+
export { WORKER_PROBLEM_KINDS } from "./worker.js";
|