@oberik/sdk 0.6.0 → 0.8.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/cjs/index.js CHANGED
@@ -34,6 +34,9 @@
34
34
  */
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OberikProject = exports.DEFAULT_CONTROL_PLANE_URL = exports.AgentFramework = exports.AgentStreamError = exports.AgentApiError = exports.AgentCancelledError = exports.DEFAULT_BASE_URL = void 0;
37
+ exports.coerceUiArgs = coerceUiArgs;
38
+ exports.uiSchemaViolations = uiSchemaViolations;
39
+ exports.blockingBudgetIsHonoured = blockingBudgetIsHonoured;
37
40
  exports.createProjectClient = createProjectClient;
38
41
  exports.createClient = createClient;
39
42
  // ============================================================================
@@ -68,6 +71,169 @@ exports.AgentCancelledError = AgentCancelledError;
68
71
  // Errors
69
72
  // ============================================================================
70
73
  /** Does this look like a page rather than an answer? */
74
+ // ---- UI component arguments ----
75
+ //
76
+ // A UI component declares `parameters` exactly as a client tool does, and `render` draws
77
+ // into the customer's own product. Two schema violations of the *same* declared schema
78
+ // reached `render` unchecked: `bars` as a JSON string rather than an array, and array
79
+ // items missing a required `percent` while carrying two fields the schema never mentioned.
80
+ //
81
+ // For a client *tool* you can validate in the handler and answer the model. A UI component
82
+ // paints, so the check has to happen before it draws.
83
+ /** JSON Schema `properties`, whichever of the two shapes the app declared. */
84
+ function uiProps(schema) {
85
+ const s = schema;
86
+ return (s?.properties ?? s?.parameters?.properties ?? {});
87
+ }
88
+ function parseIfJson(value, want) {
89
+ if (typeof value !== "string")
90
+ return value;
91
+ const text = value.trim();
92
+ if (!text)
93
+ return value;
94
+ const opens = want === "array" ? "[" : "{";
95
+ if (!text.startsWith(opens))
96
+ return value;
97
+ try {
98
+ return JSON.parse(text);
99
+ }
100
+ catch {
101
+ // Passed through untouched. A cosmetic mismatch must not become a thrown render.
102
+ return value;
103
+ }
104
+ }
105
+ /**
106
+ * Make the arguments match the declared types, as far as that is safe.
107
+ *
108
+ * Narrow on purpose — this is the app's own schema and the values go straight back to it.
109
+ * A container declared as `array`/`object` that arrived as its JSON text is parsed, and
110
+ * that parsing recurses into array items, which is where the server's own coercion stops.
111
+ */
112
+ function coerceUiArgs(schema, args) {
113
+ const props = uiProps(schema);
114
+ if (!Object.keys(props).length)
115
+ return args;
116
+ const out = { ...args };
117
+ for (const [key, spec] of Object.entries(props)) {
118
+ if (!(key in out))
119
+ continue;
120
+ const want = String(spec?.type ?? "");
121
+ if (want === "array" || want === "object")
122
+ out[key] = parseIfJson(out[key], want);
123
+ // Items of an array of objects: the same JSON-text-instead-of-a-container mistake,
124
+ // one level down, which is the level a chart's data actually lives at.
125
+ if (want === "array" && Array.isArray(out[key]) && spec?.items?.type === "object") {
126
+ out[key] = out[key].map((item) => parseIfJson(item, "object"));
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ /** How `args` fails `schema`, as sentences — empty when it does not. */
132
+ function uiSchemaViolations(schema, args) {
133
+ const props = uiProps(schema);
134
+ if (!Object.keys(props).length)
135
+ return [];
136
+ const required = (schema?.required ?? []);
137
+ const out = [];
138
+ for (const key of required) {
139
+ if (args[key] === undefined || args[key] === null)
140
+ out.push(`missing required "${key}"`);
141
+ }
142
+ for (const [key, spec] of Object.entries(props)) {
143
+ const value = args[key];
144
+ if (value === undefined || value === null)
145
+ continue;
146
+ const want = String(spec?.type ?? "");
147
+ if (want === "array" && !Array.isArray(value)) {
148
+ out.push(`"${key}" should be an array, got ${typeof value}`);
149
+ continue;
150
+ }
151
+ if (want === "array" && Array.isArray(value) && spec?.items) {
152
+ const itemRequired = (spec.items.required ?? []);
153
+ value.forEach((item, i) => {
154
+ if (typeof item !== "object" || item === null)
155
+ return;
156
+ for (const f of itemRequired) {
157
+ if (item[f] === undefined)
158
+ out.push(`"${key}[${i}]" is missing required "${f}"`);
159
+ }
160
+ });
161
+ }
162
+ if (want === "number" && typeof value !== "number") {
163
+ out.push(`"${key}" should be a number, got ${typeof value}`);
164
+ }
165
+ if (want === "string" && typeof value !== "string") {
166
+ out.push(`"${key}" should be a string, got ${typeof value}`);
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ // ---- Node's five-minute wall ----
172
+ //
173
+ // `AbortSignal.timeout` bounds the whole request and does not touch undici's
174
+ // `headersTimeout`, which is 300s and fires first. So `timeoutMs: 600_000` really meant
175
+ // "about five minutes", and a blocking turn that ran a long sandbox job still died as
176
+ // `UND_ERR_HEADERS_TIMEOUT` — the exact failure this option was added to fix.
177
+ //
178
+ // The only way to move that ceiling is an undici `Agent`. It is not a dependency here (the
179
+ // SDK ships with none, and must load in a browser), so it is imported at runtime if the
180
+ // host happens to have it and skipped otherwise. Node's global `fetch` accepts a
181
+ // `dispatcher` on the request because it *is* undici underneath.
182
+ /** One agent per timeout value; building one per request would leak sockets. */
183
+ const _dispatchers = new Map();
184
+ /** Settled results, so the hot path never awaits.
185
+ *
186
+ * Awaiting the lookup on every request put each one a microtask later — invisible for a
187
+ * chat turn and not for a hand-off, where pointer events are relayed as they happen and
188
+ * the whole design is "relay the gesture, not an approximation of it". */
189
+ const _settled = new Map();
190
+ /** Node's default headers timeout. Below this, nothing needs raising. */
191
+ const NODE_HEADERS_TIMEOUT_MS = 300_000;
192
+ async function longRequestDispatcher(ms) {
193
+ const proc = globalThis.process;
194
+ if (!proc?.versions?.node)
195
+ return undefined; // a browser: no such ceiling
196
+ if (ms <= NODE_HEADERS_TIMEOUT_MS)
197
+ return undefined;
198
+ let pending = _dispatchers.get(ms);
199
+ if (!pending) {
200
+ pending = (async () => {
201
+ try {
202
+ // The specifier is built at runtime so a bundler does not try to resolve a
203
+ // package that is deliberately not a dependency.
204
+ const mod = await Promise.resolve(`${["undici"].join("")}`).then(s => require(s));
205
+ const Agent = mod?.Agent ?? mod?.default?.Agent;
206
+ return Agent ? new Agent({ headersTimeout: ms, bodyTimeout: ms }) : undefined;
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ })();
212
+ _dispatchers.set(ms, pending);
213
+ pending.then((d) => _settled.set(ms, d)).catch(() => _settled.set(ms, undefined));
214
+ }
215
+ return pending;
216
+ }
217
+ /** Whether this request needs the dispatcher looked up at all.
218
+ *
219
+ * `explicit` matters: the lookup costs a dynamic import the first time, and only a
220
+ * caller who actually set `timeoutMs` past Node's 300s ceiling is asking for something
221
+ * the default cannot give them. Everyone else — including a hand-off viewer relaying
222
+ * pointer events, which sets no timeout at all — dispatches in the same tick it always
223
+ * did. */
224
+ function needsDispatcher(ms, explicit) {
225
+ const proc = globalThis.process;
226
+ return explicit && Boolean(proc?.versions?.node) && ms > NODE_HEADERS_TIMEOUT_MS;
227
+ }
228
+ /** Whether a long blocking budget will actually be honoured here. */
229
+ async function blockingBudgetIsHonoured(ms) {
230
+ const proc = globalThis.process;
231
+ if (!proc?.versions?.node)
232
+ return true;
233
+ if (ms <= NODE_HEADERS_TIMEOUT_MS)
234
+ return true;
235
+ return (await longRequestDispatcher(ms)) !== undefined;
236
+ }
71
237
  function looksLikeHtml(text) {
72
238
  return /^\s*(<!doctype html|<html|<head|<body)/i.test(text) || /<\/html>\s*$/i.test(text);
73
239
  }
@@ -88,9 +254,11 @@ function looksLikeHtml(text) {
88
254
  function describeDetail(status, detail) {
89
255
  if (typeof detail === "string") {
90
256
  if (looksLikeHtml(detail)) {
257
+ // Report what arrived, not a guess about what produced it. The API answers with
258
+ // JSON, so an HTML body is worth saying plainly — but naming whatever returned it
259
+ // would be speculation about someone else's infrastructure.
91
260
  const title = /<title[^>]*>([^<]{1,160})<\/title>/i.exec(detail)?.[1]?.trim();
92
- const where = status >= 502 ? " this came from a proxy in front of the API, not from the API" : "";
93
- return `HTTP ${status}${title ? `: ${title}` : ""}${where}`;
261
+ return `HTTP ${status}${title ? `: ${title}` : ""} (the response body was HTML, not JSON)`;
94
262
  }
95
263
  return detail.slice(0, 2000) || `HTTP ${status}`;
96
264
  }
@@ -930,8 +1098,25 @@ class AgentFramework {
930
1098
  const component = registry.get(r.name);
931
1099
  if (!component)
932
1100
  continue;
1101
+ // Coerced and checked against the schema the app declared, because `render` paints
1102
+ // straight into somebody's product and the frame handed to it is the one thing you
1103
+ // would expect to have been checked against the schema you just supplied.
1104
+ //
1105
+ // The server coerces too, but only the top-level properties, and it checks nothing
1106
+ // required — so a declared `bars: array of {label, percent}` arrived as a JSON
1107
+ // *string* in a browser and as `[{label, value, target}]` in Node, and both were
1108
+ // dispatched. The page drew "? 0.0%" four times.
1109
+ const args = coerceUiArgs(component.parameters, r.args ?? {});
1110
+ const problems = uiSchemaViolations(component.parameters, args);
1111
+ if (problems.length) {
1112
+ // Warn, then draw anyway. The agent has already told the user it showed them
1113
+ // something, so silently skipping leaves them looking for a chart that never
1114
+ // appears — worse than a chart with a gap in it. Loud enough to find in a console.
1115
+ console.warn(`[oberik] UI component "${r.name}" was called with arguments that do not match ` +
1116
+ `its declared schema: ${problems.join("; ")}. Rendering anyway.`);
1117
+ }
933
1118
  try {
934
- component.render(r.args ?? {});
1119
+ component.render(args);
935
1120
  }
936
1121
  catch (e) {
937
1122
  console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
@@ -975,10 +1160,28 @@ class AgentFramework {
975
1160
  // turn that ran a long sandbox job simply died as `TypeError: fetch failed` with
976
1161
  // nothing naming this SDK. Composed with the caller's own signal rather than
977
1162
  // replacing it, so `opts.signal` still cancels.
1163
+ const explicitTimeout = this.opts.timeoutMs !== undefined;
978
1164
  const ms = this.opts.timeoutMs ?? 600_000;
979
1165
  const timer = ms > 0 ? AbortSignal.timeout(ms) : undefined;
980
1166
  const signal = combineSignals(init.signal, timer);
981
- return this._fetch(url.toString(), { method, headers, body, signal });
1167
+ // The abort signal alone does not raise Node's 300s header wait; the dispatcher
1168
+ // does. Undefined in a browser, and on a Node without `undici` installed — where
1169
+ // the effective ceiling stays five minutes and streaming is the answer.
1170
+ //
1171
+ // Resolved without awaiting once it has settled, and not consulted at all where it
1172
+ // could not apply: in a browser this whole branch is skipped, so the request is
1173
+ // dispatched in the same tick it always was.
1174
+ let dispatcher;
1175
+ if (ms > 0 && needsDispatcher(ms, explicitTimeout)) {
1176
+ dispatcher = _settled.has(ms) ? _settled.get(ms) : await longRequestDispatcher(ms);
1177
+ }
1178
+ return this._fetch(url.toString(), {
1179
+ method,
1180
+ headers,
1181
+ body,
1182
+ signal,
1183
+ ...(dispatcher ? { dispatcher } : {}),
1184
+ });
982
1185
  };
983
1186
  let res = await send();
984
1187
  // Token expired mid-session: get a fresh one and replay the request once, so the
@@ -1557,8 +1760,14 @@ class AgentFramework {
1557
1760
  const hasApprovals = onApproval != null && (resp.approvals?.length ?? 0) > 0;
1558
1761
  if (!hasTools && !hasQuestions && !hasApprovals)
1559
1762
  return merged;
1763
+ // Hand back the turn, don't throw it away.
1764
+ //
1765
+ // This threw, so hitting the ceiling produced no content and no `session_id` —
1766
+ // nothing to render and nothing to resume from — while the SERVER's ceiling on the
1767
+ // same loop returns the answer produced so far plus `finish_reason`, which the docs
1768
+ // even give a UI recipe for. Two ceilings on one loop with opposite semantics.
1560
1769
  if (rounds++ >= maxRounds)
1561
- throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1770
+ return { ...merged, finish_reason: "max_tool_rounds" };
1562
1771
  const next = {
1563
1772
  session_id: resp.session_id, client_tools: schemas, ui_tools: uiSchemas,
1564
1773
  };
@@ -1604,8 +1813,10 @@ class AgentFramework {
1604
1813
  return merged;
1605
1814
  if (stopped)
1606
1815
  return merged;
1816
+ // Returned rather than thrown, for the same reason as the blocking path above:
1817
+ // the work is done and paid for, and the caller has somewhere to resume from.
1607
1818
  if (rounds++ >= maxRounds)
1608
- throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1819
+ return { ...merged, finish_reason: "max_tool_rounds" };
1609
1820
  reqBody = { session_id: d.session_id, client_tools: schemas, ui_tools: uiSchemas };
1610
1821
  if (hasTools) {
1611
1822
  handlers.onToolCalls?.(d.tool_calls);
@@ -2221,7 +2432,9 @@ class OberikProject {
2221
2432
  const text = await res.text();
2222
2433
  const data = text ? safeJson(text) : null;
2223
2434
  if (!res.ok) {
2224
- throw new AgentApiError(res.status, (data && (data.error || data.message)) || `HTTP ${res.status}`);
2435
+ // `detail` on both planes now, so an integrator writes one accessor rather than
2436
+ // discovering that the control plane spelled it differently.
2437
+ throw new AgentApiError(res.status, (data && (data.detail || data.message)) || `HTTP ${res.status}`);
2225
2438
  }
2226
2439
  return data;
2227
2440
  }
@@ -2246,11 +2459,38 @@ class OberikProject {
2246
2459
  */
2247
2460
  forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
2248
2461
  };
2462
+ /**
2463
+ * The project itself — its name, description, and the settings that are properties of
2464
+ * the project rather than permissions on a token.
2465
+ *
2466
+ * These had no SDK path at all. `doneWebhookUrl` is documented in the capability table
2467
+ * but is NOT a capability, so the obvious `capabilities.set({ doneWebhookUrl })` stored
2468
+ * nothing; the only symptom was `turn.stopped` never firing. Renaming a project, or
2469
+ * turning citation markers off, meant dropping to raw HTTP through `raw()`.
2470
+ */
2471
+ project = {
2472
+ get: () => this.request("GET", ""),
2473
+ /** Merges — send only what you want to change. */
2474
+ set: (fields) => this.request("PATCH", "", fields),
2475
+ /** Whether `[2]` stays in the visible answer. Its own route, not a PATCH field.
2476
+ * `stripped` leaves `claims[].start/end` as the only way to place a footnote. */
2477
+ citations: (markers) => this.request("PUT", "/citations", { markers }),
2478
+ };
2249
2479
  /** The capability ceiling: the maximum any token minted here may hold. */
2250
2480
  capabilities = {
2251
2481
  get: () => this.request("GET", "/capabilities"),
2252
- /** Merges — send only what you want to change. */
2253
- set: (caps) => this.request("PATCH", "", { capabilities: caps }),
2482
+ /**
2483
+ * Merges send only what you want to change.
2484
+ *
2485
+ * Read-only fields that `get()` returns are dropped rather than rejected, so
2486
+ * `set(await get())` — the obvious way to flip one flag — works. Anything else
2487
+ * unrecognised still errors: a capability nobody enforces must not be accepted in
2488
+ * silence, which is the failure this whole check exists for.
2489
+ */
2490
+ set: (caps) => {
2491
+ const { supportedModalities: _readOnly, ...settable } = caps;
2492
+ return this.request("PATCH", "", { capabilities: settable });
2493
+ },
2254
2494
  };
2255
2495
  /** Documents owned by the project rather than by any one end-user: the corpus you
2256
2496
  * curate and your users only read. */
@@ -2267,7 +2507,11 @@ class OberikProject {
2267
2507
  * accepted nor needed — a recipe that worked by luck rather than by expression.
2268
2508
  */
2269
2509
  documents = {
2270
- list: () => this.request("GET", "/documents"),
2510
+ /** Same filters as the end-user client's `documents.list`, so the two agree. */
2511
+ list: (query = {}) => {
2512
+ const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString();
2513
+ return this.request("GET", `/documents${qs ? `?${qs}` : ""}`);
2514
+ },
2271
2515
  get: (documentId) => this.request("GET", `/documents/${documentId}`),
2272
2516
  /** `file` may be a Blob/File, an ArrayBuffer or a Uint8Array — the same shapes the
2273
2517
  * end-user client takes. It was `Blob | File` only, so the quickstart's own
@@ -2364,9 +2608,19 @@ class OberikProject {
2364
2608
  /** Procedures you publish as Agent Plugins, and what your end-users have added. */
2365
2609
  skills = {
2366
2610
  list: () => this.request("GET", "/skills"),
2367
- upload: (zip, filename) => {
2611
+ /**
2612
+ * `zip` takes the same shapes as every other upload here — a Blob/File, an
2613
+ * ArrayBuffer or a Uint8Array — and `filename` goes in the options object.
2614
+ *
2615
+ * It was `Blob | File` with a positional filename, alone among four upload methods.
2616
+ * `await readFile("skill.zip")` (a Buffer) died as `parameter 2 is not of type 'Blob'`
2617
+ * inside undici with nothing naming Oberik in the stack — the identical failure that
2618
+ * was fixed for `documents.upload` and left here — and passing `{ filename }` in the
2619
+ * second slot, as the siblings take it, silently named the file `[object Object]`.
2620
+ */
2621
+ upload: (zip, opts = {}) => {
2368
2622
  const form = new FormData();
2369
- form.append("file", zip, filename ?? zip.name ?? "skill.zip");
2623
+ form.append("file", asBlob(zip), opts.filename ?? zip.name ?? "skill.zip");
2370
2624
  return this.request("POST", "/skills", undefined, form);
2371
2625
  },
2372
2626
  delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
@@ -2386,9 +2640,16 @@ class OberikProject {
2386
2640
  tasks = {
2387
2641
  list: () => this.request("GET", "/tasks"),
2388
2642
  };
2389
- /** What the agent has written down: remembered facts and wiki pages. */
2643
+ /**
2644
+ * What the agent has written down: remembered facts and wiki pages.
2645
+ *
2646
+ * `kind` defaults to `"wiki"` — the route's default, and the reason this looked like
2647
+ * it "returns pages only": it does, and there was no argument here to ask for the
2648
+ * memories. Same shape and same type as the end-user client's `knowledge.list`, so the
2649
+ * two do not disagree about what a stored item is.
2650
+ */
2390
2651
  wiki = {
2391
- list: () => this.request("GET", "/wiki"),
2652
+ list: (opts = {}) => this.request("GET", `/wiki?kind=${opts.kind ?? "wiki"}`),
2392
2653
  delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
2393
2654
  };
2394
2655
  /** Live sandboxes, and what to do about one. */