@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.
- package/README.md +69 -1
- package/dist/cjs/agentProto.d.ts +1 -1
- package/dist/cjs/agentProto.d.ts.map +1 -1
- package/dist/cjs/agentProto.js +6 -0
- package/dist/cjs/client.d.ts +7 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/langChain.d.ts +67 -0
- package/dist/cjs/langChain.d.ts.map +1 -1
- package/dist/cjs/langChain.js +317 -34
- package/dist/cjs/serve.d.ts.map +1 -1
- package/dist/cjs/serve.js +104 -37
- package/dist/esm/agentProto.d.ts +1 -1
- package/dist/esm/agentProto.d.ts.map +1 -1
- package/dist/esm/agentProto.js +6 -0
- package/dist/esm/client.d.ts +7 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/langChain.d.ts +67 -0
- package/dist/esm/langChain.d.ts.map +1 -1
- package/dist/esm/langChain.js +317 -34
- package/dist/esm/serve.d.ts.map +1 -1
- package/dist/esm/serve.js +104 -37
- package/package.json +2 -2
package/dist/cjs/langChain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
930
|
+
return bound.withConfig({ responseSchema: jsonSchema });
|
|
690
931
|
}
|
|
691
|
-
return
|
|
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
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
1137
|
-
//
|
|
1138
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
|
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;
|
package/dist/cjs/serve.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,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
|
|
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
|
|
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
|
|
110
|
-
if (
|
|
111
|
-
|
|
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: ${
|
|
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, () =>
|
|
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) },
|