@oberik/sdk 0.1.0 → 0.3.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 +558 -38
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +757 -70
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +555 -37
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cjs/index.js
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* await handle.done;
|
|
34
34
|
*/
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.AgentFramework = exports.AgentStreamError = exports.AgentApiError = exports.AgentCancelledError = exports.DEFAULT_BASE_URL = void 0;
|
|
36
|
+
exports.OberikProject = exports.DEFAULT_CONTROL_PLANE_URL = exports.AgentFramework = exports.AgentStreamError = exports.AgentApiError = exports.AgentCancelledError = exports.DEFAULT_BASE_URL = void 0;
|
|
37
|
+
exports.createProjectClient = createProjectClient;
|
|
37
38
|
exports.createClient = createClient;
|
|
38
39
|
// ============================================================================
|
|
39
40
|
// Types
|
|
@@ -96,6 +97,88 @@ function toolSchemas(tools) {
|
|
|
96
97
|
function: { name: t.name, description: t.description, parameters: t.parameters ?? { type: "object", properties: {} } },
|
|
97
98
|
}));
|
|
98
99
|
}
|
|
100
|
+
/** The same shape as a client tool on the wire — the server builds a callable tool from
|
|
101
|
+
* it either way. What differs is what happens when the agent calls it: a client tool's
|
|
102
|
+
* result is awaited, and this one's is nothing. */
|
|
103
|
+
function uiToolSchemas(ui) {
|
|
104
|
+
return [...ui.values()].map((c) => ({
|
|
105
|
+
type: "function",
|
|
106
|
+
function: {
|
|
107
|
+
name: c.name,
|
|
108
|
+
description: c.description,
|
|
109
|
+
parameters: c.parameters ?? { type: "object", properties: {} },
|
|
110
|
+
},
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Fold a later turn's payload into the running one.
|
|
115
|
+
*
|
|
116
|
+
* A pause is a new server turn. When the agent stops for a client tool, a question or an
|
|
117
|
+
* approval, resuming starts a *fresh* request, and the server builds its `done` from that
|
|
118
|
+
* turn alone — so the citations from the retrieval it did before the pause simply are not
|
|
119
|
+
* in the payload the loop finally returns.
|
|
120
|
+
*
|
|
121
|
+
* Which is what happened: an app that combined retrieval with its own tools — the app the
|
|
122
|
+
* docs tell you to build — got `citations: 0` and `sources: 0` on every turn where a tool
|
|
123
|
+
* ran, and an empty `ui` for anything drawn before the pause. Meanwhile both were streamed
|
|
124
|
+
* live and both rendered, so it worked on screen and vanished from the object. The docs
|
|
125
|
+
* promise the opposite in as many words: "the `done` payload carries the final state of
|
|
126
|
+
* everything above, so a client that ignored the incremental frames still ends up with the
|
|
127
|
+
* whole turn."
|
|
128
|
+
*
|
|
129
|
+
* Three kinds of field, and getting the kind wrong is its own bug:
|
|
130
|
+
* - accumulated: prose and lists the turn produced across all its segments
|
|
131
|
+
* - deduped: retrieval, which is very likely to repeat across segments
|
|
132
|
+
* - last-wins: what the *server* recomputes each time and is authoritative about
|
|
133
|
+
*/
|
|
134
|
+
function mergeDone(prev, next) {
|
|
135
|
+
if (!prev)
|
|
136
|
+
return next;
|
|
137
|
+
const byKey = (items, key) => {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const it of items) {
|
|
141
|
+
const k = key(it);
|
|
142
|
+
if (seen.has(k))
|
|
143
|
+
continue;
|
|
144
|
+
seen.add(k);
|
|
145
|
+
out.push(it);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
...next,
|
|
151
|
+
// Accumulated: everything the agent said and drew, in order.
|
|
152
|
+
content: [prev.content, next.content].filter(Boolean).join(""),
|
|
153
|
+
// Claims accumulate, and the later segment's offsets shift by however much prose came
|
|
154
|
+
// before it. Getting this wrong points a footnote at the wrong sentence, which is worse
|
|
155
|
+
// than having no footnote.
|
|
156
|
+
claims: [
|
|
157
|
+
...(prev.claims ?? []),
|
|
158
|
+
...(next.claims ?? []).map((claim) => ({
|
|
159
|
+
...claim,
|
|
160
|
+
start: claim.start == null ? null : claim.start + prev.content.length,
|
|
161
|
+
end: claim.end == null ? null : claim.end + prev.content.length,
|
|
162
|
+
})),
|
|
163
|
+
],
|
|
164
|
+
attribution: prev.attribution === "per-claim" || next.attribution === "per-claim"
|
|
165
|
+
? "per-claim"
|
|
166
|
+
: prev.attribution === "retrieval-only" || next.attribution === "retrieval-only"
|
|
167
|
+
? "retrieval-only"
|
|
168
|
+
: "none",
|
|
169
|
+
reasoning: [prev.reasoning ?? "", next.reasoning ?? ""].filter(Boolean).join("") || undefined,
|
|
170
|
+
ui: [...(prev.ui ?? []), ...(next.ui ?? [])],
|
|
171
|
+
guard_flags: [...new Set([...(prev.guard_flags ?? []), ...(next.guard_flags ?? [])])],
|
|
172
|
+
// Deduped: the same chunk retrieved twice is one citation, and an attachment carried
|
|
173
|
+
// forward across a pause is one file.
|
|
174
|
+
citations: byKey([...(prev.citations ?? []), ...(next.citations ?? [])], (c) => `${c.document_id}:${c.chunk_index}`),
|
|
175
|
+
sources: byKey([...(prev.sources ?? []), ...(next.sources ?? [])], (s) => String(s.url ?? `${s.document_id}:${s.chunk_index ?? ""}`)),
|
|
176
|
+
attachments: byKey([...(prev.attachments ?? []), ...(next.attachments ?? [])], (a) => String(a.id ?? a.s3_key ?? a.filename)),
|
|
177
|
+
// Last-wins: the server recomputes these per turn and is right about them. `todos` is
|
|
178
|
+
// the whole current plan, not a delta; `subagents` is every subagent of the
|
|
179
|
+
// conversation; `context` describes the window as it is now.
|
|
180
|
+
};
|
|
181
|
+
}
|
|
99
182
|
/** Run each pending client tool via its handler; failures become error results
|
|
100
183
|
* (not thrown) so one bad tool doesn't abort the whole turn. */
|
|
101
184
|
async function executeToolCalls(calls, tools) {
|
|
@@ -117,6 +200,26 @@ async function executeToolCalls(calls, tools) {
|
|
|
117
200
|
return { tool_call_id: call.id, content };
|
|
118
201
|
}));
|
|
119
202
|
}
|
|
203
|
+
/** Put each approval request to the handler and shape the result for the API.
|
|
204
|
+
*
|
|
205
|
+
* A handler that throws is treated as a refusal rather than as a failure. That is the
|
|
206
|
+
* safe direction and the only defensible one: the alternative is a UI bug becoming an
|
|
207
|
+
* irreversible action. */
|
|
208
|
+
async function collectDecisions(pending, handler) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const request of pending) {
|
|
211
|
+
let decision;
|
|
212
|
+
try {
|
|
213
|
+
const result = await handler(request);
|
|
214
|
+
decision = typeof result === "boolean" ? { approved: result } : result;
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
decision = { approved: false, note: String(e?.message ?? e) };
|
|
218
|
+
}
|
|
219
|
+
out.push({ ...decision, tool_call_id: decision.tool_call_id ?? request.tool_call_id });
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
120
223
|
/** Put each paused question batch to the handler and shape the result for the API.
|
|
121
224
|
*
|
|
122
225
|
* A handler that throws is treated as the user declining rather than as a failure:
|
|
@@ -199,6 +302,7 @@ class AgentFramework {
|
|
|
199
302
|
opts;
|
|
200
303
|
_fetch;
|
|
201
304
|
toolRegistry = new Map();
|
|
305
|
+
uiRegistry = new Map();
|
|
202
306
|
/** Active session watchers, so a streamed turn can mark its own messages seen. */
|
|
203
307
|
watchers = new Set();
|
|
204
308
|
/** The bearer in use: `opts.token` initially, replaced on refresh. */
|
|
@@ -220,6 +324,8 @@ class AgentFramework {
|
|
|
220
324
|
this.currentToken = opts.token;
|
|
221
325
|
for (const t of opts.tools ?? [])
|
|
222
326
|
this.toolRegistry.set(t.name, t);
|
|
327
|
+
for (const c of opts.ui ?? [])
|
|
328
|
+
this.uiRegistry.set(c.name, c);
|
|
223
329
|
}
|
|
224
330
|
/** Seconds before a token's own expiry at which we stop using it.
|
|
225
331
|
*
|
|
@@ -695,6 +801,19 @@ class AgentFramework {
|
|
|
695
801
|
this.toolRegistry.set(t.name, t);
|
|
696
802
|
return this;
|
|
697
803
|
}
|
|
804
|
+
/** Register a UI component the agent can draw into your app.
|
|
805
|
+
*
|
|
806
|
+
* Unlike a tool, nothing is handed back: the agent calls it, your `render` runs, and
|
|
807
|
+
* the turn carries on without waiting. */
|
|
808
|
+
registerUi(component) {
|
|
809
|
+
this.uiRegistry.set(component.name, component);
|
|
810
|
+
return this;
|
|
811
|
+
}
|
|
812
|
+
registerUiComponents(components) {
|
|
813
|
+
for (const c of components)
|
|
814
|
+
this.uiRegistry.set(c.name, c);
|
|
815
|
+
return this;
|
|
816
|
+
}
|
|
698
817
|
/** Merge the client-level registry with any per-call tools (per-call wins). */
|
|
699
818
|
resolveTools(extra) {
|
|
700
819
|
const m = new Map(this.toolRegistry);
|
|
@@ -702,6 +821,29 @@ class AgentFramework {
|
|
|
702
821
|
m.set(t.name, t);
|
|
703
822
|
return m;
|
|
704
823
|
}
|
|
824
|
+
resolveUi(extra) {
|
|
825
|
+
const m = new Map(this.uiRegistry);
|
|
826
|
+
for (const c of extra ?? [])
|
|
827
|
+
m.set(c.name, c);
|
|
828
|
+
return m;
|
|
829
|
+
}
|
|
830
|
+
/** Draw whatever the agent asked for, in order.
|
|
831
|
+
*
|
|
832
|
+
* A component that throws is logged and skipped: one broken chart must not take down
|
|
833
|
+
* the turn that drew it, and there is nothing to report back to the agent anyway. */
|
|
834
|
+
renderUi(renders, registry) {
|
|
835
|
+
for (const r of renders ?? []) {
|
|
836
|
+
const component = registry.get(r.name);
|
|
837
|
+
if (!component)
|
|
838
|
+
continue;
|
|
839
|
+
try {
|
|
840
|
+
component.render(r.args ?? {});
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
705
847
|
// -- auth headers --------------------------------------------------------
|
|
706
848
|
/** `bearer` overrides token resolution — used to replay a request with the token a
|
|
707
849
|
* refresh just produced, instead of asking for one again. */
|
|
@@ -1006,6 +1148,50 @@ class AgentFramework {
|
|
|
1006
1148
|
},
|
|
1007
1149
|
};
|
|
1008
1150
|
// -- documents -----------------------------------------------------------
|
|
1151
|
+
/** Agent Plugins — the skills this token can reach for, and the ones it may add.
|
|
1152
|
+
*
|
|
1153
|
+
* Two sources, one list: what the project published plus anything this end-user
|
|
1154
|
+
* uploaded. Uploading needs the `plugins:write` capability; reading does not, because
|
|
1155
|
+
* a project that publishes a procedure wants its agent to use it. */
|
|
1156
|
+
plugins = {
|
|
1157
|
+
list: () => this.request("GET", "/plugins"),
|
|
1158
|
+
delete: (id) => this.request("DELETE", `/plugins/${id}`),
|
|
1159
|
+
/** Publish a skill. Re-uploading a name replaces it.
|
|
1160
|
+
*
|
|
1161
|
+
* Takes whatever the customer actually has:
|
|
1162
|
+
*
|
|
1163
|
+
* - a packaged Agent Plugin (`plugin.json` + `skills/`), read as-is;
|
|
1164
|
+
* - a zipped folder of skills, or a single `SKILL.md` — a manifest is written
|
|
1165
|
+
* for them, because requiring one to publish a file of instructions is a
|
|
1166
|
+
* packaging exercise standing in front of the feature;
|
|
1167
|
+
* - a folder's files, from a directory picker, each keyed by its relative path.
|
|
1168
|
+
*
|
|
1169
|
+
* An end-user's plugin is private to them and unioned on top of the project's —
|
|
1170
|
+
* only a project key can publish to everyone. */
|
|
1171
|
+
upload: async (file, opts = {}) => {
|
|
1172
|
+
const form = new FormData();
|
|
1173
|
+
const blob = typeof Blob !== "undefined" && file instanceof Blob
|
|
1174
|
+
? file
|
|
1175
|
+
: new Blob([file], { type: "application/zip" });
|
|
1176
|
+
form.append("file", blob, opts.filename ?? file?.name ?? "plugin.zip");
|
|
1177
|
+
return this.request("POST", "/plugins", { form, signal: opts.signal });
|
|
1178
|
+
},
|
|
1179
|
+
/** Publish a folder of skills without zipping it.
|
|
1180
|
+
*
|
|
1181
|
+
* `files` is what a browser directory picker gives you. Each part is sent under
|
|
1182
|
+
* its path relative to the folder, which is all the server needs to lay the
|
|
1183
|
+
* skills out — so no zip library is needed on your side. */
|
|
1184
|
+
uploadFolder: async (files, opts = {}) => {
|
|
1185
|
+
const form = new FormData();
|
|
1186
|
+
for (const { path, content } of files) {
|
|
1187
|
+
const blob = typeof Blob !== "undefined" && content instanceof Blob
|
|
1188
|
+
? content
|
|
1189
|
+
: new Blob([content]);
|
|
1190
|
+
form.append("files", blob, path);
|
|
1191
|
+
}
|
|
1192
|
+
return this.request("POST", "/plugins", { form, signal: opts.signal });
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1009
1195
|
documents = {
|
|
1010
1196
|
list: (query = {}) => this.request("GET", "/documents", { query }),
|
|
1011
1197
|
get: (id) => this.request("GET", `/documents/${id}`),
|
|
@@ -1096,20 +1282,61 @@ class AgentFramework {
|
|
|
1096
1282
|
get: (id) => this.request("GET", `/tasks/${id}`),
|
|
1097
1283
|
cancel: (id) => this.request("POST", `/tasks/${id}/cancel`),
|
|
1098
1284
|
};
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* Turns that start because something happened somewhere else.
|
|
1287
|
+
*
|
|
1288
|
+
* A trigger is a URL you give another system — a ticket tracker, a CI job, a Zapier step
|
|
1289
|
+
* — and the path is the credential, so it runs as a fixed subject chosen at creation.
|
|
1290
|
+
* These were the only routes in the API reference with no SDK method: every integration
|
|
1291
|
+
* hand-wrote `fetch` for them.
|
|
1292
|
+
*
|
|
1293
|
+
* The URL comes back absolute and is not a secret we can show once: the whole point is
|
|
1294
|
+
* that someone else's configuration holds it. `secret` IS shown once — with it, the
|
|
1295
|
+
* sender signs the body and the URL stops being a bearer token.
|
|
1296
|
+
*/
|
|
1297
|
+
triggers = {
|
|
1298
|
+
list: () => this.request("GET", "/triggers"),
|
|
1299
|
+
/** `prompt` may interpolate the event: `"A ticket arrived: {{ body.title }}"`. */
|
|
1300
|
+
create: (opts) => this.request("POST", "/triggers", {
|
|
1301
|
+
body: {
|
|
1302
|
+
prompt: opts.prompt,
|
|
1303
|
+
name: opts.name,
|
|
1304
|
+
system_prompt: opts.systemPrompt,
|
|
1305
|
+
session_id: opts.sessionId,
|
|
1306
|
+
signed: opts.signed ?? false,
|
|
1307
|
+
},
|
|
1308
|
+
}),
|
|
1309
|
+
delete: (triggerId) => this.request("DELETE", `/triggers/${triggerId}`),
|
|
1310
|
+
/** A new URL, with the old one alive for 24 hours — so telling the other system its new
|
|
1311
|
+
* address is not an outage. */
|
|
1312
|
+
rotate: (triggerId) => this.request("POST", `/triggers/${triggerId}/rotate`),
|
|
1313
|
+
};
|
|
1314
|
+
/**
|
|
1315
|
+
* What the agent wrote down — remembered facts and wiki pages.
|
|
1316
|
+
*
|
|
1317
|
+
* Retrieval reads these back on every turn, so when an answer looks wrong a
|
|
1318
|
+
* remembered fact is often the reason. The ACL that governs retrieval governs this
|
|
1319
|
+
* too: a caller sees exactly what its token could have retrieved.
|
|
1320
|
+
*/
|
|
1321
|
+
memory = {
|
|
1322
|
+
/** `kind` is "memory" (facts, per end-user) or "wiki" (pages, per user or shared). */
|
|
1323
|
+
list: (opts = {}) => this.request("GET", "/memory", { query: { kind: opts.kind ?? "memory", limit: opts.limit } }),
|
|
1324
|
+
/** Forget one. The agent can write it again; this removes what is there now. */
|
|
1325
|
+
delete: (itemId) => this.request("DELETE", `/memory/${itemId}`),
|
|
1112
1326
|
};
|
|
1327
|
+
/**
|
|
1328
|
+
* What this token can actually do.
|
|
1329
|
+
*
|
|
1330
|
+
* The three gates on a turn are the platform's kill switch, the token's capability
|
|
1331
|
+
* and the per-request `enable_*` flag, and until this existed a client could read
|
|
1332
|
+
* none of them. `flags[].effective` is the useful one: false means setting that flag
|
|
1333
|
+
* changes nothing on this token — which is otherwise indistinguishable from the agent
|
|
1334
|
+
* simply choosing not to use the tool.
|
|
1335
|
+
*
|
|
1336
|
+
* Cheap and safe to call on load: a token asking what it holds is reading its own
|
|
1337
|
+
* claims back, so it needs no capability of its own.
|
|
1338
|
+
*/
|
|
1339
|
+
capabilities = () => this.request("GET", "/capabilities");
|
|
1113
1340
|
// -- governance: audit trail + right-to-be-forgotten (admin) -------------
|
|
1114
1341
|
audit = {
|
|
1115
1342
|
/** Read the tenant's audit trail (admin). Filter by action/subject. */
|
|
@@ -1162,81 +1389,93 @@ class AgentFramework {
|
|
|
1162
1389
|
return res.blob();
|
|
1163
1390
|
},
|
|
1164
1391
|
};
|
|
1165
|
-
/**
|
|
1166
|
-
* Connect an external data source and keep it live in the agent's knowledge.
|
|
1167
|
-
* Validates the connector, runs an initial sync, and (when an interval is set)
|
|
1168
|
-
* schedules recurring auto-refresh so the data never goes stale.
|
|
1169
|
-
*
|
|
1170
|
-
* await ai.sync({ name: "orders", connector_type: "postgres",
|
|
1171
|
-
* config: { dsn, query: "select id, status, total from orders",
|
|
1172
|
-
* cursor_column: "updated_at" }, sync_interval_seconds: 900 });
|
|
1173
|
-
*/
|
|
1174
|
-
sync = (config) => this.request("POST", "/sources", { body: config });
|
|
1175
1392
|
// ==========================================================================
|
|
1176
1393
|
// Automatic client-tool dispatch
|
|
1177
1394
|
// ==========================================================================
|
|
1178
1395
|
async runWithTools(opts) {
|
|
1179
|
-
const { tools: extra, maxToolRounds, onToolCalls, onQuestion, ...body } = opts;
|
|
1396
|
+
const { tools: extra, ui: extraUi, maxToolRounds, onToolCalls, onQuestion, onApproval, ...body } = opts;
|
|
1180
1397
|
const tools = this.resolveTools(extra);
|
|
1398
|
+
const ui = this.resolveUi(extraUi);
|
|
1181
1399
|
const schemas = tools.size ? toolSchemas(tools) : undefined;
|
|
1400
|
+
const uiSchemas = ui.size ? uiToolSchemas(ui) : undefined;
|
|
1182
1401
|
const maxRounds = maxToolRounds ?? 10;
|
|
1183
1402
|
let resp = await this.request("POST", "/chat", {
|
|
1184
|
-
body: { ...body, client_tools: schemas },
|
|
1403
|
+
body: { ...body, client_tools: schemas, ui_tools: uiSchemas },
|
|
1185
1404
|
signal: opts.signal,
|
|
1186
1405
|
});
|
|
1406
|
+
this.renderUi(resp.ui, ui);
|
|
1407
|
+
// What the caller gets back: every segment folded together. Each pause starts a new
|
|
1408
|
+
// server turn, so the last segment's payload knows nothing about the retrieval that
|
|
1409
|
+
// happened before it.
|
|
1410
|
+
let merged = resp;
|
|
1187
1411
|
let rounds = 0;
|
|
1188
|
-
//
|
|
1189
|
-
// to
|
|
1190
|
-
// so a pathological alternation can't loop forever.
|
|
1412
|
+
// Three ways a turn pauses: work for the client to run, a question for the user to
|
|
1413
|
+
// answer, or permission to ask for. All resume the same way, and the round budget
|
|
1414
|
+
// covers them together so a pathological alternation can't loop forever.
|
|
1191
1415
|
for (;;) {
|
|
1192
1416
|
const hasTools = resp.requires_action && resp.tool_calls.length > 0;
|
|
1193
1417
|
const hasQuestions = onQuestion != null && (resp.questions?.length ?? 0) > 0;
|
|
1194
|
-
|
|
1195
|
-
|
|
1418
|
+
const hasApprovals = onApproval != null && (resp.approvals?.length ?? 0) > 0;
|
|
1419
|
+
if (!hasTools && !hasQuestions && !hasApprovals)
|
|
1420
|
+
return merged;
|
|
1196
1421
|
if (rounds++ >= maxRounds)
|
|
1197
1422
|
throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
|
|
1198
|
-
const next = {
|
|
1423
|
+
const next = {
|
|
1424
|
+
session_id: resp.session_id, client_tools: schemas, ui_tools: uiSchemas,
|
|
1425
|
+
};
|
|
1199
1426
|
if (hasTools) {
|
|
1200
1427
|
onToolCalls?.(resp.tool_calls);
|
|
1201
1428
|
next.tool_results = await executeToolCalls(resp.tool_calls, tools);
|
|
1202
1429
|
}
|
|
1203
1430
|
if (hasQuestions)
|
|
1204
1431
|
next.question_answers = await collectAnswers(resp.questions, onQuestion);
|
|
1432
|
+
if (hasApprovals)
|
|
1433
|
+
next.approval_decisions = await collectDecisions(resp.approvals, onApproval);
|
|
1205
1434
|
resp = await this.request("POST", "/chat", { body: next, signal: opts.signal });
|
|
1435
|
+
this.renderUi(resp.ui, ui);
|
|
1436
|
+
merged = mergeDone(merged, resp);
|
|
1206
1437
|
}
|
|
1207
1438
|
}
|
|
1208
1439
|
/** Wrap startStream so a `done` carrying `requires_action` auto-executes the
|
|
1209
1440
|
* client tools and continues the stream (same session) until the agent ends. */
|
|
1210
1441
|
startStreamWithTools(body, handlers) {
|
|
1211
1442
|
const tools = this.resolveTools(handlers.tools);
|
|
1443
|
+
const ui = this.resolveUi(handlers.ui);
|
|
1212
1444
|
const onQuestion = handlers.onQuestion;
|
|
1213
|
-
|
|
1445
|
+
const onApproval = handlers.onApproval;
|
|
1446
|
+
if (tools.size === 0 && ui.size === 0 && onQuestion == null && onApproval == null)
|
|
1214
1447
|
return this.startStream(body, handlers);
|
|
1215
1448
|
const schemas = tools.size ? toolSchemas(tools) : undefined;
|
|
1216
1449
|
const maxRounds = handlers.maxToolRounds ?? 10;
|
|
1217
1450
|
let current;
|
|
1218
1451
|
let stopped = false;
|
|
1219
1452
|
const done = (async () => {
|
|
1220
|
-
|
|
1453
|
+
const uiSchemas = ui.size ? uiToolSchemas(ui) : undefined;
|
|
1454
|
+
let reqBody = { ...body, client_tools: schemas, ui_tools: uiSchemas };
|
|
1455
|
+
let merged;
|
|
1221
1456
|
let rounds = 0;
|
|
1222
1457
|
for (;;) {
|
|
1223
1458
|
current = this.startStream(reqBody, handlers);
|
|
1224
1459
|
const d = await current.done;
|
|
1460
|
+
merged = mergeDone(merged, d);
|
|
1225
1461
|
const hasTools = d.requires_action && d.tool_calls.length > 0;
|
|
1226
1462
|
const hasQuestions = onQuestion != null && (d.questions?.length ?? 0) > 0;
|
|
1227
|
-
|
|
1228
|
-
|
|
1463
|
+
const hasApprovals = onApproval != null && (d.approvals?.length ?? 0) > 0;
|
|
1464
|
+
if (!hasTools && !hasQuestions && !hasApprovals)
|
|
1465
|
+
return merged;
|
|
1229
1466
|
if (stopped)
|
|
1230
|
-
return
|
|
1467
|
+
return merged;
|
|
1231
1468
|
if (rounds++ >= maxRounds)
|
|
1232
1469
|
throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
|
|
1233
|
-
reqBody = { session_id: d.session_id, client_tools: schemas };
|
|
1470
|
+
reqBody = { session_id: d.session_id, client_tools: schemas, ui_tools: uiSchemas };
|
|
1234
1471
|
if (hasTools) {
|
|
1235
1472
|
handlers.onToolCalls?.(d.tool_calls);
|
|
1236
1473
|
reqBody.tool_results = await executeToolCalls(d.tool_calls, tools);
|
|
1237
1474
|
}
|
|
1238
1475
|
if (hasQuestions)
|
|
1239
1476
|
reqBody.question_answers = await collectAnswers(d.questions, onQuestion);
|
|
1477
|
+
if (hasApprovals)
|
|
1478
|
+
reqBody.approval_decisions = await collectDecisions(d.approvals, onApproval);
|
|
1240
1479
|
}
|
|
1241
1480
|
})();
|
|
1242
1481
|
return makeStreamHandle(done, {
|
|
@@ -1373,6 +1612,9 @@ class AgentFramework {
|
|
|
1373
1612
|
const onOuterAbort = () => ac.abort();
|
|
1374
1613
|
handlers.signal?.addEventListener("abort", onOuterAbort);
|
|
1375
1614
|
const maxRetries = handlers.maxRetries ?? 10;
|
|
1615
|
+
// The components this stream may draw. Resolved once: the registry can be added to
|
|
1616
|
+
// between turns, and a stream should draw with what it was started with.
|
|
1617
|
+
const uiRegistry = handlers.ui || this.uiRegistry.size ? this.resolveUi(handlers.ui) : undefined;
|
|
1376
1618
|
let runId;
|
|
1377
1619
|
let lastId = -1;
|
|
1378
1620
|
let full = "";
|
|
@@ -1458,6 +1700,12 @@ class AgentFramework {
|
|
|
1458
1700
|
case "todos":
|
|
1459
1701
|
handlers.onTodos?.(ev.data.todos);
|
|
1460
1702
|
break;
|
|
1703
|
+
case "ui":
|
|
1704
|
+
// Drawn the moment the agent calls it, mid-turn — waiting for `done`
|
|
1705
|
+
// would mean a chart appearing after the paragraph that refers to it.
|
|
1706
|
+
uiRegistry?.get(ev.data.name)?.render(ev.data.args ?? {});
|
|
1707
|
+
handlers.onUi?.(ev.data);
|
|
1708
|
+
break;
|
|
1461
1709
|
case "subagent":
|
|
1462
1710
|
subagents.set(ev.data.subagent.ref, ev.data.subagent);
|
|
1463
1711
|
handlers.onSubagents?.([...subagents.values()]);
|
|
@@ -1743,6 +1991,278 @@ function safeJson(s) {
|
|
|
1743
1991
|
return s;
|
|
1744
1992
|
}
|
|
1745
1993
|
}
|
|
1994
|
+
// ============================================================================
|
|
1995
|
+
// The server side: a project key, and everything it can do
|
|
1996
|
+
// ============================================================================
|
|
1997
|
+
/** Where projects are administered and end-user tokens are minted. A different host
|
|
1998
|
+
* from {@link DEFAULT_BASE_URL}, and a different credential — the two are not
|
|
1999
|
+
* interchangeable, which is the whole reason there are two clients. */
|
|
2000
|
+
exports.DEFAULT_CONTROL_PLANE_URL = "https://oberik.com";
|
|
2001
|
+
/**
|
|
2002
|
+
* The control plane, from your backend.
|
|
2003
|
+
*
|
|
2004
|
+
* The other client in this package talks to the data plane as one end-user. This one
|
|
2005
|
+
* holds the project key and administers the project itself: minting those tokens,
|
|
2006
|
+
* setting the capability ceiling, curating the corpus.
|
|
2007
|
+
*
|
|
2008
|
+
* They are separate classes on purpose. A project key can mint a token with any
|
|
2009
|
+
* capability the project allows — and create further keys, and delete the project — so
|
|
2010
|
+
* it must never travel to the same place an end-user token does. Two types make that a
|
|
2011
|
+
* decision someone has to make rather than a field they can accidentally set.
|
|
2012
|
+
*/
|
|
2013
|
+
class OberikProject {
|
|
2014
|
+
baseUrl;
|
|
2015
|
+
projectId;
|
|
2016
|
+
key;
|
|
2017
|
+
_fetch;
|
|
2018
|
+
constructor(opts) {
|
|
2019
|
+
if (!opts.projectId)
|
|
2020
|
+
throw new Error("projectId is required");
|
|
2021
|
+
if (!opts.projectKey)
|
|
2022
|
+
throw new Error("projectKey is required");
|
|
2023
|
+
// A project key in a browser is never right: it mints any capability the project
|
|
2024
|
+
// allows and can delete the project outright. Bundling this by accident is easy —
|
|
2025
|
+
// it is one import away from the client that DOES belong there — so it fails at
|
|
2026
|
+
// construction, where the stack trace names the file that did it, rather than
|
|
2027
|
+
// shipping and leaking the key to every visitor.
|
|
2028
|
+
if (typeof window !== "undefined" && typeof window.document !== "undefined") {
|
|
2029
|
+
throw new Error("OberikProject holds a project key and must never run in a browser — it can mint " +
|
|
2030
|
+
"any capability the project allows, create more keys, and delete the project. " +
|
|
2031
|
+
"Mint tokens on your server and send the token to the browser instead: " +
|
|
2032
|
+
"createClient({ getToken }).");
|
|
2033
|
+
}
|
|
2034
|
+
this.projectId = opts.projectId;
|
|
2035
|
+
this.key = opts.projectKey;
|
|
2036
|
+
this.baseUrl = (opts.baseUrl || exports.DEFAULT_CONTROL_PLANE_URL).replace(/\/+$/, "");
|
|
2037
|
+
const raw = opts.fetch ?? globalThis.fetch;
|
|
2038
|
+
if (!raw)
|
|
2039
|
+
throw new Error("No fetch available; pass options.fetch");
|
|
2040
|
+
this._fetch = raw.bind(globalThis);
|
|
2041
|
+
}
|
|
2042
|
+
async request(method, path, body, form) {
|
|
2043
|
+
const res = await this._fetch(`${this.baseUrl}/api/projects/${this.projectId}${path}`, {
|
|
2044
|
+
method,
|
|
2045
|
+
headers: {
|
|
2046
|
+
"X-API-Key": this.key,
|
|
2047
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
2048
|
+
},
|
|
2049
|
+
body: form ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
|
2050
|
+
});
|
|
2051
|
+
const text = await res.text();
|
|
2052
|
+
const data = text ? safeJson(text) : null;
|
|
2053
|
+
if (!res.ok) {
|
|
2054
|
+
throw new AgentApiError(res.status, (data && (data.error || data.message)) || `HTTP ${res.status}`);
|
|
2055
|
+
}
|
|
2056
|
+
return data;
|
|
2057
|
+
}
|
|
2058
|
+
tokens = {
|
|
2059
|
+
/**
|
|
2060
|
+
* Mint a short-lived token for ONE end-user.
|
|
2061
|
+
*
|
|
2062
|
+
* const { access_token } = await oberik.tokens.mint({
|
|
2063
|
+
* subject: `${user.orgId}:${user.id}`,
|
|
2064
|
+
* scope: `${user.orgId}:${user.id}`,
|
|
2065
|
+
* capabilities: ["chat", "documents:read"],
|
|
2066
|
+
* });
|
|
2067
|
+
*/
|
|
2068
|
+
mint: (input) => this.request("POST", "/token", input),
|
|
2069
|
+
/**
|
|
2070
|
+
* The same thing shaped as the callback {@link createClient} wants, so the two
|
|
2071
|
+
* halves of this package fit together without a wrapper:
|
|
2072
|
+
*
|
|
2073
|
+
* const ai = createClient({ getToken: oberik.tokens.forUser({ subject: id }) });
|
|
2074
|
+
*
|
|
2075
|
+
* Called again whenever a token expires, so the client refreshes on its own.
|
|
2076
|
+
*/
|
|
2077
|
+
forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
|
|
2078
|
+
};
|
|
2079
|
+
/** The capability ceiling: the maximum any token minted here may hold. */
|
|
2080
|
+
capabilities = {
|
|
2081
|
+
get: () => this.request("GET", "/capabilities"),
|
|
2082
|
+
/** Merges — send only what you want to change. */
|
|
2083
|
+
set: (caps) => this.request("PATCH", "", { capabilities: caps }),
|
|
2084
|
+
};
|
|
2085
|
+
/** Documents owned by the project rather than by any one end-user: the corpus you
|
|
2086
|
+
* curate and your users only read. */
|
|
2087
|
+
/**
|
|
2088
|
+
* The corpus you curate.
|
|
2089
|
+
*
|
|
2090
|
+
* Uploads here are stored `tenant`-visible — readable by every end-user of the project —
|
|
2091
|
+
* which is what "a corpus you curate, that users only read" means. That is the default
|
|
2092
|
+
* and the only option, deliberately: a project key is not a person, so there is no
|
|
2093
|
+
* per-user subtree for it to write into. Per-user documents go through the data-plane
|
|
2094
|
+
* client with an end-user token, where the subject IS the owner.
|
|
2095
|
+
*
|
|
2096
|
+
* The docs used to show `visibility: "tenant"` being passed here, which was neither
|
|
2097
|
+
* accepted nor needed — a recipe that worked by luck rather than by expression.
|
|
2098
|
+
*/
|
|
2099
|
+
documents = {
|
|
2100
|
+
list: () => this.request("GET", "/documents"),
|
|
2101
|
+
upload: (file, opts = {}) => {
|
|
2102
|
+
const form = new FormData();
|
|
2103
|
+
form.append("file", file, opts.filename ?? file.name ?? "upload.bin");
|
|
2104
|
+
form.append("tags", (opts.tags ?? []).join(","));
|
|
2105
|
+
return this.request("POST", "/documents", undefined, form);
|
|
2106
|
+
},
|
|
2107
|
+
delete: (documentId) => this.request("DELETE", `/documents/${documentId}`),
|
|
2108
|
+
};
|
|
2109
|
+
/**
|
|
2110
|
+
* The models this project runs on, and the retrieval it uses.
|
|
2111
|
+
*
|
|
2112
|
+
* These had no methods at all: `project-api.md` documents them in a table and every
|
|
2113
|
+
* integration hand-rolled `fetch` for half its setup. `providers.add` is the one that
|
|
2114
|
+
* matters most — it is the step a new project cannot answer a question without, and it
|
|
2115
|
+
* finishes the rest of the setup itself (see `derived` in the response).
|
|
2116
|
+
*
|
|
2117
|
+
* `providers.catalog` is deliberately absent: it is not project-scoped, so it does not
|
|
2118
|
+
* belong on a client whose every path hangs off one project.
|
|
2119
|
+
*/
|
|
2120
|
+
providers = {
|
|
2121
|
+
list: () => this.request("GET", "/providers"),
|
|
2122
|
+
/** Name a chat model AND an embedding model: the first lets the agent answer, the
|
|
2123
|
+
* second lets it index. The response's `derived` says what was set for you. */
|
|
2124
|
+
add: (opts) => this.request("POST", "/providers", opts),
|
|
2125
|
+
edit: (credId, opts) => this.request("PATCH", `/providers/${credId}`, opts),
|
|
2126
|
+
/** Re-read the provider's catalog: a model registered before its price was published
|
|
2127
|
+
* bills nothing, so the usage cap never trips. */
|
|
2128
|
+
refresh: (credId) => this.request("POST", `/providers/${credId}/refresh`, {}),
|
|
2129
|
+
remove: (credId) => this.request("DELETE", `/providers/${credId}`),
|
|
2130
|
+
};
|
|
2131
|
+
/** The model used when a request does not name one. */
|
|
2132
|
+
defaultModel = {
|
|
2133
|
+
set: (model) => this.request("POST", "/default-model", { model }),
|
|
2134
|
+
};
|
|
2135
|
+
/** Embedding and rerank overrides. Set for you when you register an embedding model, so
|
|
2136
|
+
* this is for changing it rather than for getting started. */
|
|
2137
|
+
retrieval = {
|
|
2138
|
+
set: (opts) => this.request("PUT", "/retrieval", opts),
|
|
2139
|
+
/** How many floats a model returns, measured by embedding one word. No provider
|
|
2140
|
+
* publishes it, and a wrong one fails at the first ingest rather than here. */
|
|
2141
|
+
probe: (model) => this.request("POST", "/retrieval/probe", { model }),
|
|
2142
|
+
};
|
|
2143
|
+
/** How documents are read: the built-in parser, or a vision model you choose. */
|
|
2144
|
+
documentProcessor = {
|
|
2145
|
+
set: (opts) => this.request("PUT", "/document-processor", opts),
|
|
2146
|
+
};
|
|
2147
|
+
/** What happens when a conversation outgrows the model's window. */
|
|
2148
|
+
context = {
|
|
2149
|
+
get: () => this.request("GET", "/context"),
|
|
2150
|
+
set: (opts) => this.request("PUT", "/context", opts),
|
|
2151
|
+
};
|
|
2152
|
+
/** Spend and rate caps on this project's LLM key. Enforced by the biller, so they hold
|
|
2153
|
+
* even when the usage views cannot be read. */
|
|
2154
|
+
limits = {
|
|
2155
|
+
get: () => this.request("GET", "/limits"),
|
|
2156
|
+
set: (opts) => this.request("PUT", "/limits", opts),
|
|
2157
|
+
};
|
|
2158
|
+
/** Which models a delegate may run on, and how many may run at once. Without this the
|
|
2159
|
+
* subagents capability stays unavailable however it is granted. */
|
|
2160
|
+
subagents = {
|
|
2161
|
+
set: (opts) => this.request("PUT", "/subagents", opts),
|
|
2162
|
+
};
|
|
2163
|
+
/** Procedures you publish as Agent Plugins, and what your end-users have added. */
|
|
2164
|
+
skills = {
|
|
2165
|
+
list: () => this.request("GET", "/skills"),
|
|
2166
|
+
upload: (zip, filename) => {
|
|
2167
|
+
const form = new FormData();
|
|
2168
|
+
form.append("file", zip, filename ?? zip.name ?? "skill.zip");
|
|
2169
|
+
return this.request("POST", "/skills", undefined, form);
|
|
2170
|
+
},
|
|
2171
|
+
delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
|
|
2172
|
+
};
|
|
2173
|
+
/** MCP servers whose tools join this project's catalog. */
|
|
2174
|
+
mcp = {
|
|
2175
|
+
list: () => this.request("GET", "/mcp"),
|
|
2176
|
+
add: (opts) => this.request("POST", "/mcp", opts),
|
|
2177
|
+
remove: (mcpId) => this.request("DELETE", `/mcp/${mcpId}`),
|
|
2178
|
+
};
|
|
2179
|
+
/** Conversations, and what was said in them. */
|
|
2180
|
+
sessions = {
|
|
2181
|
+
list: () => this.request("GET", "/sessions"),
|
|
2182
|
+
messages: (sessionId) => this.request("GET", `/sessions/${sessionId}/messages`),
|
|
2183
|
+
};
|
|
2184
|
+
/** Scheduled work this project's end-users have created. */
|
|
2185
|
+
tasks = {
|
|
2186
|
+
list: () => this.request("GET", "/tasks"),
|
|
2187
|
+
};
|
|
2188
|
+
/** What the agent has written down: remembered facts and wiki pages. */
|
|
2189
|
+
wiki = {
|
|
2190
|
+
list: () => this.request("GET", "/wiki"),
|
|
2191
|
+
delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
|
|
2192
|
+
};
|
|
2193
|
+
/** Live sandboxes, and what to do about one. */
|
|
2194
|
+
sandboxes = {
|
|
2195
|
+
list: () => this.request("GET", "/sandboxes"),
|
|
2196
|
+
action: (sessionId, action) => this.request("POST", `/sandboxes/${sessionId}/${action}`, {}),
|
|
2197
|
+
};
|
|
2198
|
+
/** Prepended to every request for this project, above anything a caller sends. */
|
|
2199
|
+
systemPrompt = {
|
|
2200
|
+
set: (systemPrompt) => this.request("PUT", "/system-prompt", { systemPrompt }),
|
|
2201
|
+
};
|
|
2202
|
+
/** Browser origins allowed to call the data plane with this project's tokens. */
|
|
2203
|
+
origins = {
|
|
2204
|
+
set: (origins) => this.request("PUT", "/origins", { origins }),
|
|
2205
|
+
};
|
|
2206
|
+
/** Tools the agent calls by URL. The signing secret comes back once, on create. */
|
|
2207
|
+
webhookTools = {
|
|
2208
|
+
list: () => this.request("GET", "/webhook-tools"),
|
|
2209
|
+
create: (tool) => this.request("POST", "/webhook-tools", tool),
|
|
2210
|
+
delete: (toolId) => this.request("DELETE", `/webhook-tools/${toolId}`),
|
|
2211
|
+
};
|
|
2212
|
+
/** Server-side keys. A created one is returned once and never again. */
|
|
2213
|
+
/**
|
|
2214
|
+
* Checks on what goes into the model and what comes back.
|
|
2215
|
+
*
|
|
2216
|
+
* The enforcement has existed for a long time and there was no way to configure it — no
|
|
2217
|
+
* route, no dashboard section, no column — so a documentation page described switches
|
|
2218
|
+
* that could not be reached. `set` takes a partial: what you do not mention is left as it
|
|
2219
|
+
* is.
|
|
2220
|
+
*/
|
|
2221
|
+
guardrails = {
|
|
2222
|
+
get: () => this.request("GET", "/guardrails"),
|
|
2223
|
+
set: (policy) => this.request("PUT", "/guardrails", { body: policy }),
|
|
2224
|
+
};
|
|
2225
|
+
/**
|
|
2226
|
+
* Whether this project can actually answer a question yet.
|
|
2227
|
+
*
|
|
2228
|
+
* A new project has no models, so it can neither answer nor index anything — and the
|
|
2229
|
+
* flag that used to be the closest thing to this (`hasLlm`) meant "a LiteLLM key was
|
|
2230
|
+
* provisioned", which is true from the moment a project exists. Every unfinished step
|
|
2231
|
+
* names what it blocks and the one call that fixes it.
|
|
2232
|
+
*
|
|
2233
|
+
* Worth calling in a deploy check: a project that is not ready fails every request with
|
|
2234
|
+
* the provider's own error, which reads as your bug rather than as missing setup.
|
|
2235
|
+
*/
|
|
2236
|
+
readiness = () => this.request("GET", "/readiness");
|
|
2237
|
+
/** The starting snippet and this project's endpoints — the same one the dashboard and the
|
|
2238
|
+
* SSH gateway show, so there is one of it rather than three. */
|
|
2239
|
+
connect = () => this.request("GET", "/connect");
|
|
2240
|
+
/**
|
|
2241
|
+
* Further project keys.
|
|
2242
|
+
*
|
|
2243
|
+
* `scope` is the important argument and it defaults to the narrow one. A `mint` key
|
|
2244
|
+
* can turn your signed-in user into an end-user token and nothing else — it cannot
|
|
2245
|
+
* read the corpus, raise the capability ceiling, issue more keys, or delete the
|
|
2246
|
+
* project. That is what almost every backend actually needs, and it is the difference
|
|
2247
|
+
* between a leaked key costing you some tokens and costing you the workspace.
|
|
2248
|
+
*/
|
|
2249
|
+
keys = {
|
|
2250
|
+
list: () => this.request("GET", "/keys"),
|
|
2251
|
+
create: (name, opts = {}) => this.request("POST", "/keys", { name, scope: opts.scope ?? "mint" }),
|
|
2252
|
+
revoke: (keyId) => this.request("DELETE", `/keys/${keyId}`),
|
|
2253
|
+
};
|
|
2254
|
+
/** Spend, requests, tokens and latency — including per end-user, since spend is
|
|
2255
|
+
* attributed to the token's subject. */
|
|
2256
|
+
usage = {
|
|
2257
|
+
summary: () => this.request("GET", "/usage"),
|
|
2258
|
+
observability: (windowSeconds = 86_400) => this.request("GET", `/observability?window=${windowSeconds}`),
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
exports.OberikProject = OberikProject;
|
|
2262
|
+
/** Factory helper for the server-side client. */
|
|
2263
|
+
function createProjectClient(opts) {
|
|
2264
|
+
return new OberikProject(opts);
|
|
2265
|
+
}
|
|
1746
2266
|
/** Factory helper. */
|
|
1747
2267
|
function createClient(opts) {
|
|
1748
2268
|
return new AgentFramework(opts);
|