@oberik/sdk 0.5.0 → 0.7.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/dist/esm/index.js CHANGED
@@ -62,6 +62,103 @@ export class AgentCancelledError extends Error {
62
62
  // Errors
63
63
  // ============================================================================
64
64
  /** Does this look like a page rather than an answer? */
65
+ // ---- UI component arguments ----
66
+ //
67
+ // A UI component declares `parameters` exactly as a client tool does, and `render` draws
68
+ // into the customer's own product. Two schema violations of the *same* declared schema
69
+ // reached `render` unchecked: `bars` as a JSON string rather than an array, and array
70
+ // items missing a required `percent` while carrying two fields the schema never mentioned.
71
+ //
72
+ // For a client *tool* you can validate in the handler and answer the model. A UI component
73
+ // paints, so the check has to happen before it draws.
74
+ /** JSON Schema `properties`, whichever of the two shapes the app declared. */
75
+ function uiProps(schema) {
76
+ const s = schema;
77
+ return (s?.properties ?? s?.parameters?.properties ?? {});
78
+ }
79
+ function parseIfJson(value, want) {
80
+ if (typeof value !== "string")
81
+ return value;
82
+ const text = value.trim();
83
+ if (!text)
84
+ return value;
85
+ const opens = want === "array" ? "[" : "{";
86
+ if (!text.startsWith(opens))
87
+ return value;
88
+ try {
89
+ return JSON.parse(text);
90
+ }
91
+ catch {
92
+ // Passed through untouched. A cosmetic mismatch must not become a thrown render.
93
+ return value;
94
+ }
95
+ }
96
+ /**
97
+ * Make the arguments match the declared types, as far as that is safe.
98
+ *
99
+ * Narrow on purpose — this is the app's own schema and the values go straight back to it.
100
+ * A container declared as `array`/`object` that arrived as its JSON text is parsed, and
101
+ * that parsing recurses into array items, which is where the server's own coercion stops.
102
+ */
103
+ export function coerceUiArgs(schema, args) {
104
+ const props = uiProps(schema);
105
+ if (!Object.keys(props).length)
106
+ return args;
107
+ const out = { ...args };
108
+ for (const [key, spec] of Object.entries(props)) {
109
+ if (!(key in out))
110
+ continue;
111
+ const want = String(spec?.type ?? "");
112
+ if (want === "array" || want === "object")
113
+ out[key] = parseIfJson(out[key], want);
114
+ // Items of an array of objects: the same JSON-text-instead-of-a-container mistake,
115
+ // one level down, which is the level a chart's data actually lives at.
116
+ if (want === "array" && Array.isArray(out[key]) && spec?.items?.type === "object") {
117
+ out[key] = out[key].map((item) => parseIfJson(item, "object"));
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /** How `args` fails `schema`, as sentences — empty when it does not. */
123
+ export function uiSchemaViolations(schema, args) {
124
+ const props = uiProps(schema);
125
+ if (!Object.keys(props).length)
126
+ return [];
127
+ const required = (schema?.required ?? []);
128
+ const out = [];
129
+ for (const key of required) {
130
+ if (args[key] === undefined || args[key] === null)
131
+ out.push(`missing required "${key}"`);
132
+ }
133
+ for (const [key, spec] of Object.entries(props)) {
134
+ const value = args[key];
135
+ if (value === undefined || value === null)
136
+ continue;
137
+ const want = String(spec?.type ?? "");
138
+ if (want === "array" && !Array.isArray(value)) {
139
+ out.push(`"${key}" should be an array, got ${typeof value}`);
140
+ continue;
141
+ }
142
+ if (want === "array" && Array.isArray(value) && spec?.items) {
143
+ const itemRequired = (spec.items.required ?? []);
144
+ value.forEach((item, i) => {
145
+ if (typeof item !== "object" || item === null)
146
+ return;
147
+ for (const f of itemRequired) {
148
+ if (item[f] === undefined)
149
+ out.push(`"${key}[${i}]" is missing required "${f}"`);
150
+ }
151
+ });
152
+ }
153
+ if (want === "number" && typeof value !== "number") {
154
+ out.push(`"${key}" should be a number, got ${typeof value}`);
155
+ }
156
+ if (want === "string" && typeof value !== "string") {
157
+ out.push(`"${key}" should be a string, got ${typeof value}`);
158
+ }
159
+ }
160
+ return out;
161
+ }
65
162
  function looksLikeHtml(text) {
66
163
  return /^\s*(<!doctype html|<html|<head|<body)/i.test(text) || /<\/html>\s*$/i.test(text);
67
164
  }
@@ -82,9 +179,11 @@ function looksLikeHtml(text) {
82
179
  function describeDetail(status, detail) {
83
180
  if (typeof detail === "string") {
84
181
  if (looksLikeHtml(detail)) {
182
+ // Report what arrived, not a guess about what produced it. The API answers with
183
+ // JSON, so an HTML body is worth saying plainly — but naming whatever returned it
184
+ // would be speculation about someone else's infrastructure.
85
185
  const title = /<title[^>]*>([^<]{1,160})<\/title>/i.exec(detail)?.[1]?.trim();
86
- const where = status >= 502 ? " this came from a proxy in front of the API, not from the API" : "";
87
- return `HTTP ${status}${title ? `: ${title}` : ""}${where}`;
186
+ return `HTTP ${status}${title ? `: ${title}` : ""} (the response body was HTML, not JSON)`;
88
187
  }
89
188
  return detail.slice(0, 2000) || `HTTP ${status}`;
90
189
  }
@@ -280,6 +379,37 @@ async function collectDecisions(pending, handler) {
280
379
  * A handler that throws is treated as the user declining rather than as a failure:
281
380
  * the alternative is leaving the turn paused forever on a question nobody can now
282
381
  * answer, and "they'd rather talk about it" is both true and recoverable. */
382
+ /** One signal that aborts when any of its inputs does.
383
+ *
384
+ * `AbortSignal.any` exists on Node 20+ and modern browsers; the manual path is for
385
+ * anything older, because a client that cannot compose signals should lose the timeout,
386
+ * not the request. */
387
+ function combineSignals(...signals) {
388
+ const live = signals.filter(Boolean);
389
+ if (live.length <= 1)
390
+ return live[0];
391
+ const anyOf = AbortSignal.any;
392
+ if (typeof anyOf === "function")
393
+ return anyOf(live);
394
+ const ac = new AbortController();
395
+ for (const s of live) {
396
+ if (s.aborted) {
397
+ ac.abort(s.reason);
398
+ break;
399
+ }
400
+ s.addEventListener("abort", () => ac.abort(s.reason), { once: true });
401
+ }
402
+ return ac.signal;
403
+ }
404
+ /** Anything `UploadInput` allows, as a Blob for `FormData`. */
405
+ function asBlob(input) {
406
+ if (typeof Blob !== "undefined" && input instanceof Blob)
407
+ return input;
408
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
409
+ // Copied into a fresh buffer: a Node Buffer is a view onto a pooled allocation, and
410
+ // handing that straight to Blob can carry bytes belonging to something else.
411
+ return new Blob([bytes.slice()]);
412
+ }
283
413
  async function collectAnswers(pending, handler) {
284
414
  const out = [];
285
415
  for (const batch of pending) {
@@ -891,8 +1021,25 @@ export class AgentFramework {
891
1021
  const component = registry.get(r.name);
892
1022
  if (!component)
893
1023
  continue;
1024
+ // Coerced and checked against the schema the app declared, because `render` paints
1025
+ // straight into somebody's product and the frame handed to it is the one thing you
1026
+ // would expect to have been checked against the schema you just supplied.
1027
+ //
1028
+ // The server coerces too, but only the top-level properties, and it checks nothing
1029
+ // required — so a declared `bars: array of {label, percent}` arrived as a JSON
1030
+ // *string* in a browser and as `[{label, value, target}]` in Node, and both were
1031
+ // dispatched. The page drew "? 0.0%" four times.
1032
+ const args = coerceUiArgs(component.parameters, r.args ?? {});
1033
+ const problems = uiSchemaViolations(component.parameters, args);
1034
+ if (problems.length) {
1035
+ // Warn, then draw anyway. The agent has already told the user it showed them
1036
+ // something, so silently skipping leaves them looking for a chart that never
1037
+ // appears — worse than a chart with a gap in it. Loud enough to find in a console.
1038
+ console.warn(`[oberik] UI component "${r.name}" was called with arguments that do not match ` +
1039
+ `its declared schema: ${problems.join("; ")}. Rendering anyway.`);
1040
+ }
894
1041
  try {
895
- component.render(r.args ?? {});
1042
+ component.render(args);
896
1043
  }
897
1044
  catch (e) {
898
1045
  console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
@@ -930,7 +1077,16 @@ export class AgentFramework {
930
1077
  headers["Content-Type"] = "application/json";
931
1078
  body = JSON.stringify(init.body);
932
1079
  }
933
- return this._fetch(url.toString(), { method, headers, body, signal: init.signal });
1080
+ // A timeout the caller can actually set. Node's built-in fetch gives up waiting
1081
+ // for response headers after 300s with `UND_ERR_HEADERS_TIMEOUT`, and there was
1082
+ // no way to raise it short of hand-building an undici dispatcher — so a blocking
1083
+ // turn that ran a long sandbox job simply died as `TypeError: fetch failed` with
1084
+ // nothing naming this SDK. Composed with the caller's own signal rather than
1085
+ // replacing it, so `opts.signal` still cancels.
1086
+ const ms = this.opts.timeoutMs ?? 600_000;
1087
+ const timer = ms > 0 ? AbortSignal.timeout(ms) : undefined;
1088
+ const signal = combineSignals(init.signal, timer);
1089
+ return this._fetch(url.toString(), { method, headers, body, signal });
934
1090
  };
935
1091
  let res = await send();
936
1092
  // Token expired mid-session: get a fresh one and replay the request once, so the
@@ -1032,8 +1188,15 @@ export class AgentFramework {
1032
1188
  },
1033
1189
  /** Blocking chat that AUTO-EXECUTES registered client tools: it sends the
1034
1190
  * message, and whenever the agent asks for client tools it runs their
1035
- * handlers, submits the results, and repeats until the agent is done. */
1036
- run: (opts = {}) => this.runWithTools(opts),
1191
+ * handlers, submits the results, and repeats until the agent is done.
1192
+ *
1193
+ * Takes ONE argument, with the handlers inside it — unlike `chat.stream(req,
1194
+ * handlers)`, which takes two. Both forms are accepted here because the asymmetry
1195
+ * is a trap: passing handlers the way `stream` takes them was silently ignored, so
1196
+ * `onApproval` never fired and the turn came back with `approvals` set, which reads
1197
+ * exactly like approvals being broken. TypeScript catches it; the docs' own browser
1198
+ * examples are plain JS, where it does not. */
1199
+ run: (opts = {}, handlers) => this.runWithTools(handlers ? { ...opts, ...handlers } : opts),
1037
1200
  /** Streaming chat. When client tools are registered/passed, tool calls are
1038
1201
  * auto-executed and the stream resumes, so `done` only resolves when the
1039
1202
  * agent finishes (never with `requires_action`). */
@@ -2139,6 +2302,20 @@ export class OberikProject {
2139
2302
  throw new Error("No fetch available; pass options.fetch");
2140
2303
  this._fetch = raw.bind(globalThis);
2141
2304
  }
2305
+ /**
2306
+ * Any Project API route, with this project's key — the escape hatch.
2307
+ *
2308
+ * The end-user client has always had `raw()`; this one had a private `request` and
2309
+ * nothing else, so the documented routes with no method here (the webhook secret,
2310
+ * webhook-tool rotation, `webhook-tools/deliveries`) meant hand-rolling `fetch` with
2311
+ * an `X-API-Key` header and re-deriving the base URL. A published SDK should not make
2312
+ * you leave it to use the API it wraps.
2313
+ *
2314
+ * `path` is relative to `/api/projects/<id>` — `raw("GET", "/webhook-secret")`.
2315
+ */
2316
+ async raw(method, path, body, form) {
2317
+ return this.request(method, path, body, form);
2318
+ }
2142
2319
  async request(method, path, body, form) {
2143
2320
  const res = await this._fetch(`${this.baseUrl}/api/projects/${this.projectId}${path}`, {
2144
2321
  method,
@@ -2151,7 +2328,9 @@ export class OberikProject {
2151
2328
  const text = await res.text();
2152
2329
  const data = text ? safeJson(text) : null;
2153
2330
  if (!res.ok) {
2154
- throw new AgentApiError(res.status, (data && (data.error || data.message)) || `HTTP ${res.status}`);
2331
+ // `detail` on both planes now, so an integrator writes one accessor rather than
2332
+ // discovering that the control plane spelled it differently.
2333
+ throw new AgentApiError(res.status, (data && (data.detail || data.message)) || `HTTP ${res.status}`);
2155
2334
  }
2156
2335
  return data;
2157
2336
  }
@@ -2176,11 +2355,38 @@ export class OberikProject {
2176
2355
  */
2177
2356
  forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
2178
2357
  };
2358
+ /**
2359
+ * The project itself — its name, description, and the settings that are properties of
2360
+ * the project rather than permissions on a token.
2361
+ *
2362
+ * These had no SDK path at all. `doneWebhookUrl` is documented in the capability table
2363
+ * but is NOT a capability, so the obvious `capabilities.set({ doneWebhookUrl })` stored
2364
+ * nothing; the only symptom was `turn.stopped` never firing. Renaming a project, or
2365
+ * turning citation markers off, meant dropping to raw HTTP through `raw()`.
2366
+ */
2367
+ project = {
2368
+ get: () => this.request("GET", ""),
2369
+ /** Merges — send only what you want to change. */
2370
+ set: (fields) => this.request("PATCH", "", fields),
2371
+ /** Whether `[2]` stays in the visible answer. Its own route, not a PATCH field.
2372
+ * `stripped` leaves `claims[].start/end` as the only way to place a footnote. */
2373
+ citations: (markers) => this.request("PUT", "/citations", { markers }),
2374
+ };
2179
2375
  /** The capability ceiling: the maximum any token minted here may hold. */
2180
2376
  capabilities = {
2181
2377
  get: () => this.request("GET", "/capabilities"),
2182
- /** Merges — send only what you want to change. */
2183
- set: (caps) => this.request("PATCH", "", { capabilities: caps }),
2378
+ /**
2379
+ * Merges send only what you want to change.
2380
+ *
2381
+ * Read-only fields that `get()` returns are dropped rather than rejected, so
2382
+ * `set(await get())` — the obvious way to flip one flag — works. Anything else
2383
+ * unrecognised still errors: a capability nobody enforces must not be accepted in
2384
+ * silence, which is the failure this whole check exists for.
2385
+ */
2386
+ set: (caps) => {
2387
+ const { supportedModalities: _readOnly, ...settable } = caps;
2388
+ return this.request("PATCH", "", { capabilities: settable });
2389
+ },
2184
2390
  };
2185
2391
  /** Documents owned by the project rather than by any one end-user: the corpus you
2186
2392
  * curate and your users only read. */
@@ -2197,11 +2403,20 @@ export class OberikProject {
2197
2403
  * accepted nor needed — a recipe that worked by luck rather than by expression.
2198
2404
  */
2199
2405
  documents = {
2200
- list: () => this.request("GET", "/documents"),
2406
+ /** Same filters as the end-user client's `documents.list`, so the two agree. */
2407
+ list: (query = {}) => {
2408
+ const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString();
2409
+ return this.request("GET", `/documents${qs ? `?${qs}` : ""}`);
2410
+ },
2201
2411
  get: (documentId) => this.request("GET", `/documents/${documentId}`),
2412
+ /** `file` may be a Blob/File, an ArrayBuffer or a Uint8Array — the same shapes the
2413
+ * end-user client takes. It was `Blob | File` only, so the quickstart's own
2414
+ * `await readFile("q3-report.pdf")` (a Buffer) failed deep inside undici as
2415
+ * `parameter 2 is not of type 'Blob'`, with nothing naming Oberik in the stack. */
2202
2416
  upload: (file, opts = {}) => {
2203
2417
  const form = new FormData();
2204
- form.append("file", file, opts.filename ?? file.name ?? "upload.bin");
2418
+ const name = opts.filename ?? file.name ?? "upload.bin";
2419
+ form.append("file", asBlob(file), name);
2205
2420
  form.append("tags", (opts.tags ?? []).join(","));
2206
2421
  return this.request("POST", "/documents", undefined, form);
2207
2422
  },
@@ -2289,9 +2504,19 @@ export class OberikProject {
2289
2504
  /** Procedures you publish as Agent Plugins, and what your end-users have added. */
2290
2505
  skills = {
2291
2506
  list: () => this.request("GET", "/skills"),
2292
- upload: (zip, filename) => {
2507
+ /**
2508
+ * `zip` takes the same shapes as every other upload here — a Blob/File, an
2509
+ * ArrayBuffer or a Uint8Array — and `filename` goes in the options object.
2510
+ *
2511
+ * It was `Blob | File` with a positional filename, alone among four upload methods.
2512
+ * `await readFile("skill.zip")` (a Buffer) died as `parameter 2 is not of type 'Blob'`
2513
+ * inside undici with nothing naming Oberik in the stack — the identical failure that
2514
+ * was fixed for `documents.upload` and left here — and passing `{ filename }` in the
2515
+ * second slot, as the siblings take it, silently named the file `[object Object]`.
2516
+ */
2517
+ upload: (zip, opts = {}) => {
2293
2518
  const form = new FormData();
2294
- form.append("file", zip, filename ?? zip.name ?? "skill.zip");
2519
+ form.append("file", asBlob(zip), opts.filename ?? zip.name ?? "skill.zip");
2295
2520
  return this.request("POST", "/skills", undefined, form);
2296
2521
  },
2297
2522
  delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
@@ -2311,9 +2536,16 @@ export class OberikProject {
2311
2536
  tasks = {
2312
2537
  list: () => this.request("GET", "/tasks"),
2313
2538
  };
2314
- /** What the agent has written down: remembered facts and wiki pages. */
2539
+ /**
2540
+ * What the agent has written down: remembered facts and wiki pages.
2541
+ *
2542
+ * `kind` defaults to `"wiki"` — the route's default, and the reason this looked like
2543
+ * it "returns pages only": it does, and there was no argument here to ask for the
2544
+ * memories. Same shape and same type as the end-user client's `knowledge.list`, so the
2545
+ * two do not disagree about what a stored item is.
2546
+ */
2315
2547
  wiki = {
2316
- list: () => this.request("GET", "/wiki"),
2548
+ list: (opts = {}) => this.request("GET", `/wiki?kind=${opts.kind ?? "wiki"}`),
2317
2549
  delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
2318
2550
  };
2319
2551
  /** Live sandboxes, and what to do about one. */
@@ -2327,14 +2559,33 @@ export class OberikProject {
2327
2559
  };
2328
2560
  /** Browser origins allowed to call the data plane with this project's tokens. */
2329
2561
  origins = {
2562
+ /** What is allowed right now — the first thing you want when a browser request is
2563
+ * being refused, and previously unanswerable without the dashboard. */
2564
+ get: () => this.request("GET", "/origins"),
2330
2565
  set: (origins) => this.request("PUT", "/origins", { origins }),
2331
2566
  };
2332
2567
  /** Tools the agent calls by URL. The signing secret comes back once, on create. */
2333
2568
  webhookTools = {
2334
2569
  list: () => this.request("GET", "/webhook-tools"),
2335
2570
  create: (tool) => this.request("POST", "/webhook-tools", tool),
2571
+ /** Change a tool WITHOUT rotating its secret.
2572
+ *
2573
+ * There was no update at all, so moving a URL after a tunnel restart meant
2574
+ * delete-and-recreate — which issues a new signing secret, shown once, that your
2575
+ * handler then has to be redeployed with. A URL change is not a credential
2576
+ * rotation. `name` is what the model calls and is not editable. */
2577
+ update: (toolId, patch) => this.request("PATCH", `/webhook-tools/${toolId}`, patch),
2578
+ /** A new signing secret, with the old one still verifying for 24 hours. */
2579
+ rotate: (toolId) => this.request("POST", `/webhook-tools/${toolId}/rotate`, {}),
2580
+ /** Recent calls to your handlers: when, which tool, what came back, how long. */
2581
+ deliveries: () => this.request("GET", "/webhook-tools/deliveries"),
2336
2582
  delete: (toolId) => this.request("DELETE", `/webhook-tools/${toolId}`),
2337
2583
  };
2584
+ /** The key that signs `turn.stopped` and scheduled-task deliveries to you. */
2585
+ webhookSecret = {
2586
+ get: () => this.request("GET", "/webhook-secret"),
2587
+ rotate: () => this.request("POST", "/webhook-secret/rotate", {}),
2588
+ };
2338
2589
  /** Server-side keys. A created one is returned once and never again. */
2339
2590
  /**
2340
2591
  * Checks on what goes into the model and what comes back.