@stackfactor/agent-utils 1.2.16 → 1.2.19

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.
@@ -608,6 +608,234 @@ const resolveTemperatureSetting = (modelName, config) => {
608
608
  }
609
609
  return { temperature: clamped };
610
610
  };
611
+ /**
612
+ * Whether a Claude model can run web search through dynamic filtering, where
613
+ * Claude writes and runs code that filters the search results before they reach
614
+ * the context window instead of loading every result into it. Requires Claude
615
+ * 4.6 or later (the models with programmatic tool calling); on anything earlier
616
+ * the filtering tool versions return a 400 unless search is pinned to
617
+ * `allowed_callers: ["direct"]`.
618
+ * @param modelName - The Claude model identifier being routed
619
+ * @returns `true` when the model supports dynamic filtering
620
+ */
621
+ const supportsAnthropicDynamicFiltering = (modelName) => /^claude-(opus|sonnet|haiku)-4-(?:[6-9]|\d\d)\b/.test(modelName) ||
622
+ /^claude-(opus|sonnet|haiku|fable|mythos)-(?:[5-9]|\d\d)\b/.test(modelName);
623
+ /**
624
+ * Normalizes `config.webSearch` into an options object, returning `null` when
625
+ * web search is off so callers can use it as the single enablement gate.
626
+ */
627
+ const getWebSearchOptions = (config) => {
628
+ const webSearch = config?.webSearch;
629
+ if (!webSearch)
630
+ return null;
631
+ return webSearch === true ? {} : webSearch;
632
+ };
633
+ /**
634
+ * Builds the provider-native web search tool definition for a model:
635
+ * - `claude-` → Anthropic's `web_search` server tool, executed by the Messages
636
+ * API within a single request and answered with citations. Defaults to the
637
+ * dynamic-filtering tool version on models that support it, so search results
638
+ * are filtered by code before they reach the context window.
639
+ * - `gpt-` → OpenAI's hosted `web_search` tool (Responses API). Context spend
640
+ * is governed by `search_context_size` (OpenAI defaults to `medium`).
641
+ * - `gemini-` → Google's `googleSearch` grounding tool. Google exposes no
642
+ * result-filtering or context-size control; leaving `searchTypes` unset keeps
643
+ * grounding on text-only web results rather than image bytes.
644
+ * Returns `null` for providers with no native web search (DeepSeek, Kimi, GLM),
645
+ * warning instead of throwing so one config can be pointed at any model.
646
+ * @param modelName - The model identifier being routed
647
+ * @param options - Normalized options from `getWebSearchOptions`
648
+ * @returns The provider's tool definition, or `null` when unsupported
649
+ */
650
+ const buildWebSearchTool = (modelName, options) => {
651
+ const { allowedDomains, blockedDomains, userLocation } = options;
652
+ if (modelName.startsWith("claude-")) {
653
+ // The API returns a 400 when both filters are present, so fail locally
654
+ // rather than paying for the round trip.
655
+ if (allowedDomains && blockedDomains) {
656
+ throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Anthropic web search accepts allowedDomains or blockedDomains, not both.");
657
+ }
658
+ // Prefer the newest tool version the model can actually run. Basic search
659
+ // loads every result into the context window; from `web_search_20260209`
660
+ // Claude filters them with code first, and `web_search_20260318` can also
661
+ // keep the consumed results out of the response.
662
+ const canFilter = supportsAnthropicDynamicFiltering(modelName);
663
+ const type = options.toolVersion ||
664
+ (canFilter ? "web_search_20260318" : "web_search_20250305");
665
+ const version = Number(type.slice(-8));
666
+ // Filtering versions default to running search from inside code execution.
667
+ // Say so explicitly when it is not wanted (or not possible), which is what
668
+ // the API requires from models without programmatic tool calling.
669
+ const directOnly = version >= 20260209 && (options.dynamicFiltering === false || !canFilter);
670
+ return {
671
+ type,
672
+ name: "web_search",
673
+ ...(options.maxUses ? { max_uses: options.maxUses } : {}),
674
+ ...(allowedDomains ? { allowed_domains: allowedDomains } : {}),
675
+ ...(blockedDomains ? { blocked_domains: blockedDomains } : {}),
676
+ ...(userLocation
677
+ ? { user_location: { type: "approximate", ...userLocation } }
678
+ : {}),
679
+ ...(directOnly ? { allowed_callers: ["direct"] } : {}),
680
+ ...(version >= 20260318
681
+ ? { response_inclusion: options.responseInclusion || "excluded" }
682
+ : {}),
683
+ };
684
+ }
685
+ if (modelName.startsWith("gpt-")) {
686
+ return {
687
+ type: "web_search",
688
+ ...(allowedDomains
689
+ ? { filters: { allowed_domains: allowedDomains } }
690
+ : {}),
691
+ ...(userLocation
692
+ ? { user_location: { type: "approximate", ...userLocation } }
693
+ : {}),
694
+ ...(options.searchContextSize
695
+ ? { search_context_size: options.searchContextSize }
696
+ : {}),
697
+ };
698
+ }
699
+ if (modelName.startsWith("gemini-")) {
700
+ // `timeRangeFilter` is the only filter the Gemini API exposes;
701
+ // `excludeDomains` is a Vertex AI field and is rejected here, so domain
702
+ // filters are deliberately not mapped for Google.
703
+ return {
704
+ googleSearch: options.timeRange
705
+ ? { timeRangeFilter: options.timeRange }
706
+ : {},
707
+ };
708
+ }
709
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `Model "${modelName}" has no native web search tool; ignoring the configured webSearch options.`);
710
+ return null;
711
+ };
712
+ /**
713
+ * Flattens LangChain message content into plain text. Content is a string for
714
+ * ordinary completions, but every provider switches to an array of blocks once
715
+ * a server-side tool runs — Anthropic interleaves `server_tool_use` and
716
+ * `web_search_tool_result` blocks with the answer text, and OpenAI's Responses
717
+ * API returns annotated text blocks — so without this the JSON parse pipeline
718
+ * would receive a non-string and every web-search call would fail.
719
+ * @param content - A message's `content` field, or a raw string
720
+ * @returns The concatenated text of all text blocks
721
+ */
722
+ const extractTextContent = (content) => {
723
+ if (typeof content === "string")
724
+ return content;
725
+ if (!Array.isArray(content))
726
+ return "";
727
+ let text = "";
728
+ for (const block of content) {
729
+ if (typeof block === "string")
730
+ text += block;
731
+ else if (block?.type === "text" && typeof block.text === "string") {
732
+ text += block.text;
733
+ }
734
+ }
735
+ return text;
736
+ };
737
+ const createWebSearchUsage = () => ({
738
+ reportedRequests: 0,
739
+ callIds: new Set(),
740
+ grounded: false,
741
+ sources: new Map(),
742
+ });
743
+ /**
744
+ * Folds one message — or one streaming chunk — into a `WebSearchUsage`. Safe to
745
+ * call on every chunk of a stream and on messages that involved no search.
746
+ */
747
+ const collectWebSearchUsage = (payload, usage) => {
748
+ if (!payload)
749
+ return;
750
+ const addSource = (url, title) => {
751
+ if (typeof url === "string" && url && !usage.sources.has(url)) {
752
+ usage.sources.set(url, {
753
+ url,
754
+ ...(typeof title === "string" ? { title } : {}),
755
+ });
756
+ }
757
+ };
758
+ if (Array.isArray(payload.content)) {
759
+ for (const block of payload.content) {
760
+ if (!block || typeof block !== "object")
761
+ continue;
762
+ // Anthropic: results of a search the API executed server-side.
763
+ if (block.type === "web_search_tool_result" &&
764
+ Array.isArray(block.content)) {
765
+ for (const result of block.content) {
766
+ addSource(result?.url, result?.title);
767
+ }
768
+ }
769
+ // Anthropic: citations attached to the answer's text blocks.
770
+ if (Array.isArray(block.citations)) {
771
+ for (const citation of block.citations) {
772
+ addSource(citation?.url, citation?.title);
773
+ }
774
+ }
775
+ // OpenAI Responses API: one block per executed search, plus url citations.
776
+ if (block.type === "web_search_call" && block.id) {
777
+ usage.callIds.add(block.id);
778
+ }
779
+ if (Array.isArray(block.annotations)) {
780
+ for (const annotation of block.annotations) {
781
+ if (annotation?.type === "url_citation") {
782
+ addSource(annotation.url, annotation.title);
783
+ }
784
+ }
785
+ }
786
+ }
787
+ }
788
+ const metadata = payload.response_metadata;
789
+ if (!metadata)
790
+ return;
791
+ const requests = metadata.usage?.server_tool_use?.web_search_requests;
792
+ if (typeof requests === "number" && requests > usage.reportedRequests) {
793
+ usage.reportedRequests = requests;
794
+ }
795
+ const grounding = metadata.groundingMetadata;
796
+ if (grounding) {
797
+ usage.grounded = true;
798
+ for (const chunk of grounding.groundingChunks || []) {
799
+ addSource(chunk?.web?.uri, chunk?.web?.title);
800
+ }
801
+ }
802
+ };
803
+ /**
804
+ * Adds a call's web-search usage to the caller-supplied tracker. Searches are
805
+ * billed per request rather than per token (Anthropic charges $10 per 1,000
806
+ * searches; Google charges per grounded request), so the rate is read from the
807
+ * `<model>-web-search-costs` constant expressed in USD per 1,000 searches and
808
+ * accumulated under `<model>_webSearches`. Sources are appended to
809
+ * `tracker.webSearchSources`, deduplicated by URL across the whole run.
810
+ */
811
+ const updateWebSearchUsageTracker = (tracker, modelName, usage, config) => {
812
+ if (!tracker || !modelName)
813
+ return;
814
+ // The three signals describe the same searches from different providers, so
815
+ // the largest one is the count rather than their sum.
816
+ const searches = Math.max(usage.reportedRequests, usage.callIds.size, usage.grounded ? 1 : 0);
817
+ if (!searches && usage.sources.size === 0)
818
+ return;
819
+ if (typeof tracker.cost !== "number")
820
+ tracker.cost = 0;
821
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
822
+ tracker.tokens = {};
823
+ if (searches > 0) {
824
+ const addedCost = (searches / 1_000) * getModelRate(modelName, config, "web-search");
825
+ if (Number.isFinite(addedCost) && addedCost > 0)
826
+ tracker.cost += addedCost;
827
+ const key = `${modelName}_webSearches`;
828
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + searches;
829
+ }
830
+ if (usage.sources.size > 0) {
831
+ const sources = tracker.webSearchSources || (tracker.webSearchSources = []);
832
+ for (const source of usage.sources.values()) {
833
+ if (!sources.some((existing) => existing.url === source.url)) {
834
+ sources.push(source);
835
+ }
836
+ }
837
+ }
838
+ };
611
839
  /**
612
840
  * Instantiates and returns the appropriate LangChain chat model based on the model
613
841
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
@@ -634,6 +862,13 @@ const getLLMModel = (modelName, config, schema = null) => {
634
862
  // Resolve `temperature` with presence/support/range handling (see
635
863
  // resolveTemperatureSetting). Applied uniformly to every provider below.
636
864
  const modelSettings = resolveTemperatureSetting(modelName, config);
865
+ // Native web search (see buildWebSearchTool). The tool is bound to the model
866
+ // so both `.invoke()` and `.stream()` pick it up; the provider runs the search
867
+ // server-side within the same request, so no client-side agent loop is needed.
868
+ const webSearchOptions = getWebSearchOptions(config);
869
+ const webSearchTool = webSearchOptions
870
+ ? buildWebSearchTool(modelName, webSearchOptions)
871
+ : null;
637
872
  // Claude models (Anthropic)
638
873
  if (modelName.startsWith("claude-")) {
639
874
  // Anthropic's SDK rejects non-streamed requests when max_tokens is large
@@ -658,7 +893,7 @@ const getLLMModel = (modelName, config, schema = null) => {
658
893
  },
659
894
  }
660
895
  : {};
661
- return new anthropic_1.ChatAnthropic({
896
+ const model = new anthropic_1.ChatAnthropic({
662
897
  apiKey: config.anthropicAPIKey,
663
898
  maxTokens,
664
899
  modelName: modelName,
@@ -667,6 +902,7 @@ const getLLMModel = (modelName, config, schema = null) => {
667
902
  ...outputConfig,
668
903
  ...modelSettings,
669
904
  });
905
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
670
906
  }
671
907
  // Gemini models (Google)
672
908
  else if (modelName.startsWith("gemini-")) {
@@ -679,6 +915,11 @@ const getLLMModel = (modelName, config, schema = null) => {
679
915
  ...(schema ? { json: true } : {}),
680
916
  ...modelSettings,
681
917
  });
918
+ // Combining grounding with structured output requires Gemini 3 or later;
919
+ // Gemini 1.5/2.x reject `responseSchema` alongside `googleSearch` with a 400.
920
+ const bound = webSearchTool
921
+ ? model.bindTools([webSearchTool])
922
+ : model;
682
923
  // `responseSchema` additionally constrains the output shape. It is a
683
924
  // call-time option (not a constructor field), so it is bound onto the model
684
925
  // via `withConfig`. Gemini's schema is an OpenAPI 3.0 subset, so
@@ -686,9 +927,9 @@ const getLLMModel = (modelName, config, schema = null) => {
686
927
  // parse/validate pipeline is unchanged.
687
928
  if (schema) {
688
929
  const jsonSchema = sanitizeGeminiSchema(buildJsonSchema(schema));
689
- return model.withConfig({ responseSchema: jsonSchema });
930
+ return bound.withConfig({ responseSchema: jsonSchema });
690
931
  }
691
- return model;
932
+ return bound;
692
933
  }
693
934
  // GPT models (OpenAI)
694
935
  else if (modelName.startsWith("gpt-")) {
@@ -696,23 +937,41 @@ const getLLMModel = (modelName, config, schema = null) => {
696
937
  apiKey: config.openAIAPIKey,
697
938
  max_tokens: config.maxTokens || 200000,
698
939
  modelName: modelName,
940
+ // `web_search` is a hosted Responses API tool, so the request has to go to
941
+ // `/v1/responses` rather than `/v1/chat/completions`.
942
+ ...(webSearchTool ? { useResponsesApi: true } : {}),
699
943
  ...modelSettings,
700
944
  };
701
- // Use native response_format with JSON schema for structured output
945
+ // Use native structured output with a JSON schema. The two endpoints spell
946
+ // the same thing differently — Chat Completions takes `response_format`,
947
+ // the Responses API takes `text.format` with the schema flattened one level
948
+ // — and `modelKwargs` is spread verbatim into whichever request is built.
702
949
  if (schema) {
703
950
  const jsonSchema = strictifyJsonSchema(buildJsonSchema(schema));
704
- openAISettings.modelKwargs = {
705
- response_format: {
706
- type: "json_schema",
707
- json_schema: {
708
- name: "response_schema",
709
- strict: true,
710
- schema: jsonSchema,
951
+ openAISettings.modelKwargs = webSearchTool
952
+ ? {
953
+ text: {
954
+ format: {
955
+ type: "json_schema",
956
+ name: "response_schema",
957
+ strict: true,
958
+ schema: jsonSchema,
959
+ },
711
960
  },
712
- },
713
- };
961
+ }
962
+ : {
963
+ response_format: {
964
+ type: "json_schema",
965
+ json_schema: {
966
+ name: "response_schema",
967
+ strict: true,
968
+ schema: jsonSchema,
969
+ },
970
+ },
971
+ };
714
972
  }
715
- return new openai_1.ChatOpenAI(openAISettings);
973
+ const model = new openai_1.ChatOpenAI(openAISettings);
974
+ return webSearchTool ? model.bindTools([webSearchTool]) : model;
716
975
  }
717
976
  // OpenAI-compatible providers: DeepSeek, Kimi (Moonshot), GLM (Zhipu)
718
977
  const openAICompatible = getOpenAICompatibleProvider(modelName, config);
@@ -741,11 +1000,18 @@ const getLLMModel = (modelName, config, schema = null) => {
741
1000
  * @returns A configured LangChain agent instance ready to be run with `runAgent`
742
1001
  */
