@f5xc-salesdemos/xcsh 17.4.3 → 17.4.4
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/package.json +7 -7
- package/src/web/search/errors.ts +120 -0
- package/src/web/search/index.ts +53 -12
- package/src/web/search/params.ts +129 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "17.4.
|
|
4
|
+
"version": "17.4.4",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
48
48
|
"@mozilla/readability": "^0.6",
|
|
49
|
-
"@f5xc-salesdemos/xcsh-stats": "17.4.
|
|
50
|
-
"@f5xc-salesdemos/pi-agent-core": "17.4.
|
|
51
|
-
"@f5xc-salesdemos/pi-ai": "17.4.
|
|
52
|
-
"@f5xc-salesdemos/pi-natives": "17.4.
|
|
53
|
-
"@f5xc-salesdemos/pi-tui": "17.4.
|
|
54
|
-
"@f5xc-salesdemos/pi-utils": "17.4.
|
|
49
|
+
"@f5xc-salesdemos/xcsh-stats": "17.4.4",
|
|
50
|
+
"@f5xc-salesdemos/pi-agent-core": "17.4.4",
|
|
51
|
+
"@f5xc-salesdemos/pi-ai": "17.4.4",
|
|
52
|
+
"@f5xc-salesdemos/pi-natives": "17.4.4",
|
|
53
|
+
"@f5xc-salesdemos/pi-tui": "17.4.4",
|
|
54
|
+
"@f5xc-salesdemos/pi-utils": "17.4.4",
|
|
55
55
|
"@sinclair/typebox": "^0.34",
|
|
56
56
|
"@xterm/headless": "^6.0",
|
|
57
57
|
"ajv": "^8.18",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { SearchProviderError } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface WebSearchError {
|
|
4
|
+
field?: string;
|
|
5
|
+
constraint?: string;
|
|
6
|
+
userMessage: string;
|
|
7
|
+
status?: number;
|
|
8
|
+
raw: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FIELD_PATTERN = /tools\.\d+\.web_search_\d+\.([A-Za-z_][A-Za-z_0-9.]*?):\s*(.+?)(?:\n|$)/;
|
|
12
|
+
|
|
13
|
+
const LITELLM_NOISE_PATTERNS: RegExp[] = [
|
|
14
|
+
/No fallback model group found for original model_group=[^\n]*/gi,
|
|
15
|
+
/Fallbacks=\[[^\]]*\][^\n]*/gi,
|
|
16
|
+
/Available Model Group Fallbacks=[^\n]*/gi,
|
|
17
|
+
/claude-haiku-[0-9a-z.-]+/gi,
|
|
18
|
+
/claude-[a-z0-9-]+-\d+/gi,
|
|
19
|
+
/model_group=[^\s,]+/gi,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function scrubLitellmNoise(text: string): string {
|
|
23
|
+
let cleaned = text;
|
|
24
|
+
for (const pattern of LITELLM_NOISE_PATTERNS) {
|
|
25
|
+
cleaned = cleaned.replace(pattern, "");
|
|
26
|
+
}
|
|
27
|
+
return cleaned
|
|
28
|
+
.replace(/\n{2,}/g, "\n")
|
|
29
|
+
.replace(/\s{2,}/g, " ")
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fieldHint(field: string): string | undefined {
|
|
34
|
+
if (field === "user_location.country") {
|
|
35
|
+
return "user_location.country must be an ISO 3166-1 alpha-2 code (e.g. US, JP, GB)";
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractRawMessage(input: unknown): string {
|
|
41
|
+
if (typeof input === "string") return input;
|
|
42
|
+
if (input instanceof SearchProviderError) return input.message;
|
|
43
|
+
if (input instanceof Error) return input.message;
|
|
44
|
+
if (input && typeof input === "object") {
|
|
45
|
+
const body = (input as { error?: { message?: unknown } }).error;
|
|
46
|
+
if (body && typeof body.message === "string") return body.message;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tryParseJsonString(raw: string): unknown | undefined {
|
|
52
|
+
const trimmed = raw.trim();
|
|
53
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(trimmed);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function matchFieldPattern(text: string): RegExpMatchArray | null {
|
|
62
|
+
return text.match(FIELD_PATTERN);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseWebSearchError(input: unknown): WebSearchError {
|
|
66
|
+
if (input instanceof SearchProviderError) {
|
|
67
|
+
if (input.status === 401 || input.status === 403) {
|
|
68
|
+
return {
|
|
69
|
+
status: input.status,
|
|
70
|
+
userMessage: `Web search authorization failed (${input.status}). Check API key or base URL.`,
|
|
71
|
+
raw: input,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (input.status === 404) {
|
|
75
|
+
return {
|
|
76
|
+
status: input.status,
|
|
77
|
+
userMessage: "Web search returned 404 (model or endpoint not found).",
|
|
78
|
+
raw: input,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let bodyForFieldScan: unknown = input;
|
|
84
|
+
if (typeof input === "string") {
|
|
85
|
+
const parsed = tryParseJsonString(input);
|
|
86
|
+
if (parsed !== undefined) bodyForFieldScan = parsed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rawMessage = extractRawMessage(bodyForFieldScan);
|
|
90
|
+
|
|
91
|
+
if (rawMessage.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
userMessage: "web_search failed — no error detail available.",
|
|
94
|
+
raw: input,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const match = matchFieldPattern(rawMessage);
|
|
99
|
+
if (match) {
|
|
100
|
+
const field = match[1];
|
|
101
|
+
const constraint = match[2].trim();
|
|
102
|
+
const hint = fieldHint(field);
|
|
103
|
+
const userMessage = hint ? `web_search error: ${hint}.` : `web_search error: ${field} — ${constraint}.`;
|
|
104
|
+
return { field, constraint, userMessage, raw: input };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (input instanceof Error && !(input instanceof SearchProviderError)) {
|
|
108
|
+
return {
|
|
109
|
+
userMessage: `web_search transport error: ${input.message}`,
|
|
110
|
+
raw: input,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const scrubbed = scrubLitellmNoise(rawMessage);
|
|
115
|
+
const userMessage =
|
|
116
|
+
scrubbed.length > 0
|
|
117
|
+
? `web_search failed — ${scrubbed}`
|
|
118
|
+
: "web_search failed — upstream error (details suppressed).";
|
|
119
|
+
return { userMessage, raw: input };
|
|
120
|
+
}
|
package/src/web/search/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
|
|
|
21
21
|
import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
|
|
22
22
|
import type { ToolSession } from "../../tools";
|
|
23
23
|
import { formatAge } from "../../tools/render-utils";
|
|
24
|
+
import { parseWebSearchError } from "./errors";
|
|
25
|
+
import { normalizeUserLocation, validateWebSearchParams, type WebSearchParams } from "./params";
|
|
24
26
|
import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
|
|
25
27
|
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
|
26
28
|
import type { SearchProviderId, SearchResponse } from "./types";
|
|
@@ -28,26 +30,52 @@ import { SearchProviderError } from "./types";
|
|
|
28
30
|
|
|
29
31
|
/** Web search tool parameters schema */
|
|
30
32
|
export const webSearchSchema = Type.Object({
|
|
31
|
-
query: Type.String({ description: "Search query" }),
|
|
33
|
+
query: Type.String({ description: "Search query (non-empty, whitespace-only is rejected)" }),
|
|
32
34
|
recency: Type.Optional(
|
|
33
35
|
StringEnum(["day", "week", "month", "year"], {
|
|
34
|
-
description: "Recency filter
|
|
36
|
+
description: "Recency filter. Exhaustive enum — one of: day, week, month, year.",
|
|
35
37
|
}),
|
|
36
38
|
),
|
|
37
|
-
limit: Type.Optional(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
limit: Type.Optional(
|
|
40
|
+
Type.Number({
|
|
41
|
+
description:
|
|
42
|
+
"Post-processing cap on the number of sources surfaced in the tool result. Positive integer. Controls output verbosity; see num_search_results for backend fetch count.",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens. Positive integer." })),
|
|
46
|
+
temperature: Type.Optional(Type.Number({ description: "Sampling temperature between 0 and 2." })),
|
|
47
|
+
num_search_results: Type.Optional(
|
|
48
|
+
Type.Number({
|
|
49
|
+
description:
|
|
50
|
+
"Number of sources the backend should fetch. Positive integer. Controls provider fetch count; see limit for output-side trimming.",
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
allowed_domains: Type.Optional(
|
|
54
|
+
Type.Array(Type.String(), {
|
|
55
|
+
description: "Only return results from these domains. Entries must be non-empty strings.",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
blocked_domains: Type.Optional(
|
|
59
|
+
Type.Array(Type.String(), {
|
|
60
|
+
description: "Exclude results from these domains. Entries must be non-empty strings.",
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
max_uses: Type.Optional(
|
|
64
|
+
Type.Number({ description: "Maximum number of web searches per request. Positive integer." }),
|
|
65
|
+
),
|
|
44
66
|
user_location: Type.Optional(
|
|
45
67
|
Type.Object(
|
|
46
68
|
{
|
|
47
69
|
type: Type.Literal("approximate"),
|
|
48
70
|
city: Type.Optional(Type.String()),
|
|
49
71
|
region: Type.Optional(Type.String()),
|
|
50
|
-
country: Type.Optional(
|
|
72
|
+
country: Type.Optional(
|
|
73
|
+
Type.String({
|
|
74
|
+
minLength: 2,
|
|
75
|
+
maxLength: 2,
|
|
76
|
+
description: "ISO 3166-1 alpha-2 country code (e.g. US, JP, GB)",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
51
79
|
timezone: Type.Optional(Type.String()),
|
|
52
80
|
},
|
|
53
81
|
{ description: "Approximate user location for localized results" },
|
|
@@ -96,9 +124,9 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
|
|
|
96
124
|
}
|
|
97
125
|
return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`;
|
|
98
126
|
}
|
|
99
|
-
return error.
|
|
127
|
+
return parseWebSearchError(error).userMessage;
|
|
100
128
|
}
|
|
101
|
-
if (error instanceof Error) return error.
|
|
129
|
+
if (error instanceof Error) return parseWebSearchError(error).userMessage;
|
|
102
130
|
return `Unknown error from ${provider.label}`;
|
|
103
131
|
}
|
|
104
132
|
|
|
@@ -168,6 +196,19 @@ async function executeSearch(
|
|
|
168
196
|
_toolCallId: string,
|
|
169
197
|
params: SearchQueryParams,
|
|
170
198
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
|
|
199
|
+
const validation = validateWebSearchParams(params as WebSearchParams);
|
|
200
|
+
if (!validation.valid) {
|
|
201
|
+
const message = `web_search invalid parameter: ${validation.error}`;
|
|
202
|
+
return {
|
|
203
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
204
|
+
details: { response: { provider: "none", sources: [] }, error: message },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const normalizedLocation = normalizeUserLocation(params.user_location);
|
|
208
|
+
const normalizedParams: SearchQueryParams = normalizedLocation
|
|
209
|
+
? { ...params, user_location: normalizedLocation }
|
|
210
|
+
: params;
|
|
211
|
+
params = normalizedParams;
|
|
171
212
|
const hasAnthropicOnlyParams =
|
|
172
213
|
params.allowed_domains?.length || params.blocked_domains?.length || params.max_uses || params.user_location;
|
|
173
214
|
const effectiveProvider =
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type WebSearchRecency = "day" | "week" | "month" | "year";
|
|
2
|
+
|
|
3
|
+
export interface WebSearchUserLocation {
|
|
4
|
+
type: "approximate";
|
|
5
|
+
city?: string;
|
|
6
|
+
region?: string;
|
|
7
|
+
country?: string;
|
|
8
|
+
timezone?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WebSearchParams {
|
|
12
|
+
query: string;
|
|
13
|
+
recency?: WebSearchRecency;
|
|
14
|
+
limit?: number;
|
|
15
|
+
max_tokens?: number;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
num_search_results?: number;
|
|
18
|
+
max_uses?: number;
|
|
19
|
+
allowed_domains?: string[];
|
|
20
|
+
blocked_domains?: string[];
|
|
21
|
+
user_location?: WebSearchUserLocation;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ValidationResult = { valid: true } | { valid: false; error: string };
|
|
25
|
+
|
|
26
|
+
const RECENCY_VALUES: readonly WebSearchRecency[] = ["day", "week", "month", "year"] as const;
|
|
27
|
+
const ISO_ALPHA2 = /^[A-Za-z]{2}$/;
|
|
28
|
+
|
|
29
|
+
function fail(error: string): ValidationResult {
|
|
30
|
+
return { valid: false, error };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPositiveInteger(value: number): boolean {
|
|
34
|
+
return Number.isInteger(value) && value > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validatePositiveInteger(name: string, value: number | undefined): ValidationResult | null {
|
|
38
|
+
if (value === undefined) return null;
|
|
39
|
+
if (!isPositiveInteger(value)) {
|
|
40
|
+
return fail(`${name} must be a positive integer (received ${value})`);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validateDomainList(name: string, list: string[] | undefined): ValidationResult | null {
|
|
46
|
+
if (list === undefined) return null;
|
|
47
|
+
for (const entry of list) {
|
|
48
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
49
|
+
return fail(`${name} must not contain empty or whitespace-only entries`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateUserLocation(loc: WebSearchUserLocation | undefined): ValidationResult | null {
|
|
56
|
+
if (loc === undefined) return null;
|
|
57
|
+
if (loc.type !== "approximate") {
|
|
58
|
+
return fail(`user_location.type must be "approximate" (received "${String(loc.type)}")`);
|
|
59
|
+
}
|
|
60
|
+
if (loc.country !== undefined) {
|
|
61
|
+
if (typeof loc.country !== "string" || !ISO_ALPHA2.test(loc.country)) {
|
|
62
|
+
return fail(
|
|
63
|
+
`user_location.country must be an ISO 3166-1 alpha-2 code (e.g. "US", "JP", "GB") (received "${String(loc.country)}")`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (loc.city !== undefined && (typeof loc.city !== "string" || loc.city.trim().length === 0)) {
|
|
68
|
+
return fail(`user_location.city must be a non-empty string`);
|
|
69
|
+
}
|
|
70
|
+
if (loc.region !== undefined && (typeof loc.region !== "string" || loc.region.trim().length === 0)) {
|
|
71
|
+
return fail(`user_location.region must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
if (loc.timezone !== undefined && (typeof loc.timezone !== "string" || loc.timezone.trim().length === 0)) {
|
|
74
|
+
return fail(`user_location.timezone must be a non-empty string`);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateWebSearchParams(params: WebSearchParams): ValidationResult {
|
|
80
|
+
if (typeof params.query !== "string" || params.query.trim().length === 0) {
|
|
81
|
+
return fail("query must be a non-empty string");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (params.recency !== undefined && !RECENCY_VALUES.includes(params.recency)) {
|
|
85
|
+
return fail(
|
|
86
|
+
`recency must be one of ${RECENCY_VALUES.map(v => `"${v}"`).join(", ")} (received "${String(params.recency)}")`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const positiveIntegerFields: [string, number | undefined][] = [
|
|
91
|
+
["num_search_results", params.num_search_results],
|
|
92
|
+
["limit", params.limit],
|
|
93
|
+
["max_tokens", params.max_tokens],
|
|
94
|
+
["max_uses", params.max_uses],
|
|
95
|
+
];
|
|
96
|
+
for (const [name, value] of positiveIntegerFields) {
|
|
97
|
+
const result = validatePositiveInteger(name, value);
|
|
98
|
+
if (result) return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (params.temperature !== undefined) {
|
|
102
|
+
if (typeof params.temperature !== "number" || params.temperature < 0 || params.temperature > 2) {
|
|
103
|
+
return fail(`temperature must be a number between 0 and 2 (received ${params.temperature})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const [name, list] of [
|
|
108
|
+
["allowed_domains", params.allowed_domains],
|
|
109
|
+
["blocked_domains", params.blocked_domains],
|
|
110
|
+
] as const) {
|
|
111
|
+
const result = validateDomainList(name, list);
|
|
112
|
+
if (result) return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const locResult = validateUserLocation(params.user_location);
|
|
116
|
+
if (locResult) return locResult;
|
|
117
|
+
|
|
118
|
+
return { valid: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function normalizeUserLocation(loc: WebSearchUserLocation | undefined): WebSearchUserLocation | undefined {
|
|
122
|
+
if (loc === undefined) return undefined;
|
|
123
|
+
const next: WebSearchUserLocation = { type: loc.type };
|
|
124
|
+
if (loc.city !== undefined) next.city = loc.city;
|
|
125
|
+
if (loc.region !== undefined) next.region = loc.region;
|
|
126
|
+
if (loc.timezone !== undefined) next.timezone = loc.timezone;
|
|
127
|
+
if (loc.country !== undefined) next.country = loc.country.toUpperCase();
|
|
128
|
+
return next;
|
|
129
|
+
}
|