@f5xc-salesdemos/xcsh 17.4.2 → 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/modes/components/status-line/segments.ts +2 -2
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/status-line.ts +23 -7
- package/src/modes/interactive-mode.ts +4 -0
- 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",
|
|
@@ -2,7 +2,7 @@ import * as os from "node:os";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
|
|
4
4
|
import { TERMINAL } from "@f5xc-salesdemos/pi-tui";
|
|
5
|
-
import { formatDuration, formatNumber,
|
|
5
|
+
import { formatDuration, formatNumber, relativePathWithinRoot } from "@f5xc-salesdemos/pi-utils";
|
|
6
6
|
import { theme } from "../../../modes/theme/theme";
|
|
7
7
|
import { shortenPath } from "../../../tools/render-utils";
|
|
8
8
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
@@ -115,7 +115,7 @@ const pathSegment: StatusLineSegment = {
|
|
|
115
115
|
render(ctx) {
|
|
116
116
|
const opts = ctx.options.path ?? {};
|
|
117
117
|
|
|
118
|
-
let pwd =
|
|
118
|
+
let pwd = ctx.cwd;
|
|
119
119
|
|
|
120
120
|
if (opts.stripWorkPrefix !== false) {
|
|
121
121
|
pwd = stripDisplayRoot(pwd);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import type { AssistantMessage } from "@f5xc-salesdemos/pi-ai";
|
|
3
3
|
import { type Component, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
-
import { formatCount,
|
|
4
|
+
import { formatCount, getShellPwd } from "@f5xc-salesdemos/pi-utils";
|
|
5
5
|
import { $ } from "bun";
|
|
6
6
|
import { settings } from "../../config/settings";
|
|
7
7
|
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
|
|
8
8
|
import { theme } from "../../modes/theme/theme";
|
|
9
9
|
import type { AgentSession } from "../../session/agent-session";
|
|
10
10
|
import { calculatePromptTokens } from "../../session/compaction/compaction";
|
|
11
|
+
import type { EventBus } from "../../utils/event-bus";
|
|
11
12
|
import * as git from "../../utils/git";
|
|
12
13
|
import { queryGitStatus } from "../../utils/gitstatus";
|
|
13
14
|
import { sanitizeStatusText } from "../shared";
|
|
@@ -52,6 +53,7 @@ export class StatusLineComponent implements Component {
|
|
|
52
53
|
#cachedBranch: string | null | undefined = undefined;
|
|
53
54
|
#cachedBranchRepoId: string | null | undefined = undefined;
|
|
54
55
|
#gitWatcher: fs.FSWatcher | null = null;
|
|
56
|
+
#cwdUnsubscribe: (() => void) | null = null;
|
|
55
57
|
#onBranchChange: (() => void) | null = null;
|
|
56
58
|
#onStatusChanged: (() => void) | null = null;
|
|
57
59
|
#autoCompactEnabled: boolean = true;
|
|
@@ -130,13 +132,22 @@ export class StatusLineComponent implements Component {
|
|
|
130
132
|
this.#setupGitWatcher();
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
watchCwd(eventBus: EventBus): void {
|
|
136
|
+
this.#cwdUnsubscribe?.();
|
|
137
|
+
this.#cwdUnsubscribe = eventBus.on("cwd:changed", () => {
|
|
138
|
+
this.#invalidateGitCaches();
|
|
139
|
+
this.#setupGitWatcher();
|
|
140
|
+
this.#onStatusChanged?.();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
#setupGitWatcher(): void {
|
|
134
145
|
if (this.#gitWatcher) {
|
|
135
146
|
this.#gitWatcher.close();
|
|
136
147
|
this.#gitWatcher = null;
|
|
137
148
|
}
|
|
138
149
|
|
|
139
|
-
const gitHeadPath = git.repo.resolveSync(
|
|
150
|
+
const gitHeadPath = git.repo.resolveSync(getShellPwd())?.headPath ?? null;
|
|
140
151
|
if (!gitHeadPath) return;
|
|
141
152
|
|
|
142
153
|
try {
|
|
@@ -156,6 +167,10 @@ export class StatusLineComponent implements Component {
|
|
|
156
167
|
this.#gitWatcher.close();
|
|
157
168
|
this.#gitWatcher = null;
|
|
158
169
|
}
|
|
170
|
+
if (this.#cwdUnsubscribe) {
|
|
171
|
+
this.#cwdUnsubscribe();
|
|
172
|
+
this.#cwdUnsubscribe = null;
|
|
173
|
+
}
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
invalidate(): void {
|
|
@@ -168,7 +183,7 @@ export class StatusLineComponent implements Component {
|
|
|
168
183
|
this.#cachedPrContext = undefined;
|
|
169
184
|
}
|
|
170
185
|
#getCurrentBranch(): string | null {
|
|
171
|
-
const head = git.head.resolveSync(
|
|
186
|
+
const head = git.head.resolveSync(getShellPwd());
|
|
172
187
|
const gitHeadPath = head?.headPath ?? null;
|
|
173
188
|
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
|
|
174
189
|
return this.#cachedBranch;
|
|
@@ -189,7 +204,7 @@ export class StatusLineComponent implements Component {
|
|
|
189
204
|
if (this.#defaultBranch === undefined) {
|
|
190
205
|
this.#defaultBranch = "main";
|
|
191
206
|
(async () => {
|
|
192
|
-
const resolved = await git.branch.default(
|
|
207
|
+
const resolved = await git.branch.default(getShellPwd());
|
|
193
208
|
if (resolved) {
|
|
194
209
|
this.#defaultBranch = resolved;
|
|
195
210
|
if (this.#onBranchChange) {
|
|
@@ -220,7 +235,7 @@ export class StatusLineComponent implements Component {
|
|
|
220
235
|
(async () => {
|
|
221
236
|
try {
|
|
222
237
|
// Prefer gitstatusd daemon (10x faster than git CLI)
|
|
223
|
-
const gsResult = await queryGitStatus(
|
|
238
|
+
const gsResult = await queryGitStatus(getShellPwd());
|
|
224
239
|
if (gsResult) {
|
|
225
240
|
this.#cachedGitStatus = {
|
|
226
241
|
staged: gsResult.staged,
|
|
@@ -234,7 +249,7 @@ export class StatusLineComponent implements Component {
|
|
|
234
249
|
};
|
|
235
250
|
} else {
|
|
236
251
|
// Fallback to git CLI
|
|
237
|
-
const summary = await git.status.summary(
|
|
252
|
+
const summary = await git.status.summary(getShellPwd());
|
|
238
253
|
this.#cachedGitStatus = summary
|
|
239
254
|
? { ...summary, conflicted: 0, ahead: 0, behind: 0, stashes: 0, action: "" }
|
|
240
255
|
: null;
|
|
@@ -284,7 +299,7 @@ export class StatusLineComponent implements Component {
|
|
|
284
299
|
};
|
|
285
300
|
try {
|
|
286
301
|
// Requires `gh repo set-default` to be configured; fails gracefully if not
|
|
287
|
-
const result = await $`gh pr view --json number,url`.quiet().nothrow();
|
|
302
|
+
const result = await $`gh pr view --json number,url`.cwd(getShellPwd()).quiet().nothrow();
|
|
288
303
|
if (result.exitCode !== 0) {
|
|
289
304
|
setCachedPr(null);
|
|
290
305
|
return;
|
|
@@ -368,6 +383,7 @@ export class StatusLineComponent implements Component {
|
|
|
368
383
|
return {
|
|
369
384
|
session: this.session,
|
|
370
385
|
width,
|
|
386
|
+
cwd: getShellPwd(),
|
|
371
387
|
options: this.#resolveSettings().segmentOptions ?? {},
|
|
372
388
|
planMode: this.#planModeStatus,
|
|
373
389
|
usageStats,
|
|
@@ -408,6 +408,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
408
408
|
this.ui.requestRender();
|
|
409
409
|
});
|
|
410
410
|
|
|
411
|
+
if (this.#eventBus) {
|
|
412
|
+
this.statusLine.watchCwd(this.#eventBus);
|
|
413
|
+
}
|
|
414
|
+
|
|
411
415
|
// Initial top border update
|
|
412
416
|
this.updateEditorTopBorder();
|
|
413
417
|
}
|
|
@@ -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
|
+
}
|