743
1002
  const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config) => {
1003
+ // Native web search joins the agent's tool list instead of being bound inside
1004
+ // `getLLMModel`: the agent binds its own tools to the model, which would drop
1005
+ // anything already bound there.
1006
+ const webSearchOptions = getWebSearchOptions(config);
1007
+ const webSearchTool = webSearchOptions
1008
+ ? buildWebSearchTool(modelName, webSearchOptions)
1009
+ : null;
744
1010
  const agent = (0, langchain_1.createAgent)({
745
1011
  name: name,
746
- model: getLLMModel(modelName, config),
1012
+ model: getLLMModel(modelName, { ...config, webSearch: null }),
747
1013
  systemPrompt: systemPrompt.trim(),
748
- tools,
1014
+ tools: webSearchTool ? [...tools, webSearchTool] : tools,
749
1015
  ...(responseFormat ? { responseFormat: responseFormat } : {}),
750
1016
  });
751
1017
  return agent;
@@ -832,6 +1098,13 @@ const runAgent = async (agent, prompt, config, onProgress = null, usageTracker =
832
1098
  }
833
1099
  }
834
1100
  updateUsageTracker(usageTracker, modelName, sumAgentResponseUsage(response), config);
1101
+ // Web-search activity is spread across the agent's messages — one search may
1102
+ // be reported by the message that ran it and cited by a later one.
1103
+ const webSearchUsage = createWebSearchUsage();
1104
+ for (const message of response?.messages || []) {
1105
+ collectWebSearchUsage(message, webSearchUsage);
1106
+ }
1107
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
835
1108
  const endTime = Date.now();
