@bike4mind/cli 1.2.0 → 2.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.
- package/README.md +3 -1
- package/dist/{AgentHistoryStore-B2NEOvSW.mjs → AgentHistoryStore-DdYKfx7C.mjs} +599 -363
- package/dist/{ApiClient-D0fQ2FT6.mjs → ApiClient-dIB3YQc2.mjs} +44 -5
- package/dist/{ConfigStore-qV7NrCgZ.mjs → ConfigStore-BygzEI6H.mjs} +12830 -9263
- package/dist/{WorkItemsClient-Cow6nXx7.mjs → WorkItemsClient-ClBC49y0.mjs} +52 -8
- package/dist/{buildAgent-C-C-VGff.mjs → buildAgent-B7cMwRAy.mjs} +146 -10
- package/dist/commands/acpCommand.mjs +5 -4
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +5 -4
- package/dist/commands/mcpCommand.mjs +3 -3
- package/dist/commands/pluginCommand.mjs +1 -1
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/index.mjs +592 -52
- package/dist/{package-CGZIoxcs.mjs → package-CRNnNXxq.mjs} +1 -1
- package/dist/serve-DM7ld3-o.mjs +1365 -0
- package/package.json +14 -14
- package/dist/serve-CPXqcEZr.mjs +0 -774
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { A as ChatModels, B as NotFoundError, E as BedrockEmbeddingModel, H as OpenAIEmbeddingModel, I as LOCATION_MAP_LANGUAGE, J as SupportedFabFileMimeTypes, K as SEARCH_RESULT_CARDS_LANGUAGE, Mt as APP_NAME, Nt as WEBSITE_URL, R as McpServerName, V as OllamaEmbeddingModel, W as PermissionDeniedError, X as WORK_ITEM_STATUSES, Y as VoyageAIEmbeddingModel, a as htmlErrorTitle, c as loadContextFiles, ct as isRetryableError, d as isReadOnlyTool, dt as obfuscateApiKey, gt as splitEquals, ht as signImageUrl, i as isValidSessionId, it as googleMapsSearchUrl, l as canTrustTool, mt as secureParameters, n as logger, nt as getMcpProviderMetadata, o as htmlFirstH1, r as SESSION_ID_PATTERN, st as isPlaceholderImageSigningSecret, u as getToolCategory, w as ApiKeyType, wt as withRetry } from "./ConfigStore-BygzEI6H.mjs";
|
|
3
3
|
import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-BRqf4HFX-CHwtwp3O.mjs";
|
|
4
4
|
import { n as isTerminalShellStatus, t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
|
|
5
5
|
import { execFile, execFileSync, spawn } from "child_process";
|
|
@@ -864,7 +864,7 @@ const getSerperKey = async (adapters) => {
|
|
|
864
864
|
};
|
|
865
865
|
const getSearxngUrl = async (adapters) => {
|
|
866
866
|
const { db } = adapters;
|
|
867
|
-
return (await db.adminSettings.findBySettingName("SearxngUrl"))?.settingValue?.trim() ||
|
|
867
|
+
return (await db.adminSettings.findBySettingName("SearxngUrl"))?.settingValue?.trim() || process.env.SEARXNG_BASE_URL?.trim() || null;
|
|
868
868
|
};
|
|
869
869
|
const getWebSearchProviderSetting = async (adapters) => {
|
|
870
870
|
const { db } = adapters;
|
|
@@ -982,7 +982,10 @@ const setApiKey = async (userId, parameters, { db }) => {
|
|
|
982
982
|
if (!apiKey) throw new NotFoundError("API key not found");
|
|
983
983
|
await db.apiKeys.updateAllByUserIdAndType(userId, type, { isActive: false });
|
|
984
984
|
apiKey.isActive = true;
|
|
985
|
-
await db.apiKeys.update(
|
|
985
|
+
await db.apiKeys.update({
|
|
986
|
+
id: apiKey.id,
|
|
987
|
+
isActive: true
|
|
988
|
+
});
|
|
986
989
|
return apiKey;
|
|
987
990
|
};
|
|
988
991
|
const listOwnApiKeys = async (userId, adapters, options = {}) => {
|
|
@@ -1629,6 +1632,7 @@ new https.Agent({
|
|
|
1629
1632
|
lookup: ssrfSafeLookup,
|
|
1630
1633
|
keepAlive: false
|
|
1631
1634
|
});
|
|
1635
|
+
`${APP_NAME || "App"}`, WEBSITE_URL && `${WEBSITE_URL}`;
|
|
1632
1636
|
`${"a, span, em, strong, b, i, u, code, kbd, samp, var, sub, sup, small, abbr, cite, q, time, mark, s, del, ins, bdi, bdo, wbr, ruby, rt, rp".split(", ").join("):not(")}`;
|
|
1633
1637
|
[
|
|
1634
1638
|
"[aria-hidden=\"true\"]",
|
|
@@ -2206,7 +2210,96 @@ const webFetchTool = {
|
|
|
2206
2210
|
})
|
|
2207
2211
|
};
|
|
2208
2212
|
//#endregion
|
|
2209
|
-
//#region ../../b4m-core/services/dist/
|
|
2213
|
+
//#region ../../b4m-core/services/dist/prompts-Df-9fQuz.mjs
|
|
2214
|
+
/**
|
|
2215
|
+
* Teaches the `b4m_cards` fence, appended to a web_search result ONLY when that search actually
|
|
2216
|
+
* returned images (see shouldIncludeImages). Delivering it with the results rather than in the system
|
|
2217
|
+
* prompt costs nothing on the turns that will never use it, and arrives exactly when it is actionable.
|
|
2218
|
+
*
|
|
2219
|
+
* The field names must stay in sync with the parser in
|
|
2220
|
+
* apps/client/app/components/Session/parseSearchResultCards.ts; the fence language is shared.
|
|
2221
|
+
*/
|
|
2222
|
+
const WEB_SEARCH_CARDS_PROMPT = [
|
|
2223
|
+
"The results above include images, so illustrate your answer - do not wait to be asked. When you name specific",
|
|
2224
|
+
`things the user would want to SEE, emit a \`\`\`${SEARCH_RESULT_CARDS_LANGUAGE} fenced block inline in your reply, placed`,
|
|
2225
|
+
"exactly where the pictures belong - right after the sentence that introduces them, not at the end.",
|
|
2226
|
+
"The block is a single JSON object:",
|
|
2227
|
+
"",
|
|
2228
|
+
`\`\`\`${SEARCH_RESULT_CARDS_LANGUAGE}`,
|
|
2229
|
+
"{\"cards\":[{\"name\":\"Orient Bambino\",\"note\":\"Your own description of this thing, in your voice.\",",
|
|
2230
|
+
"\"meta\":\"~$200\",\"url\":\"https://orientwatch.co/bambino\",\"images\":[",
|
|
2231
|
+
"{\"url\":\"https://example.com/a.jpg\",\"source\":\"orientwatch.co\"},",
|
|
2232
|
+
"{\"url\":\"https://example.com/b.jpg\",\"source\":\"jomashop\"}]}]}",
|
|
2233
|
+
"```",
|
|
2234
|
+
"",
|
|
2235
|
+
"Rules: `name` and at least one `images` entry are required. Every image `url` must be copied verbatim",
|
|
2236
|
+
"from an `image:` or Images line above, never invented or guessed, and `source` is that entry's own",
|
|
2237
|
+
"`source`/hostname, so each picture is attributed. Prefer the \"Images found for this search\" pool:",
|
|
2238
|
+
"those carry their own page and publisher. `note` is YOUR prose about the thing, not the",
|
|
2239
|
+
"search snippet. `meta` is a short footer such as a price or key spec. `url` is where the card links.",
|
|
2240
|
+
"Two to six cards is the useful range. Keep writing normally around the block - it replaces neither",
|
|
2241
|
+
"your explanation nor your citations. Omit the block only if the results genuinely have no images",
|
|
2242
|
+
"worth showing; never tell the user you could show pictures if they asked - just show them."
|
|
2243
|
+
].join("\n");
|
|
2244
|
+
/**
|
|
2245
|
+
* Teaches the `b4m_map` fence, appended to a web_search result ONLY when that search returned places
|
|
2246
|
+
* with provider coordinates (see shouldIncludePlaces). The fence names places by id alone, so the
|
|
2247
|
+
* model never writes a coordinate - the client resolves each id against the stored place citables.
|
|
2248
|
+
*
|
|
2249
|
+
* The field names must stay in sync with parseLocationMapFence in
|
|
2250
|
+
* b4m-core/common/src/constants/locationMap.ts.
|
|
2251
|
+
*/
|
|
2252
|
+
const WEB_SEARCH_MAP_PROMPT = [
|
|
2253
|
+
"The results above include places with map locations, so show them on a map - do not wait to be asked.",
|
|
2254
|
+
`Emit one \`\`\`${LOCATION_MAP_LANGUAGE} fenced block inline in your reply, placed where the map belongs - right`,
|
|
2255
|
+
"after the sentence that introduces the places. The block is a single JSON object:",
|
|
2256
|
+
"",
|
|
2257
|
+
`\`\`\`${LOCATION_MAP_LANGUAGE}`,
|
|
2258
|
+
"{\"anchor\":{\"id\":\"<anchor id>\",\"name\":\"citizenM Copenhagen\",\"label\":\"Your hotel\"},",
|
|
2259
|
+
"\"places\":[{\"id\":\"<place id>\",\"name\":\"Barr\",\"note\":\"Your own one-line take on this place.\"}]}",
|
|
2260
|
+
"```",
|
|
2261
|
+
"",
|
|
2262
|
+
"Rules: every `id` must be copied verbatim from an `id:` line above - never invent one, and never write",
|
|
2263
|
+
"coordinates; the map locates each place from its id. `name` is the place name as listed. `note` is",
|
|
2264
|
+
"YOUR short line about why it fits the request. Include `anchor` only when an \"Anchor location\" is listed",
|
|
2265
|
+
"above, with a short `label` for what it is to the user (\"Your hotel\", \"Your office\"). List the places",
|
|
2266
|
+
"worth recommending, three to ten. Keep writing normally around the block - it does not replace your",
|
|
2267
|
+
"explanation or citations. Omit it only if none of the places actually answer the question."
|
|
2268
|
+
].join("\n");
|
|
2269
|
+
//#endregion
|
|
2270
|
+
//#region ../../b4m-core/services/dist/websearch-BpzP6-Zz.mjs
|
|
2271
|
+
/**
|
|
2272
|
+
* Per-process cache for web-search results. Keyed on normalized query + result count, with a
|
|
2273
|
+
* short TTL so repeated/similar searches within a turn or across back-to-back turns reuse the
|
|
2274
|
+
* same provider response. The cache lives in the Fargate container's memory and is never shared
|
|
2275
|
+
* across containers or persisted.
|
|
2276
|
+
*/
|
|
2277
|
+
const SEARCH_CACHE_TTL_MS = 3e5;
|
|
2278
|
+
const SEARCH_CACHE_MAX_ENTRIES = 200;
|
|
2279
|
+
const searchCache = /* @__PURE__ */ new Map();
|
|
2280
|
+
function searchCacheKey(query, numResults, placeSearch) {
|
|
2281
|
+
return `${query.trim().toLowerCase()}::${numResults}${placeSearch ? "::places" : ""}`;
|
|
2282
|
+
}
|
|
2283
|
+
function searchCacheGet(query, numResults, placeSearch) {
|
|
2284
|
+
const key = searchCacheKey(query, numResults, placeSearch);
|
|
2285
|
+
const entry = searchCache.get(key);
|
|
2286
|
+
if (!entry) return void 0;
|
|
2287
|
+
if (Date.now() > entry.expiresAt) {
|
|
2288
|
+
searchCache.delete(key);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
return entry.results;
|
|
2292
|
+
}
|
|
2293
|
+
function searchCacheSet(query, numResults, results, placeSearch) {
|
|
2294
|
+
if (searchCache.size >= SEARCH_CACHE_MAX_ENTRIES) {
|
|
2295
|
+
const firstKey = searchCache.keys().next().value;
|
|
2296
|
+
if (firstKey !== void 0) searchCache.delete(firstKey);
|
|
2297
|
+
}
|
|
2298
|
+
searchCache.set(searchCacheKey(query, numResults, placeSearch), {
|
|
2299
|
+
results,
|
|
2300
|
+
expiresAt: Date.now() + SEARCH_CACHE_TTL_MS
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2210
2303
|
/**
|
|
2211
2304
|
* The coarse recency bucket both providers speak, as the smallest one containing `recencyDays`.
|
|
2212
2305
|
* Null when there is no constraint, or when the window is wider than the widest bucket - a
|
|
@@ -2228,10 +2321,19 @@ const SERPAPI_QDR = {
|
|
|
2228
2321
|
year: "qdr:y"
|
|
2229
2322
|
};
|
|
2230
2323
|
const DEFAULT_NUM_RESULTS = 3;
|
|
2231
|
-
const SEARCH_TIMEOUT_MS =
|
|
2232
|
-
const SERPAPI_ATTEMPT_TIMEOUT_MS =
|
|
2324
|
+
const SEARCH_TIMEOUT_MS = 1e4;
|
|
2325
|
+
const SERPAPI_ATTEMPT_TIMEOUT_MS = 1e4;
|
|
2233
2326
|
const SERPAPI_MAX_ATTEMPTS = 2;
|
|
2234
2327
|
const SERPAPI_RETRY_DELAY_MS = 500;
|
|
2328
|
+
/**
|
|
2329
|
+
* How long the lead provider gets before the backup is started alongside it (sooner if the lead
|
|
2330
|
+
* fails or comes back empty first). Just past SerpAPI's normal response time in production: answers come back in about
|
|
2331
|
+
* 1s typically and 3s at the 90th percentile, while a stalled request usually never answers.
|
|
2332
|
+
*/
|
|
2333
|
+
const WEB_SEARCH_HEDGE_DELAY_MS = 3e3;
|
|
2334
|
+
const SERPAPI_ORGANIC_MS = 20500;
|
|
2335
|
+
const HEDGED_BACKUP_MS = WEB_SEARCH_HEDGE_DELAY_MS + Math.max(SEARCH_TIMEOUT_MS, 1 * SERPAPI_ATTEMPT_TIMEOUT_MS);
|
|
2336
|
+
Math.max(SERPAPI_ORGANIC_MS, HEDGED_BACKUP_MS) + SEARCH_TIMEOUT_MS;
|
|
2235
2337
|
const MAX_IMAGES_PER_RESULT = 4;
|
|
2236
2338
|
const DEFAULT_IMAGE_RESULTS = 12;
|
|
2237
2339
|
const DEFAULT_PLACE_RESULTS = 10;
|
|
@@ -2319,6 +2421,11 @@ function indexImagesByLink(inlineImages, shoppingResults) {
|
|
|
2319
2421
|
function isAbortError$1(error) {
|
|
2320
2422
|
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
2321
2423
|
}
|
|
2424
|
+
function setSerpApiGeoTargeting(searchParams, options) {
|
|
2425
|
+
if (options?.locationInQuery) return;
|
|
2426
|
+
searchParams.set("location", "United States");
|
|
2427
|
+
searchParams.set("gl", "us");
|
|
2428
|
+
}
|
|
2322
2429
|
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2323
2430
|
/**
|
|
2324
2431
|
* One SerpAPI organic-search attempt, bounded by SERPAPI_ATTEMPT_TIMEOUT_MS. Never throws -
|
|
@@ -2413,21 +2520,21 @@ async function serpApiSearch(adapters, query, num_results, options) {
|
|
|
2413
2520
|
engine: "google",
|
|
2414
2521
|
api_key: apiKey,
|
|
2415
2522
|
q: query,
|
|
2416
|
-
location: "United States",
|
|
2417
2523
|
google_domain: "google.com",
|
|
2418
|
-
gl: "us",
|
|
2419
2524
|
hl: "en",
|
|
2420
2525
|
num: (num_results || DEFAULT_NUM_RESULTS).toString()
|
|
2421
2526
|
});
|
|
2527
|
+
setSerpApiGeoTargeting(searchParams, options);
|
|
2422
2528
|
const bucket = recencyBucket(options?.recencyDays);
|
|
2423
2529
|
if (bucket) searchParams.set("tbs", SERPAPI_QDR[bucket]);
|
|
2424
2530
|
url.search = searchParams.toString();
|
|
2531
|
+
const maxAttempts = Math.min(Math.max(1, options?.maxAttempts ?? SERPAPI_MAX_ATTEMPTS), SERPAPI_MAX_ATTEMPTS);
|
|
2425
2532
|
const failures = [];
|
|
2426
|
-
for (let attempt = 1; attempt <=
|
|
2533
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2427
2534
|
const outcome = await attemptSerpApiRequest(url, attempt);
|
|
2428
2535
|
if (outcome.ok) return outcome.data;
|
|
2429
2536
|
failures.push(outcome);
|
|
2430
|
-
if (!outcome.retryable || attempt ===
|
|
2537
|
+
if (!outcome.retryable || attempt === maxAttempts) break;
|
|
2431
2538
|
Logger.globalInstance.log("📡 WebSearch Tool: retrying SerpAPI after transient failure", {
|
|
2432
2539
|
attempt,
|
|
2433
2540
|
status: outcome.status,
|
|
@@ -2442,20 +2549,20 @@ async function serpApiSearch(adapters, query, num_results, options) {
|
|
|
2442
2549
|
* `include_images` on a visual query. Failures resolve to [] - missing pictures degrade the reply
|
|
2443
2550
|
* to prose, they never fail the search.
|
|
2444
2551
|
*/
|
|
2445
|
-
async function serpApiImageSearch(adapters, query, limit) {
|
|
2552
|
+
async function serpApiImageSearch(adapters, query, limit, options) {
|
|
2446
2553
|
const apiKey = await (0, apiKeyService_exports.getSerperKey)(adapters);
|
|
2447
2554
|
if (!apiKey) return [];
|
|
2448
2555
|
const url = new URL("https://serpapi.com/search");
|
|
2449
|
-
|
|
2556
|
+
const searchParams = new URLSearchParams({
|
|
2450
2557
|
engine: "google_images",
|
|
2451
2558
|
api_key: apiKey,
|
|
2452
2559
|
q: query,
|
|
2453
|
-
location: "United States",
|
|
2454
2560
|
google_domain: "google.com",
|
|
2455
|
-
gl: "us",
|
|
2456
2561
|
hl: "en",
|
|
2457
2562
|
safe: "active"
|
|
2458
|
-
})
|
|
2563
|
+
});
|
|
2564
|
+
setSerpApiGeoTargeting(searchParams, options);
|
|
2565
|
+
url.search = searchParams.toString();
|
|
2459
2566
|
const controller = new AbortController();
|
|
2460
2567
|
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
|
|
2461
2568
|
try {
|
|
@@ -2555,7 +2662,7 @@ function safeHost(url) {
|
|
|
2555
2662
|
function createSerpApiProvider(adapters) {
|
|
2556
2663
|
return {
|
|
2557
2664
|
name: "serpapi",
|
|
2558
|
-
searchImages: (query, limit) => serpApiImageSearch(adapters, query, limit ?? DEFAULT_IMAGE_RESULTS),
|
|
2665
|
+
searchImages: (query, limit, options) => serpApiImageSearch(adapters, query, limit ?? DEFAULT_IMAGE_RESULTS, options),
|
|
2559
2666
|
searchPlaces: (query, limit) => serpApiPlaceSearch(adapters, query, limit ?? DEFAULT_PLACE_RESULTS),
|
|
2560
2667
|
async search(query, numResults, options) {
|
|
2561
2668
|
const data = await serpApiSearch(adapters, query, numResults, options);
|
|
@@ -2696,90 +2803,48 @@ function createSearxngProvider(baseUrl) {
|
|
|
2696
2803
|
status: response.status,
|
|
2697
2804
|
statusText: response.statusText
|
|
2698
2805
|
});
|
|
2806
|
+
if (options?.throwOnError) throw new Error(`SearXNG error: HTTP ${response.status}`);
|
|
2699
2807
|
return [];
|
|
2700
2808
|
}
|
|
2701
2809
|
return parseSearxngResults(await response.json(), limit);
|
|
2702
2810
|
} catch (error) {
|
|
2703
2811
|
Logger.globalInstance.error("❌ WebSearch Tool: SearXNG request failed:", error);
|
|
2704
|
-
return [];
|
|
2812
|
+
if (!options?.throwOnError) return [];
|
|
2813
|
+
if (isAbortError$1(error)) throw new Error(`Web search timed out: SearXNG did not respond within ${SEARCH_TIMEOUT_MS / 1e3}s`);
|
|
2814
|
+
throw error;
|
|
2705
2815
|
} finally {
|
|
2706
2816
|
clearTimeout(timeoutId);
|
|
2707
2817
|
}
|
|
2708
2818
|
}
|
|
2709
2819
|
};
|
|
2710
2820
|
}
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
const choice = await (0, apiKeyService_exports.getWebSearchProviderSetting)(adapters) ?? "auto";
|
|
2720
|
-
const searxngUrl = await (0, apiKeyService_exports.getSearxngUrl)(adapters);
|
|
2721
|
-
const serperKey = await (0, apiKeyService_exports.getSerperKey)(adapters);
|
|
2821
|
+
async function readProviderSettings(adapters) {
|
|
2822
|
+
return {
|
|
2823
|
+
choice: await (0, apiKeyService_exports.getWebSearchProviderSetting)(adapters) ?? "auto",
|
|
2824
|
+
searxngUrl: await (0, apiKeyService_exports.getSearxngUrl)(adapters),
|
|
2825
|
+
serperKey: await (0, apiKeyService_exports.getSerperKey)(adapters)
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
function pickPrimaryProvider(adapters, { choice, searxngUrl, serperKey }) {
|
|
2722
2829
|
if (choice === "searxng") return searxngUrl ? createSearxngProvider(searxngUrl) : null;
|
|
2723
2830
|
if (choice === "serpapi") return serperKey ? createSerpApiProvider(adapters) : null;
|
|
2724
|
-
if (searxngUrl) return createSearxngProvider(searxngUrl);
|
|
2725
2831
|
if (serperKey) return createSerpApiProvider(adapters);
|
|
2832
|
+
if (searxngUrl) return createSearxngProvider(searxngUrl);
|
|
2726
2833
|
return null;
|
|
2727
2834
|
}
|
|
2728
2835
|
/**
|
|
2729
|
-
*
|
|
2730
|
-
*
|
|
2731
|
-
*
|
|
2732
|
-
*
|
|
2733
|
-
* The field names must stay in sync with the parser in
|
|
2734
|
-
* apps/client/app/components/Session/parseSearchResultCards.ts; the fence language is shared.
|
|
2836
|
+
* Returns [lead, backup] for searchWithHedge. The lead is exactly what
|
|
2837
|
+
* resolveWebSearchProvider picks: the admin's WebSearchProvider choice, or under 'auto' SerpAPI
|
|
2838
|
+
* when a key is set, else SearXNG. The backup is the other provider when it is configured too. An
|
|
2839
|
+
* unconfigured explicit choice yields no lead at all; it is never quietly replaced by the other.
|
|
2735
2840
|
*/
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
"
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
"{\"cards\":[{\"name\":\"Orient Bambino\",\"note\":\"Your own description of this thing, in your voice.\",",
|
|
2744
|
-
"\"meta\":\"~$200\",\"url\":\"https://orientwatch.co/bambino\",\"images\":[",
|
|
2745
|
-
"{\"url\":\"https://example.com/a.jpg\",\"source\":\"orientwatch.co\"},",
|
|
2746
|
-
"{\"url\":\"https://example.com/b.jpg\",\"source\":\"jomashop\"}]}]}",
|
|
2747
|
-
"```",
|
|
2748
|
-
"",
|
|
2749
|
-
"Rules: `name` and at least one `images` entry are required. Every image `url` must be copied verbatim",
|
|
2750
|
-
"from an `image:` or Images line above, never invented or guessed, and `source` is that entry's own",
|
|
2751
|
-
"`source`/hostname, so each picture is attributed. Prefer the \"Images found for this search\" pool:",
|
|
2752
|
-
"those carry their own page and publisher. `note` is YOUR prose about the thing, not the",
|
|
2753
|
-
"search snippet. `meta` is a short footer such as a price or key spec. `url` is where the card links.",
|
|
2754
|
-
"Two to six cards is the useful range. Keep writing normally around the block - it replaces neither",
|
|
2755
|
-
"your explanation nor your citations. Omit the block only if the results genuinely have no images",
|
|
2756
|
-
"worth showing; never tell the user you could show pictures if they asked - just show them."
|
|
2757
|
-
].join("\n");
|
|
2758
|
-
/**
|
|
2759
|
-
* Teaches the `b4m_map` fence, appended to a web_search result ONLY when that search returned places
|
|
2760
|
-
* with provider coordinates (see shouldIncludePlaces). The fence names places by id alone, so the
|
|
2761
|
-
* model never writes a coordinate - the client resolves each id against the stored place citables.
|
|
2762
|
-
*
|
|
2763
|
-
* The field names must stay in sync with parseLocationMapFence in
|
|
2764
|
-
* b4m-core/common/src/constants/locationMap.ts.
|
|
2765
|
-
*/
|
|
2766
|
-
const WEB_SEARCH_MAP_PROMPT = [
|
|
2767
|
-
"The results above include places with map locations, so show them on a map - do not wait to be asked.",
|
|
2768
|
-
`Emit one \`\`\`${LOCATION_MAP_LANGUAGE} fenced block inline in your reply, placed where the map belongs - right`,
|
|
2769
|
-
"after the sentence that introduces the places. The block is a single JSON object:",
|
|
2770
|
-
"",
|
|
2771
|
-
`\`\`\`${LOCATION_MAP_LANGUAGE}`,
|
|
2772
|
-
"{\"anchor\":{\"id\":\"<anchor id>\",\"name\":\"citizenM Copenhagen\",\"label\":\"Your hotel\"},",
|
|
2773
|
-
"\"places\":[{\"id\":\"<place id>\",\"name\":\"Barr\",\"note\":\"Your own one-line take on this place.\"}]}",
|
|
2774
|
-
"```",
|
|
2775
|
-
"",
|
|
2776
|
-
"Rules: every `id` must be copied verbatim from an `id:` line above - never invent one, and never write",
|
|
2777
|
-
"coordinates; the map locates each place from its id. `name` is the place name as listed. `note` is",
|
|
2778
|
-
"YOUR short line about why it fits the request. Include `anchor` only when an \"Anchor location\" is listed",
|
|
2779
|
-
"above, with a short `label` for what it is to the user (\"Your hotel\", \"Your office\"). List the places",
|
|
2780
|
-
"worth recommending, three to ten. Keep writing normally around the block - it does not replace your",
|
|
2781
|
-
"explanation or citations. Omit it only if none of the places actually answer the question."
|
|
2782
|
-
].join("\n");
|
|
2841
|
+
async function resolveWebSearchProviders(adapters) {
|
|
2842
|
+
const settings = await readProviderSettings(adapters);
|
|
2843
|
+
const lead = pickPrimaryProvider(adapters, settings);
|
|
2844
|
+
if (!lead) return [null, null];
|
|
2845
|
+
if (lead.name === "serpapi") return [lead, settings.searxngUrl ? createSearxngProvider(settings.searxngUrl) : null];
|
|
2846
|
+
return [lead, settings.serperKey ? createSerpApiProvider(adapters) : null];
|
|
2847
|
+
}
|
|
2783
2848
|
function safeHostname(url) {
|
|
2784
2849
|
try {
|
|
2785
2850
|
return new URL(url).hostname;
|
|
@@ -2864,9 +2929,73 @@ function placeCitable(place, imageUrlSigningSecret, canSignImages) {
|
|
|
2864
2929
|
};
|
|
2865
2930
|
}
|
|
2866
2931
|
const WEB_SEARCH_NOT_CONFIGURED_MSG = "Web search is not configured: an administrator needs to set a Serper API key or a local SearXNG URL in Admin > API Keys. No search was performed.";
|
|
2867
|
-
|
|
2932
|
+
var EmptySearchAnswer = class extends Error {};
|
|
2933
|
+
const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
2934
|
+
/**
|
|
2935
|
+
* Organic search as a hedged request: the lead starts at once, and the backup starts alongside it
|
|
2936
|
+
* once the lead has been silent for WEB_SEARCH_HEDGE_DELAY_MS, or as soon as the lead fails. The
|
|
2937
|
+
* first non-empty answer wins; the loser runs on to its own timeout and is ignored. An empty lead
|
|
2938
|
+
* answer also starts the backup, since a throttled SearXNG answers 200 with no results. With no
|
|
2939
|
+
* backup the lead keeps its own failure mode (SearXNG fail-soft, SerpAPI throws). Returns the
|
|
2940
|
+
* provider that answered so follow-up calls (images, places) go to a live provider. With a backup,
|
|
2941
|
+
* results are empty when neither provider has any (both empty, or one empty and the other failed),
|
|
2942
|
+
* and it throws only when both failed.
|
|
2943
|
+
*/
|
|
2944
|
+
async function searchWithHedge(lead, backup, query, numResults, searchOptions) {
|
|
2945
|
+
if (!backup) return {
|
|
2946
|
+
results: await lead.search(query, numResults, searchOptions),
|
|
2947
|
+
servedBy: lead
|
|
2948
|
+
};
|
|
2949
|
+
const fromLead = lead.search(query, numResults, {
|
|
2950
|
+
...searchOptions,
|
|
2951
|
+
throwOnError: true
|
|
2952
|
+
});
|
|
2953
|
+
const fromBackup = new Promise((start) => {
|
|
2954
|
+
const timer = setTimeout(() => start("slow"), WEB_SEARCH_HEDGE_DELAY_MS);
|
|
2955
|
+
fromLead.then((results) => {
|
|
2956
|
+
clearTimeout(timer);
|
|
2957
|
+
if (results.length === 0) start("empty");
|
|
2958
|
+
}, () => {
|
|
2959
|
+
clearTimeout(timer);
|
|
2960
|
+
start("failed");
|
|
2961
|
+
});
|
|
2962
|
+
}).then((reason) => {
|
|
2963
|
+
Logger.globalInstance.log("WebSearch Tool: starting backup provider", {
|
|
2964
|
+
lead: lead.name,
|
|
2965
|
+
backup: backup.name,
|
|
2966
|
+
reason
|
|
2967
|
+
});
|
|
2968
|
+
return backup.search(query, numResults, {
|
|
2969
|
+
...searchOptions,
|
|
2970
|
+
throwOnError: true,
|
|
2971
|
+
maxAttempts: 1
|
|
2972
|
+
});
|
|
2973
|
+
});
|
|
2974
|
+
const nonEmpty = (answer, provider) => answer.then((results) => {
|
|
2975
|
+
if (results.length === 0) throw new EmptySearchAnswer();
|
|
2976
|
+
return {
|
|
2977
|
+
results,
|
|
2978
|
+
servedBy: provider
|
|
2979
|
+
};
|
|
2980
|
+
});
|
|
2981
|
+
try {
|
|
2982
|
+
return await Promise.any([nonEmpty(fromLead, lead), nonEmpty(fromBackup, backup)]);
|
|
2983
|
+
} catch (error) {
|
|
2984
|
+
const [leadError, backupError] = error instanceof AggregateError ? error.errors : [error, error];
|
|
2985
|
+
if (leadError instanceof EmptySearchAnswer) return {
|
|
2986
|
+
results: [],
|
|
2987
|
+
servedBy: lead
|
|
2988
|
+
};
|
|
2989
|
+
if (backupError instanceof EmptySearchAnswer) return {
|
|
2990
|
+
results: [],
|
|
2991
|
+
servedBy: backup
|
|
2992
|
+
};
|
|
2993
|
+
throw new Error(`Web search failed on both providers. ${lead.name}: ${errorMessage$1(leadError)}; ${backup.name}: ${errorMessage$1(backupError)}`);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
async function performWebSearch(adapters, params, imageUrlSigningSecret = "", resolvedProviders) {
|
|
2868
2997
|
Logger.globalInstance.log("🔍 WebSearch Tool: Starting search for query:", params.query);
|
|
2869
|
-
const provider = await
|
|
2998
|
+
const [provider, backup] = await (resolvedProviders ?? resolveWebSearchProviders(adapters));
|
|
2870
2999
|
if (!provider) {
|
|
2871
3000
|
Logger.globalInstance.error("❌ WebSearch Tool: No web-search provider configured. Skipping search.");
|
|
2872
3001
|
return {
|
|
@@ -2875,21 +3004,33 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
|
|
|
2875
3004
|
};
|
|
2876
3005
|
}
|
|
2877
3006
|
try {
|
|
2878
|
-
const
|
|
2879
|
-
|
|
3007
|
+
const searchOptions = params.include_places ? { locationInQuery: true } : void 0;
|
|
3008
|
+
const numResults = params.num_results ?? 3;
|
|
3009
|
+
const isPlaceSearch = !!params.include_places;
|
|
3010
|
+
const cached = searchCacheGet(params.query, numResults, isPlaceSearch);
|
|
3011
|
+
let results;
|
|
3012
|
+
let servedBy = provider;
|
|
3013
|
+
if (cached) {
|
|
3014
|
+
Logger.globalInstance.log("WebSearch Tool: cache hit", { query: params.query });
|
|
3015
|
+
results = cached;
|
|
3016
|
+
} else {
|
|
3017
|
+
({results, servedBy} = await searchWithHedge(provider, backup, params.query, numResults, searchOptions));
|
|
3018
|
+
if (results.length > 0) searchCacheSet(params.query, numResults, results, isPlaceSearch);
|
|
3019
|
+
}
|
|
3020
|
+
Logger.globalInstance.log(`📊 WebSearch Tool: ${servedBy.name} found ${results.length} results${cached ? " (cached)" : ""}`);
|
|
2880
3021
|
const canSignImages = !isPlaceholderImageSigningSecret(imageUrlSigningSecret);
|
|
2881
3022
|
const wantsImages = !!params.include_images && canSignImages;
|
|
2882
3023
|
const anchorQuery = params.include_places ? params.anchor_location?.trim() : void 0;
|
|
2883
3024
|
const [imageResults, placeResults, anchorResults] = await Promise.all([
|
|
2884
|
-
wantsImages ?
|
|
2885
|
-
params.include_places ?
|
|
2886
|
-
anchorQuery ?
|
|
3025
|
+
wantsImages ? servedBy.searchImages?.(params.query, void 0, searchOptions) : void 0,
|
|
3026
|
+
params.include_places ? servedBy.searchPlaces?.(params.query) : void 0,
|
|
3027
|
+
anchorQuery ? servedBy.searchPlaces?.(anchorQuery, 1) : void 0
|
|
2887
3028
|
]).then((all) => all.map((result) => result ?? []));
|
|
2888
3029
|
const anchor = anchorResults[0];
|
|
2889
3030
|
const places = placeResults.filter((place) => place.id !== anchor?.id);
|
|
2890
3031
|
const withPlaces = shouldIncludePlaces(places, params.include_places);
|
|
2891
|
-
if (placeResults.length) Logger.globalInstance.log(`WebSearch Tool: ${
|
|
2892
|
-
if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${
|
|
3032
|
+
if (placeResults.length) Logger.globalInstance.log(`WebSearch Tool: ${servedBy.name} found ${placeResults.length} places`);
|
|
3033
|
+
if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${servedBy.name} found ${imageResults.length} images`);
|
|
2893
3034
|
const withImages = shouldIncludeImages(results, wantsImages, imageResults);
|
|
2894
3035
|
const citables = results.map((result, index) => ({
|
|
2895
3036
|
id: result.url,
|
|
@@ -2927,50 +3068,66 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
|
|
|
2927
3068
|
}
|
|
2928
3069
|
const webSearchTool = {
|
|
2929
3070
|
name: "web_search",
|
|
2930
|
-
implementation: (context,
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
3071
|
+
implementation: (context, config) => {
|
|
3072
|
+
let searchCallCount = 0;
|
|
3073
|
+
let providersPromise;
|
|
3074
|
+
return {
|
|
3075
|
+
toolFn: async (value) => {
|
|
3076
|
+
const params = value;
|
|
3077
|
+
const callNumber = ++searchCallCount;
|
|
3078
|
+
await context.onStart?.("web_search", params);
|
|
3079
|
+
providersPromise ??= resolveWebSearchProviders({ db: context.db }).catch((error) => {
|
|
3080
|
+
providersPromise = void 0;
|
|
3081
|
+
throw error;
|
|
3082
|
+
});
|
|
3083
|
+
const startedAt = Date.now();
|
|
3084
|
+
const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, config?.imageUrlSigningSecret, providersPromise);
|
|
3085
|
+
context.logger.log("🔍 WebSearch Tool: search timing", {
|
|
3086
|
+
callNumber,
|
|
3087
|
+
query: params.query,
|
|
3088
|
+
durationMs: Date.now() - startedAt,
|
|
3089
|
+
resultCount: citables.length
|
|
3090
|
+
});
|
|
3091
|
+
if (citables.length > 0) {
|
|
3092
|
+
await context.statusUpdate({ promptMeta: { citables } }, "Web search complete");
|
|
3093
|
+
Logger.globalInstance.log(`📚 WebSearch Tool: Stored ${citables.length} citables`);
|
|
3094
|
+
}
|
|
3095
|
+
return formattedResults;
|
|
3096
|
+
},
|
|
3097
|
+
toolSchema: {
|
|
3098
|
+
name: "web_search",
|
|
3099
|
+
description: "Search the web using Google Search API to FIND pages about a topic. Use this when you need to find URLs or search for information. DO NOT use this if the user provides a specific URL - use web_fetch instead to read the full content.",
|
|
3100
|
+
parameters: {
|
|
3101
|
+
type: "object",
|
|
3102
|
+
properties: {
|
|
3103
|
+
query: {
|
|
3104
|
+
type: "string",
|
|
3105
|
+
description: "The search query to look up"
|
|
3106
|
+
},
|
|
3107
|
+
num_results: {
|
|
3108
|
+
type: "number",
|
|
3109
|
+
description: "Number of results to return (default: 3, max: 10)",
|
|
3110
|
+
minimum: 1,
|
|
3111
|
+
maximum: 10
|
|
3112
|
+
},
|
|
3113
|
+
include_images: {
|
|
3114
|
+
type: "boolean",
|
|
3115
|
+
description: "Set true whenever the answer is about things worth SEEING - products, watches, gear, places, buildings, plants, animals, people, cars, art, food, anything with a look. Decide this yourself from the subject matter: the user will NOT ask for pictures, and an answer that describes a physical object without showing it is a worse answer. Adds a set of attributed images so you can illustrate your reply with a b4m_cards block. Leave unset only for genuinely non-visual questions - code, math, definitions, policy - where images would be junk tokens."
|
|
3116
|
+
},
|
|
3117
|
+
include_places: {
|
|
3118
|
+
type: "boolean",
|
|
3119
|
+
description: "Set true when the answer is a set of PLACES the user would want to see on a map - restaurants, bars, cafes, shops, hotels, attractions, things to do in or near somewhere. Decide this yourself: the user will not ask for a map. Adds places with map locations so you can show them with a b4m_map block. Leave unset for anything that is not about physical locations."
|
|
3120
|
+
},
|
|
3121
|
+
anchor_location: {
|
|
3122
|
+
type: "string",
|
|
3123
|
+
description: "With include_places: the reference place the user named that results should be near, as a searchable name with its city, e.g. \"citizenM Copenhagen Radhuspladsen\". Shown distinctly on the map. Omit when the user named none."
|
|
3124
|
+
}
|
|
2964
3125
|
},
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
description: "With include_places: the reference place the user named that results should be near, as a searchable name with its city, e.g. \"citizenM Copenhagen Radhuspladsen\". Shown distinctly on the map. Omit when the user named none."
|
|
2968
|
-
}
|
|
2969
|
-
},
|
|
2970
|
-
required: ["query"]
|
|
3126
|
+
required: ["query"]
|
|
3127
|
+
}
|
|
2971
3128
|
}
|
|
2972
|
-
}
|
|
2973
|
-
}
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
2974
3131
|
};
|
|
2975
3132
|
//#endregion
|
|
2976
3133
|
//#region ../../b4m-core/services/dist/weather-jxxOpyX5.mjs
|
|
@@ -3086,81 +3243,32 @@ function normalizeId(value) {
|
|
|
3086
3243
|
return typeof value === "number" && Number.isFinite(value) ? String(value) : void 0;
|
|
3087
3244
|
}
|
|
3088
3245
|
//#endregion
|
|
3089
|
-
//#region ../../b4m-core/services/dist/latticeModelService-
|
|
3090
|
-
const WHITESPACE$1 = /\s/;
|
|
3091
|
-
const EQUALS_OPS = [
|
|
3092
|
-
"=",
|
|
3093
|
-
"equals",
|
|
3094
|
-
"equal"
|
|
3095
|
-
];
|
|
3096
|
-
function isLineTerminator(s, i) {
|
|
3097
|
-
const c = s.charCodeAt(i);
|
|
3098
|
-
return c === 10 || c === 13 || c === 8232 || c === 8233;
|
|
3099
|
-
}
|
|
3100
|
-
function indexText(s) {
|
|
3101
|
-
const n = s.length;
|
|
3102
|
-
const wsEnd = new Int32Array(n + 1);
|
|
3103
|
-
const lineEnd = new Int32Array(n + 1);
|
|
3104
|
-
wsEnd[n] = n;
|
|
3105
|
-
lineEnd[n] = n;
|
|
3106
|
-
let lastLineTerminator = -1;
|
|
3107
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
3108
|
-
wsEnd[i] = WHITESPACE$1.test(s[i]) ? wsEnd[i + 1] : i;
|
|
3109
|
-
const lt = isLineTerminator(s, i);
|
|
3110
|
-
lineEnd[i] = lt ? i : lineEnd[i + 1];
|
|
3111
|
-
if (lt && lastLineTerminator < 0) lastLineTerminator = i;
|
|
3112
|
-
}
|
|
3113
|
-
return {
|
|
3114
|
-
s,
|
|
3115
|
-
n,
|
|
3116
|
-
wsEnd,
|
|
3117
|
-
lineEnd,
|
|
3118
|
-
lastLineTerminator
|
|
3119
|
-
};
|
|
3120
|
-
}
|
|
3121
|
-
/** Case-insensitive match of a lowercase ASCII `word` at i, as the `i` flag compares ASCII letters. */
|
|
3122
|
-
function wordAt(s, i, word) {
|
|
3123
|
-
if (i + word.length > s.length) return false;
|
|
3124
|
-
for (let j = 0; j < word.length; j++) {
|
|
3125
|
-
let c = s.charCodeAt(i + j);
|
|
3126
|
-
if (c >= 65 && c <= 90) c += 32;
|
|
3127
|
-
if (c !== word.charCodeAt(j)) return false;
|
|
3128
|
-
}
|
|
3129
|
-
return true;
|
|
3130
|
-
}
|
|
3131
|
-
/** `/^(.+?)\s*(?:=|equals?)\s*(.+)$/i` -> [output, expression]. */
|
|
3132
|
-
function splitEquals(s) {
|
|
3133
|
-
const ix = indexText(s);
|
|
3134
|
-
const limit = Math.min(ix.lineEnd[0], ix.n - 1);
|
|
3135
|
-
let lastK = -1;
|
|
3136
|
-
for (let g = 1; g <= limit; g++) {
|
|
3137
|
-
const k = ix.wsEnd[g];
|
|
3138
|
-
if (k === lastK) continue;
|
|
3139
|
-
lastK = k;
|
|
3140
|
-
for (const op of EQUALS_OPS) {
|
|
3141
|
-
if (!wordAt(s, k, op)) continue;
|
|
3142
|
-
const p = k + op.length;
|
|
3143
|
-
const w = ix.wsEnd[p];
|
|
3144
|
-
let rest = -1;
|
|
3145
|
-
if (w < ix.n) rest = ix.lastLineTerminator < w ? w : -1;
|
|
3146
|
-
else if (ix.n - 1 >= p && !isLineTerminator(s, ix.n - 1)) rest = ix.n - 1;
|
|
3147
|
-
if (rest >= 0) return [s.slice(0, g), s.slice(rest)];
|
|
3148
|
-
}
|
|
3149
|
-
}
|
|
3150
|
-
return null;
|
|
3151
|
-
}
|
|
3246
|
+
//#region ../../b4m-core/services/dist/latticeModelService-DTKpo_hV.mjs
|
|
3152
3247
|
/**
|
|
3153
3248
|
* The model's creator, compared through `normalizeId` on both sides.
|
|
3154
3249
|
*
|
|
3155
|
-
* This is the WRITE gate (via `getModelForWrite`) and the owner arm of the read gate
|
|
3156
|
-
* subagent Lattice tools in `llm/tools/implementation/lattice` import it so the two surfaces
|
|
3157
|
-
* cannot drift apart again.
|
|
3250
|
+
* This is the WRITE gate (via `getModelForWrite`) and the owner arm of the read gate.
|
|
3158
3251
|
*/
|
|
3159
3252
|
function isModelOwner(model, user) {
|
|
3160
3253
|
const ownerId = normalizeId(model.userId);
|
|
3161
3254
|
return ownerId !== void 0 && ownerId === normalizeId(user.id);
|
|
3162
3255
|
}
|
|
3163
3256
|
/**
|
|
3257
|
+
* READ gate: the creator, or a member of the organization the model was created in.
|
|
3258
|
+
*
|
|
3259
|
+
* Both org ids go through `normalizeId` because they are NOT the same runtime type:
|
|
3260
|
+
* `LatticeModel.organizationId` is a String schema path while `req.user.organizationId` is an
|
|
3261
|
+
* ObjectId on the hydrated user document, so a raw `===` was always false and silently collapsed
|
|
3262
|
+
* org sharing to owner-only everywhere.
|
|
3263
|
+
*
|
|
3264
|
+
* Write authority stays narrower on purpose - see `getModelForWrite`.
|
|
3265
|
+
*/
|
|
3266
|
+
function canReadModel(model, user) {
|
|
3267
|
+
if (isModelOwner(model, user)) return true;
|
|
3268
|
+
const modelOrgId = normalizeId(model.organizationId);
|
|
3269
|
+
return modelOrgId !== void 0 && modelOrgId === normalizeId(user.organizationId);
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3164
3272
|
* The document every newly created model starts from: ownership, org membership, session/project
|
|
3165
3273
|
* scoping, and the empty three-layer skeleton.
|
|
3166
3274
|
*
|
|
@@ -3205,8 +3313,36 @@ function buildNewModel(user, options) {
|
|
|
3205
3313
|
updatedAt: now
|
|
3206
3314
|
};
|
|
3207
3315
|
}
|
|
3316
|
+
/**
|
|
3317
|
+
* Get a model by ID with authorization check
|
|
3318
|
+
*/
|
|
3319
|
+
async function getModel(user, modelId, deps) {
|
|
3320
|
+
const model = await deps.db.latticeModels.findById(modelId);
|
|
3321
|
+
if (!model) return null;
|
|
3322
|
+
return canReadModel(model, user) ? model : null;
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Get a model the caller may MUTATE, or null when they may not.
|
|
3326
|
+
*
|
|
3327
|
+
* Ownership only, and deliberately NARROWER than `getModel`. `getModel` is the READ gate and admits
|
|
3328
|
+
* same-org members by design (a model carries its creator's `organizationId`), but read access is
|
|
3329
|
+
* not write access - and every mutator took its authorization from it, so a colleague could rename,
|
|
3330
|
+
* re-value, re-rule or delete a model they did not create. Callers that only read keep using `getModel`: `listModels`,
|
|
3331
|
+
* `searchModels`, `hydrateModel` (computes derived values and stamps `lastComputedAt`), and
|
|
3332
|
+
* `duplicateModel` (reads one model and creates a NEW one owned by the caller).
|
|
3333
|
+
*
|
|
3334
|
+
* Lattice models carry no share/grant field, so the creator is the only principal that can hold
|
|
3335
|
+
* write authority today. If an explicit write grant is ever added, this is the one place to admit
|
|
3336
|
+
* it - keeping the check here rather than inlined in six mutators is what makes that a one-line
|
|
3337
|
+
* change instead of a six-site audit.
|
|
3338
|
+
*/
|
|
3339
|
+
async function getModelForWrite(user, modelId, deps) {
|
|
3340
|
+
const model = await getModel(user, modelId, deps);
|
|
3341
|
+
if (!model) return null;
|
|
3342
|
+
return isModelOwner(model, user) ? model : null;
|
|
3343
|
+
}
|
|
3208
3344
|
//#endregion
|
|
3209
|
-
//#region ../../b4m-core/services/dist/toolGenerators-
|
|
3345
|
+
//#region ../../b4m-core/services/dist/toolGenerators-DT6OlvUe.mjs
|
|
3210
3346
|
const diceRoll = async (parameters) => {
|
|
3211
3347
|
if (!parameters?.sides || !parameters?.times) throw new Error("Tool dice roll: Missing required parameters");
|
|
3212
3348
|
return sum(times(parameters.times, () => random(1, parameters.sides))).toString();
|
|
@@ -3796,27 +3932,6 @@ const promptEnhancementTool = {
|
|
|
3796
3932
|
}
|
|
3797
3933
|
})
|
|
3798
3934
|
};
|
|
3799
|
-
/**
|
|
3800
|
-
* Is this value shaped like something Mongoose can cast to an `_id`?
|
|
3801
|
-
*
|
|
3802
|
-
* Tool arguments are composed by the model out of conversation text and reach us as unvalidated
|
|
3803
|
-
* JSON, so an id parameter routinely holds something that is not an id at all - a filename token,
|
|
3804
|
-
* an arXiv number, a bare integer. Mongoose casts `_id` and throws a CastError on those, which a
|
|
3805
|
-
* generic catch upstream then reports as an outage rather than the bad argument it is (#2530).
|
|
3806
|
-
* Call this before handing a model-supplied id to `findById` and answer a false the same way the
|
|
3807
|
-
* surface answers a genuinely missing row.
|
|
3808
|
-
*
|
|
3809
|
-
* `isObjectIdOrHexString`, not `isValidObjectId`: the latter also accepts a number and casts it to
|
|
3810
|
-
* a fabricated id, and a model emitting `{"file_id": 12}` gives us exactly that despite the
|
|
3811
|
-
* `string` type. Same choice, same reason, as `usableObjectIds` in @bike4mind/db-core, which is
|
|
3812
|
-
* the array-shaped version of this check.
|
|
3813
|
-
*
|
|
3814
|
-
* NOT usable for artifact ids (`artifact_<...>`), which are matched on a string `id` field rather
|
|
3815
|
-
* than `_id` - see `createArtifactId` in @bike4mind/common.
|
|
3816
|
-
*/
|
|
3817
|
-
function isObjectIdShaped$1(id) {
|
|
3818
|
-
return isObjectIdOrHexString(id);
|
|
3819
|
-
}
|
|
3820
3935
|
let _showUserQuestion = null;
|
|
3821
3936
|
/**
|
|
3822
3937
|
* Inject the CLI callback that displays the question UI.
|
|
@@ -3936,7 +4051,7 @@ const askUserQuestionTool = {
|
|
|
3936
4051
|
* re-export them without pulling the full tool graph. `index.ts` re-exports them
|
|
3937
4052
|
* so the server barrel's public API is unchanged.
|
|
3938
4053
|
*/
|
|
3939
|
-
const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionLakeScopeExplicit, sessionPreauthorizedLakeIds, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
|
|
4054
|
+
const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, attachedFileIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionReaderConsentDatalakeTags, sessionLakeScopeExplicit, sessionIncludeLibraryFiles, sessionPreauthorizedLakeIds, organizationId, apiKeyId, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
|
|
3940
4055
|
const context = {
|
|
3941
4056
|
userId,
|
|
3942
4057
|
user,
|
|
@@ -3957,32 +4072,61 @@ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inl
|
|
|
3957
4072
|
retrievalFilter,
|
|
3958
4073
|
kbScope,
|
|
3959
4074
|
inlinedAttachmentIds,
|
|
4075
|
+
attachedFileIds,
|
|
3960
4076
|
fullyInlinedAttachmentIds,
|
|
3961
4077
|
suppressLakeArms,
|
|
3962
4078
|
sessionRetrievalTags,
|
|
4079
|
+
sessionReaderConsentDatalakeTags,
|
|
3963
4080
|
sessionLakeScopeExplicit,
|
|
4081
|
+
sessionIncludeLibraryFiles,
|
|
3964
4082
|
sessionPreauthorizedLakeIds,
|
|
4083
|
+
organizationId,
|
|
4084
|
+
apiKeyId,
|
|
3965
4085
|
codeMinifier,
|
|
3966
4086
|
availableModels,
|
|
3967
4087
|
onToolLlmUsage,
|
|
3968
4088
|
getAbortSignal
|
|
3969
4089
|
};
|
|
3970
|
-
return Object.entries(tools).reduce((acc, [key, tool]) =>
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
4090
|
+
return Object.entries(tools).reduce((acc, [key, tool]) => {
|
|
4091
|
+
const built = tool.implementation(context, config[key]);
|
|
4092
|
+
return {
|
|
4093
|
+
...acc,
|
|
4094
|
+
[key]: tool.artifactType ? {
|
|
4095
|
+
...built,
|
|
4096
|
+
artifactType: tool.artifactType
|
|
4097
|
+
} : built
|
|
4098
|
+
};
|
|
4099
|
+
}, {});
|
|
3974
4100
|
};
|
|
3975
4101
|
/**
|
|
4102
|
+
* MCP tool args only the confirm-button handlers may set. Those handlers call the MCP host
|
|
4103
|
+
* directly, never through these generators, so a model-originated call carrying one is forged.
|
|
4104
|
+
* `_executeFromButton` mirrors the button-only keys of `confirmationParams` in
|
|
4105
|
+
* b4m-core/mcp/src/shared/schemas.ts (pinned by github/__tests__/helpers/schemas.test.ts there);
|
|
4106
|
+
* `_confirmToken` is the preview token tools return.
|
|
4107
|
+
*/
|
|
4108
|
+
const SERVER_ONLY_MCP_ARG_KEYS = ["_executeFromButton", "_confirmToken"];
|
|
4109
|
+
const isPlainObject$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4110
|
+
function stripServerOnlyArgs(args) {
|
|
4111
|
+
if (!isPlainObject$1(args)) return args;
|
|
4112
|
+
return Object.fromEntries(Object.entries(args).filter(([key]) => !SERVER_ONLY_MCP_ARG_KEYS.includes(key)));
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
3976
4115
|
* Normalize MCP tool parameters for OpenAI compatibility.
|
|
3977
4116
|
* OpenAI requires 'properties' on object schemas - MCP tools like current_user
|
|
3978
4117
|
* return { type: 'object' } without it, causing 400 errors.
|
|
3979
4118
|
*/
|
|
3980
4119
|
function normalizeToolParameters(rest) {
|
|
3981
4120
|
const rawParameters = rest?.input_schema ?? rest?.inputSchema ?? rest?.parameters;
|
|
3982
|
-
if (rawParameters
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
4121
|
+
if (isPlainObject$1(rawParameters)) {
|
|
4122
|
+
const properties = isPlainObject$1(rawParameters.properties) ? stripServerOnlyArgs(rawParameters.properties) : rawParameters.properties ?? {};
|
|
4123
|
+
const required = Array.isArray(rawParameters.required) ? rawParameters.required.filter((key) => typeof key !== "string" || !SERVER_ONLY_MCP_ARG_KEYS.includes(key)) : rawParameters.required;
|
|
4124
|
+
return {
|
|
4125
|
+
...rawParameters,
|
|
4126
|
+
properties,
|
|
4127
|
+
...required !== void 0 ? { required } : {}
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
3986
4130
|
return {
|
|
3987
4131
|
type: "object",
|
|
3988
4132
|
properties: {},
|
|
@@ -4015,7 +4159,7 @@ const generateMcpTools = async (mcpData) => {
|
|
|
4015
4159
|
toolFn: async (args) => {
|
|
4016
4160
|
Logger.debug(`Calling ${originalToolName} tool via ${mcpData.serverName}`, args);
|
|
4017
4161
|
try {
|
|
4018
|
-
const toolResult = await mcpData.callTool(originalToolName, args);
|
|
4162
|
+
const toolResult = await mcpData.callTool(originalToolName, stripServerOnlyArgs(args));
|
|
4019
4163
|
const contentBlocks = toolResult?.content;
|
|
4020
4164
|
if (Array.isArray(contentBlocks) && contentBlocks.length > 0) {
|
|
4021
4165
|
const normalized = contentBlocks.map((entry) => {
|
|
@@ -4067,7 +4211,7 @@ const generateMcpToolsFromCache = (serverName, cachedTools, callTool) => {
|
|
|
4067
4211
|
toolFn: async (args) => {
|
|
4068
4212
|
Logger.debug(`Calling ${originalToolName} tool via ${serverName}`, args);
|
|
4069
4213
|
try {
|
|
4070
|
-
const toolResult = await callTool(originalToolName, args);
|
|
4214
|
+
const toolResult = await callTool(originalToolName, stripServerOnlyArgs(args));
|
|
4071
4215
|
const contentBlocks = toolResult?.content;
|
|
4072
4216
|
if (Array.isArray(contentBlocks) && contentBlocks.length > 0) {
|
|
4073
4217
|
const normalized = contentBlocks.map((entry) => {
|
|
@@ -4104,6 +4248,29 @@ const generateMcpToolsFromCache = (serverName, cachedTools, callTool) => {
|
|
|
4104
4248
|
return result;
|
|
4105
4249
|
};
|
|
4106
4250
|
//#endregion
|
|
4251
|
+
//#region ../../b4m-core/services/dist/objectId-DTad97PS.mjs
|
|
4252
|
+
/**
|
|
4253
|
+
* Is this value shaped like something Mongoose can cast to an `_id`?
|
|
4254
|
+
*
|
|
4255
|
+
* Tool arguments are composed by the model out of conversation text and reach us as unvalidated
|
|
4256
|
+
* JSON, so an id parameter routinely holds something that is not an id at all - a filename token,
|
|
4257
|
+
* an arXiv number, a bare integer. Mongoose casts `_id` and throws a CastError on those, which a
|
|
4258
|
+
* generic catch upstream then reports as an outage rather than the bad argument it is (#2530).
|
|
4259
|
+
* Call this before handing a model-supplied id to `findById` and answer a false the same way the
|
|
4260
|
+
* surface answers a genuinely missing row.
|
|
4261
|
+
*
|
|
4262
|
+
* `isObjectIdOrHexString`, not `isValidObjectId`: the latter also accepts a number and casts it to
|
|
4263
|
+
* a fabricated id, and a model emitting `{"file_id": 12}` gives us exactly that despite the
|
|
4264
|
+
* `string` type. Same choice, same reason, as `usableObjectIds` in @bike4mind/db-core, which is
|
|
4265
|
+
* the array-shaped version of this check.
|
|
4266
|
+
*
|
|
4267
|
+
* NOT usable for artifact ids (`artifact_<...>`), which are matched on a string `id` field rather
|
|
4268
|
+
* than `_id` - see `createArtifactId` in @bike4mind/common.
|
|
4269
|
+
*/
|
|
4270
|
+
function isObjectIdShaped(id) {
|
|
4271
|
+
return isObjectIdOrHexString(id);
|
|
4272
|
+
}
|
|
4273
|
+
//#endregion
|
|
4107
4274
|
//#region ../../b4m-core/services/dist/llm/tools/cliTools.mjs
|
|
4108
4275
|
/**
|
|
4109
4276
|
* Validated fuzzy fallback for `edit_local_file` string matching.
|
|
@@ -5493,35 +5660,29 @@ const latticeAddEntityTool = {
|
|
|
5493
5660
|
createdAt: /* @__PURE__ */ new Date(),
|
|
5494
5661
|
updatedAt: /* @__PURE__ */ new Date()
|
|
5495
5662
|
};
|
|
5496
|
-
if (context.db.latticeModels && modelId && isObjectIdShaped
|
|
5497
|
-
const model = await
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
id: modelId,
|
|
5504
|
-
data: model.data,
|
|
5505
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
5506
|
-
});
|
|
5507
|
-
context.logger.info(`[Lattice] Added entity ${entityId} to model ${modelId}`);
|
|
5508
|
-
} else if (model) {
|
|
5509
|
-
context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
|
|
5663
|
+
if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
|
|
5664
|
+
const model = await getModelForWrite({
|
|
5665
|
+
id: context.userId,
|
|
5666
|
+
organizationId: context.user?.organizationId
|
|
5667
|
+
}, modelId, { db: { latticeModels: context.db.latticeModels } });
|
|
5668
|
+
if (!model) {
|
|
5669
|
+
context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
|
|
5510
5670
|
return JSON.stringify({
|
|
5511
5671
|
success: false,
|
|
5512
5672
|
action: "ADD_ENTITY",
|
|
5513
5673
|
modelId,
|
|
5514
|
-
error: `
|
|
5515
|
-
});
|
|
5516
|
-
} else {
|
|
5517
|
-
context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
|
|
5518
|
-
return JSON.stringify({
|
|
5519
|
-
success: false,
|
|
5520
|
-
action: "ADD_ENTITY",
|
|
5521
|
-
modelId,
|
|
5522
|
-
error: `Model ${modelId} not found`
|
|
5674
|
+
error: `Model ${modelId} not found or you do not have permission to modify it`
|
|
5523
5675
|
});
|
|
5524
5676
|
}
|
|
5677
|
+
const existingIndex = model.data.entities.findIndex((e) => e.id === entityId);
|
|
5678
|
+
if (existingIndex >= 0) model.data.entities[existingIndex] = entityData;
|
|
5679
|
+
else model.data.entities.push(entityData);
|
|
5680
|
+
await context.db.latticeModels.update({
|
|
5681
|
+
id: modelId,
|
|
5682
|
+
data: model.data,
|
|
5683
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
5684
|
+
});
|
|
5685
|
+
context.logger.info(`[Lattice] Added entity ${entityId} to model ${modelId}`);
|
|
5525
5686
|
} catch (error) {
|
|
5526
5687
|
context.logger.error(`[Lattice] Failed to persist entity to database:`, error);
|
|
5527
5688
|
return JSON.stringify({
|
|
@@ -5643,52 +5804,46 @@ const latticeSetValueTool = {
|
|
|
5643
5804
|
else if (rawValue.toLowerCase() === "true") value = true;
|
|
5644
5805
|
else if (rawValue.toLowerCase() === "false") value = false;
|
|
5645
5806
|
const entityId = toEntityId(entityName);
|
|
5646
|
-
if (context.db.latticeModels && modelId && isObjectIdShaped
|
|
5647
|
-
const model = await
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
success: false,
|
|
5654
|
-
action: "SET_VALUE",
|
|
5655
|
-
modelId,
|
|
5656
|
-
error: entityNotFoundError(model.data.entities, entityName, attributeKey)
|
|
5657
|
-
});
|
|
5658
|
-
}
|
|
5659
|
-
const attrIndex = entity.attributes.findIndex((a) => a.key === attributeKey);
|
|
5660
|
-
const attributeData = {
|
|
5661
|
-
key: attributeKey,
|
|
5662
|
-
value,
|
|
5663
|
-
dataType: typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string",
|
|
5664
|
-
isComputed: false
|
|
5665
|
-
};
|
|
5666
|
-
if (attrIndex >= 0) entity.attributes[attrIndex] = attributeData;
|
|
5667
|
-
else entity.attributes.push(attributeData);
|
|
5668
|
-
entity.updatedAt = /* @__PURE__ */ new Date();
|
|
5669
|
-
await context.db.latticeModels.update({
|
|
5670
|
-
id: modelId,
|
|
5671
|
-
data: model.data,
|
|
5672
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
5673
|
-
});
|
|
5674
|
-
context.logger.info(`[Lattice] Set ${entityId}.${attributeKey} = ${value} in model ${modelId}`);
|
|
5675
|
-
} else if (model) {
|
|
5676
|
-
context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
|
|
5807
|
+
if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
|
|
5808
|
+
const model = await getModelForWrite({
|
|
5809
|
+
id: context.userId,
|
|
5810
|
+
organizationId: context.user?.organizationId
|
|
5811
|
+
}, modelId, { db: { latticeModels: context.db.latticeModels } });
|
|
5812
|
+
if (!model) {
|
|
5813
|
+
context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
|
|
5677
5814
|
return JSON.stringify({
|
|
5678
5815
|
success: false,
|
|
5679
5816
|
action: "SET_VALUE",
|
|
5680
5817
|
modelId,
|
|
5681
|
-
error: `
|
|
5818
|
+
error: `Model ${modelId} not found or you do not have permission to modify it`
|
|
5682
5819
|
});
|
|
5683
|
-
}
|
|
5684
|
-
|
|
5820
|
+
}
|
|
5821
|
+
const entity = model.data.entities.find((e) => e.id === entityId || toEntityId(e.name) === entityId);
|
|
5822
|
+
if (!entity) {
|
|
5823
|
+
context.logger.warn(`[Lattice] Entity ${entityName} not found in model ${modelId}`);
|
|
5685
5824
|
return JSON.stringify({
|
|
5686
5825
|
success: false,
|
|
5687
5826
|
action: "SET_VALUE",
|
|
5688
5827
|
modelId,
|
|
5689
|
-
error:
|
|
5828
|
+
error: entityNotFoundError(model.data.entities, entityName, attributeKey)
|
|
5690
5829
|
});
|
|
5691
5830
|
}
|
|
5831
|
+
const attrIndex = entity.attributes.findIndex((a) => a.key === attributeKey);
|
|
5832
|
+
const attributeData = {
|
|
5833
|
+
key: attributeKey,
|
|
5834
|
+
value,
|
|
5835
|
+
dataType: typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string",
|
|
5836
|
+
isComputed: false
|
|
5837
|
+
};
|
|
5838
|
+
if (attrIndex >= 0) entity.attributes[attrIndex] = attributeData;
|
|
5839
|
+
else entity.attributes.push(attributeData);
|
|
5840
|
+
entity.updatedAt = /* @__PURE__ */ new Date();
|
|
5841
|
+
await context.db.latticeModels.update({
|
|
5842
|
+
id: modelId,
|
|
5843
|
+
data: model.data,
|
|
5844
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
5845
|
+
});
|
|
5846
|
+
context.logger.info(`[Lattice] Set ${entityId}.${attributeKey} = ${value} in model ${modelId}`);
|
|
5692
5847
|
} catch (error) {
|
|
5693
5848
|
context.logger.error(`[Lattice] Failed to persist value to database:`, error);
|
|
5694
5849
|
return JSON.stringify({
|
|
@@ -5794,62 +5949,56 @@ const latticeCreateRuleTool = {
|
|
|
5794
5949
|
};
|
|
5795
5950
|
const outputEntityId = parsedRule.outputEntity.toLowerCase().replace(/\s+/g, "_");
|
|
5796
5951
|
let entityCreatedMessage = "";
|
|
5797
|
-
if (context.db.latticeModels && modelId && isObjectIdShaped
|
|
5798
|
-
const model = await
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
type: "line_item",
|
|
5805
|
-
name: parsedRule.outputEntity,
|
|
5806
|
-
displayName: parsedRule.outputEntity,
|
|
5807
|
-
attributes: [{
|
|
5808
|
-
key: "value",
|
|
5809
|
-
value: 0,
|
|
5810
|
-
dataType: "currency",
|
|
5811
|
-
isComputed: true
|
|
5812
|
-
}, {
|
|
5813
|
-
key: "category",
|
|
5814
|
-
value: "Computed",
|
|
5815
|
-
dataType: "string",
|
|
5816
|
-
isComputed: false
|
|
5817
|
-
}],
|
|
5818
|
-
metadata: { isComputed: true },
|
|
5819
|
-
createdAt: now,
|
|
5820
|
-
updatedAt: now
|
|
5821
|
-
};
|
|
5822
|
-
model.data.entities.push(newEntity);
|
|
5823
|
-
entityCreatedMessage = ` Created output entity "${parsedRule.outputEntity}".`;
|
|
5824
|
-
context.logger.info(`[Lattice] Auto-created output entity ${outputEntityId} for rule ${ruleId}`);
|
|
5825
|
-
}
|
|
5826
|
-
const existingIndex = model.rules.rules.findIndex((r) => r.id === ruleId || r.name === name);
|
|
5827
|
-
if (existingIndex >= 0) model.rules.rules[existingIndex] = ruleData;
|
|
5828
|
-
else model.rules.rules.push(ruleData);
|
|
5829
|
-
await context.db.latticeModels.update({
|
|
5830
|
-
id: modelId,
|
|
5831
|
-
data: model.data,
|
|
5832
|
-
rules: model.rules,
|
|
5833
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
5834
|
-
});
|
|
5835
|
-
context.logger.info(`[Lattice] Created rule ${ruleId} in model ${modelId}`);
|
|
5836
|
-
} else if (model) {
|
|
5837
|
-
context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
|
|
5952
|
+
if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
|
|
5953
|
+
const model = await getModelForWrite({
|
|
5954
|
+
id: context.userId,
|
|
5955
|
+
organizationId: context.user?.organizationId
|
|
5956
|
+
}, modelId, { db: { latticeModels: context.db.latticeModels } });
|
|
5957
|
+
if (!model) {
|
|
5958
|
+
context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
|
|
5838
5959
|
return JSON.stringify({
|
|
5839
5960
|
success: false,
|
|
5840
5961
|
action: "CREATE_RULE",
|
|
5841
5962
|
modelId,
|
|
5842
|
-
error: `
|
|
5843
|
-
});
|
|
5844
|
-
} else {
|
|
5845
|
-
context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
|
|
5846
|
-
return JSON.stringify({
|
|
5847
|
-
success: false,
|
|
5848
|
-
action: "CREATE_RULE",
|
|
5849
|
-
modelId,
|
|
5850
|
-
error: `Model ${modelId} not found`
|
|
5963
|
+
error: `Model ${modelId} not found or you do not have permission to modify it`
|
|
5851
5964
|
});
|
|
5852
5965
|
}
|
|
5966
|
+
if (!model.data.entities.some((e) => e.id === outputEntityId || e.name.toLowerCase() === parsedRule.outputEntity.toLowerCase()) && parsedRule.outputEntity !== "unknown") {
|
|
5967
|
+
const now = /* @__PURE__ */ new Date();
|
|
5968
|
+
const newEntity = {
|
|
5969
|
+
id: outputEntityId,
|
|
5970
|
+
type: "line_item",
|
|
5971
|
+
name: parsedRule.outputEntity,
|
|
5972
|
+
displayName: parsedRule.outputEntity,
|
|
5973
|
+
attributes: [{
|
|
5974
|
+
key: "value",
|
|
5975
|
+
value: 0,
|
|
5976
|
+
dataType: "currency",
|
|
5977
|
+
isComputed: true
|
|
5978
|
+
}, {
|
|
5979
|
+
key: "category",
|
|
5980
|
+
value: "Computed",
|
|
5981
|
+
dataType: "string",
|
|
5982
|
+
isComputed: false
|
|
5983
|
+
}],
|
|
5984
|
+
metadata: { isComputed: true },
|
|
5985
|
+
createdAt: now,
|
|
5986
|
+
updatedAt: now
|
|
5987
|
+
};
|
|
5988
|
+
model.data.entities.push(newEntity);
|
|
5989
|
+
entityCreatedMessage = ` Created output entity "${parsedRule.outputEntity}".`;
|
|
5990
|
+
context.logger.info(`[Lattice] Auto-created output entity ${outputEntityId} for rule ${ruleId}`);
|
|
5991
|
+
}
|
|
5992
|
+
const existingIndex = model.rules.rules.findIndex((r) => r.id === ruleId || r.name === name);
|
|
5993
|
+
if (existingIndex >= 0) model.rules.rules[existingIndex] = ruleData;
|
|
5994
|
+
else model.rules.rules.push(ruleData);
|
|
5995
|
+
await context.db.latticeModels.update({
|
|
5996
|
+
id: modelId,
|
|
5997
|
+
data: model.data,
|
|
5998
|
+
rules: model.rules,
|
|
5999
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
6000
|
+
});
|
|
6001
|
+
context.logger.info(`[Lattice] Created rule ${ruleId} in model ${modelId}`);
|
|
5853
6002
|
} catch (error) {
|
|
5854
6003
|
context.logger.error(`[Lattice] Failed to persist rule to database:`, error);
|
|
5855
6004
|
return JSON.stringify({
|
|
@@ -6041,7 +6190,8 @@ This tool traces the calculation chain from the target value back through all it
|
|
|
6041
6190
|
* This is a simplified implementation - production would use LLM structured output
|
|
6042
6191
|
*/
|
|
6043
6192
|
function parseFormula(formula) {
|
|
6044
|
-
const
|
|
6193
|
+
const normalized = formula.toLowerCase().trim();
|
|
6194
|
+
const equalsMatch = splitEquals(normalized);
|
|
6045
6195
|
if (equalsMatch) {
|
|
6046
6196
|
const [output, expression] = equalsMatch;
|
|
6047
6197
|
if (expression.includes("+") || expression.includes("plus")) return {
|
|
@@ -9128,16 +9278,24 @@ function leadingProgram(args) {
|
|
|
9128
9278
|
return index < args.length ? programName(args[index]) : "";
|
|
9129
9279
|
}
|
|
9130
9280
|
/**
|
|
9131
|
-
* True if any
|
|
9132
|
-
* host shapes: SCSI/SATA (`sd[a-z]`), NVMe, Xen (`xvd[a-z]`,
|
|
9133
|
-
* AMIs), virtio (`vd[a-z]`), legacy IDE (`hd[a-z]`), device-mapper/LVM
|
|
9134
|
-
* `mapper/`), software RAID (`md N`), eMMC/SD (`mmcblkN`), loop, and macOS `diskN`.
|
|
9281
|
+
* True if any write redirection (`>`, `>>`, `>|`, `&>`, `&>>`, `<>`) targets a raw block
|
|
9282
|
+
* device. Covers the common host shapes: SCSI/SATA (`sd[a-z]`), NVMe, Xen (`xvd[a-z]`,
|
|
9283
|
+
* default on many EC2 AMIs), virtio (`vd[a-z]`), legacy IDE (`hd[a-z]`), device-mapper/LVM
|
|
9284
|
+
* (`dm-N`, `mapper/`), software RAID (`md N`), eMMC/SD (`mmcblkN`), loop, and macOS `diskN`.
|
|
9135
9285
|
*/
|
|
9136
9286
|
const BLOCK_DEVICE_RE = /^\/dev\/(sd[a-z]|nvme\d+n\d+|xvd[a-z]|vd[a-z]|hd[a-z]|dm-\d+|md\d+|mmcblk\d+|loop\d+|disk\d+|mapper\/)/;
|
|
9287
|
+
const WRITE_REDIRECT_OPS = /* @__PURE__ */ new Set([
|
|
9288
|
+
">",
|
|
9289
|
+
">>",
|
|
9290
|
+
">|",
|
|
9291
|
+
"&>",
|
|
9292
|
+
"&>>",
|
|
9293
|
+
"<>"
|
|
9294
|
+
]);
|
|
9137
9295
|
function writesToBlockDevice(tokens) {
|
|
9138
9296
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
9139
9297
|
const token = tokens[i];
|
|
9140
|
-
if (isOperator(token) && (token.op
|
|
9298
|
+
if (isOperator(token) && WRITE_REDIRECT_OPS.has(token.op)) {
|
|
9141
9299
|
let targetIndex = i + 1;
|
|
9142
9300
|
const following = tokens[targetIndex];
|
|
9143
9301
|
if (isOperator(following) && following.op === "|") targetIndex += 1;
|
|
@@ -9681,6 +9839,13 @@ var CliLogger = class extends Logger {
|
|
|
9681
9839
|
debug(...args) {}
|
|
9682
9840
|
log(...args) {}
|
|
9683
9841
|
};
|
|
9842
|
+
const FILE_WRITE_SUCCESS = /^File (edited|created|overwritten) successfully: /;
|
|
9843
|
+
const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set(["edit_local_file", "create_file"]);
|
|
9844
|
+
function notifyFileChanged(agentContext, toolName, args, result) {
|
|
9845
|
+
if (!agentContext.onFileChanged || !FILE_WRITE_TOOLS.has(toolName)) return;
|
|
9846
|
+
if (typeof args.path !== "string" || !FILE_WRITE_SUCCESS.test(result)) return;
|
|
9847
|
+
agentContext.onFileChanged(args.path);
|
|
9848
|
+
}
|
|
9684
9849
|
/**
|
|
9685
9850
|
* Wrap a tool with permission checking, server routing, and observation tracking.
|
|
9686
9851
|
*/
|
|
@@ -9727,8 +9892,8 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
|
|
|
9727
9892
|
} else if (decision.type === "unsandboxed") sandboxOrchestrator.recordUnsandboxed();
|
|
9728
9893
|
}
|
|
9729
9894
|
const effectiveArgs = isSandboxed ? sandboxedArgs : args;
|
|
9730
|
-
const { gateSnapshot: _modelSuppliedGateSnapshot, ...
|
|
9731
|
-
let execArgs =
|
|
9895
|
+
const { gateSnapshot: _modelSuppliedGateSnapshot, confirmedFuzzyHash: _modelSuppliedFuzzyHash, ...effectiveArgsSansGateFields } = effectiveArgs ?? {};
|
|
9896
|
+
let execArgs = effectiveArgsSansGateFields;
|
|
9732
9897
|
const sandboxCleanupPaths = isSandboxed ? sandboxedArgs?._sandboxCleanup : void 0;
|
|
9733
9898
|
/**
|
|
9734
9899
|
* Shared execution flow: run tool, capture violations, offer retry on
|
|
@@ -9750,6 +9915,7 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
|
|
|
9750
9915
|
toolName,
|
|
9751
9916
|
result
|
|
9752
9917
|
});
|
|
9918
|
+
notifyFileChanged(agentContext, toolName, execArgs, result);
|
|
9753
9919
|
getProcessHooks()?.firePostToolUse(toolName);
|
|
9754
9920
|
return result;
|
|
9755
9921
|
}
|
|
@@ -10767,6 +10933,25 @@ function appendWorkflowReminder(messages, provider) {
|
|
|
10767
10933
|
content: `${WORKFLOW_REMINDER_MARKER}\n${body}`
|
|
10768
10934
|
});
|
|
10769
10935
|
}
|
|
10936
|
+
const TOOL_RESULTS_NUDGE = "Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them.";
|
|
10937
|
+
/**
|
|
10938
|
+
* The end-of-iteration nudge, with any drained host feedback folded in. Folding
|
|
10939
|
+
* (rather than pushing a second plain-string user message) keeps exactly one
|
|
10940
|
+
* iteration boundary for findIterationBoundary's trimming heuristic.
|
|
10941
|
+
*/
|
|
10942
|
+
function buildToolResultsNudge(feedback) {
|
|
10943
|
+
return feedback ? `${TOOL_RESULTS_NUDGE}\n\n${feedback}` : TOOL_RESULTS_NUDGE;
|
|
10944
|
+
}
|
|
10945
|
+
/** Calls the host's feedback drain. A failing drain is logged and treated as empty - it must never fail the run. */
|
|
10946
|
+
async function drainHostFeedback(drain, phase, logger) {
|
|
10947
|
+
if (!drain) return null;
|
|
10948
|
+
try {
|
|
10949
|
+
return (await drain(phase))?.trim() || null;
|
|
10950
|
+
} catch (error) {
|
|
10951
|
+
logger.warn(`[ReActAgent] drainFeedback(${phase}) failed; continuing without feedback:`, error);
|
|
10952
|
+
return null;
|
|
10953
|
+
}
|
|
10954
|
+
}
|
|
10770
10955
|
/**
|
|
10771
10956
|
* Build the per-iteration prompt-cache strategy. History caching anchors on the
|
|
10772
10957
|
* last message so the cached prefix grows each turn (standard Anthropic
|
|
@@ -11146,7 +11331,9 @@ var ReActAgent = class extends EventEmitter {
|
|
|
11146
11331
|
}
|
|
11147
11332
|
throw completionError;
|
|
11148
11333
|
}
|
|
11149
|
-
|
|
11334
|
+
const finalFeedback = !hadToolCalls && currentText.trim() && iterations < maxIterations ? await drainHostFeedback(options.drainFeedback, "final", this.context.logger) : null;
|
|
11335
|
+
if (finalFeedback) this.reopenAnswerWithFeedback(messages, currentText.trim(), finalFeedback);
|
|
11336
|
+
else if (!hadToolCalls && currentText.trim()) {
|
|
11150
11337
|
finalAnswer = currentText.trim();
|
|
11151
11338
|
const iterInputTokens = this.totalInputTokens - preCallTotalInputTokens;
|
|
11152
11339
|
const iterOutputTokens = this.totalOutputTokens - preCallTotalOutputTokens;
|
|
@@ -11194,10 +11381,13 @@ var ReActAgent = class extends EventEmitter {
|
|
|
11194
11381
|
});
|
|
11195
11382
|
}
|
|
11196
11383
|
this.iterationConfidences = [];
|
|
11197
|
-
if (!iterationComplete && hadToolCalls)
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11384
|
+
if (!iterationComplete && hadToolCalls) {
|
|
11385
|
+
const feedback = await drainHostFeedback(options.drainFeedback, "turn", this.context.logger);
|
|
11386
|
+
messages.push({
|
|
11387
|
+
role: "user",
|
|
11388
|
+
content: buildToolResultsNudge(feedback)
|
|
11389
|
+
});
|
|
11390
|
+
}
|
|
11201
11391
|
if (maxTotalTokens !== void 0 && this.totalTokens >= maxTotalTokens && !iterationComplete) {
|
|
11202
11392
|
reachedMaxTotalTokens = true;
|
|
11203
11393
|
finalAnswer = currentText.trim() || `I stopped after reaching the cumulative token ceiling (${this.totalTokens}/${maxTotalTokens}) without arriving at a final answer.`;
|
|
@@ -11311,6 +11501,29 @@ BEHAVIOR GUIDELINES:
|
|
|
11311
11501
|
Remember: You are an autonomous AGENT. Act independently and solve problems proactively.`;
|
|
11312
11502
|
}
|
|
11313
11503
|
/**
|
|
11504
|
+
* Demote a would-be final answer to a thought and hand the model drained host
|
|
11505
|
+
* feedback (see AgentRunOptions.drainFeedback) as the next user message, so
|
|
11506
|
+
* the loop runs another iteration instead of ending. The user message is a
|
|
11507
|
+
* plain string and so is a normal iteration boundary for trimming.
|
|
11508
|
+
*/
|
|
11509
|
+
reopenAnswerWithFeedback(messages, answer, feedback) {
|
|
11510
|
+
const thoughtStep = {
|
|
11511
|
+
type: "thought",
|
|
11512
|
+
content: answer,
|
|
11513
|
+
metadata: { timestamp: Date.now() }
|
|
11514
|
+
};
|
|
11515
|
+
this.steps.push(thoughtStep);
|
|
11516
|
+
this.emit("thought", thoughtStep);
|
|
11517
|
+
messages.push({
|
|
11518
|
+
role: "assistant",
|
|
11519
|
+
content: answer
|
|
11520
|
+
}, {
|
|
11521
|
+
role: "user",
|
|
11522
|
+
content: feedback
|
|
11523
|
+
});
|
|
11524
|
+
return thoughtStep;
|
|
11525
|
+
}
|
|
11526
|
+
/**
|
|
11314
11527
|
* Add an observation step (used by tool wrappers to record tool results).
|
|
11315
11528
|
* Kept for backwards compatibility but no longer used. Observations are now
|
|
11316
11529
|
* queued and drained in the completion callback for correct ordering.
|
|
@@ -11689,7 +11902,9 @@ Remember: You are an autonomous AGENT. Act independently and solve problems proa
|
|
|
11689
11902
|
}
|
|
11690
11903
|
}
|
|
11691
11904
|
});
|
|
11692
|
-
|
|
11905
|
+
const finalFeedback = !hadToolCalls && currentText.trim() && this.iterations < maxIterations ? await drainHostFeedback(options.drainFeedback, "final", this.context.logger) : null;
|
|
11906
|
+
if (finalFeedback) iterationSteps.push(this.reopenAnswerWithFeedback(this.messages, currentText.trim(), finalFeedback));
|
|
11907
|
+
else if (!hadToolCalls && currentText.trim()) {
|
|
11693
11908
|
finalAnswer = currentText.trim();
|
|
11694
11909
|
const iterInputTokens = this.totalInputTokens - iterStartInputTokens;
|
|
11695
11910
|
const iterOutputTokens = this.totalOutputTokens - iterStartOutputTokens;
|
|
@@ -11746,10 +11961,13 @@ Remember: You are an autonomous AGENT. Act independently and solve problems proa
|
|
|
11746
11961
|
});
|
|
11747
11962
|
}
|
|
11748
11963
|
this.iterationConfidences = [];
|
|
11749
|
-
if (!iterationComplete && hadToolCalls)
|
|
11750
|
-
|
|
11751
|
-
|
|
11752
|
-
|
|
11964
|
+
if (!iterationComplete && hadToolCalls) {
|
|
11965
|
+
const feedback = await drainHostFeedback(options.drainFeedback, "turn", this.context.logger);
|
|
11966
|
+
this.messages.push({
|
|
11967
|
+
role: "user",
|
|
11968
|
+
content: buildToolResultsNudge(feedback)
|
|
11969
|
+
});
|
|
11970
|
+
}
|
|
11753
11971
|
if (!iterationComplete && maxTotalTokens !== void 0 && this.totalTokens >= maxTotalTokens) {
|
|
11754
11972
|
finalAnswer = currentText.trim() || `I stopped after reaching the cumulative token ceiling (${this.totalTokens}/${maxTotalTokens}) without arriving at a final answer.`;
|
|
11755
11973
|
const ceilingStep = {
|
|
@@ -15704,6 +15922,10 @@ var StreamAccumulator = class {
|
|
|
15704
15922
|
isEmpty() {
|
|
15705
15923
|
return this.accumulatedText.trim().length === 0 && this.toolsUsed.length === 0;
|
|
15706
15924
|
}
|
|
15925
|
+
/** The stop reason the stream reported, if any: its presence means the stream ended normally. */
|
|
15926
|
+
get finalStopReason() {
|
|
15927
|
+
return this.stopReason;
|
|
15928
|
+
}
|
|
15707
15929
|
get accumulatedLength() {
|
|
15708
15930
|
return this.accumulatedText.length;
|
|
15709
15931
|
}
|
|
@@ -15749,6 +15971,19 @@ var EmptyCompletionError = class extends Error {
|
|
|
15749
15971
|
this.name = "EmptyCompletionError";
|
|
15750
15972
|
}
|
|
15751
15973
|
};
|
|
15974
|
+
/**
|
|
15975
|
+
* Thrown when a stream finished normally (it carried a stop reason) but produced no reply,
|
|
15976
|
+
* e.g. a reasoning model that spent its whole output budget thinking: the server drops
|
|
15977
|
+
* reasoning text, so such a turn arrives blank. Not retried - the same request would
|
|
15978
|
+
* finish the same way and be billed again.
|
|
15979
|
+
*/
|
|
15980
|
+
var NoReplyCompletionError = class extends Error {
|
|
15981
|
+
constructor(stopReason) {
|
|
15982
|
+
super(stopReason === "max_tokens" ? "The model used its whole output limit on reasoning and produced no reply. Raise the max tokens setting or ask a narrower question." : `The model finished (stop reason: ${stopReason}) without producing a reply.`);
|
|
15983
|
+
this.stopReason = stopReason;
|
|
15984
|
+
this.name = "NoReplyCompletionError";
|
|
15985
|
+
}
|
|
15986
|
+
};
|
|
15752
15987
|
/** User-facing message when a transient network drop survives all retries. */
|
|
15753
15988
|
const TRANSIENT_EXHAUSTED_MESSAGE = "The connection dropped mid-response (likely a network timeout during a long thinking step). It was retried automatically but kept failing - type \"continue\" to resume.";
|
|
15754
15989
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -15793,12 +16028,13 @@ async function runCompletion(transport, req, callback, policy, signal) {
|
|
|
15793
16028
|
await accumulator.finalize(callback);
|
|
15794
16029
|
return;
|
|
15795
16030
|
}
|
|
16031
|
+
if (accumulator.finalStopReason) throw new NoReplyCompletionError(accumulator.finalStopReason);
|
|
15796
16032
|
lastError = new EmptyCompletionError();
|
|
15797
16033
|
logger.warn("[runCompletion] Stream produced no content; treating as retryable.");
|
|
15798
16034
|
} catch (error) {
|
|
15799
16035
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
15800
16036
|
if (signal?.aborted) return;
|
|
15801
|
-
if (!policy.isRetryable(lastError)) throw lastError;
|
|
16037
|
+
if (lastError instanceof NoReplyCompletionError || !policy.isRetryable(lastError)) throw lastError;
|
|
15802
16038
|
logger.warn(`[runCompletion] Transient stream failure (attempt ${attempt + 1}): ${lastError.message}`);
|
|
15803
16039
|
}
|
|
15804
16040
|
if (attempt < policy.maxRetries && backoffMs > 0) await sleep(backoffMs * (attempt + 1));
|
|
@@ -16069,10 +16305,10 @@ var ServerLlmBackend = class {
|
|
|
16069
16305
|
} else if (typeof stream === "string") responseText = stream;
|
|
16070
16306
|
logger.debug(`[ServerLlmBackend] Response preview: ${responseText.substring(0, 200)}`);
|
|
16071
16307
|
if (responseText.includes("<!DOCTYPE") || responseText.includes("<html")) {
|
|
16072
|
-
const
|
|
16073
|
-
const
|
|
16074
|
-
if (
|
|
16075
|
-
else if (
|
|
16308
|
+
const title = htmlErrorTitle(responseText);
|
|
16309
|
+
const h1 = htmlFirstH1(responseText);
|
|
16310
|
+
if (title !== null) errorDetails = title;
|
|
16311
|
+
else if (h1 !== null) errorDetails = h1.trim();
|
|
16076
16312
|
} else if (responseText) errorDetails = responseText.substring(0, 100).trim();
|
|
16077
16313
|
} catch (extractError) {
|
|
16078
16314
|
logger.error("[ServerLlmBackend] Error extracting response:", extractError);
|