agentic-assistant 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +35 -5
- package/dist/{chat-pBg4wRjQ.d.ts → chat-CXbhePC5.d.ts} +1 -1
- package/dist/{chunk-T4ZMDOB4.js → chunk-C44QT474.js} +174 -2
- package/dist/chunk-FQD4OK2R.js +142 -0
- package/dist/{chunk-6UD2QQA4.js → chunk-KNUEYFX2.js} +2 -2
- package/dist/{chunk-BDXAUNMR.js → chunk-MESH6X6N.js} +6 -135
- package/dist/chunk-YMEM57LX.js +305 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +4 -3
- package/dist/client.d.ts +78 -0
- package/dist/client.js +195 -0
- package/dist/demo.d.ts +21 -0
- package/dist/demo.js +158 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +8 -6
- package/dist/{loop-42giKLVa.d.ts → loop-D4qWbRu6.d.ts} +6 -1
- package/dist/{mcp-server-X5VTHZVM.js → mcp-server-LXGWKEHG.js} +4 -3
- package/dist/react.d.ts +1 -1
- package/dist/worker.d.ts +16 -6
- package/dist/worker.js +8 -271
- package/llms.txt +2 -1
- package/package.json +10 -4
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VERSION,
|
|
3
|
+
createPrincipal,
|
|
4
|
+
hasScope,
|
|
5
|
+
parseScope,
|
|
6
|
+
runAssistant
|
|
7
|
+
} from "./chunk-C44QT474.js";
|
|
8
|
+
|
|
9
|
+
// src/api.ts
|
|
10
|
+
import { PRINCIPAL_KINDS } from "agentic-identity";
|
|
11
|
+
import { requirePrincipal } from "agentic-identity/http";
|
|
12
|
+
var MAX_BODY_BYTES = 65536;
|
|
13
|
+
var MAX_MESSAGE_CHARS = 1e4;
|
|
14
|
+
var MAX_REASON_CHARS = 500;
|
|
15
|
+
var DEFAULT_MAX_CONCURRENT_RUNS = 4;
|
|
16
|
+
var APPROVER_SCOPE = "assistant:admin";
|
|
17
|
+
async function callerOf(request, auth, origins) {
|
|
18
|
+
const found = await requirePrincipal(request, auth, { origins });
|
|
19
|
+
if (found instanceof Response) return found.status === 401 ? fail(401, "sign in with a valid key", { "www-authenticate": "Bearer" }) : found;
|
|
20
|
+
return toCaller(found) ?? fail(401, "sign in with a valid key", { "www-authenticate": "Bearer" });
|
|
21
|
+
}
|
|
22
|
+
var json = (body, status = 200, headers = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } });
|
|
23
|
+
var fail = (status, error, headers = {}) => json({ error }, status, headers);
|
|
24
|
+
function toCaller(context) {
|
|
25
|
+
if (typeof context !== "object" || context === null) return void 0;
|
|
26
|
+
const { principalId, kind, chain, scopes } = context;
|
|
27
|
+
if (typeof principalId !== "string" || principalId === "" || !Array.isArray(chain) || chain.length === 0) return void 0;
|
|
28
|
+
if (!PRINCIPAL_KINDS.includes(kind)) return void 0;
|
|
29
|
+
if (chain[chain.length - 1]?.id !== principalId) return void 0;
|
|
30
|
+
if (chain[chain.length - 1]?.kind !== kind || kind === "human" && chain.length !== 1) return void 0;
|
|
31
|
+
const ids = chain.map((step) => step?.id);
|
|
32
|
+
if (ids.some((id) => typeof id !== "string" || id === "")) return void 0;
|
|
33
|
+
const rootId = ids[0];
|
|
34
|
+
if (!Array.isArray(scopes)) return void 0;
|
|
35
|
+
try {
|
|
36
|
+
for (const scope of scopes) parseScope(scope);
|
|
37
|
+
} catch {
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
return { id: principalId, kind, rootId, chain: ids, scopes: [...scopes] };
|
|
41
|
+
}
|
|
42
|
+
var assistantId = (caller) => `assistant:${caller.id}`;
|
|
43
|
+
async function readJson(request) {
|
|
44
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return void 0;
|
|
45
|
+
let text = "";
|
|
46
|
+
if (request.body !== null) {
|
|
47
|
+
const reader = request.body.getReader();
|
|
48
|
+
const decoder = new TextDecoder();
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
for (; ; ) {
|
|
51
|
+
const chunk = await reader.read();
|
|
52
|
+
if (chunk.done) break;
|
|
53
|
+
bytes += chunk.value.byteLength;
|
|
54
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
55
|
+
await reader.cancel();
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
58
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
59
|
+
}
|
|
60
|
+
text += decoder.decode();
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const body = JSON.parse(text);
|
|
64
|
+
return typeof body === "object" && body !== null && !Array.isArray(body) ? body : void 0;
|
|
65
|
+
} catch {
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function sseChunk(event) {
|
|
70
|
+
return new TextEncoder().encode(`event: ${event.type}
|
|
71
|
+
data: ${JSON.stringify(event)}
|
|
72
|
+
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
function createAssistantApi(options) {
|
|
76
|
+
const pending = /* @__PURE__ */ new Map();
|
|
77
|
+
const running = /* @__PURE__ */ new Map();
|
|
78
|
+
const maxRuns = options.maxConcurrentRuns ?? DEFAULT_MAX_CONCURRENT_RUNS;
|
|
79
|
+
function whyNotDecide(caller, entry) {
|
|
80
|
+
const { proposer } = entry;
|
|
81
|
+
if (caller.id === assistantId(proposer)) return "an agent cannot decide on its own action";
|
|
82
|
+
if (proposer.kind !== "human") {
|
|
83
|
+
if (caller.chain.includes(proposer.id) || caller.kind !== "human" && proposer.chain.includes(caller.id)) {
|
|
84
|
+
return "an agent cannot decide on its own action, or on one from an agent it made or that made it";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (caller.kind !== "human" && !hasScope(caller.scopes, APPROVER_SCOPE)) {
|
|
88
|
+
return `an agent needs the ${APPROVER_SCOPE} scope to decide`;
|
|
89
|
+
}
|
|
90
|
+
const tool = options.registry.get(entry.request.tool);
|
|
91
|
+
if (tool === void 0 || !hasScope(caller.scopes, tool.scope)) return "the approver does not hold the scope of this tool";
|
|
92
|
+
return void 0;
|
|
93
|
+
}
|
|
94
|
+
const gateFor = (proposer) => ({
|
|
95
|
+
request(request, signal) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
if (signal.aborted) return reject(new Error("the request was cancelled"));
|
|
98
|
+
pending.set(request.id, { request, proposer, resolve });
|
|
99
|
+
signal.addEventListener(
|
|
100
|
+
"abort",
|
|
101
|
+
() => {
|
|
102
|
+
pending.delete(request.id);
|
|
103
|
+
reject(new Error("the request was cancelled"));
|
|
104
|
+
},
|
|
105
|
+
{ once: true }
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
function chat(request, caller, message) {
|
|
111
|
+
const abort = new AbortController();
|
|
112
|
+
running.set(caller.id, (running.get(caller.id) ?? 0) + 1);
|
|
113
|
+
let released = false;
|
|
114
|
+
const release = () => {
|
|
115
|
+
if (released) return;
|
|
116
|
+
released = true;
|
|
117
|
+
const left = (running.get(caller.id) ?? 1) - 1;
|
|
118
|
+
if (left <= 0) running.delete(caller.id);
|
|
119
|
+
else running.set(caller.id, left);
|
|
120
|
+
};
|
|
121
|
+
request.signal.addEventListener(
|
|
122
|
+
"abort",
|
|
123
|
+
() => {
|
|
124
|
+
abort.abort();
|
|
125
|
+
release();
|
|
126
|
+
},
|
|
127
|
+
{ once: true }
|
|
128
|
+
);
|
|
129
|
+
let events;
|
|
130
|
+
try {
|
|
131
|
+
events = runAssistant({
|
|
132
|
+
provider: options.provider(),
|
|
133
|
+
registry: options.registry,
|
|
134
|
+
// The assistant holds the caller's scopes and no more.
|
|
135
|
+
principal: createPrincipal({
|
|
136
|
+
id: assistantId(caller),
|
|
137
|
+
kind: "agent_of_human",
|
|
138
|
+
parentId: caller.id,
|
|
139
|
+
scopes: caller.scopes,
|
|
140
|
+
// Each check asks the Auth again. A key revoked during a run, or one with other scopes, stops it.
|
|
141
|
+
isRevoked: async () => {
|
|
142
|
+
const now = await callerOf(request, options.auth, options.origins);
|
|
143
|
+
return now instanceof Response || now.id !== caller.id || [...now.scopes].sort().join(" ") !== [...caller.scopes].sort().join(" ");
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
approvals: gateFor(caller),
|
|
147
|
+
messages: [{ role: "user", content: [{ type: "text", text: message }] }],
|
|
148
|
+
system: options.system,
|
|
149
|
+
limits: options.limits,
|
|
150
|
+
signal: abort.signal
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
release();
|
|
154
|
+
return fail(500, "the assistant could not start");
|
|
155
|
+
}
|
|
156
|
+
const body = new ReadableStream({
|
|
157
|
+
async pull(controller) {
|
|
158
|
+
try {
|
|
159
|
+
const next = await events.next();
|
|
160
|
+
if (next.done) {
|
|
161
|
+
release();
|
|
162
|
+
controller.close();
|
|
163
|
+
} else controller.enqueue(sseChunk(next.value));
|
|
164
|
+
} catch {
|
|
165
|
+
release();
|
|
166
|
+
controller.enqueue(sseChunk({ type: "error", error: "the run failed" }));
|
|
167
|
+
controller.close();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
cancel() {
|
|
171
|
+
abort.abort();
|
|
172
|
+
release();
|
|
173
|
+
void events.return(void 0).catch(() => void 0);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return new Response(body, {
|
|
177
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-store", "x-accel-buffering": "no", "x-ai-generated": "true" }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async function decide(request, caller, id, approve) {
|
|
181
|
+
const body = await readJson(request);
|
|
182
|
+
if (body === void 0) return fail(400, "the body must be a JSON object");
|
|
183
|
+
const entry = pending.get(id);
|
|
184
|
+
if (entry === void 0 || entry.proposer.rootId !== caller.rootId) return fail(404, "no pending approval with this id");
|
|
185
|
+
const why = whyNotDecide(caller, entry);
|
|
186
|
+
if (why !== void 0) return fail(403, why);
|
|
187
|
+
let decision;
|
|
188
|
+
if (approve) {
|
|
189
|
+
if (body.digest !== entry.request.digest) return fail(409, "digest does not match the pending call");
|
|
190
|
+
decision = { requestId: id, approved: true, digest: entry.request.digest, decidedBy: caller.id, approverKind: caller.kind === "human" ? "human" : "agent" };
|
|
191
|
+
} else {
|
|
192
|
+
const reason = typeof body.reason === "string" ? body.reason.slice(0, MAX_REASON_CHARS) : void 0;
|
|
193
|
+
decision = { requestId: id, approved: false, decidedBy: caller.id, reason };
|
|
194
|
+
}
|
|
195
|
+
pending.delete(id);
|
|
196
|
+
entry.resolve(decision);
|
|
197
|
+
return json({ requestId: id, approved: approve, decidedBy: caller.id, machineMade: caller.kind !== "human" });
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
async fetch(request) {
|
|
201
|
+
const url = new URL(request.url);
|
|
202
|
+
const match = /^\/api\/v1\/(tools|chat|approvals)(?:\/([^/]+)\/(approve|deny))?\/?$/.exec(url.pathname);
|
|
203
|
+
if (match === null) return fail(404, "not found");
|
|
204
|
+
const [, route, id, verb] = match;
|
|
205
|
+
const allowed = route === "chat" || verb !== void 0 ? "POST" : "GET";
|
|
206
|
+
if (request.method !== allowed) return fail(405, "method not allowed", { allow: allowed });
|
|
207
|
+
if (route !== "approvals" && id !== void 0) return fail(404, "not found");
|
|
208
|
+
const caller = await callerOf(request, options.auth, options.origins);
|
|
209
|
+
if (caller instanceof Response) return caller;
|
|
210
|
+
if (route === "tools") {
|
|
211
|
+
const tools = options.registry.list().filter((tool) => hasScope(caller.scopes, tool.scope)).map(({ name, description, scope, risk, inputSchema }) => ({ name, description, scope, risk, inputSchema }));
|
|
212
|
+
return json({ tools });
|
|
213
|
+
}
|
|
214
|
+
if (route === "approvals" && verb === void 0) {
|
|
215
|
+
const list = [...pending.values()].filter((entry) => entry.proposer.rootId === caller.rootId).filter((entry) => {
|
|
216
|
+
const tool = options.registry.get(entry.request.tool);
|
|
217
|
+
return tool !== void 0 && hasScope(caller.scopes, tool.scope);
|
|
218
|
+
}).map((entry) => ({
|
|
219
|
+
id: entry.request.id,
|
|
220
|
+
tool: entry.request.tool,
|
|
221
|
+
risk: entry.request.risk,
|
|
222
|
+
input: entry.request.input,
|
|
223
|
+
digest: entry.request.digest,
|
|
224
|
+
proposedBy: entry.request.actor,
|
|
225
|
+
requestedBy: entry.proposer.id,
|
|
226
|
+
canDecide: whyNotDecide(caller, entry) === void 0
|
|
227
|
+
}));
|
|
228
|
+
return json({ approvals: list });
|
|
229
|
+
}
|
|
230
|
+
if (route === "approvals" && id !== void 0) {
|
|
231
|
+
let requestId;
|
|
232
|
+
try {
|
|
233
|
+
requestId = decodeURIComponent(id);
|
|
234
|
+
} catch {
|
|
235
|
+
return fail(404, "no pending approval with this id");
|
|
236
|
+
}
|
|
237
|
+
return decide(request, caller, requestId, verb === "approve");
|
|
238
|
+
}
|
|
239
|
+
const body = await readJson(request);
|
|
240
|
+
const message = body?.message;
|
|
241
|
+
if (typeof message !== "string" || message.trim() === "" || message.length > MAX_MESSAGE_CHARS) {
|
|
242
|
+
return fail(400, `message must be a text of 1 to ${MAX_MESSAGE_CHARS} characters`);
|
|
243
|
+
}
|
|
244
|
+
if ((running.get(caller.id) ?? 0) >= maxRuns) return fail(429, "too many chat turns at once", { "retry-after": "5" });
|
|
245
|
+
return chat(request, caller, message);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/static-auth.ts
|
|
251
|
+
var MIN_TOKEN_BYTES = 32;
|
|
252
|
+
var encoder = new TextEncoder();
|
|
253
|
+
async function digest(token) {
|
|
254
|
+
const hash = await crypto.subtle.digest("SHA-256", encoder.encode(token));
|
|
255
|
+
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
256
|
+
}
|
|
257
|
+
async function createStaticTokenAuth(tokens) {
|
|
258
|
+
const byDigest = /* @__PURE__ */ new Map();
|
|
259
|
+
for (const [token, entry] of Object.entries(tokens)) {
|
|
260
|
+
if (encoder.encode(token).length < MIN_TOKEN_BYTES) throw new Error(`tokens must hold at least ${MIN_TOKEN_BYTES} bytes`);
|
|
261
|
+
if (/\s/.test(token)) throw new Error("a token may not hold white space");
|
|
262
|
+
if (!/^[A-Za-z0-9_-]{1,512}$/.test(token)) throw new Error("a token may hold only A-Z a-z 0-9 _ - and at most 512 characters");
|
|
263
|
+
if (typeof entry !== "object" || entry === null || Object.keys(entry).some((key) => key !== "id" && key !== "scopes")) {
|
|
264
|
+
throw new Error("a token entry has only an id and scopes: every caller is a person");
|
|
265
|
+
}
|
|
266
|
+
if (typeof entry?.id !== "string" || entry.id === "" || !Array.isArray(entry.scopes) || entry.scopes.some((s) => typeof s !== "string")) {
|
|
267
|
+
throw new Error("each token needs an id and a list of scopes");
|
|
268
|
+
}
|
|
269
|
+
for (const scope of entry.scopes) parseScope(scope);
|
|
270
|
+
byDigest.set(await digest(token), {
|
|
271
|
+
principalId: entry.id,
|
|
272
|
+
kind: "human",
|
|
273
|
+
chain: [{ id: entry.id, kind: "human" }],
|
|
274
|
+
scopes: [...entry.scopes]
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
async authenticate(request) {
|
|
279
|
+
const match = /^Bearer ([^\s]+)$/.exec(request.headers.get("authorization") ?? "");
|
|
280
|
+
const context = match ? byDigest.get(await digest(match[1])) : void 0;
|
|
281
|
+
return context ? { ...context, chain: context.chain.map((step) => ({ ...step })), scopes: [...context.scopes] } : null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/worker.ts
|
|
287
|
+
var health = (env) => Response.json({ ok: true, pack: env.PACK_NAME ?? "agentic-assistant", version: VERSION });
|
|
288
|
+
function createWorker(options) {
|
|
289
|
+
const api = createAssistantApi(options);
|
|
290
|
+
return {
|
|
291
|
+
async fetch(request, env = {}) {
|
|
292
|
+
const url = new URL(request.url);
|
|
293
|
+
if (request.method === "GET" && url.pathname === "/health") return health(env);
|
|
294
|
+
if (url.pathname.startsWith("/api/v1/")) return api.fetch(request);
|
|
295
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export {
|
|
301
|
+
APPROVER_SCOPE,
|
|
302
|
+
createAssistantApi,
|
|
303
|
+
createStaticTokenAuth,
|
|
304
|
+
createWorker
|
|
305
|
+
};
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as ChatConfig } from './chat-
|
|
3
|
-
import './loop-
|
|
2
|
+
import { C as ChatConfig } from './chat-CXbhePC5.js';
|
|
3
|
+
import './loop-D4qWbRu6.js';
|
|
4
4
|
import './types-DGH8FS56.js';
|
|
5
5
|
|
|
6
6
|
declare class UsageError extends Error {
|
package/dist/cli.js
CHANGED
|
@@ -4,14 +4,15 @@ import {
|
|
|
4
4
|
buildSetup,
|
|
5
5
|
readTextFile,
|
|
6
6
|
runChat
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-MESH6X6N.js";
|
|
8
8
|
import "./chunk-HYT732TN.js";
|
|
9
|
+
import "./chunk-FQD4OK2R.js";
|
|
9
10
|
import {
|
|
10
11
|
TOOL_RISKS,
|
|
11
12
|
VERSION,
|
|
12
13
|
printable,
|
|
13
14
|
provenanceLabel
|
|
14
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-C44QT474.js";
|
|
15
16
|
|
|
16
17
|
// src/cli.ts
|
|
17
18
|
import { realpathSync } from "fs";
|
|
@@ -271,7 +272,7 @@ async function runCli(argv, io = defaultIo()) {
|
|
|
271
272
|
for (const skip of setup.skipped.slice(0, MAX_SKIPPED_LINES)) io.err(`skipped tool ${oneLine(skip.name, 100)}: ${oneLine(skip.reason, 200)}`);
|
|
272
273
|
if (setup.skipped.length > MAX_SKIPPED_LINES) io.err(`skipped ${setup.skipped.length - MAX_SKIPPED_LINES} more tools`);
|
|
273
274
|
if (parsed.command === "mcp") {
|
|
274
|
-
const { startAssistantMcp } = await import("./mcp-server-
|
|
275
|
+
const { startAssistantMcp } = await import("./mcp-server-LXGWKEHG.js");
|
|
275
276
|
await startAssistantMcp(setup);
|
|
276
277
|
return 0;
|
|
277
278
|
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { A as AssistantEvent, a as ApprovalRequest, b as Actor } from './loop-D4qWbRu6.js';
|
|
2
|
+
export { D as DeniedReason, c as DoneReason, R as RunTotals } from './loop-D4qWbRu6.js';
|
|
3
|
+
import './types-DGH8FS56.js';
|
|
4
|
+
|
|
5
|
+
/** The error code of a failed call. The client derives it from the HTTP status and the error body. */
|
|
6
|
+
type ApiErrorCode = "INVALID_INPUT" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "CONFLICT" | "RATE_LIMITED" | "NOT_CONFIGURED" | "INTERNAL" | "UNKNOWN";
|
|
7
|
+
/** Thrown for every reply that is not a success. `code` is the error code and `status` is the HTTP status. */
|
|
8
|
+
declare class AssistantApiError extends Error {
|
|
9
|
+
readonly code: ApiErrorCode;
|
|
10
|
+
readonly status: number;
|
|
11
|
+
/** Seconds to wait before a new try, from `retry-after` of a 429 reply. */
|
|
12
|
+
readonly retryAfter?: number;
|
|
13
|
+
constructor(code: ApiErrorCode, status: number, message: string, retryAfter?: number);
|
|
14
|
+
}
|
|
15
|
+
interface ClientOptions {
|
|
16
|
+
/** The Worker origin, for example https://assistant.example.com. The client adds /api/v1. */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** A bearer credential that the Worker's auth accepts. */
|
|
19
|
+
token: string;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
/** Every method takes an optional signal. An aborted call rejects with the signal reason, not an AssistantApiError. */
|
|
23
|
+
interface CallOptions {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
interface ToolInfo {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
scope: string;
|
|
30
|
+
risk: "safe" | "risky" | "irreversible";
|
|
31
|
+
inputSchema: unknown;
|
|
32
|
+
}
|
|
33
|
+
interface PendingApproval {
|
|
34
|
+
id: string;
|
|
35
|
+
tool: string;
|
|
36
|
+
risk: ApprovalRequest["risk"];
|
|
37
|
+
input: Record<string, unknown>;
|
|
38
|
+
/** Send this digest back to approve the exact call. */
|
|
39
|
+
digest: string;
|
|
40
|
+
proposedBy: Actor;
|
|
41
|
+
requestedBy: string;
|
|
42
|
+
/** False when this caller may not decide, for example an agent that asked for the turn. */
|
|
43
|
+
canDecide: boolean;
|
|
44
|
+
}
|
|
45
|
+
interface Decision {
|
|
46
|
+
requestId: string;
|
|
47
|
+
approved: boolean;
|
|
48
|
+
decidedBy: string;
|
|
49
|
+
/** True when the decider is not a person. */
|
|
50
|
+
machineMade: boolean;
|
|
51
|
+
}
|
|
52
|
+
interface Health {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
pack: string;
|
|
55
|
+
version: string;
|
|
56
|
+
}
|
|
57
|
+
/** An event of the chat stream. `error` ends the stream: the run failed on the server. */
|
|
58
|
+
type ChatEvent = AssistantEvent | {
|
|
59
|
+
type: "error";
|
|
60
|
+
error: string;
|
|
61
|
+
};
|
|
62
|
+
interface AssistantClient {
|
|
63
|
+
health(options?: CallOptions): Promise<Health>;
|
|
64
|
+
tools(options?: CallOptions): Promise<ToolInfo[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Yields the events of one new conversation, ending with `done` (or `error`). The request is sent on the
|
|
67
|
+
* first read, not when you call `chat`. The stream stays open while a risky call waits for a person.
|
|
68
|
+
* Stop reading, or abort the signal, to stop the run. A stream that ends with no `done` or `error` throws.
|
|
69
|
+
*/
|
|
70
|
+
chat(message: string, options?: CallOptions): AsyncGenerator<ChatEvent, void, undefined>;
|
|
71
|
+
approvals(options?: CallOptions): Promise<PendingApproval[]>;
|
|
72
|
+
/** Approves the exact call: `digest` comes from the pending call. A wrong digest is a CONFLICT. */
|
|
73
|
+
approve(id: string, digest: string, options?: CallOptions): Promise<Decision>;
|
|
74
|
+
deny(id: string, reason?: string, options?: CallOptions): Promise<Decision>;
|
|
75
|
+
}
|
|
76
|
+
declare function createClient(options: ClientOptions): AssistantClient;
|
|
77
|
+
|
|
78
|
+
export { Actor, type ApiErrorCode, ApprovalRequest, AssistantApiError, type AssistantClient, AssistantEvent, type CallOptions, type ChatEvent, type ClientOptions, type Decision, type Health, type PendingApproval, type ToolInfo, createClient };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
var STATUS_CODES = {
|
|
3
|
+
400: "INVALID_INPUT",
|
|
4
|
+
401: "UNAUTHENTICATED",
|
|
5
|
+
403: "FORBIDDEN",
|
|
6
|
+
404: "NOT_FOUND",
|
|
7
|
+
405: "METHOD_NOT_ALLOWED",
|
|
8
|
+
409: "CONFLICT",
|
|
9
|
+
429: "RATE_LIMITED"
|
|
10
|
+
};
|
|
11
|
+
var AssistantApiError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
status;
|
|
14
|
+
/** Seconds to wait before a new try, from `retry-after` of a 429 reply. */
|
|
15
|
+
retryAfter;
|
|
16
|
+
constructor(code, status, message, retryAfter) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "AssistantApiError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.retryAfter = retryAfter;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
25
|
+
var isString = (v) => typeof v === "string";
|
|
26
|
+
var RISKS = ["safe", "risky", "irreversible"];
|
|
27
|
+
var isTool = (v) => isObject(v) && isString(v.name) && isString(v.description) && isString(v.scope) && RISKS.includes(v.risk) && "inputSchema" in v;
|
|
28
|
+
var isPending = (v) => isObject(v) && isString(v.id) && isString(v.tool) && RISKS.includes(v.risk) && isString(v.digest) && isObject(v.input) && isObject(v.proposedBy) && isString(v.requestedBy) && typeof v.canDecide === "boolean";
|
|
29
|
+
var SHAPES = {
|
|
30
|
+
health: (v) => isObject(v) && typeof v.ok === "boolean" && isString(v.pack) && isString(v.version),
|
|
31
|
+
tools: (v) => isObject(v) && Array.isArray(v.tools) && v.tools.every(isTool),
|
|
32
|
+
approvals: (v) => isObject(v) && Array.isArray(v.approvals) && v.approvals.every(isPending),
|
|
33
|
+
decision: (v) => isObject(v) && isString(v.requestId) && typeof v.approved === "boolean" && isString(v.decidedBy) && typeof v.machineMade === "boolean"
|
|
34
|
+
};
|
|
35
|
+
var MAX_EVENT_CHARS = 4194304;
|
|
36
|
+
var EVENT_TYPES = ["run_start", "turn_start", "text_delta", "tool_call", "approval_request", "approval_decision", "tool_result", "turn_end", "done", "error"];
|
|
37
|
+
var isEvent = (v) => {
|
|
38
|
+
if (!isObject(v) || typeof v.type !== "string" || !EVENT_TYPES.includes(v.type)) return false;
|
|
39
|
+
switch (v.type) {
|
|
40
|
+
case "done":
|
|
41
|
+
return isString(v.reason) && isObject(v.totals);
|
|
42
|
+
case "error":
|
|
43
|
+
return isString(v.error);
|
|
44
|
+
case "text_delta":
|
|
45
|
+
return isString(v.text);
|
|
46
|
+
case "tool_result":
|
|
47
|
+
return isString(v.id) && isString(v.name) && isString(v.content) && typeof v.isError === "boolean";
|
|
48
|
+
case "tool_call":
|
|
49
|
+
return isString(v.id) && isString(v.name);
|
|
50
|
+
case "approval_request":
|
|
51
|
+
return isObject(v.request) && isString(v.request.id) && isString(v.request.digest);
|
|
52
|
+
case "approval_decision":
|
|
53
|
+
return isString(v.requestId) && typeof v.approved === "boolean";
|
|
54
|
+
default:
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function createClient(options) {
|
|
59
|
+
if (typeof options.baseUrl !== "string" || options.baseUrl === "") throw new TypeError("createClient needs a baseUrl");
|
|
60
|
+
if (typeof options.token !== "string" || options.token === "") throw new TypeError("createClient needs a token");
|
|
61
|
+
let origin;
|
|
62
|
+
try {
|
|
63
|
+
origin = new URL(options.baseUrl);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new TypeError("baseUrl must be an absolute URL");
|
|
66
|
+
}
|
|
67
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
|
|
68
|
+
if (origin.protocol !== "https:" && !(origin.protocol === "http:" && local)) {
|
|
69
|
+
throw new TypeError("baseUrl must be an https URL, or an http URL on localhost, 127.0.0.1, or [::1]");
|
|
70
|
+
}
|
|
71
|
+
if (origin.search !== "" || origin.hash !== "" || origin.username !== "" || origin.password !== "") {
|
|
72
|
+
throw new TypeError("baseUrl must have no query, hash, or credentials");
|
|
73
|
+
}
|
|
74
|
+
const prefix = origin.pathname.replace(/\/+$/, "");
|
|
75
|
+
const root = `${origin.origin}${prefix}/api/v1`;
|
|
76
|
+
const token = options.token;
|
|
77
|
+
const send = (input, init) => {
|
|
78
|
+
const run = options.fetch ?? globalThis.fetch;
|
|
79
|
+
return run(input, init);
|
|
80
|
+
};
|
|
81
|
+
const seg = (value) => {
|
|
82
|
+
if (typeof value !== "string" || /^(?:\.|%2e){0,2}$/i.test(value)) throw new TypeError("an id must be a non-empty string that is not . or ..");
|
|
83
|
+
return encodeURIComponent(value);
|
|
84
|
+
};
|
|
85
|
+
async function request(url, method, body, callOptions, authed = true) {
|
|
86
|
+
const headers = authed ? { authorization: `Bearer ${token}` } : {};
|
|
87
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
88
|
+
return send(url, {
|
|
89
|
+
method,
|
|
90
|
+
headers,
|
|
91
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
92
|
+
signal: callOptions?.signal,
|
|
93
|
+
// Never follow a redirect: it would send the token to another host.
|
|
94
|
+
redirect: "manual"
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function failure(response) {
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(await response.text());
|
|
101
|
+
} catch {
|
|
102
|
+
parsed = void 0;
|
|
103
|
+
}
|
|
104
|
+
const error = isObject(parsed) ? parsed.error : void 0;
|
|
105
|
+
const message = isObject(parsed) && typeof parsed.message === "string" ? parsed.message : typeof error === "string" ? error : `HTTP ${response.status}`;
|
|
106
|
+
let code = STATUS_CODES[response.status] ?? (response.status >= 500 ? "INTERNAL" : "UNKNOWN");
|
|
107
|
+
if (response.status === 503 && error === "not_configured") code = "NOT_CONFIGURED";
|
|
108
|
+
if (typeof error !== "string") code = response.status >= 500 ? "INTERNAL" : "UNKNOWN";
|
|
109
|
+
const after = response.headers.get("retry-after")?.trim() ?? "";
|
|
110
|
+
return new AssistantApiError(code, response.status, message, /^\d{1,9}$/.test(after) ? Number(after) : void 0);
|
|
111
|
+
}
|
|
112
|
+
async function call(url, method, body, callOptions, shape, authed = true) {
|
|
113
|
+
const response = await request(url, method, body, callOptions, authed);
|
|
114
|
+
if (!response.ok) throw await failure(response);
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(await response.text());
|
|
118
|
+
} catch {
|
|
119
|
+
parsed = void 0;
|
|
120
|
+
}
|
|
121
|
+
if (!SHAPES[shape](parsed)) throw new AssistantApiError("UNKNOWN", response.status, `the reply is not a ${shape}`);
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
async function* chat(message, callOptions) {
|
|
125
|
+
const response = await request(`${root}/chat`, "POST", { message }, callOptions);
|
|
126
|
+
if (!response.ok) throw await failure(response);
|
|
127
|
+
if (!(response.headers.get("content-type") ?? "").startsWith("text/event-stream") || response.body === null) {
|
|
128
|
+
await response.body?.cancel();
|
|
129
|
+
throw new AssistantApiError("UNKNOWN", response.status, "the reply is not an event stream");
|
|
130
|
+
}
|
|
131
|
+
const reader = response.body.getReader();
|
|
132
|
+
const decoder = new TextDecoder();
|
|
133
|
+
let buffer = "";
|
|
134
|
+
let ended = false;
|
|
135
|
+
function* drain(final) {
|
|
136
|
+
const text = final ? buffer : buffer.replace(/\r$/, "");
|
|
137
|
+
const normal = text.replace(/\r\n?/g, "\n");
|
|
138
|
+
const rest = buffer.slice(text.length);
|
|
139
|
+
let from = 0;
|
|
140
|
+
for (let end = normal.indexOf("\n\n", from); end >= 0; end = normal.indexOf("\n\n", from)) {
|
|
141
|
+
const event = parseBlock(normal.slice(from, end), response.status);
|
|
142
|
+
from = end + 2;
|
|
143
|
+
if (event === void 0) continue;
|
|
144
|
+
if (ended) throw new AssistantApiError("UNKNOWN", response.status, "an event came after the end of the stream");
|
|
145
|
+
ended = event.type === "done" || event.type === "error";
|
|
146
|
+
yield event;
|
|
147
|
+
}
|
|
148
|
+
buffer = normal.slice(from) + rest;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
for (; ; ) {
|
|
152
|
+
const chunk = await reader.read();
|
|
153
|
+
if (chunk.done) break;
|
|
154
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
155
|
+
yield* drain(false);
|
|
156
|
+
if (buffer.length > MAX_EVENT_CHARS) throw new AssistantApiError("UNKNOWN", response.status, "an event is too long");
|
|
157
|
+
}
|
|
158
|
+
buffer += decoder.decode();
|
|
159
|
+
yield* drain(true);
|
|
160
|
+
if (!ended || buffer.trim() !== "") throw new AssistantApiError("UNKNOWN", response.status, "the stream ended before a done event");
|
|
161
|
+
} finally {
|
|
162
|
+
await reader.cancel().catch(() => void 0);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function parseBlock(block, status) {
|
|
166
|
+
const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
167
|
+
if (data === "") return void 0;
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(data);
|
|
171
|
+
} catch {
|
|
172
|
+
parsed = void 0;
|
|
173
|
+
}
|
|
174
|
+
if (!isEvent(parsed)) throw new AssistantApiError("UNKNOWN", status, "an event is not valid");
|
|
175
|
+
return parsed;
|
|
176
|
+
}
|
|
177
|
+
const decided = (id, decision) => {
|
|
178
|
+
if (decision.requestId !== id) throw new AssistantApiError("UNKNOWN", 200, "the reply is for another approval");
|
|
179
|
+
return decision;
|
|
180
|
+
};
|
|
181
|
+
const methods = {
|
|
182
|
+
// /health is outside /api/v1 and needs no token, so the client sends none.
|
|
183
|
+
health: async (o) => call(`${origin.origin}${prefix}/health`, "GET", void 0, o, "health", false),
|
|
184
|
+
tools: async (o) => (await call(`${root}/tools`, "GET", void 0, o, "tools")).tools,
|
|
185
|
+
chat,
|
|
186
|
+
approvals: async (o) => (await call(`${root}/approvals`, "GET", void 0, o, "approvals")).approvals,
|
|
187
|
+
approve: async (id, digest, o) => decided(id, await call(`${root}/approvals/${seg(id)}/approve`, "POST", { digest }, o, "decision")),
|
|
188
|
+
deny: async (id, reason, o) => decided(id, await call(`${root}/approvals/${seg(id)}/deny`, "POST", reason === void 0 ? {} : { reason }, o, "decision"))
|
|
189
|
+
};
|
|
190
|
+
return methods;
|
|
191
|
+
}
|
|
192
|
+
export {
|
|
193
|
+
AssistantApiError,
|
|
194
|
+
createClient
|
|
195
|
+
};
|
package/dist/demo.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface DemoEnv {
|
|
2
|
+
PACK_NAME?: string;
|
|
3
|
+
/** Secret. A JSON map of bearer token (32 bytes or more) to `{ "id": "...", "scopes": ["demo:read"] }`. Unset means every call is a 401. */
|
|
4
|
+
ASSISTANT_TOKENS?: string;
|
|
5
|
+
/** Secret. The model API key. Unset means chat answers 503 "not_configured". */
|
|
6
|
+
MODEL_KEY?: string;
|
|
7
|
+
/** Base URL of the model endpoint, for example an EU-hosted gateway. */
|
|
8
|
+
MODEL_URL?: string;
|
|
9
|
+
/** Set to "1" to call api.anthropic.com, a US-hosted endpoint, when `MODEL_URL` is not set. */
|
|
10
|
+
MODEL_USE_DEFAULT_ENDPOINT?: string;
|
|
11
|
+
MODEL_NAME?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The demo Worker. It serves a demo page at `/`, `/health`, and the `/api/v1` routes with bearer auth.
|
|
15
|
+
* It never fakes output: with no tokens every call is a 401, and with no model key chat answers 503.
|
|
16
|
+
*/
|
|
17
|
+
declare const _default: {
|
|
18
|
+
fetch(request: Request, env: DemoEnv): Promise<Response>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { type DemoEnv, _default as default };
|