836
1109
  const duration = endTime - startTime;
837
1110
  logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -1068,10 +1341,16 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
1068
1341
  *
1069
1342
  * When `expectsJsonResponse` is `true`, JSON escape instructions are prepended to the
1070
1343
  * system prompt and the parsed result is optionally validated against `schema`.
1344
+ *
1345
+ * Setting `config.webSearch` enables the provider's native web search tool in every
1346
+ * mode (see `buildWebSearchTool`). The provider runs the search server-side inside the
1347
+ * same request, so the return contract is unchanged; the searches performed and the
1348
+ * sources cited are recorded on `usageTracker`.
1071
1349
  * @param modelName - The model identifier, e.g. `"gpt-4o"`, `"claude-3-5-sonnet"`,
1072
1350
  * `"gemini-1.5-pro"`
1073
1351
  * @param config - Configuration object with API keys, `temperature`, optional `agentic`
1074
- * flag, and optional `recursionLimit`
1352
+ * flag, optional `recursionLimit`, and optional `webSearch` (`true` or a
1353
+ * `WebSearchConfig`)
1075
1354
  * @param prompt - The prompt to send; either a plain string (user message only) or an
1076
1355
  * array of `{ role, content }` message objects
1077
1356
  * @param onProgressReport - Optional async callback invoked with `{ message, progress }`
