@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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as withRetry, A as NotFoundError, B as WORK_ITEM_STATUSES, E as LOCATION_MAP_LANGUAGE, G as googleMapsSearchUrl, I as SEARCH_RESULT_CARDS_LANGUAGE, J as isRetryableError, M as OpenAIEmbeddingModel, O as McpServerName, P as PermissionDeniedError, Q as signImageUrl, R as SupportedFabFileMimeTypes, U as getMcpProviderMetadata, X as obfuscateApiKey, Z as secureParameters, a as canTrustTool, b as ApiKeyType, c as SESSION_ID_PATTERN, i as loadContextFiles, j as OllamaEmbeddingModel, l as isValidSessionId, n as logger, o as getToolCategory, q as isPlaceholderImageSigningSecret, s as isReadOnlyTool, w as ChatModels, x as BedrockEmbeddingModel, z as VoyageAIEmbeddingModel } from "./ConfigStore-qV7NrCgZ.mjs";
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() || envKey("SEARXNG_BASE_URL");
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(apiKey);
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/websearch-CChnLwlz.mjs
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 = 6e4;
2232
- const SERPAPI_ATTEMPT_TIMEOUT_MS = 2e4;
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 <= SERPAPI_MAX_ATTEMPTS; 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 === SERPAPI_MAX_ATTEMPTS) break;
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
- url.search = new URLSearchParams({
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
- }).toString();
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
- * Resolve the active web-search provider, or null when none is configured. Precedence:
2713
- * - explicit admin choice ('serpapi' | 'searxng') forces that provider (null if it's unconfigured)
2714
- * - 'auto' (default): SearXNG if a URL is configured (admin setting or SEARXNG_BASE_URL env),
2715
- * else SerpAPI if a Serper key is set, else null.
2716
- * Mirrored by computeToolAvailability in serverConfig.ts so the picker's gating matches the tool.
2717
- */
2718
- async function resolveWebSearchProvider(adapters) {
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
- * Teaches the `b4m_cards` fence, appended to a web_search result ONLY when that search actually
2730
- * returned images (see shouldIncludeImages). Delivering it with the results rather than in the system
2731
- * prompt costs nothing on the turns that will never use it, and arrives exactly when it is actionable.
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
- const WEB_SEARCH_CARDS_PROMPT = [
2737
- "The results above include images, so illustrate your answer - do not wait to be asked. When you name specific",
2738
- `things the user would want to SEE, emit a \`\`\`${SEARCH_RESULT_CARDS_LANGUAGE} fenced block inline in your reply, placed`,
2739
- "exactly where the pictures belong - right after the sentence that introduces them, not at the end.",
2740
- "The block is a single JSON object:",
2741
- "",
2742
- `\`\`\`${SEARCH_RESULT_CARDS_LANGUAGE}`,
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
- async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
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 resolveWebSearchProvider(adapters);
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 results = await provider.search(params.query, params.num_results);
2879
- Logger.globalInstance.log(`📊 WebSearch Tool: ${provider.name} found ${results.length} results`);
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 ? provider.searchImages?.(params.query) : void 0,
2885
- params.include_places ? provider.searchPlaces?.(params.query) : void 0,
2886
- anchorQuery ? provider.searchPlaces?.(anchorQuery, 1) : void 0
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: ${provider.name} found ${placeResults.length} places`);
2892
- if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${provider.name} found ${imageResults.length} images`);
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, toolConfig) => ({
2931
- toolFn: async (value) => {
2932
- const params = value;
2933
- await context.onStart?.("web_search", params);
2934
- const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, toolConfig?.imageUrlSigningSecret);
2935
- if (citables.length > 0) {
2936
- await context.statusUpdate({ promptMeta: { citables } }, "Web search complete");
2937
- Logger.globalInstance.log(`📚 WebSearch Tool: Stored ${citables.length} citables`);
2938
- }
2939
- return formattedResults;
2940
- },
2941
- toolSchema: {
2942
- name: "web_search",
2943
- 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.",
2944
- parameters: {
2945
- type: "object",
2946
- properties: {
2947
- query: {
2948
- type: "string",
2949
- description: "The search query to look up"
2950
- },
2951
- num_results: {
2952
- type: "number",
2953
- description: "Number of results to return (default: 3, max: 10)",
2954
- minimum: 1,
2955
- maximum: 10
2956
- },
2957
- include_images: {
2958
- type: "boolean",
2959
- 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."
2960
- },
2961
- include_places: {
2962
- type: "boolean",
2963
- 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."
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
- anchor_location: {
2966
- type: "string",
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-9biwOOa0.mjs
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, and the
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-iHwi8W0C.mjs
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
- ...acc,
3972
- [key]: tool.implementation(context, config[key])
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 && typeof rawParameters === "object") return {
3983
- ...rawParameters,
3984
- properties: rawParameters.properties ?? {}
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$1(modelId)) try {
5497
- const model = await context.db.latticeModels.findById(modelId);
5498
- if (model && isModelOwner(model, { id: context.userId })) {
5499
- const existingIndex = model.data.entities.findIndex((e) => e.id === entityId);
5500
- if (existingIndex >= 0) model.data.entities[existingIndex] = entityData;
5501
- else model.data.entities.push(entityData);
5502
- await context.db.latticeModels.update({
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: `Access denied: you do not have permission to modify model ${modelId}`
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$1(modelId)) try {
5647
- const model = await context.db.latticeModels.findById(modelId);
5648
- if (model && isModelOwner(model, { id: context.userId })) {
5649
- const entity = model.data.entities.find((e) => e.id === entityId || toEntityId(e.name) === entityId);
5650
- if (!entity) {
5651
- context.logger.warn(`[Lattice] Entity ${entityName} not found in model ${modelId}`);
5652
- return JSON.stringify({
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: `Access denied: you do not have permission to modify model ${modelId}`
5818
+ error: `Model ${modelId} not found or you do not have permission to modify it`
5682
5819
  });
5683
- } else {
5684
- context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
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: `Model ${modelId} not found`
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$1(modelId)) try {
5798
- const model = await context.db.latticeModels.findById(modelId);
5799
- if (model && isModelOwner(model, { id: context.userId })) {
5800
- if (!model.data.entities.some((e) => e.id === outputEntityId || e.name.toLowerCase() === parsedRule.outputEntity.toLowerCase()) && parsedRule.outputEntity !== "unknown") {
5801
- const now = /* @__PURE__ */ new Date();
5802
- const newEntity = {
5803
- id: outputEntityId,
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: `Access denied: you do not have permission to modify model ${modelId}`
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 equalsMatch = splitEquals(formula.toLowerCase().trim());
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 `>`/`>>` redirection targets a raw block device. Covers the common
9132
- * host shapes: SCSI/SATA (`sd[a-z]`), NVMe, Xen (`xvd[a-z]`, default on many EC2
9133
- * AMIs), virtio (`vd[a-z]`), legacy IDE (`hd[a-z]`), device-mapper/LVM (`dm-N`,
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 === ">" || 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, ...effectiveArgsSansGateSnapshot } = effectiveArgs ?? {};
9731
- let execArgs = effectiveArgsSansGateSnapshot;
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
- if (!hadToolCalls && currentText.trim()) {
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) messages.push({
11198
- role: "user",
11199
- content: `Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them.`
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
- if (!hadToolCalls && currentText.trim()) {
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) this.messages.push({
11750
- role: "user",
11751
- content: "Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them."
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 titleMatch = responseText.match(/<title>(.*?)<\/title>/i);
16073
- const h1Match = responseText.match(/<h1>(.*?)<\/h1>/i);
16074
- if (titleMatch && titleMatch[1] !== "Error") errorDetails = titleMatch[1].trim();
16075
- else if (h1Match) errorDetails = h1Match[1].trim();
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);