@exulu/backend 3.0.0 → 3.1.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.
@@ -1285,69 +1285,132 @@ async function resolveModel(input) {
1285
1285
  return { languageModel, model, exuluProvider, apiKey };
1286
1286
  }
1287
1287
 
1288
- // src/exulu/oauth/validate.ts
1289
- var REQUIRED_STRING_FIELDS = [
1288
+ // src/exulu/auth/validate.ts
1289
+ var OAUTH_REQUIRED_STRING_FIELDS = [
1290
1290
  "authorizationUrl",
1291
1291
  "tokenUrl",
1292
1292
  "clientId",
1293
1293
  "clientSecret"
1294
1294
  ];
1295
- var validateOauthConfig = (toolId, config) => {
1296
- for (const field of REQUIRED_STRING_FIELDS) {
1297
- if (!config[field] || typeof config[field] !== "string") {
1295
+ var validateAuthConfig = (toolId, config) => {
1296
+ if (config.authType === "oauth") {
1297
+ for (const field of OAUTH_REQUIRED_STRING_FIELDS) {
1298
+ const value = config[field];
1299
+ if (!value || typeof value !== "string") {
1300
+ throw new Error(
1301
+ `ExuluTool "${toolId}": oauth.${field} is required and must be a non-empty string.`
1302
+ );
1303
+ }
1304
+ }
1305
+ if (!Array.isArray(config.scopes)) {
1298
1306
  throw new Error(
1299
- `ExuluTool "${toolId}": oauth.${field} is required and must be a non-empty string.`
1307
+ `ExuluTool "${toolId}": oauth.scopes must be an array of strings (use [] to request no scopes).`
1300
1308
  );
1301
1309
  }
1310
+ if (config.provider !== void 0) {
1311
+ if (typeof config.provider !== "string" || config.provider.length === 0 || config.provider.trim() !== config.provider) {
1312
+ throw new Error(
1313
+ `ExuluTool "${toolId}": oauth.provider must be a non-empty string with no leading or trailing whitespace when set.`
1314
+ );
1315
+ }
1316
+ }
1317
+ if (!process.env.BACKEND) {
1318
+ throw new Error(
1319
+ `ExuluTool "${toolId}": oauth requires the BACKEND environment variable (the backend's public base URL) to build the redirect URI.`
1320
+ );
1321
+ }
1322
+ return;
1302
1323
  }
1303
- if (!Array.isArray(config.scopes)) {
1304
- throw new Error(
1305
- `ExuluTool "${toolId}": oauth.scopes must be an array of strings (use [] to request no scopes).`
1306
- );
1307
- }
1308
- if (config.provider !== void 0) {
1324
+ if (config.authType === "user_credentials") {
1309
1325
  if (typeof config.provider !== "string" || config.provider.length === 0 || config.provider.trim() !== config.provider) {
1310
1326
  throw new Error(
1311
- `ExuluTool "${toolId}": oauth.provider must be a non-empty string with no leading or trailing whitespace when set.`
1327
+ `ExuluTool "${toolId}": user_credentials.provider must be a non-empty string with no leading or trailing whitespace.`
1312
1328
  );
1313
1329
  }
1330
+ if (!Array.isArray(config.fields) || config.fields.length === 0) {
1331
+ throw new Error(
1332
+ `ExuluTool "${toolId}": user_credentials.fields must contain at least one field.`
1333
+ );
1334
+ }
1335
+ const seenNames = /* @__PURE__ */ new Set();
1336
+ for (let i = 0; i < config.fields.length; i++) {
1337
+ const field = config.fields[i];
1338
+ const name = field.name;
1339
+ if (typeof name !== "string" || name.length === 0 || name.trim() !== name) {
1340
+ throw new Error(
1341
+ `ExuluTool "${toolId}": user_credentials.fields[${i}].name must be a non-empty string with no leading or trailing whitespace.`
1342
+ );
1343
+ }
1344
+ if (seenNames.has(name)) {
1345
+ throw new Error(
1346
+ `ExuluTool "${toolId}": user_credentials.fields has duplicate field name '${name}'.`
1347
+ );
1348
+ }
1349
+ seenNames.add(name);
1350
+ const type = field.type;
1351
+ if (type !== "text" && type !== "password") {
1352
+ throw new Error(
1353
+ `ExuluTool "${toolId}": user_credentials.fields[${i}].type must be 'text' or 'password' (got '${type}').`
1354
+ );
1355
+ }
1356
+ }
1357
+ if (!process.env.BACKEND) {
1358
+ throw new Error(
1359
+ `ExuluTool "${toolId}": user_credentials requires the BACKEND environment variable (the backend's public base URL) to build the credential submit URL.`
1360
+ );
1361
+ }
1362
+ return;
1314
1363
  }
1315
- if (!process.env.BACKEND) {
1316
- throw new Error(
1317
- `ExuluTool "${toolId}": oauth requires the BACKEND environment variable (the backend's public base URL) to build the redirect URI.`
1318
- );
1319
- }
1364
+ throw new Error(
1365
+ `ExuluTool "${toolId}": auth.authType '${config.authType}' is not supported.`
1366
+ );
1320
1367
  };
1321
1368
 
1322
- // src/exulu/oauth/provider-key.ts
1369
+ // src/exulu/auth/provider-key.ts
1323
1370
  var providerKeyFor = (toolId, config) => config.provider && config.provider.length > 0 ? config.provider : toolId;
1324
1371
 
1325
- // src/exulu/oauth/registry.ts
1372
+ // src/exulu/auth/registry.ts
1326
1373
  var byProvider = /* @__PURE__ */ new Map();
1327
1374
  var byTool = /* @__PURE__ */ new Map();
1328
- var STABLE_STRING_FIELDS = [
1375
+ var STABLE_OAUTH_STRING_FIELDS = [
1329
1376
  "authorizationUrl",
1330
1377
  "tokenUrl",
1331
1378
  "clientId",
1332
1379
  "clientSecret"
1333
1380
  ];
1334
1381
  var assertCompatible = (providerKey, toolId, existing, next) => {
1335
- for (const field of STABLE_STRING_FIELDS) {
1336
- if (existing[field] !== next[field]) {
1382
+ if (existing.authType !== next.authType) {
1383
+ throw new Error(
1384
+ `ExuluTool "${toolId}": auth.authType '${next.authType}' disagrees with another tool that shares provider "${providerKey}" using authType '${existing.authType}'.`
1385
+ );
1386
+ }
1387
+ if (existing.authType === "oauth" && next.authType === "oauth") {
1388
+ for (const field of STABLE_OAUTH_STRING_FIELDS) {
1389
+ if (existing[field] !== next[field]) {
1390
+ throw new Error(
1391
+ `ExuluTool "${toolId}": oauth.${field} disagrees with another tool that shares provider "${providerKey}". Every tool on the same provider must use identical authorizationUrl/tokenUrl/clientId/clientSecret.`
1392
+ );
1393
+ }
1394
+ }
1395
+ const a = new Set(existing.scopes);
1396
+ const b = new Set(next.scopes);
1397
+ if (a.size !== b.size || [...a].some((s) => !b.has(s))) {
1337
1398
  throw new Error(
1338
- `ExuluTool "${toolId}": oauth.${field} disagrees with another tool that shares provider "${providerKey}". Every tool on the same provider must use identical authorizationUrl/tokenUrl/clientId/clientSecret.`
1399
+ `ExuluTool "${toolId}": oauth.scopes disagrees with another tool that shares provider "${providerKey}". Every tool on the same provider must declare the same scope superset. Existing: [${[...a].sort().join(", ")}]. This tool: [${[...b].sort().join(", ")}].`
1339
1400
  );
1340
1401
  }
1402
+ return;
1341
1403
  }
1342
- const a = new Set(existing.scopes);
1343
- const b = new Set(next.scopes);
1344
- if (a.size !== b.size || [...a].some((s) => !b.has(s))) {
1345
- throw new Error(
1346
- `ExuluTool "${toolId}": oauth.scopes disagrees with another tool that shares provider "${providerKey}". Every tool on the same provider must declare the same scope superset. Existing: [${[...a].sort().join(", ")}]. This tool: [${[...b].sort().join(", ")}].`
1347
- );
1404
+ if (existing.authType === "user_credentials" && next.authType === "user_credentials") {
1405
+ if (JSON.stringify(existing.fields) !== JSON.stringify(next.fields)) {
1406
+ throw new Error(
1407
+ `ExuluTool "${toolId}": user_credentials.fields disagrees with another tool that shares provider "${providerKey}". Every tool on the same provider must declare structurally identical fields (same names, types, and order).`
1408
+ );
1409
+ }
1410
+ return;
1348
1411
  }
1349
1412
  };
1350
- var oauthRegistry = {
1413
+ var authRegistry = {
1351
1414
  register: (toolId, config) => {
1352
1415
  const providerKey = providerKeyFor(toolId, config);
1353
1416
  const existing = byProvider.get(providerKey);
@@ -1367,60 +1430,74 @@ var oauthRegistry = {
1367
1430
  }
1368
1431
  };
1369
1432
 
1370
- // src/exulu/oauth/flow.ts
1433
+ // src/exulu/auth/flow.ts
1371
1434
  import CryptoJS3 from "crypto-js";
1372
1435
  import { createHash, randomBytes } from "crypto";
1373
1436
 
1374
- // src/exulu/oauth/token-store.ts
1437
+ // src/exulu/auth/credential-store.ts
1375
1438
  import CryptoJS2 from "crypto-js";
1376
- var TABLE = "oauth_tokens";
1439
+ var TABLE = "user_credentials";
1377
1440
  var encrypt = (value) => CryptoJS2.AES.encrypt(value, process.env.NEXTAUTH_SECRET).toString();
1378
1441
  var decrypt = (value) => CryptoJS2.AES.decrypt(value, process.env.NEXTAUTH_SECRET).toString(CryptoJS2.enc.Utf8);
1379
- var oauthTokenStore = {
1380
- get: async (providerKey, userId) => {
1381
- const { db: db2 } = await postgresClient();
1382
- const row = await db2.from(TABLE).where({ provider: providerKey, user_id: userId }).first();
1383
- if (!row) {
1384
- return null;
1385
- }
1386
- return {
1387
- accessToken: decrypt(row.access_token),
1388
- refreshToken: row.refresh_token ? decrypt(row.refresh_token) : null,
1389
- tokenType: row.token_type ?? null,
1390
- scopes: row.scopes ?? null,
1391
- expiresAt: row.expires_at ? new Date(row.expires_at) : null
1392
- };
1393
- },
1394
- // toolId is stored on every written row as an audit trail — which tool
1395
- // triggered the last grant/refresh — but does NOT participate in the key.
1396
- upsert: async (providerKey, userId, toolId, record) => {
1397
- const { db: db2 } = await postgresClient();
1398
- const existing = await db2.from(TABLE).where({ provider: providerKey, user_id: userId }).first();
1399
- const values = {
1400
- provider: providerKey,
1401
- tool_id: toolId,
1402
- access_token: encrypt(record.accessToken),
1403
- // Providers like Google only send a refresh_token on first consent;
1404
- // never overwrite a stored one with nothing.
1405
- refresh_token: record.refreshToken ? encrypt(record.refreshToken) : existing?.refresh_token ?? null,
1406
- token_type: record.tokenType ?? null,
1407
- scopes: record.scopes ?? null,
1408
- expires_at: record.expiresAt ?? null,
1409
- updatedAt: /* @__PURE__ */ new Date()
1410
- };
1411
- if (existing) {
1412
- await db2.from(TABLE).where({ provider: providerKey, user_id: userId }).update(values);
1413
- } else {
1414
- await db2.from(TABLE).insert({ user_id: userId, ...values });
1415
- }
1416
- },
1417
- delete: async (providerKey, userId) => {
1418
- const { db: db2 } = await postgresClient();
1419
- await db2.from(TABLE).where({ provider: providerKey, user_id: userId }).del();
1442
+ async function get(provider, userId) {
1443
+ const { db: db2 } = await postgresClient();
1444
+ const row = await db2.from(TABLE).where({ provider, user_id: String(userId) }).first();
1445
+ if (!row) {
1446
+ return null;
1420
1447
  }
1421
- };
1448
+ return {
1449
+ provider,
1450
+ userId,
1451
+ authType: row.auth_type,
1452
+ data: JSON.parse(decrypt(row.data))
1453
+ };
1454
+ }
1455
+ async function upsert(record) {
1456
+ const { db: db2 } = await postgresClient();
1457
+ const encrypted = encrypt(JSON.stringify(record.data));
1458
+ await db2.from(TABLE).insert({
1459
+ provider: record.provider,
1460
+ user_id: String(record.userId),
1461
+ auth_type: record.authType,
1462
+ data: encrypted,
1463
+ updated_at: /* @__PURE__ */ new Date()
1464
+ }).onConflict(["provider", "user_id"]).merge({ auth_type: record.authType, data: encrypted, updated_at: /* @__PURE__ */ new Date() });
1465
+ }
1466
+ async function listByUser(userId) {
1467
+ const { db: db2 } = await postgresClient();
1468
+ const list = await db2.from(TABLE).where({ user_id: String(userId) }).orderBy("provider");
1469
+ return list.map((row) => ({
1470
+ provider: row.provider,
1471
+ authType: row.auth_type,
1472
+ createdAt: row.created_at,
1473
+ updatedAt: row.updated_at
1474
+ }));
1475
+ }
1476
+ async function del(provider, userId) {
1477
+ const { db: db2 } = await postgresClient();
1478
+ await db2.from(TABLE).where({ provider, user_id: String(userId) }).del();
1479
+ }
1480
+ var credentialStore = { get, upsert, listByUser, delete: del };
1422
1481
 
1423
- // src/exulu/oauth/flow.ts
1482
+ // src/exulu/auth/flow.ts
1483
+ function oauthRecordToBlob(r) {
1484
+ return {
1485
+ accessToken: r.accessToken,
1486
+ refreshToken: r.refreshToken ?? null,
1487
+ tokenType: r.tokenType ?? null,
1488
+ scopes: r.scopes ?? null,
1489
+ expiresAt: r.expiresAt ? r.expiresAt.toISOString() : null
1490
+ };
1491
+ }
1492
+ function oauthBlobToRecord(b) {
1493
+ return {
1494
+ accessToken: b.accessToken,
1495
+ refreshToken: b.refreshToken,
1496
+ tokenType: b.tokenType,
1497
+ scopes: b.scopes,
1498
+ expiresAt: b.expiresAt ? new Date(b.expiresAt) : null
1499
+ };
1500
+ }
1424
1501
  var OAUTH_CALLBACK_PATH = "/oauth/callback";
1425
1502
  var STATE_TTL_MS = 10 * 60 * 1e3;
1426
1503
  var EXPIRY_SKEW_MS = 30 * 1e3;
@@ -1563,7 +1640,8 @@ var getValidAccessToken = async ({
1563
1640
  toolId,
1564
1641
  config
1565
1642
  }) => {
1566
- const stored = await oauthTokenStore.get(providerKey, userId);
1643
+ const credRow = await credentialStore.get(providerKey, userId);
1644
+ const stored = credRow ? oauthBlobToRecord(credRow.data) : null;
1567
1645
  if (!stored) {
1568
1646
  return null;
1569
1647
  }
@@ -1572,7 +1650,7 @@ var getValidAccessToken = async ({
1572
1650
  return stored;
1573
1651
  }
1574
1652
  if (!stored.refreshToken) {
1575
- await oauthTokenStore.delete(providerKey, userId);
1653
+ await credentialStore.delete(providerKey, userId);
1576
1654
  return null;
1577
1655
  }
1578
1656
  try {
@@ -1580,20 +1658,127 @@ var getValidAccessToken = async ({
1580
1658
  if (!refreshed.refreshToken) {
1581
1659
  refreshed.refreshToken = stored.refreshToken;
1582
1660
  }
1583
- await oauthTokenStore.upsert(providerKey, userId, toolId, refreshed);
1661
+ await credentialStore.upsert({
1662
+ provider: providerKey,
1663
+ userId,
1664
+ authType: "oauth",
1665
+ data: oauthRecordToBlob(refreshed)
1666
+ });
1584
1667
  return refreshed;
1585
1668
  } catch (error) {
1586
1669
  console.error(
1587
1670
  `[EXULU] OAuth token refresh failed for provider "${providerKey}" tool "${toolId}" user ${userId}:`,
1588
1671
  error
1589
1672
  );
1590
- await oauthTokenStore.delete(providerKey, userId);
1673
+ await credentialStore.delete(providerKey, userId);
1591
1674
  return null;
1592
1675
  }
1593
1676
  };
1594
1677
 
1595
- // src/exulu/oauth/wrap-execute.ts
1596
- var wrapExecuteWithOauth = (toolId, config, execute) => {
1678
+ // src/exulu/auth/state.ts
1679
+ async function getValidUserCredentials(cfg, userId) {
1680
+ const row = await credentialStore.get(cfg.provider, userId);
1681
+ if (!row || row.authType !== "user_credentials") return null;
1682
+ const values = {};
1683
+ for (const [k, v] of Object.entries(row.data)) {
1684
+ if (typeof v !== "string") return null;
1685
+ values[k] = v;
1686
+ }
1687
+ return values;
1688
+ }
1689
+
1690
+ // src/exulu/auth/credentials-request.ts
1691
+ var DEFAULT_TTL_SECONDS = 15 * 60;
1692
+ function buildCredentialRequest(cfg, opts) {
1693
+ const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
1694
+ const claims = {
1695
+ provider: cfg.provider,
1696
+ userId: opts.userId,
1697
+ expiresAt: Math.floor(Date.now() / 1e3) + ttl
1698
+ };
1699
+ const nonce = encrypt(JSON.stringify(claims));
1700
+ return {
1701
+ provider: cfg.provider,
1702
+ fields: cfg.fields,
1703
+ submitUrl: `${opts.baseUrl.replace(/\/+$/, "")}/credentials/submit`,
1704
+ nonce
1705
+ };
1706
+ }
1707
+ function verifyCredentialNonce(nonce) {
1708
+ let claims;
1709
+ try {
1710
+ claims = JSON.parse(decrypt(nonce));
1711
+ } catch {
1712
+ throw new Error("Invalid credential nonce");
1713
+ }
1714
+ if (typeof claims.provider !== "string" || typeof claims.userId !== "string" || typeof claims.expiresAt !== "number") {
1715
+ throw new Error("Malformed credential nonce claims");
1716
+ }
1717
+ if (claims.expiresAt < Math.floor(Date.now() / 1e3)) {
1718
+ throw new Error("Credential nonce expired");
1719
+ }
1720
+ return claims;
1721
+ }
1722
+
1723
+ // src/exulu/auth/short-circuit.ts
1724
+ function credentialRequestResult(request) {
1725
+ return { credentialRequest: request, result: null };
1726
+ }
1727
+
1728
+ // src/exulu/auth/errors.ts
1729
+ var CredentialInvalidError = class extends Error {
1730
+ provider;
1731
+ reason;
1732
+ constructor(provider, reason) {
1733
+ super(reason ? `Credential invalid for provider '${provider}': ${reason}` : `Credential invalid for provider '${provider}'`);
1734
+ this.name = "CredentialInvalidError";
1735
+ this.provider = provider;
1736
+ this.reason = reason;
1737
+ }
1738
+ };
1739
+
1740
+ // src/exulu/auth/wrap-execute.ts
1741
+ var wrapExecuteWithAuth = (toolId, config, execute) => {
1742
+ if (config.authType === "oauth") {
1743
+ return wrapExecuteWithOauthInternal(toolId, config, execute);
1744
+ }
1745
+ if (config.authType === "user_credentials") {
1746
+ return wrapUserCredentials(toolId, config, execute);
1747
+ }
1748
+ throw new Error(`ExuluTool "${toolId}": unknown authType`);
1749
+ };
1750
+ var wrapUserCredentials = (toolId, config, execute) => {
1751
+ return async (inputs, options) => {
1752
+ const userId = inputs?.user?.id;
1753
+ if (!userId) {
1754
+ return {
1755
+ result: `The "${toolId}" tool requires user-supplied credentials, which needs a signed-in user. No user identity is available for this run.`
1756
+ };
1757
+ }
1758
+ const baseUrl = (process.env.BACKEND ?? "").replace(/\/+$/, "");
1759
+ if (!baseUrl) {
1760
+ return {
1761
+ result: `The "${toolId}" tool requires the BACKEND env var to build the credential submit URL.`
1762
+ };
1763
+ }
1764
+ const values = await getValidUserCredentials(config, userId);
1765
+ if (!values) {
1766
+ const request = buildCredentialRequest(config, { baseUrl, userId: String(userId) });
1767
+ return credentialRequestResult(request);
1768
+ }
1769
+ try {
1770
+ return await execute({ ...inputs, credentials: values }, options);
1771
+ } catch (e) {
1772
+ if (e instanceof CredentialInvalidError && e.provider === config.provider) {
1773
+ await credentialStore.delete(config.provider, userId);
1774
+ const request = buildCredentialRequest(config, { baseUrl, userId: String(userId) });
1775
+ return credentialRequestResult(request);
1776
+ }
1777
+ throw e;
1778
+ }
1779
+ };
1780
+ };
1781
+ var wrapExecuteWithOauthInternal = (toolId, config, execute) => {
1597
1782
  return async (inputs, options) => {
1598
1783
  const userId = inputs?.user?.id;
1599
1784
  if (!userId) {
@@ -1633,7 +1818,7 @@ var ExuluTool = class _ExuluTool {
1633
1818
  type;
1634
1819
  tool;
1635
1820
  needsApproval;
1636
- oauth;
1821
+ authentication;
1637
1822
  config;
1638
1823
  constructor({
1639
1824
  id,
@@ -1645,7 +1830,7 @@ var ExuluTool = class _ExuluTool {
1645
1830
  execute,
1646
1831
  config,
1647
1832
  needsApproval,
1648
- oauth
1833
+ authentication: authentication2
1649
1834
  }) {
1650
1835
  if (!PUBLIC_TOOL_TYPES.includes(type)) {
1651
1836
  throw new Error(
@@ -1654,11 +1839,11 @@ var ExuluTool = class _ExuluTool {
1654
1839
  )}. The "agent" and "context" types are managed by Exulu internally and cannot be set on a tool.`
1655
1840
  );
1656
1841
  }
1657
- if (oauth) {
1658
- validateOauthConfig(id, oauth);
1659
- oauthRegistry.register(id, oauth);
1842
+ if (authentication2) {
1843
+ validateAuthConfig(id, authentication2);
1844
+ authRegistry.register(id, authentication2);
1660
1845
  }
1661
- this.oauth = oauth;
1846
+ this.authentication = authentication2;
1662
1847
  this.id = id;
1663
1848
  this.config = config;
1664
1849
  this.needsApproval = needsApproval ?? true;
@@ -1670,7 +1855,7 @@ var ExuluTool = class _ExuluTool {
1670
1855
  this.tool = tool({
1671
1856
  description,
1672
1857
  inputSchema: inputSchema || z.object({}),
1673
- execute: oauth ? wrapExecuteWithOauth(id, oauth, execute) : execute
1858
+ execute: authentication2 ? wrapExecuteWithAuth(id, authentication2, execute) : execute
1674
1859
  });
1675
1860
  }
1676
1861
  /**
@@ -1718,7 +1903,7 @@ var ExuluTool = class _ExuluTool {
1718
1903
  });
1719
1904
  providerapikey = resolved.apiKey;
1720
1905
  }
1721
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-YY2WIMMJ.js");
1906
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-FN6WZSIQ.js");
1722
1907
  const tools = await convertExuluToolsToAiSdkTools2(
1723
1908
  [this],
1724
1909
  [],
@@ -2230,11 +2415,14 @@ function buildProjectKbProfileDefaults(items) {
2230
2415
  }
2231
2416
 
2232
2417
  // ee/agentic-retrieval/pipeline/routing.ts
2233
- import { generateText as generateText2, Output as Output2 } from "ai";
2234
2418
  import { z as z5 } from "zod";
2235
2419
 
2420
+ // ee/agentic-retrieval/pipeline/micro-call.ts
2421
+ import { generateText, NoOutputGeneratedError, Output } from "ai";
2422
+
2236
2423
  // src/utils/with-retry.ts
2237
- async function withRetry(generateFn, maxRetries = 3) {
2424
+ async function withRetry(generateFn, maxRetries = 3, opts = {}) {
2425
+ const { shouldRetry, baseDelayMs = 1e3 } = opts;
2238
2426
  let lastError;
2239
2427
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
2240
2428
  try {
@@ -2242,18 +2430,66 @@ async function withRetry(generateFn, maxRetries = 3) {
2242
2430
  } catch (error) {
2243
2431
  lastError = error;
2244
2432
  console.error(`[EXULU] generateText attempt ${attempt} failed:`, error);
2245
- if (attempt === maxRetries) {
2433
+ if (attempt === maxRetries || shouldRetry && !shouldRetry(error)) {
2246
2434
  throw error;
2247
2435
  }
2248
- await new Promise((resolve3) => setTimeout(resolve3, Math.pow(2, attempt) * 1e3));
2436
+ await new Promise((resolve3) => setTimeout(resolve3, Math.pow(2, attempt) * baseDelayMs));
2249
2437
  }
2250
2438
  }
2251
2439
  throw lastError;
2252
2440
  }
2253
2441
 
2442
+ // ee/agentic-retrieval/pipeline/micro-call.ts
2443
+ var MICRO_CALL_MAX_OUTPUT_TOKENS = 2e3;
2444
+ function microCallProviderOptions(model) {
2445
+ const modelId = typeof model === "string" ? model : model?.modelId;
2446
+ return typeof modelId === "string" && /gemini/i.test(modelId) ? { litellm: { reasoningEffort: "disable" } } : void 0;
2447
+ }
2448
+ async function microCall(args) {
2449
+ const {
2450
+ model,
2451
+ system,
2452
+ prompt,
2453
+ messages,
2454
+ schema,
2455
+ temperature = 0,
2456
+ maxOutputTokens = MICRO_CALL_MAX_OUTPUT_TOKENS,
2457
+ maxAttempts = 3,
2458
+ retryBaseDelayMs
2459
+ } = args;
2460
+ return withRetry(
2461
+ async () => {
2462
+ const result = await generateText({
2463
+ model,
2464
+ temperature,
2465
+ system,
2466
+ prompt,
2467
+ messages,
2468
+ ...schema ? { output: Output.object({ schema }) } : {},
2469
+ maxOutputTokens,
2470
+ // withRetry owns retries. The SDK's internal retries on top of it
2471
+ // tripled request volume per attempt while the provider was already
2472
+ // rate-limiting.
2473
+ maxRetries: 0,
2474
+ providerOptions: microCallProviderOptions(model)
2475
+ });
2476
+ return {
2477
+ output: schema ? result.output : void 0,
2478
+ text: result.text
2479
+ };
2480
+ },
2481
+ maxAttempts,
2482
+ {
2483
+ // Empty output is deterministic for identical params — retrying only
2484
+ // added latency before the degraded path. Fail fast instead.
2485
+ shouldRetry: (error) => !NoOutputGeneratedError.isInstance(error),
2486
+ ...retryBaseDelayMs !== void 0 ? { baseDelayMs: retryBaseDelayMs } : {}
2487
+ }
2488
+ );
2489
+ }
2490
+
2254
2491
  // ee/agentic-retrieval/pipeline/prefilter.ts
2255
2492
  import Fuse from "fuse.js";
2256
- import { generateText, Output } from "ai";
2257
2493
  import { z as z4 } from "zod";
2258
2494
 
2259
2495
  // ee/agentic-retrieval/pipeline/text-utils.ts
@@ -2492,22 +2728,15 @@ async function resolveIdentifierPins({
2492
2728
  identifierSets.map(async (set) => {
2493
2729
  if (!set.contexts.length) return;
2494
2730
  try {
2495
- const { output } = await withRetry(
2496
- () => generateText({
2497
- model,
2498
- temperature: 0,
2499
- system: set.strategy === "exact" ? EXACT_EXTRACTION_PROMPT(set) : FUZZY_EXTRACTION_PROMPT(set),
2500
- messages: [{ role: "user", content: question }],
2501
- output: Output.object({
2502
- schema: z4.object({
2503
- hasMatches: z4.boolean(),
2504
- matches: z4.array(z4.string()).optional()
2505
- })
2506
- }),
2507
- maxOutputTokens: 300
2508
- }),
2509
- 3
2510
- );
2731
+ const { output } = await microCall({
2732
+ model,
2733
+ system: set.strategy === "exact" ? EXACT_EXTRACTION_PROMPT(set) : FUZZY_EXTRACTION_PROMPT(set),
2734
+ messages: [{ role: "user", content: question }],
2735
+ schema: z4.object({
2736
+ hasMatches: z4.boolean(),
2737
+ matches: z4.array(z4.string()).optional()
2738
+ })
2739
+ });
2511
2740
  if (!output?.hasMatches || !output.matches?.length) return;
2512
2741
  steps.push({ text: `Detected ${set.name} in the question: ${output.matches.join(", ")}` });
2513
2742
  await Promise.all(
@@ -2605,24 +2834,17 @@ If explicit, return the knowledge base ids. If not, return an empty array.`;
2605
2834
  const [docPageRaw, explicitKBRaw] = await Promise.all([
2606
2835
  (async () => {
2607
2836
  try {
2608
- return await withRetry(
2609
- () => generateText2({
2610
- model,
2611
- temperature: 0,
2612
- system: buildDocPagePrompt(knownIdentifiers),
2613
- messages: [{ role: "user", content: question }],
2614
- output: Output2.object({
2615
- schema: z5.object({
2616
- hasFilenameHint: z5.boolean(),
2617
- filenameHints: z5.array(z5.string()).optional(),
2618
- hasPageHint: z5.boolean(),
2619
- pageNumber: z5.number().int().nullable().optional()
2620
- })
2621
- }),
2622
- maxOutputTokens: 300
2623
- }),
2624
- 3
2625
- );
2837
+ return await microCall({
2838
+ model,
2839
+ system: buildDocPagePrompt(knownIdentifiers),
2840
+ messages: [{ role: "user", content: question }],
2841
+ schema: z5.object({
2842
+ hasFilenameHint: z5.boolean(),
2843
+ filenameHints: z5.array(z5.string()).optional(),
2844
+ hasPageHint: z5.boolean(),
2845
+ pageNumber: z5.number().int().nullable().optional()
2846
+ })
2847
+ });
2626
2848
  } catch (err) {
2627
2849
  steps.push({ text: "Doc/page detection failed \u2014 skipping filename and page hints." });
2628
2850
  return {
@@ -2637,23 +2859,16 @@ If explicit, return the knowledge base ids. If not, return an empty array.`;
2637
2859
  })(),
2638
2860
  (async () => {
2639
2861
  try {
2640
- return await withRetry(
2641
- () => generateText2({
2642
- model,
2643
- temperature: 0,
2644
- system: kbSystemPrompt,
2645
- output: Output2.object({
2646
- schema: z5.object({
2647
- explicitlyRequestedKnowledgeBases: z5.array(
2648
- z5.enum(enabledContexts.map((c) => c.id))
2649
- )
2650
- })
2651
- }),
2652
- messages: [{ role: "user", content: question }],
2653
- maxOutputTokens: 200
2862
+ return await microCall({
2863
+ model,
2864
+ system: kbSystemPrompt,
2865
+ schema: z5.object({
2866
+ explicitlyRequestedKnowledgeBases: z5.array(
2867
+ z5.enum(enabledContexts.map((c) => c.id))
2868
+ )
2654
2869
  }),
2655
- 3
2656
- );
2870
+ messages: [{ role: "user", content: question }]
2871
+ });
2657
2872
  } catch (err) {
2658
2873
  return { output: { explicitlyRequestedKnowledgeBases: [] } };
2659
2874
  }
@@ -2736,22 +2951,15 @@ ${extraInstructions}
2736
2951
  </instructions>`;
2737
2952
  }
2738
2953
  try {
2739
- const { output: classified } = await withRetry(
2740
- () => generateText2({
2741
- model,
2742
- temperature: 0,
2743
- system: classifyPrompt,
2744
- messages: [{ role: "user", content: question }],
2745
- output: Output2.object({
2746
- schema: z5.object({
2747
- ruleId: z5.enum(ruleIds),
2748
- reason: z5.string()
2749
- })
2750
- }),
2751
- maxOutputTokens: 200
2752
- }),
2753
- 3
2754
- );
2954
+ const { output: classified } = await microCall({
2955
+ model,
2956
+ system: classifyPrompt,
2957
+ messages: [{ role: "user", content: question }],
2958
+ schema: z5.object({
2959
+ ruleId: z5.enum(ruleIds),
2960
+ reason: z5.string()
2961
+ })
2962
+ });
2755
2963
  const matchedRule = routingRules.find((r) => r.id === classified.ruleId);
2756
2964
  if (matchedRule) {
2757
2965
  const main = matchedRule.main.filter((id) => enabledIds.has(id));
@@ -2806,7 +3014,6 @@ ${extraInstructions}
2806
3014
  }
2807
3015
 
2808
3016
  // ee/agentic-retrieval/pipeline/memory.ts
2809
- import { generateText as generateText3, Output as Output3 } from "ai";
2810
3017
  import { z as z6 } from "zod";
2811
3018
 
2812
3019
  // ee/agentic-retrieval/pipeline/multi-query.ts
@@ -3032,32 +3239,25 @@ async function runMemoryPhase({
3032
3239
  `;
3033
3240
  let relevantMemoryChunks = [];
3034
3241
  try {
3035
- const { output: output_relevant_memory } = await withRetry(
3036
- () => generateText3({
3037
- model,
3038
- temperature: 0,
3039
- system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
3040
- messages: [
3041
- {
3042
- role: "user",
3043
- content: `
3242
+ const { output: output_relevant_memory } = await microCall({
3243
+ model,
3244
+ system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
3245
+ messages: [
3246
+ {
3247
+ role: "user",
3248
+ content: `
3044
3249
  <user_question>${question}</user_question>
3045
3250
  <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
3046
3251
  <important_keyword>${importantKeyword}</important_keyword>
3047
3252
  `
3048
- }
3049
- ],
3050
- output: Output3.object({
3051
- schema: z6.object({
3052
- relevantChunkIds: z6.array(z6.string()).describe(
3053
- "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant."
3054
- )
3055
- })
3056
- }),
3057
- maxOutputTokens: 400
3058
- }),
3059
- 3
3060
- );
3253
+ }
3254
+ ],
3255
+ schema: z6.object({
3256
+ relevantChunkIds: z6.array(z6.string()).describe(
3257
+ "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant."
3258
+ )
3259
+ })
3260
+ });
3061
3261
  const ids = new Set(output_relevant_memory?.relevantChunkIds ?? []);
3062
3262
  relevantMemoryChunks = ids.size === 0 ? [] : retrieved_memory.filter((c) => ids.has(c.chunk_id));
3063
3263
  } catch (e) {
@@ -3149,41 +3349,34 @@ ${glossary.map((g) => `${g.term} : ${g.meaning}`).join("\n")}` : "";
3149
3349
  `;
3150
3350
  const [overrideResult, fileResult, queryResult] = await Promise.all([
3151
3351
  // Override check: strict gate to decide if memory should be authoritative
3152
- memoryConfig.override ? withRetry(
3153
- () => generateText3({
3154
- model,
3155
- temperature: 0,
3156
- system: CHECK_MEMORY_OVERRIDE,
3157
- messages: [
3158
- {
3159
- role: "user",
3160
- content: `
3352
+ memoryConfig.override ? microCall({
3353
+ model,
3354
+ system: CHECK_MEMORY_OVERRIDE,
3355
+ messages: [
3356
+ {
3357
+ role: "user",
3358
+ content: `
3161
3359
  <user_question>${question}</user_question>
3162
3360
  <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
3163
3361
  <important_keyword>${importantKeyword}</important_keyword>
3164
3362
  `
3165
- }
3166
- ],
3167
- output: Output3.object({
3168
- schema: z6.object({
3169
- overrides: z6.boolean().describe(
3170
- "True ONLY if a memory chunk directly and sufficiently answers the user's question and should be authoritative over the documents. Be strict; when unsure, false."
3171
- ),
3172
- confidence: z6.enum(["high", "medium", "low"]).describe(
3173
- "Confidence that the selected memory chunk(s) fully and directly answer the question."
3174
- ),
3175
- authoritativeChunkIds: z6.array(z6.string()).describe(
3176
- "The chunk_ids of the memory chunk(s) that directly answer the question. Empty if overrides is false."
3177
- ),
3178
- reason: z6.string().describe(
3179
- "One short sentence: why this memory does or does not directly answer the question."
3180
- )
3181
- })
3182
- }),
3183
- maxOutputTokens: 300
3184
- }),
3185
- 3
3186
- ).catch(() => ({
3363
+ }
3364
+ ],
3365
+ schema: z6.object({
3366
+ overrides: z6.boolean().describe(
3367
+ "True ONLY if a memory chunk directly and sufficiently answers the user's question and should be authoritative over the documents. Be strict; when unsure, false."
3368
+ ),
3369
+ confidence: z6.enum(["high", "medium", "low"]).describe(
3370
+ "Confidence that the selected memory chunk(s) fully and directly answer the question."
3371
+ ),
3372
+ authoritativeChunkIds: z6.array(z6.string()).describe(
3373
+ "The chunk_ids of the memory chunk(s) that directly answer the question. Empty if overrides is false."
3374
+ ),
3375
+ reason: z6.string().describe(
3376
+ "One short sentence: why this memory does or does not directly answer the question."
3377
+ )
3378
+ })
3379
+ }).catch(() => ({
3187
3380
  output: {
3188
3381
  overrides: false,
3189
3382
  confidence: "low",
@@ -3199,44 +3392,30 @@ ${glossary.map((g) => `${g.term} : ${g.meaning}`).join("\n")}` : "";
3199
3392
  }
3200
3393
  }),
3201
3394
  // File prioritization: detect explicit document-pinning instructions in memory
3202
- memoryConfig.filePrioritization ? withRetry(
3203
- () => generateText3({
3204
- model,
3205
- temperature: 0,
3206
- system: "You are a helpful assistant that will strictly follow the user's instructions.",
3207
- messages: [{ role: "user", content: PROMPT_EXTRACT_PRIORITIZED_FILES }],
3208
- output: Output3.object({
3209
- schema: z6.object({
3210
- shouldPrioritizeFiles: z6.boolean(),
3211
- fileNameHints: z6.array(z6.string()).optional()
3212
- })
3213
- }),
3214
- maxOutputTokens: 300
3215
- }),
3216
- 3
3217
- ).catch(() => ({
3395
+ memoryConfig.filePrioritization ? microCall({
3396
+ model,
3397
+ system: "You are a helpful assistant that will strictly follow the user's instructions.",
3398
+ messages: [{ role: "user", content: PROMPT_EXTRACT_PRIORITIZED_FILES }],
3399
+ schema: z6.object({
3400
+ shouldPrioritizeFiles: z6.boolean(),
3401
+ fileNameHints: z6.array(z6.string()).optional()
3402
+ })
3403
+ }).catch(() => ({
3218
3404
  output: { shouldPrioritizeFiles: false, fileNameHints: [] }
3219
3405
  })) : Promise.resolve({
3220
3406
  output: { shouldPrioritizeFiles: false, fileNameHints: [] }
3221
3407
  }),
3222
3408
  // Query augmentation: expand keywords with synonyms/abbreviations from memory
3223
- memoryConfig.queryAugmentation && hasAugmentationContent ? withRetry(
3224
- () => generateText3({
3225
- model,
3226
- temperature: 0,
3227
- system: "You are a helpful assistant that will strictly follow the user's instructions.",
3228
- messages: [{ role: "user", content: QUERY_AUGMENTATION_PROMPT }],
3229
- output: Output3.object({
3230
- schema: z6.object({
3231
- updatedUserQuestion: z6.string(),
3232
- updatedRelevantKeywords: z6.array(z6.string()),
3233
- updatedImportantKeyword: z6.string()
3234
- })
3235
- }),
3236
- maxOutputTokens: 600
3237
- }),
3238
- 3
3239
- ).catch(() => ({
3409
+ memoryConfig.queryAugmentation && hasAugmentationContent ? microCall({
3410
+ model,
3411
+ system: "You are a helpful assistant that will strictly follow the user's instructions.",
3412
+ messages: [{ role: "user", content: QUERY_AUGMENTATION_PROMPT }],
3413
+ schema: z6.object({
3414
+ updatedUserQuestion: z6.string(),
3415
+ updatedRelevantKeywords: z6.array(z6.string()),
3416
+ updatedImportantKeyword: z6.string()
3417
+ })
3418
+ }).catch(() => ({
3240
3419
  output: {
3241
3420
  updatedUserQuestion: question,
3242
3421
  updatedRelevantKeywords: [],
@@ -3318,7 +3497,6 @@ ${glossary.map((g) => `${g.term} : ${g.meaning}`).join("\n")}` : "";
3318
3497
  }
3319
3498
 
3320
3499
  // ee/agentic-retrieval/pipeline/hyde.ts
3321
- import { generateText as generateText4 } from "ai";
3322
3500
  var hydeCache = /* @__PURE__ */ new Map();
3323
3501
  var HYDE_CACHE_MAX = 200;
3324
3502
  function hydeCacheKey(originalQuestion, relevantKeywords, styleHint, importantKeyword) {
@@ -3387,11 +3565,11 @@ IMPORTANT:
3387
3565
  prompt += `
3388
3566
  Question: "${originalQuestion}"
3389
3567
  Relevant keywords: ${relevantKeywords.join(", ")}`;
3390
- const { text } = await generateText4({
3568
+ const { text } = await microCall({
3391
3569
  model,
3392
3570
  prompt,
3393
3571
  temperature: 0.3,
3394
- maxOutputTokens: 500
3572
+ maxAttempts: 1
3395
3573
  });
3396
3574
  const passage = (text || "").trim();
3397
3575
  return passage.length > 0 ? passage : null;
@@ -4333,6 +4511,388 @@ var createNewMemoryItemTool = (agent, context) => {
4333
4511
  });
4334
4512
  };
4335
4513
 
4514
+ // src/templates/tools/context-write-tools.ts
4515
+ import { z as z10 } from "zod";
4516
+
4517
+ // src/exulu/table-names.ts
4518
+ var getTableName = (id) => sanitizeName(id) + "_items";
4519
+ var getChunksTableName = (id) => sanitizeName(id) + "_chunks";
4520
+
4521
+ // src/utils/check-item-write-access.ts
4522
+ var checkItemWriteAccess = async (context, record, user) => {
4523
+ if (!user) {
4524
+ return false;
4525
+ }
4526
+ if (user.super_admin === true) {
4527
+ return true;
4528
+ }
4529
+ if (user.type === "api" && (!user.scope_mode || user.scope_mode === "admin")) {
4530
+ return true;
4531
+ }
4532
+ if (record.rights_mode === "public") {
4533
+ return true;
4534
+ }
4535
+ if (record.rights_mode === "private") {
4536
+ return record.created_by != null && String(record.created_by) === String(user.id);
4537
+ }
4538
+ const validRightsModes = ["users", "roles", "teams"];
4539
+ if (!validRightsModes.includes(record.rights_mode)) {
4540
+ return false;
4541
+ }
4542
+ const entity = getTableName(context.id);
4543
+ const { db: db2 } = await postgresClient();
4544
+ if (record.rights_mode === "users") {
4545
+ const grant = await db2.from("rbac").where({
4546
+ entity,
4547
+ target_resource_id: record.id,
4548
+ access_type: "User",
4549
+ user_id: user.id,
4550
+ rights: "write"
4551
+ }).first();
4552
+ return !!grant;
4553
+ }
4554
+ if (record.rights_mode === "roles") {
4555
+ const roleId = typeof user.role === "string" ? user.role : user.role?.id;
4556
+ if (!roleId) {
4557
+ return false;
4558
+ }
4559
+ const grant = await db2.from("rbac").where({
4560
+ entity,
4561
+ target_resource_id: record.id,
4562
+ access_type: "Role",
4563
+ role_id: roleId,
4564
+ rights: "write"
4565
+ }).first();
4566
+ return !!grant;
4567
+ }
4568
+ if (record.rights_mode === "teams") {
4569
+ const teamId = typeof user.team === "string" ? user.team : user.team?.id;
4570
+ if (!teamId) {
4571
+ return false;
4572
+ }
4573
+ const grant = await db2.from("rbac").where({
4574
+ entity,
4575
+ target_resource_id: record.id,
4576
+ access_type: "Team",
4577
+ team_id: teamId,
4578
+ rights: "write"
4579
+ }).first();
4580
+ return !!grant;
4581
+ }
4582
+ return false;
4583
+ };
4584
+
4585
+ // src/templates/tools/kb-editor-config.ts
4586
+ import { z as z9 } from "zod";
4587
+ var KB_EDITOR_TOOL_ID = "knowledge_base_editor";
4588
+ var permissionsSchema = z9.object({
4589
+ create: z9.boolean().catch(false).default(false),
4590
+ update: z9.boolean().catch(false).default(false)
4591
+ });
4592
+ var emptyConfig = () => ({
4593
+ enabled: false,
4594
+ knowledgeBases: {},
4595
+ skipApproval: false
4596
+ });
4597
+ var parseKbEditorConfig = (tools) => {
4598
+ let entries = tools;
4599
+ if (typeof entries === "string") {
4600
+ try {
4601
+ entries = JSON.parse(entries);
4602
+ } catch {
4603
+ return emptyConfig();
4604
+ }
4605
+ }
4606
+ if (!Array.isArray(entries)) {
4607
+ return emptyConfig();
4608
+ }
4609
+ const entry = entries.find((t) => t?.id === KB_EDITOR_TOOL_ID);
4610
+ if (!entry) {
4611
+ return emptyConfig();
4612
+ }
4613
+ const rawValue = (name) => {
4614
+ const row = Array.isArray(entry.config) ? entry.config.find((c) => c?.name === name) : void 0;
4615
+ return row?.value ?? row?.variable ?? row?.default;
4616
+ };
4617
+ let kbsRaw = rawValue("knowledge_bases");
4618
+ if (typeof kbsRaw === "string" && kbsRaw) {
4619
+ try {
4620
+ kbsRaw = JSON.parse(kbsRaw);
4621
+ } catch {
4622
+ kbsRaw = {};
4623
+ }
4624
+ }
4625
+ const knowledgeBases = {};
4626
+ if (kbsRaw && typeof kbsRaw === "object" && !Array.isArray(kbsRaw)) {
4627
+ for (const [contextId, value] of Object.entries(kbsRaw)) {
4628
+ const parsed = permissionsSchema.safeParse(value);
4629
+ if (parsed.success && (parsed.data.create || parsed.data.update)) {
4630
+ knowledgeBases[contextId] = parsed.data;
4631
+ }
4632
+ }
4633
+ }
4634
+ const skipRaw = rawValue("skip_approval");
4635
+ const skipApproval = skipRaw === true || skipRaw === "true" || skipRaw === 1;
4636
+ return { enabled: true, knowledgeBases, skipApproval };
4637
+ };
4638
+
4639
+ // src/templates/tools/context-write-tools.ts
4640
+ var MAX_CONTEXT_SEGMENT = 68;
4641
+ var RESERVED_INPUT_KEYS = /* @__PURE__ */ new Set([
4642
+ "model",
4643
+ "user",
4644
+ "contexts",
4645
+ "memory",
4646
+ "req",
4647
+ "upload",
4648
+ "sessionID",
4649
+ "sessionItems",
4650
+ "providerapikey",
4651
+ "allExuluTools",
4652
+ "currentTools",
4653
+ "exuluConfig",
4654
+ "toolVariablesConfig",
4655
+ "oauth"
4656
+ ]);
4657
+ var buildWriteSchema = (context, mode) => {
4658
+ const shape = {};
4659
+ const contentKeys = [];
4660
+ const addContent = (key, schema, required) => {
4661
+ shape[key] = required && mode === "create" ? schema : schema.optional();
4662
+ contentKeys.push(key);
4663
+ };
4664
+ if (mode === "update") {
4665
+ shape["id"] = z10.string().optional().describe("The id of the item to update.");
4666
+ shape["external_id"] = z10.string().optional().describe("The external_id of the item to update, if the id is unknown. Lookup only \u2014 it is never changed.");
4667
+ }
4668
+ addContent("name", z10.string().describe("The name of the item."), true);
4669
+ addContent("description", z10.string().describe("A description of the item."), false);
4670
+ addContent("tags", z10.array(z10.string()).describe("Tags for the item."), false);
4671
+ if (mode === "create") {
4672
+ addContent(
4673
+ "external_id",
4674
+ z10.string().describe("An optional external identifier for the item, e.g. an id from a source system."),
4675
+ false
4676
+ );
4677
+ }
4678
+ for (const field of context.fields ?? []) {
4679
+ if (field.type === "file" || field.type === "uuid") continue;
4680
+ if (field.calculated === true || field.editable === false) continue;
4681
+ if (field.hidden === true) continue;
4682
+ if (RESERVED_INPUT_KEYS.has(field.name)) continue;
4683
+ let schema;
4684
+ switch (field.type) {
4685
+ case "enum":
4686
+ schema = z10.string().describe(
4687
+ `The ${field.name} of the item. Must be one of: ${(field.enumValues ?? []).join(", ")}`
4688
+ );
4689
+ break;
4690
+ case "json":
4691
+ schema = z10.string().describe(`The ${field.name} of the item, as a valid JSON string.`);
4692
+ break;
4693
+ case "markdown":
4694
+ schema = z10.string().describe(`The ${field.name} of the item, as a valid Markdown string.`);
4695
+ break;
4696
+ case "date":
4697
+ schema = z10.string().describe(`The ${field.name} of the item, as an ISO-8601 date string.`);
4698
+ break;
4699
+ case "number":
4700
+ schema = z10.number().describe(`The ${field.name} of the item.`);
4701
+ break;
4702
+ case "boolean":
4703
+ schema = z10.boolean().describe(`The ${field.name} of the item.`);
4704
+ break;
4705
+ default:
4706
+ schema = z10.string().describe(`The ${field.name} of the item.`);
4707
+ break;
4708
+ }
4709
+ addContent(field.name, schema, field.required === true);
4710
+ }
4711
+ return { shape, contentKeys };
4712
+ };
4713
+ var canonicalizeEnumFields = (context, params) => {
4714
+ for (const field of context.fields ?? []) {
4715
+ if (field.type !== "enum" || !field.enumValues?.length) continue;
4716
+ const raw = params[field.name];
4717
+ if (raw === void 0 || raw === null || raw === "") continue;
4718
+ const rawStr = String(raw);
4719
+ const canonical = field.enumValues.find((v) => v.toUpperCase() === rawStr.toUpperCase());
4720
+ if (canonical === void 0) {
4721
+ return `Invalid value "${rawStr}" for field "${field.name}". Allowed values: ${field.enumValues.join(", ")}.`;
4722
+ }
4723
+ params[field.name] = canonical;
4724
+ }
4725
+ return void 0;
4726
+ };
4727
+ var pickContent = (params, contentKeys) => {
4728
+ const item = {};
4729
+ for (const key of contentKeys) {
4730
+ if (params[key] !== void 0) {
4731
+ item[key] = params[key];
4732
+ }
4733
+ }
4734
+ return item;
4735
+ };
4736
+ var jobNote = (job) => job ? ` Processing/embeddings queued (job: ${job}); changes become searchable when the job completes.` : "";
4737
+ var createContextWriteTools = (context, perms, skipApproval) => {
4738
+ const tools = [];
4739
+ const segment = sanitizeName(context.id).slice(0, MAX_CONTEXT_SEGMENT);
4740
+ const contextLabel = context.description ? ` ${context.description}` : "";
4741
+ if (perms.create) {
4742
+ const { shape, contentKeys } = buildWriteSchema(context, "create");
4743
+ tools.push(
4744
+ new ExuluTool({
4745
+ id: `create_${segment}_item`,
4746
+ name: `Create ${context.name} item`,
4747
+ category: "knowledge_base_editing",
4748
+ description: `Create a new item in the "${context.name}" knowledge base.${contextLabel}`,
4749
+ type: "function",
4750
+ inputSchema: z10.object(shape),
4751
+ config: [],
4752
+ needsApproval: !skipApproval,
4753
+ execute: async (params) => {
4754
+ const { user, exuluConfig } = params;
4755
+ if (!user?.id) {
4756
+ return { result: "Knowledge base writes require an authenticated user." };
4757
+ }
4758
+ try {
4759
+ const enumError = canonicalizeEnumFields(context, params);
4760
+ if (enumError) {
4761
+ return { result: enumError };
4762
+ }
4763
+ const item = pickContent(params, contentKeys);
4764
+ item.created_by = String(user.id);
4765
+ const { item: created, job } = await context.createItem(
4766
+ item,
4767
+ exuluConfig,
4768
+ user?.id,
4769
+ user?.role?.id,
4770
+ false
4771
+ );
4772
+ if (!created?.id) {
4773
+ return { result: `Failed to create item in "${context.name}".` };
4774
+ }
4775
+ return {
4776
+ result: `Created item ${created.id} in knowledge base "${context.name}".${jobNote(job)}`
4777
+ };
4778
+ } catch (error) {
4779
+ console.error(`[EXULU] Error creating item in context ${context.id}`, error);
4780
+ return {
4781
+ result: `Failed to create item in "${context.name}": ${error instanceof Error ? error.message : String(error)}`
4782
+ };
4783
+ }
4784
+ }
4785
+ })
4786
+ );
4787
+ }
4788
+ if (perms.update) {
4789
+ const { shape, contentKeys } = buildWriteSchema(context, "update");
4790
+ const NOT_FOUND = `Item not found in "${context.name}" or you don't have write access to it.`;
4791
+ tools.push(
4792
+ new ExuluTool({
4793
+ id: `update_${segment}_item`,
4794
+ name: `Update ${context.name} item`,
4795
+ category: "knowledge_base_editing",
4796
+ description: `Update an existing item in the "${context.name}" knowledge base. Provide the item's id (or external_id) plus only the fields to change; omitted fields keep their values.`,
4797
+ type: "function",
4798
+ inputSchema: z10.object(shape),
4799
+ config: [],
4800
+ needsApproval: !skipApproval,
4801
+ execute: async (params) => {
4802
+ const { user, exuluConfig } = params;
4803
+ if (!user?.id) {
4804
+ return { result: "Knowledge base writes require an authenticated user." };
4805
+ }
4806
+ try {
4807
+ if (!params.id && !params.external_id) {
4808
+ return { result: "Provide the id or external_id of the item to update." };
4809
+ }
4810
+ const existing = await context.getItem({
4811
+ item: { id: params.id, external_id: params.external_id }
4812
+ });
4813
+ if (!existing?.id) {
4814
+ return { result: NOT_FOUND };
4815
+ }
4816
+ const allowed = await checkItemWriteAccess(context, existing, user);
4817
+ if (!allowed) {
4818
+ return { result: NOT_FOUND };
4819
+ }
4820
+ const enumError = canonicalizeEnumFields(context, params);
4821
+ if (enumError) {
4822
+ return { result: enumError };
4823
+ }
4824
+ const patch = pickContent(params, contentKeys);
4825
+ if (Object.keys(patch).length === 0) {
4826
+ return { result: "No fields to update were provided." };
4827
+ }
4828
+ patch.id = existing.id;
4829
+ const { job } = await context.updateItem(patch, exuluConfig, user?.id, user?.role?.id);
4830
+ const fresh = await context.getItem({ item: { id: existing.id } });
4831
+ const summary = { id: existing.id };
4832
+ for (const key of contentKeys) {
4833
+ if (fresh?.[key] !== void 0 && fresh?.[key] !== null) {
4834
+ summary[key] = fresh[key];
4835
+ }
4836
+ }
4837
+ return {
4838
+ result: `Updated item ${existing.id} in knowledge base "${context.name}".${jobNote(job)}
4839
+ Current item: ${JSON.stringify(summary)}`
4840
+ };
4841
+ } catch (error) {
4842
+ console.error(`[EXULU] Error updating item in context ${context.id}`, error);
4843
+ return {
4844
+ result: `Failed to update item in "${context.name}": ${error instanceof Error ? error.message : String(error)}`
4845
+ };
4846
+ }
4847
+ }
4848
+ })
4849
+ );
4850
+ }
4851
+ return tools;
4852
+ };
4853
+ var createKbEditorPickerTool = () => new ExuluTool({
4854
+ id: KB_EDITOR_TOOL_ID,
4855
+ name: "Knowledge base editor",
4856
+ category: "default",
4857
+ description: "Let this agent create or update items in selected knowledge bases during chat. Configure per knowledge base whether the agent may create and/or update items.",
4858
+ type: "function",
4859
+ inputSchema: z10.object({}),
4860
+ config: [
4861
+ {
4862
+ name: "knowledge_bases",
4863
+ description: "JSON record of context id to { create: boolean, update: boolean }. Contexts absent here get no write access.",
4864
+ type: "json"
4865
+ },
4866
+ {
4867
+ name: "skip_approval",
4868
+ description: "Run knowledge base writes without asking for approval in the chat.",
4869
+ type: "boolean",
4870
+ default: false
4871
+ }
4872
+ ],
4873
+ execute: async () => ({
4874
+ result: "This entry is configuration-only; Exulu expands it into per-context create/update tools at runtime."
4875
+ })
4876
+ });
4877
+ var collectKbWriteTools = (agent, contexts) => {
4878
+ if (!agent?.tools || !contexts?.length) {
4879
+ return [];
4880
+ }
4881
+ const config = parseKbEditorConfig(agent.tools);
4882
+ if (!config.enabled) {
4883
+ return [];
4884
+ }
4885
+ const tools = [];
4886
+ for (const [contextId, perms] of Object.entries(config.knowledgeBases)) {
4887
+ const context = contexts.find((c) => c.id === contextId);
4888
+ if (!context) {
4889
+ continue;
4890
+ }
4891
+ tools.push(...createContextWriteTools(context, perms, config.skipApproval));
4892
+ }
4893
+ return tools;
4894
+ };
4895
+
4336
4896
  // ee/invoke-skills/create-sandbox.ts
4337
4897
  import {
4338
4898
  SandboxManager
@@ -5468,7 +6028,7 @@ ${body}`
5468
6028
  // ee/invoke-skills/create-sandbox.ts
5469
6029
  import { createBashTool } from "bash-tool";
5470
6030
  import { tool as tool2 } from "ai";
5471
- import { z as z9 } from "zod";
6031
+ import { z as z11 } from "zod";
5472
6032
  import CryptoJS4 from "crypto-js";
5473
6033
  var getAllExuluVariables = async () => {
5474
6034
  const { db: db2 } = await postgresClient();
@@ -5886,9 +6446,9 @@ Probe error: ${probe.reason ?? "(no detail)"}`
5886
6446
  });
5887
6447
  const writeFileTool = tool2({
5888
6448
  description: 'Write content to a file in the sandbox. Creates parent directories if needed. Paths are always resolved against the session sandbox root \u2014 both relative paths ("skills/foo.md") and leading-slash paths ("/skills/foo.md") work and reach the same file. When the path is under the session artifact tree, the file is also uploaded to S3 and a short-lived presigned URL is returned in the tool output.',
5889
- inputSchema: z9.object({
5890
- path: z9.string().describe("The path where the file should be written. Relative paths and leading-slash paths are both resolved against the session sandbox root."),
5891
- content: z9.string().describe("The content to write to the file")
6449
+ inputSchema: z11.object({
6450
+ path: z11.string().describe("The path where the file should be written. Relative paths and leading-slash paths are both resolved against the session sandbox root."),
6451
+ content: z11.string().describe("The content to write to the file")
5892
6452
  }),
5893
6453
  execute: async ({ path, content }) => {
5894
6454
  const resolvedPath = resolveSessionPath(path, sessionDir);
@@ -5907,8 +6467,8 @@ Probe error: ${probe.reason ?? "(no detail)"}`
5907
6467
  });
5908
6468
  const readFileTool = tool2({
5909
6469
  description: 'Read the contents of a file from the sandbox. Paths are always resolved against the session sandbox root \u2014 both relative paths ("skills/foo.md") and leading-slash paths ("/skills/foo.md") work and reach the same file. If the file does not exist, the error message is surfaced verbatim.',
5910
- inputSchema: z9.object({
5911
- path: z9.string().describe("The path of the file to read. Relative paths and leading-slash paths are both resolved against the session sandbox root.")
6470
+ inputSchema: z11.object({
6471
+ path: z11.string().describe("The path of the file to read. Relative paths and leading-slash paths are both resolved against the session sandbox root.")
5912
6472
  }),
5913
6473
  execute: async ({ path }) => {
5914
6474
  const resolvedPath = resolveSessionPath(path, sessionDir);
@@ -5919,8 +6479,8 @@ Probe error: ${probe.reason ?? "(no detail)"}`
5919
6479
  const originalBashTool = tools.bash;
5920
6480
  const bashTool = tool2({
5921
6481
  description: originalBashTool.description ?? "",
5922
- inputSchema: z9.object({
5923
- command: z9.string().describe("The bash command to execute.")
6482
+ inputSchema: z11.object({
6483
+ command: z11.string().describe("The bash command to execute.")
5924
6484
  }),
5925
6485
  execute: async (args, opts) => {
5926
6486
  const before = persistenceEnabled ? await snapshotSessionArtifacts() : null;
@@ -6167,8 +6727,27 @@ var guardExtractedFileText = async (filename, text, ctx) => {
6167
6727
  ${notice}`;
6168
6728
  };
6169
6729
 
6730
+ // src/exulu/auth/scrub-text.ts
6731
+ var credentialScrubText = (provider, fieldLabels) => `A secure credential form for provider "${provider}"` + (fieldLabels.length ? ` (fields: ${fieldLabels.join(", ")})` : "") + ` is shown to the user in the chat UI. Never ask for these values in chat. After the user confirms saving, call the tool again.`;
6732
+ var SCRUBBED_CREDENTIAL_TEXT = "A secure credential form was shown to the user in the chat UI. Never ask for credential values in chat. After the user confirms saving, call the tool again.";
6733
+ var SCRUBBED_OAUTH_TEXT = "Authorization is required. A Connect button was shown to the user in the chat UI. Do not relay any URL in chat. After the user confirms connecting, call the tool again.";
6734
+
6735
+ // src/templates/tools/auth-tool-model-output.ts
6736
+ var buildAuthToolModelOutput = (tool3) => ({ output }) => {
6737
+ if (output && typeof output === "object" && output.credentialRequest) {
6738
+ const auth = tool3.authentication;
6739
+ const labels = auth?.authType === "user_credentials" ? auth.fields.map((f) => f.label) : [];
6740
+ const provider = output.credentialRequest.provider ?? (auth && "provider" in auth ? auth.provider : "unknown");
6741
+ return { type: "text", value: credentialScrubText(provider, labels) };
6742
+ }
6743
+ if (output && typeof output === "object" && output.oauth?.authorizationUrl) {
6744
+ return { type: "text", value: SCRUBBED_OAUTH_TEXT };
6745
+ }
6746
+ return { type: "json", value: output ?? null };
6747
+ };
6748
+
6170
6749
  // src/templates/tools/session-file-read-tool.ts
6171
- import { z as z10 } from "zod";
6750
+ import { z as z12 } from "zod";
6172
6751
  var DEFAULT_LIMIT = 250;
6173
6752
  var MAX_CONTENT_CHARS = 16e3;
6174
6753
  var createSessionFileReadTool = ({
@@ -6220,10 +6799,10 @@ var createSessionFileReadTool = ({
6220
6799
  name: "read_session_file",
6221
6800
  needsApproval: false,
6222
6801
  description: "Read a line range from a file stored in this session's files \u2014 including offloaded tool outputs (tool-output-*.txt) and uploaded documents. Use offset (1-based line number) and limit to page through large files instead of reading everything at once.",
6223
- inputSchema: z10.object({
6224
- filename: z10.string().describe('Exact session file name as referenced in a truncation notice, e.g. "tool-output-web_search-a1b2c3d4.txt"'),
6225
- offset: z10.number().int().min(1).optional().describe("1-based first line to read (default 1)"),
6226
- limit: z10.number().int().min(1).max(1e3).optional().describe(`Number of lines to read (default ${DEFAULT_LIMIT})`)
6802
+ inputSchema: z12.object({
6803
+ filename: z12.string().describe('Exact session file name as referenced in a truncation notice, e.g. "tool-output-web_search-a1b2c3d4.txt"'),
6804
+ offset: z12.number().int().min(1).optional().describe("1-based first line to read (default 1)"),
6805
+ limit: z12.number().int().min(1).max(1e3).optional().describe(`Number of lines to read (default ${DEFAULT_LIMIT})`)
6227
6806
  }),
6228
6807
  type: "function",
6229
6808
  category: "session",
@@ -6236,7 +6815,7 @@ var createSessionFileReadTool = ({
6236
6815
  };
6237
6816
 
6238
6817
  // src/templates/tools/parse-document-tool.ts
6239
- import { z as z11 } from "zod";
6818
+ import { z as z13 } from "zod";
6240
6819
  import { extname } from "path";
6241
6820
  import { parseOfficeAsync } from "officeparser";
6242
6821
 
@@ -6399,11 +6978,11 @@ ${text.trim()}`).join("\n");
6399
6978
  name: "parse_document",
6400
6979
  needsApproval: false,
6401
6980
  description: `Extract the text of an uploaded PDF or Office document from this session's files, with "--- page N ---" markers for PDFs so you can locate content by page. Free and fast (no OCR): works only on documents with a real text layer. To SEE a page or an image inside a document, use view_document_page.`,
6402
- inputSchema: z11.object({
6403
- filename: z11.string().describe('Exact session file name, e.g. "report.pdf"'),
6404
- pages: z11.string().optional().describe('PDF page or range to extract, e.g. "2" or "1-5" (default: all pages) (PDF only)'),
6405
- offset: z11.number().int().min(1).optional().describe("1-based first output line to read (default 1)"),
6406
- limit: z11.number().int().min(1).max(1e3).optional().describe(`Number of lines to read (default ${DEFAULT_LIMIT2})`)
6981
+ inputSchema: z13.object({
6982
+ filename: z13.string().describe('Exact session file name, e.g. "report.pdf"'),
6983
+ pages: z13.string().optional().describe('PDF page or range to extract, e.g. "2" or "1-5" (default: all pages) (PDF only)'),
6984
+ offset: z13.number().int().min(1).optional().describe("1-based first output line to read (default 1)"),
6985
+ limit: z13.number().int().min(1).max(1e3).optional().describe(`Number of lines to read (default ${DEFAULT_LIMIT2})`)
6407
6986
  }),
6408
6987
  type: "function",
6409
6988
  category: "session",
@@ -6416,7 +6995,7 @@ ${text.trim()}`).join("\n");
6416
6995
  };
6417
6996
 
6418
6997
  // src/templates/tools/view-document-page-tool.ts
6419
- import { z as z12 } from "zod";
6998
+ import { z as z14 } from "zod";
6420
6999
  import { extname as extname3 } from "path";
6421
7000
 
6422
7001
  // src/sessions/pdf-preview-cache.ts
@@ -6686,9 +7265,9 @@ var createViewDocumentPageTool = ({
6686
7265
  name: "view_document_page",
6687
7266
  needsApproval: false,
6688
7267
  description: "LOOK at a page of an uploaded PDF/Office document, or at an uploaded image, from this session's files. The rendered image is attached as a user message directly after this tool result so you can visually analyze photos, charts, scans, and layouts. Use parse_document first to find which page you need. Requires a vision-capable model.",
6689
- inputSchema: z12.object({
6690
- filename: z12.string().describe('Exact session file name, e.g. "report.pdf" or "screenshot.png"'),
6691
- page: z12.number().int().min(1).optional().describe("Page number to render (default 1; ignored for image files)")
7268
+ inputSchema: z14.object({
7269
+ filename: z14.string().describe('Exact session file name, e.g. "report.pdf" or "screenshot.png"'),
7270
+ page: z14.number().int().min(1).optional().describe("Page number to render (default 1; ignored for image files)")
6692
7271
  }),
6693
7272
  type: "function",
6694
7273
  category: "session",
@@ -6863,6 +7442,11 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
6863
7442
  currentTools.push(createNewMemoryTool);
6864
7443
  }
6865
7444
  }
7445
+ for (const kbWriteTool of collectKbWriteTools(agent, contexts)) {
7446
+ if (!disabled.has(kbWriteTool.id)) {
7447
+ currentTools.push(kbWriteTool);
7448
+ }
7449
+ }
6866
7450
  console.log("[EXULU] Convert tools array to object, session items", sessionItems);
6867
7451
  if (sessionItems) {
6868
7452
  const sessionItemsRetrievalTool = await createSessionItemsRetrievalTool({
@@ -7015,6 +7599,10 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
7015
7599
  // Vercel AI SDK uses the sanitized tool name as the key, so this matches.
7016
7600
  needsApproval: approvedTools?.includes("tool-" + cur.name) || !cur.needsApproval ? false : true,
7017
7601
  // todo make configurable
7602
+ // Auth-wrapped tools: the model sees scrub text instead of the
7603
+ // credentialRequest/oauth payload; the UI stream keeps the raw
7604
+ // output (spec 2026-07-22 §1.2).
7605
+ ...cur.authentication ? { toModelOutput: buildAuthToolModelOutput(cur) } : {},
7018
7606
  async *execute(inputs, options) {
7019
7607
  console.log(
7020
7608
  "[EXULU] Executing tool",
@@ -7167,6 +7755,8 @@ export {
7167
7755
  getS3SignedUploadUrl,
7168
7756
  createUppyRoutes,
7169
7757
  sanitizeName,
7758
+ getTableName,
7759
+ getChunksTableName,
7170
7760
  LITELLM_UI_PATH,
7171
7761
  isLiteLLMEnabled,
7172
7762
  setLiteLLMPackageRoot,
@@ -7195,14 +7785,18 @@ export {
7195
7785
  ResolveModelError,
7196
7786
  resolveModel,
7197
7787
  exuluApp,
7198
- oauthRegistry,
7788
+ authRegistry,
7199
7789
  encrypt,
7200
7790
  decrypt,
7201
- oauthTokenStore,
7791
+ credentialStore,
7202
7792
  OAUTH_CALLBACK_PATH,
7203
7793
  decryptOauthState,
7204
7794
  exchangeCodeForTokens,
7795
+ verifyCredentialNonce,
7796
+ CredentialInvalidError,
7205
7797
  sanitizeToolName,
7798
+ KB_EDITOR_TOOL_ID,
7799
+ createKbEditorPickerTool,
7206
7800
  reportSystemDependencies,
7207
7801
  downloadKeyIntoSandbox,
7208
7802
  truncateToolOutput,
@@ -7216,6 +7810,8 @@ export {
7216
7810
  ContextCompactionRequiredError,
7217
7811
  mapStreamErrorMessage,
7218
7812
  guardExtractedFileText,
7813
+ SCRUBBED_CREDENTIAL_TEXT,
7814
+ SCRUBBED_OAUTH_TEXT,
7219
7815
  PreviewRenderError,
7220
7816
  getPdfPreviewBytes,
7221
7817
  imageAttachmentGuard,