@@ -1133,12 +1412,9 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1133
1412
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "Agent returned no messages");
1134
1413
  }
1135
1414
  const lastMessage = messages[messages.length - 1];
1136
- let rawContent = lastMessage?.content || "";
1137
- // Handle array content blocks (e.g., from Gemini/Claude agent responses)
1138
- if (Array.isArray(rawContent)) {
1139
- const textBlock = rawContent.find((block) => typeof block === "object" && block.type === "text");
1140
- rawContent = textBlock?.text || "";
1141
- }
1415
+ // Flattens the array content blocks that Gemini/Claude agent responses and
1416
+ // any server-tool turn (e.g. web search) return.
1417
+ const rawContent = extractTextContent(lastMessage?.content);
1142
1418
  // If not expecting JSON, return raw content directly
1143
1419
  if (!expectsJsonResponse) {
1144
1420
  return rawContent;
@@ -1260,6 +1536,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1260
1536
  output_tokens: 0,
1261
1537
  total_tokens: 0,
1262
1538
  };
1539
+ let webSearchUsage = createWebSearchUsage();
1263
1540
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1264
1541
  // Usage is only recorded on a successful stream — partial streams that
1265
1542
  // error out with a rate limit are not counted. A 429 fired mid-stream
@@ -1269,6 +1546,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1269
1546
  rawContent = "";
1270
1547
  chunkCount = 0;
1271
1548
  streamUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
1549
+ webSearchUsage = createWebSearchUsage();
1272
1550
  try {
1273
1551
  // Honour caller cancellation: passing the signal tears down the
1274
1552
  // upstream HTTP request so a cancelled call stops billing tokens.
@@ -1281,16 +1559,16 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1281
1559
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Request cancelled by caller");
1282
1560
  }
1283
1561
  accumulateChunkUsage(streamUsage, chunk);
1284
- const content = chunk?.content || chunk;
1285
- if (typeof content === "string") {
1286
- rawContent += content;
1287
- chunkCount++;
1288
- if (chunkCount % progressReportInterval === 0) {
1289
- await onProgressReport({
1290
- message: "Generating content...",
1291
- progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1292
- });
1293
- }
1562
+ collectWebSearchUsage(chunk, webSearchUsage);
1563
+ // Counting every chunk (not just the ones carrying text) keeps
1564
+ // progress ticking through the pause while a search runs.
1565
+ chunkCount++;
1566
+ rawContent += extractTextContent(chunk?.content ?? chunk);
1567
+ if (chunkCount % progressReportInterval === 0) {
1568
+ await onProgressReport({
1569
+ message: "Generating content...",
1570
+ progress: Math.min(calcCurrentProgress(), maxPercent - 5),
1571
+ });
1294
1572
  }
1295
1573
  }
1296
1574
  break; // stream completed without 429
@@ -1313,6 +1591,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1313
1591
  }
1314
1592
  }
1315
1593
  updateUsageTracker(usageTracker, modelName, streamUsage, config);
1594
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1316
1595
  if (!rawContent) {
1317
1596
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1318
1597
  }
@@ -1417,7 +1696,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1417
1696
  }
1418
1697
  }
1419
1698
  updateUsageTracker(usageTracker, modelName, extractUsageFromInvoke(response), config);
1420
- const rawContent = response?.content || response;
1699
+ const webSearchUsage = createWebSearchUsage();
1700
+ collectWebSearchUsage(response, webSearchUsage);
1701
+ updateWebSearchUsageTracker(usageTracker, modelName, webSearchUsage, config);
1702
+ // Flattened because a server-tool turn returns content blocks, not a string.
1703
+ const rawContent = extractTextContent(response?.content ?? response);
1421
1704
  // If not expecting JSON, return raw content directly
1422
1705
  if (!expectsJsonResponse) {
1423
1706
  return rawContent;
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AA8LtC;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAyHD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MA4KP,CAAC"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AA+PtC;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAyHD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MA8MP,CAAC"}
package/dist/cjs/serve.js CHANGED
@@ -53,11 +53,14 @@ const dynamicImport = new Function("specifier", "return import(specifier)");
53
53
  // own fetch/SDK calls can cooperate, and we fail the check if it overruns.
54
54
  const SELF_CHECK_TIMEOUT_MS = 20_000;
55
55
  /**
56
- * Resolves a repo-relative check-module path within the app root, rejecting any
56
+ * Resolves a repo-relative agent-module path within the app root, rejecting any
57
57
  * path that escapes it or is absolute (the path may originate from the
58
58
  * integration config, so treat it as untrusted). Returns null when unsafe.
59
+ *
60
+ * Guards both entry points that name a module by configuration: HealthCheck's
61
+ * `check_code` and Execute's `code` (a webhook's handler module).
59
62
  */
60
- const resolveCheckPath = (rel) => {
63
+ const resolveAgentModulePath = (rel) => {
61
64
  const root = process.cwd();
62
65
  const dest = (0, node_path_1.resolve)(root, rel);
63
66
  const back = (0, node_path_1.relative)(root, dest);
@@ -66,6 +69,60 @@ const resolveCheckPath = (rel) => {
66
69
  }
67
70
  return dest;
68
71
  };
72
+ /**
73
+ * Loads the function exported by a repo-relative agent module. Shared by the
74
+ * self-check (`check_code`) and the webhook entry point (`code`) so both get the
75
+ * same untrusted-path guard and the same CJS/ESM interop, and neither can drift
76
+ * into resolving a path the other would reject.
77
+ *
78
+ * Never throws: every failure comes back as a `reason` the caller maps to its
79
+ * own error shape (a CheckResult row, or a gRPC error frame).
80
+ *
81
+ * @param rel - Repo-relative module path, e.g. "src/webhooks/inbound-sms.js".
82
+ * @param namedExport - Preferred named export, tried after `default`/bare export.
83
+ */
84
+ const loadAgentModule = async (rel, namedExport) => {
85
+ const modulePath = resolveAgentModulePath(rel);
86
+ if (!modulePath) {
87
+ return {
88
+ reason: "invalid-path",
89
+ detail: `invalid module path "${rel}"`,
90
+ };
91
+ }
92
+ if (!(0, node_fs_1.existsSync)(modulePath)) {
93
+ return {
94
+ reason: "not-found",
95
+ detail: `module "${rel}" was not found in the agent image`,
96
+ };
97
+ }
98
+ let exported;
99
+ try {
100
+ // Real dynamic import via an indirection the TS compiler won't rewrite. A
101
+ // literal `import()` is downlevelled to `require()` in the CJS build, and
102
+ // `require()` cannot resolve a file:// URL ("Cannot find module
103
+ // 'file:///app/src/check.js'"). `new Function` keeps a genuine `import()` in
104
+ // both builds; Node resolves the file URL and loads CJS or ESM modules
105
+ // alike (CJS exports surface as the namespace `default`).
106
+ const mod = await dynamicImport((0, node_url_1.pathToFileURL)(modulePath).href);
107
+ exported = mod?.default ?? mod;
108
+ }
109
+ catch (error) {
110
+ return {
111
+ reason: "load-failed",
112
+ detail: String(error?.message ?? error),
113
+ };
114
+ }
115
+ const fn = typeof exported === "function"
116
+ ? exported
117
+ : exported?.[namedExport] ?? exported?.default;
118
+ if (typeof fn !== "function") {
119
+ return {
120
+ reason: "not-a-function",
121
+ detail: `module "${rel}" does not export a function`,
122
+ };
123
+ }
124
+ return { fn: fn };
125
+ };
69
126
  /** Coerces whatever the agent's check returned into well-formed CheckResult[]. */
70
127
  const normalizeChecks = (raw) => {
71
128
  if (!Array.isArray(raw)) {
@@ -106,60 +163,41 @@ const runSelfCheck = async (checkCode, config, session) => {
106
163
  },
107
164
  ];
108
165
  }
109
- const modulePath = resolveCheckPath(rel);
110
- if (!modulePath) {
111
- return [
112
- {
166
+ const loaded = await loadAgentModule(rel, "check");
167
+ if (loaded.reason) {
168
+ // These rows are the ones this check reported before the loader was factored
169
+ // out, preserved exactly: a missing or unreachable module is a non-blocking
170
+ // `warn` (synthetic testing simply isn't available), while a module that IS
171
+ // there but broken is a blocking `error` — a real defect in a live agent.
172
+ const rows = {
173
+ "invalid-path": {
113
174
  name: "synthetic_check",
114
175
  ok: false,
115
176
  detail: `invalid checkCode path "${rel}"`,
116
177
  severity: "warn",
117
178
  },
118
- ];
119
- }
120
- if (!(0, node_fs_1.existsSync)(modulePath)) {
121
- return [
122
- {
179
+ "not-found": {
123
180
  name: "synthetic_check",
124
181
  ok: false,
125
182
  detail: `checkCode "${rel}" is configured but the module was not found in the agent image`,
126
183
  severity: "warn",
127
184
  },
128
- ];
129
- }
130
- let checkFn;
131
- try {
132
- // Real dynamic import via an indirection the TS compiler won't rewrite. A
133
- // literal `import()` is downlevelled to `require()` in the CJS build, and
134
- // `require()` cannot resolve a file:// URL ("Cannot find module
135
- // 'file:///app/src/check.js'"). `new Function` keeps a genuine `import()` in
136
- // both builds; Node resolves the file URL and loads CJS or ESM check
137
- // modules alike (CJS exports surface as the namespace `default`).
138
- const mod = await dynamicImport((0, node_url_1.pathToFileURL)(modulePath).href);
139
- const exported = mod?.default ?? mod;
140
- checkFn =
141
- typeof exported === "function" ? exported : exported?.check ?? exported?.default;
142
- }
143
- catch (error) {
144
- return [
145
- {
185
+ "load-failed": {
146
186
  name: "self_check",
147
187
  ok: false,
148
- detail: `failed to load check module: ${error?.message ?? error}`,
188
+ detail: `failed to load check module: ${loaded.detail}`,
149
189
  severity: "error",
150
190
  },
151
- ];
152
- }
153
- if (typeof checkFn !== "function") {
154
- return [
155
- {
191
+ "not-a-function": {
156
192
  name: "self_check",
157
193
  ok: false,
158
194
  detail: "check module does not export a function",
159
195
  severity: "error",
160
196
  },
161
- ];
197
+ };
198
+ return [rows[loaded.reason]];
162
199
  }
200
+ const checkFn = loaded.fn;
163
201
  const abort = new AbortController();
164
202
  const timer = setTimeout(() => abort.abort(), SELF_CHECK_TIMEOUT_MS);
165
203
  // Bind the same per-request globals main() sees (config, request.session, …)
@@ -360,8 +398,37 @@ const serve = (main, options = {}) => {
360
398
  signal: abort.signal,
361
399
  session,
362
400
  };
401
+ // Which function actually runs. Default is the agent's own `main` — the
402
+ // path every caller took before `code` existed, and still the path taken
403
+ // whenever `code` is empty. A webhook instead names its own handler module
404
+ // (integration webHooks[].code), so one agent can serve many endpoints
405
+ // without `main` growing a routing switch. Resolved per request rather
406
+ // than at boot because the value is per-invocation configuration.
407
+ let entry = main;
408
+ const entryPath = String(req.code ?? "").trim();
409
+ if (entryPath) {
410
+ const loaded = await loadAgentModule(entryPath, "handler");
411
+ if (loaded.reason) {
412
+ // A named module that will not load is a deployment/config fault, not
413
+ // a bad request: the caller cannot fix it and retrying will not help.
414
+ // Report it as a result-bearing error frame (not a gRPC status) so the
415
+ // receiver gets the reason verbatim and can log which module failed.
416
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `agent Execute could not load entry module "${entryPath}" (${loaded.reason}): ${loaded.detail}`);
417
+ if (!abort.signal.aborted) {
418
+ call.write({
419
+ error: {
420
+ code: 500,
421
+ message: `entry module "${entryPath}" could not be loaded: ${loaded.detail}`,
422
+ },
423
+ });
424
+ }
425
+ call.end();
426
+ return;
427
+ }
428
+ entry = loaded.fn;
429
+ }
363
430
  try {
364
- const result = await (0, runtimeContext_js_1.runWithContext)(context, () => main(abort.signal));
431
+ const result = await (0, runtimeContext_js_1.runWithContext)(context, () => entry(abort.signal));
365
432
  if (!abort.signal.aborted) {
366
433
  call.write({
367
434
  result: { result_json: JSON.stringify(result ?? null) },