@demigodmode/pi-web-agent 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/factory.d.ts +15 -0
- package/dist/backends/factory.js +118 -91
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +33 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +111 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +14 -1
- package/dist/commands/web-agent-config.js +75 -3
- package/dist/extension.js +47 -3
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.js +64 -45
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +10 -2
- package/dist/fetch/headless-fetch.js +181 -9
- package/dist/fetch/http-fetch.js +16 -1
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +23 -0
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +21 -1
- package/dist/orchestration/research-orchestrator.js +40 -7
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.js +38 -3
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +6 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +1 -2
- package/dist/search/youcom.js +15 -76
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +40 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,20 @@ The format is intentionally simple and release-oriented.
|
|
|
18
18
|
### Breaking
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [1.12.0] - 2026-09-17
|
|
22
|
+
### Added
|
|
23
|
+
- A network allow list, under Settings → Backends → Network allow list. web_explore now refuses private and local addresses when the link came from the model or a page it read, so if you genuinely want it to read something on your own network (an internal docs site, a service on localhost), add that range here as a CIDR like `10.0.0.0/24`. Entries are checked when you save, and ones that would allow everything (`0.0.0.0/0`, `::/0`) are rejected, since that would quietly turn the protection off. It is also the fix if every fetch suddenly fails: some proxy apps run in fake-IP mode and make every site look like it lives in `198.18.0.0/15`, and adding that range gets you going again. (#53)
|
|
24
|
+
- A "Trust the upstream proxy to enforce private-address restrictions" setting (`backends.network.trustProxyDns`). With a proxy configured, pi-web-agent normally looks each address up itself and asks the proxy to connect to that exact IP, so the proxy can't send a request somewhere else. Some proxies only accept hostnames, and some networks only resolve names inside the proxy. Turning this on hands the hostname to the proxy instead. It is off by default because it moves the address decision to your proxy, and even when it is on, localhost, private IPs typed straight into a link, and anything your own machine resolves to a private address are still refused. `/web-agent doctor` shows the allow list and this setting. (#53)
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Search and fetch fallback now act on why a backend failed. Rate limits pause that backend for the time the provider asks (capped at 15 minutes), exhausted quotas and bad API keys skip it until settings change, and timeouts or server errors get one retry. An empty result is treated as a real answer instead of a failure, a private-address refusal or an invalid shared proxy setting never falls back to another backend, and answers say when some search backends were unavailable. DuckDuckGo no longer retries on its own. (#55)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- web_explore no longer fetches private, loopback, or link-local addresses when the link came from the model or a page it read. The check happens where connections are opened, through a local guard proxy, so it also covers redirects, DNS changes, and everything a headless page loads. A web page could previously steer it at cloud metadata endpoints or services on your network. Addresses you configured yourself (search backends, SearXNG, Firecrawl, the proxy) are unaffected. If you do need to reach a private range, add it under Settings → Backends → Network allow list. With an upstream proxy that only accepts hostnames, turn on "Trust the upstream proxy to enforce private-address restrictions". (#53)
|
|
31
|
+
|
|
32
|
+
### Breaking
|
|
33
|
+
- None.
|
|
34
|
+
|
|
21
35
|
## [1.11.0] - 2026-09-16
|
|
22
36
|
### Added
|
|
23
37
|
- Optional proxy support. Point web_explore at an HTTP/HTTPS proxy and everything outbound goes through it: search, fetch, Firecrawl, the GitHub/PDF/YouTube readers, doctor health checks, and the headless browser. Off unless you set it, and nothing changes if you don't. Set it in Settings → Backends, and keep credentials in `PI_WEB_AGENT_PROXY_USERNAME` / `PI_WEB_AGENT_PROXY_PASSWORD` rather than in the URL. Thanks to @lo-tp for building this. (#50)
|
|
@@ -34,17 +34,25 @@ export type FetchBackendConfig = {
|
|
|
34
34
|
export type HeadlessBackendConfig = {
|
|
35
35
|
provider: 'local-browser';
|
|
36
36
|
};
|
|
37
|
+
export type NetworkConfig = {
|
|
38
|
+
/** CIDR ranges exempted from the private-address guard (#53). */
|
|
39
|
+
allowRanges?: string[];
|
|
40
|
+
/** Trust the upstream proxy to enforce private-address restrictions. */
|
|
41
|
+
trustProxyDns?: boolean;
|
|
42
|
+
};
|
|
37
43
|
export type BackendConfig = {
|
|
38
44
|
search: SearchBackendConfig;
|
|
39
45
|
fetch: FetchBackendConfig;
|
|
40
46
|
headless: HeadlessBackendConfig;
|
|
41
47
|
proxy?: ProxyConfig;
|
|
48
|
+
network?: NetworkConfig;
|
|
42
49
|
};
|
|
43
50
|
export type BackendConfigOverride = {
|
|
44
51
|
search?: Partial<SearchBackendConfig>;
|
|
45
52
|
fetch?: Partial<FetchBackendConfig>;
|
|
46
53
|
headless?: Partial<HeadlessBackendConfig>;
|
|
47
54
|
proxy?: ProxyConfig;
|
|
55
|
+
network?: NetworkConfig;
|
|
48
56
|
};
|
|
49
57
|
export type BackendConfigFile = {
|
|
50
58
|
backends?: {
|
|
@@ -70,6 +78,10 @@ export type BackendConfigFile = {
|
|
|
70
78
|
username?: unknown;
|
|
71
79
|
password?: unknown;
|
|
72
80
|
};
|
|
81
|
+
network?: {
|
|
82
|
+
allowRanges?: unknown;
|
|
83
|
+
trustProxyDns?: unknown;
|
|
84
|
+
};
|
|
73
85
|
};
|
|
74
86
|
};
|
|
75
87
|
export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
|
|
@@ -88,6 +100,7 @@ export declare function stripProxyCredentials(url: string): string;
|
|
|
88
100
|
*/
|
|
89
101
|
export declare function isValidProxyUrl(url: string): boolean;
|
|
90
102
|
export declare function extractProxyConfig(value: unknown): ProxyConfig | undefined;
|
|
103
|
+
export declare function extractNetworkConfig(value: unknown): NetworkConfig | undefined;
|
|
91
104
|
export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
|
|
92
105
|
export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
|
|
93
106
|
export declare function validateBackendConfig(config: BackendConfig): string[];
|
package/dist/backends/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCidr } from '../fetch/network-guard.js';
|
|
1
2
|
export const DEFAULT_BACKEND_CONFIG = {
|
|
2
3
|
search: { provider: 'duckduckgo' },
|
|
3
4
|
fetch: { provider: 'http' },
|
|
@@ -79,6 +80,19 @@ export function extractProxyConfig(value) {
|
|
|
79
80
|
config.password = raw.password;
|
|
80
81
|
return config;
|
|
81
82
|
}
|
|
83
|
+
export function extractNetworkConfig(value) {
|
|
84
|
+
if (!value || typeof value !== 'object')
|
|
85
|
+
return undefined;
|
|
86
|
+
const raw = value;
|
|
87
|
+
const config = {};
|
|
88
|
+
if (Array.isArray(raw.allowRanges)) {
|
|
89
|
+
// Coerce non-string entries so validation flags them instead of dropping the list.
|
|
90
|
+
config.allowRanges = raw.allowRanges.map((entry) => (typeof entry === 'string' ? entry : String(entry)));
|
|
91
|
+
}
|
|
92
|
+
if (typeof raw.trustProxyDns === 'boolean')
|
|
93
|
+
config.trustProxyDns = raw.trustProxyDns;
|
|
94
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
95
|
+
}
|
|
82
96
|
function extractSearxngOptions(value) {
|
|
83
97
|
if (!value || typeof value !== 'object')
|
|
84
98
|
return undefined;
|
|
@@ -187,6 +201,10 @@ export function extractBackendConfigOverride(file) {
|
|
|
187
201
|
if (proxy) {
|
|
188
202
|
override.proxy = proxy;
|
|
189
203
|
}
|
|
204
|
+
const network = extractNetworkConfig(backends?.network);
|
|
205
|
+
if (network) {
|
|
206
|
+
override.network = network;
|
|
207
|
+
}
|
|
190
208
|
return override;
|
|
191
209
|
}
|
|
192
210
|
export function validateBackendConfig(config) {
|
|
@@ -214,6 +232,18 @@ export function validateBackendConfig(config) {
|
|
|
214
232
|
if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
|
|
215
233
|
issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
|
|
216
234
|
}
|
|
235
|
+
for (const range of config.network?.allowRanges ?? []) {
|
|
236
|
+
const cidr = parseCidr(range);
|
|
237
|
+
if (!cidr) {
|
|
238
|
+
issues.push(`backends.network.allowRanges entry "${range}" is not a valid CIDR range`);
|
|
239
|
+
}
|
|
240
|
+
else if (cidr.prefix === 0) {
|
|
241
|
+
issues.push(`backends.network.allowRanges entry "${range}" allows every address, which turns the guard off; list specific ranges instead`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (config.network?.trustProxyDns && !config.proxy?.url?.trim()) {
|
|
245
|
+
issues.push('backends.network.trustProxyDns has no effect without backends.proxy');
|
|
246
|
+
}
|
|
217
247
|
if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily') {
|
|
218
248
|
issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, or tavily');
|
|
219
249
|
}
|
|
@@ -260,6 +290,17 @@ function mergeFetchConfig(current, override) {
|
|
|
260
290
|
}
|
|
261
291
|
return { ...current, ...override };
|
|
262
292
|
}
|
|
293
|
+
function mergeNetworkConfig(base, layer) {
|
|
294
|
+
if (!layer)
|
|
295
|
+
return base;
|
|
296
|
+
const next = { ...base };
|
|
297
|
+
// A layer's allow list replaces the lower one outright; trust is set independently.
|
|
298
|
+
if (layer.allowRanges)
|
|
299
|
+
next.allowRanges = [...layer.allowRanges];
|
|
300
|
+
if (layer.trustProxyDns !== undefined)
|
|
301
|
+
next.trustProxyDns = layer.trustProxyDns;
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
263
304
|
export function mergeBackendConfigLayers(...layers) {
|
|
264
305
|
return layers.reduce((merged, layer) => ({
|
|
265
306
|
search: mergeSearchConfig(merged.search, layer?.search),
|
|
@@ -269,6 +310,8 @@ export function mergeBackendConfigLayers(...layers) {
|
|
|
269
310
|
? layer.proxy.url === ''
|
|
270
311
|
? undefined // explicit disable overrides any proxy from lower layers
|
|
271
312
|
: { ...merged.proxy, ...layer.proxy }
|
|
272
|
-
: merged.proxy
|
|
313
|
+
: merged.proxy,
|
|
314
|
+
// Replace, don't union: a project list is the whole list for that project.
|
|
315
|
+
network: mergeNetworkConfig(merged.network, layer?.network)
|
|
273
316
|
}), DEFAULT_BACKEND_CONFIG);
|
|
274
317
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
2
|
+
import { type GuardProxy, type GuardProxyOptions } from '../fetch/guard-proxy.js';
|
|
3
|
+
import { type NetworkGuard } from '../fetch/network-guard.js';
|
|
2
4
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
3
5
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
4
6
|
import { createExaSearchTool } from '../search/exa.js';
|
|
5
7
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
6
8
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
9
|
+
import { type PolicyDeps } from './fallback-policy.js';
|
|
10
|
+
import { type ProviderHealth } from './provider-health.js';
|
|
7
11
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
8
12
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
9
13
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
@@ -19,6 +23,8 @@ export type BackendSet = {
|
|
|
19
23
|
headlessFetch: (input: {
|
|
20
24
|
url: string;
|
|
21
25
|
}) => Promise<WebFetchHeadlessResponse>;
|
|
26
|
+
/** Releases the guard proxy and its agents. Idempotent; never starts the proxy. */
|
|
27
|
+
close: () => Promise<void>;
|
|
22
28
|
};
|
|
23
29
|
export type BackendFactoryDeps = {
|
|
24
30
|
createDuckDuckGoSearch?: typeof createWebSearchTool;
|
|
@@ -31,5 +37,14 @@ export type BackendFactoryDeps = {
|
|
|
31
37
|
createFirecrawlFetch?: typeof createFirecrawlFetcher;
|
|
32
38
|
createHeadlessFetch?: typeof createWebFetchHeadlessTool;
|
|
33
39
|
createProxyFetch?: (proxy: ProxyConfig) => typeof fetch;
|
|
40
|
+
networkGuard?: NetworkGuard;
|
|
41
|
+
/** Test seam: the fetch used for model-chosen URLs, before redirect handling. */
|
|
42
|
+
createModelFetch?: (guard: NetworkGuard) => typeof fetch;
|
|
43
|
+
createGuardProxy?: (options: GuardProxyOptions) => Promise<GuardProxy>;
|
|
44
|
+
providerHealth?: ProviderHealth;
|
|
45
|
+
/** Test seam for the retry sleep, jitter, and clock. */
|
|
46
|
+
policy?: Omit<PolicyDeps, 'health'>;
|
|
47
|
+
/** Test seam for the per-call fanout provider timeout. */
|
|
48
|
+
fanoutTimeoutMs?: number;
|
|
34
49
|
};
|
|
35
50
|
export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
|
package/dist/backends/factory.js
CHANGED
|
@@ -2,13 +2,19 @@ import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
|
2
2
|
import { createHttpFetcher } from '../fetch/http-fetch.js';
|
|
3
3
|
import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
|
|
4
4
|
import { headlessFetch } from '../fetch/headless-fetch.js';
|
|
5
|
+
import { createGuardedFetch } from '../fetch/guarded-fetch.js';
|
|
6
|
+
import { startGuardProxy } from '../fetch/guard-proxy.js';
|
|
7
|
+
import { createGuardProxyFetch } from '../fetch/guard-proxy-fetch.js';
|
|
8
|
+
import { createNetworkGuard, findGuardError } from '../fetch/network-guard.js';
|
|
5
9
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
6
10
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
7
11
|
import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
|
|
8
12
|
import { createExaSearchTool } from '../search/exa.js';
|
|
9
13
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
10
14
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
11
|
-
import { createFanoutSearch } from '../search/fanout.js';
|
|
15
|
+
import { createFanoutSearch, FANOUT_PROVIDER_TIMEOUT_MS, withCallTimeout } from '../search/fanout.js';
|
|
16
|
+
import { chainSearch, withFetchPolicy, withSearchPolicy } from './fallback-policy.js';
|
|
17
|
+
import { createProviderHealth } from './provider-health.js';
|
|
12
18
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
13
19
|
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
14
20
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
@@ -27,7 +33,8 @@ function invalidSearxngSearch() {
|
|
|
27
33
|
metadata: { backend: 'searxng', cacheHit: false },
|
|
28
34
|
error: {
|
|
29
35
|
code: 'BACKEND_CONFIG_INVALID',
|
|
30
|
-
message: 'SearXNG search requires backends.search.baseUrl.'
|
|
36
|
+
message: 'SearXNG search requires backends.search.baseUrl.',
|
|
37
|
+
failure: { kind: 'not_configured' }
|
|
31
38
|
}
|
|
32
39
|
};
|
|
33
40
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
@@ -41,47 +48,36 @@ function invalidFirecrawlFetch() {
|
|
|
41
48
|
metadata: { method: 'firecrawl', cacheHit: false },
|
|
42
49
|
error: {
|
|
43
50
|
code: 'BACKEND_CONFIG_INVALID',
|
|
44
|
-
message: 'Firecrawl fetch requires backends.fetch.baseUrl.'
|
|
51
|
+
message: 'Firecrawl fetch requires backends.fetch.baseUrl.',
|
|
52
|
+
failure: { kind: 'not_configured' }
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
55
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Checks the target URL before dispatching to the http fetcher, a content
|
|
60
|
+
* reader, or Firecrawl. Doing it here means a private URL is refused once, in
|
|
61
|
+
* one place, and never reaches Firecrawl or its http fallback (#53).
|
|
62
|
+
*/
|
|
63
|
+
function withTargetGuard(fetchPage, guard, method) {
|
|
51
64
|
return async (input) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function withFetchFallback(primary, fallback) {
|
|
71
|
-
return async (input) => {
|
|
72
|
-
const first = await primary(input);
|
|
73
|
-
if (first.status !== 'error' && first.status !== 'needs_headless')
|
|
74
|
-
return first;
|
|
75
|
-
const second = await fallback(input);
|
|
76
|
-
const result = {
|
|
77
|
-
...second,
|
|
78
|
-
metadata: {
|
|
79
|
-
...second.metadata,
|
|
80
|
-
fallbackFrom: 'firecrawl',
|
|
81
|
-
fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
return { ...result, presentation: buildFetchPresentation(result) };
|
|
65
|
+
try {
|
|
66
|
+
await guard.assertUrlAllowed(input.url);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const blocked = findGuardError(error);
|
|
70
|
+
if (!blocked)
|
|
71
|
+
throw error;
|
|
72
|
+
const result = {
|
|
73
|
+
status: 'error',
|
|
74
|
+
url: input.url,
|
|
75
|
+
metadata: { method, cacheHit: false },
|
|
76
|
+
error: { code: blocked.code, message: blocked.message, failure: { kind: 'guard_refused' } }
|
|
77
|
+
};
|
|
78
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
79
|
+
}
|
|
80
|
+
return fetchPage(input);
|
|
85
81
|
};
|
|
86
82
|
}
|
|
87
83
|
export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
@@ -109,7 +105,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
109
105
|
status: 'error',
|
|
110
106
|
results: [],
|
|
111
107
|
metadata: { backend: config.search.provider, cacheHit: false },
|
|
112
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
108
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
113
109
|
};
|
|
114
110
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
115
111
|
},
|
|
@@ -118,7 +114,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
118
114
|
status: 'error',
|
|
119
115
|
url,
|
|
120
116
|
metadata: { method: 'http', cacheHit: false },
|
|
121
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
117
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
122
118
|
};
|
|
123
119
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
124
120
|
},
|
|
@@ -127,24 +123,71 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
127
123
|
status: 'error',
|
|
128
124
|
url,
|
|
129
125
|
metadata: { method: 'headless', cacheHit: false },
|
|
130
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
126
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
131
127
|
};
|
|
132
128
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
133
|
-
}
|
|
129
|
+
},
|
|
130
|
+
close: async () => undefined
|
|
134
131
|
};
|
|
135
132
|
}
|
|
136
|
-
// When a proxy is configured, every outbound HTTP request
|
|
137
|
-
//
|
|
138
|
-
//
|
|
133
|
+
// When a proxy is configured, every outbound HTTP request goes through it.
|
|
134
|
+
// User-configured endpoints use fetchImpl directly; model-chosen fetches and
|
|
135
|
+
// the headless browser reach it through the guard proxy below.
|
|
139
136
|
const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
|
|
140
137
|
const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
// Model-chosen URLs only. Search APIs and the configured SearXNG/Firecrawl
|
|
139
|
+
// endpoints keep using fetchImpl: the user typed those (#53).
|
|
140
|
+
const networkGuard = deps.networkGuard ?? createNetworkGuard({ allowRanges: config.network?.allowRanges ?? [] });
|
|
141
|
+
// One guard proxy per backend set, started on first use. It is the single
|
|
142
|
+
// place the address policy is enforced, for Node fetches and the browser,
|
|
143
|
+
// and it chains to the user's upstream proxy itself.
|
|
144
|
+
let guardProxy;
|
|
145
|
+
let isClosed = false;
|
|
146
|
+
const getGuardProxy = () => {
|
|
147
|
+
if (isClosed)
|
|
148
|
+
return Promise.reject(new Error('Backend set is closed.'));
|
|
149
|
+
if (guardProxy)
|
|
150
|
+
return guardProxy;
|
|
151
|
+
const started = (deps.createGuardProxy ?? startGuardProxy)({
|
|
152
|
+
guard: networkGuard,
|
|
153
|
+
...(proxy
|
|
154
|
+
? {
|
|
155
|
+
upstream: {
|
|
156
|
+
url: stripProxyCredentials(proxy.url),
|
|
157
|
+
...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
|
|
158
|
+
...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
trustProxyDns: config.network?.trustProxyDns === true
|
|
163
|
+
});
|
|
164
|
+
guardProxy = started;
|
|
165
|
+
// A failed start should not stick around for the rest of the backend
|
|
166
|
+
// set's life: clear it so the next call tries again, unless a newer
|
|
167
|
+
// attempt has already replaced it.
|
|
168
|
+
started.catch(() => {
|
|
169
|
+
if (guardProxy === started)
|
|
170
|
+
guardProxy = undefined;
|
|
171
|
+
});
|
|
172
|
+
return started;
|
|
173
|
+
};
|
|
174
|
+
const modelFetch = deps.createModelFetch
|
|
175
|
+
? deps.createModelFetch(networkGuard)
|
|
176
|
+
: createGuardProxyFetch(getGuardProxy);
|
|
177
|
+
const targetFetch = createGuardedFetch(modelFetch, networkGuard);
|
|
178
|
+
let closing;
|
|
179
|
+
const close = () => (closing ??= (async () => {
|
|
180
|
+
isClosed = true;
|
|
181
|
+
if ('close' in modelFetch)
|
|
182
|
+
await modelFetch.close();
|
|
183
|
+
if (guardProxy) {
|
|
184
|
+
const started = await guardProxy.catch(() => undefined);
|
|
185
|
+
await started?.close().catch(() => undefined);
|
|
146
186
|
}
|
|
147
|
-
|
|
187
|
+
})());
|
|
188
|
+
// One health state per backend set: a rebuilt set (config change) starts fresh.
|
|
189
|
+
const policyDeps = { health: deps.providerHealth ?? createProviderHealth(deps.policy?.now ? { now: deps.policy.now } : {}), ...deps.policy };
|
|
190
|
+
const guarded = (name, search, healthKey) => withSearchPolicy(name, search, policyDeps, healthKey);
|
|
148
191
|
const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
|
|
149
192
|
function buildProviderSearch(name) {
|
|
150
193
|
switch (name) {
|
|
@@ -165,34 +208,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
165
208
|
return createDuckDuckGo();
|
|
166
209
|
}
|
|
167
210
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
: config.search.provider === 'brave'
|
|
173
|
-
? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl })
|
|
174
|
-
: config.search.provider === 'youcom'
|
|
175
|
-
? createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl })
|
|
176
|
-
: config.search.provider === 'exa'
|
|
177
|
-
? createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl })
|
|
178
|
-
: config.search.provider === 'tavily'
|
|
179
|
-
? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl })
|
|
180
|
-
: createDuckDuckGo();
|
|
181
|
-
if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
|
|
182
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'searxng');
|
|
183
|
-
}
|
|
184
|
-
if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
|
|
185
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'brave');
|
|
186
|
-
}
|
|
187
|
-
if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
|
|
188
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'youcom');
|
|
189
|
-
}
|
|
190
|
-
if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
|
|
191
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'exa');
|
|
192
|
-
}
|
|
193
|
-
if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
|
|
194
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'tavily');
|
|
211
|
+
const primarySearch = guarded(config.search.provider, buildProviderSearch(config.search.provider));
|
|
212
|
+
const chain = [primarySearch];
|
|
213
|
+
if (config.search.provider !== 'duckduckgo' && config.search.fallback === 'duckduckgo') {
|
|
214
|
+
chain.push(guarded('duckduckgo', createDuckDuckGo()));
|
|
195
215
|
}
|
|
216
|
+
let search = chainSearch(chain, policyDeps);
|
|
196
217
|
const fanoutConfig = config.search.fanout;
|
|
197
218
|
if (fanoutConfig && fanoutConfig.mode !== 'off') {
|
|
198
219
|
const baseNames = fanoutConfig.providers && fanoutConfig.providers.length > 0
|
|
@@ -203,9 +224,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
203
224
|
? [...baseNames, 'duckduckgo']
|
|
204
225
|
: baseNames;
|
|
205
226
|
const ordered = [config.search.provider, ...providerNames.filter((n) => n !== config.search.provider)].filter((n, i, arr) => arr.indexOf(n) === i);
|
|
227
|
+
const timeoutMs = deps.fanoutTimeoutMs ?? FANOUT_PROVIDER_TIMEOUT_MS;
|
|
206
228
|
search = createFanoutSearch({
|
|
207
|
-
|
|
208
|
-
|
|
229
|
+
// The timeout sits inside the policy so a stalled provider gets its one transient retry.
|
|
230
|
+
providers: ordered.map((name) => ({ name, search: guarded(name, withCallTimeout(buildProviderSearch(name), timeoutMs, name)) })),
|
|
231
|
+
mode: fanoutConfig.mode,
|
|
232
|
+
timeoutMs
|
|
209
233
|
});
|
|
210
234
|
}
|
|
211
235
|
// Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
|
|
@@ -214,11 +238,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
214
238
|
const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
|
|
215
239
|
const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
|
|
216
240
|
if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
|
|
217
|
-
|
|
241
|
+
// chainSearch only falls back on non-terminal failures, so a terminal result or bad_request never reaches keyless Tavily.
|
|
242
|
+
search = chainSearch([search, guarded('tavily', createTavilySearch({ keyless: true, fetchImpl }), 'tavily-keyless')], policyDeps);
|
|
218
243
|
}
|
|
219
|
-
const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl }) });
|
|
220
|
-
|
|
221
|
-
? config.fetch.baseUrl
|
|
244
|
+
const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl: targetFetch }) });
|
|
245
|
+
const fetchPage = config.fetch.provider === 'firecrawl'
|
|
246
|
+
? withFetchPolicy(config.fetch.baseUrl
|
|
222
247
|
? createHttpFetch({
|
|
223
248
|
fetchPage: createFirecrawlFetch({
|
|
224
249
|
baseUrl: config.fetch.baseUrl,
|
|
@@ -227,19 +252,21 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
227
252
|
fetchImpl
|
|
228
253
|
})
|
|
229
254
|
})
|
|
230
|
-
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
|
|
255
|
+
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
|
|
231
256
|
: httpFetch;
|
|
232
|
-
if (config.fetch.provider === 'firecrawl' && config.fetch.fallback === 'http') {
|
|
233
|
-
fetchPage = withFetchFallback(fetchPage, httpFetch);
|
|
234
|
-
}
|
|
235
257
|
const fetchPageWithReaders = createSpecialContentResolver({
|
|
236
|
-
readers: [
|
|
258
|
+
readers: [
|
|
259
|
+
createGithubReader({ fetchImpl: targetFetch }),
|
|
260
|
+
createPdfReader({ fetchImpl: targetFetch }),
|
|
261
|
+
createYoutubeReader({ fetchImpl: targetFetch })
|
|
262
|
+
],
|
|
237
263
|
fallback: fetchPage
|
|
238
264
|
});
|
|
239
|
-
const headlessPage = (url) =>
|
|
265
|
+
const headlessPage = (url) => headlessFetch(url, { guard: networkGuard, guardProxy: getGuardProxy });
|
|
240
266
|
return {
|
|
241
267
|
search,
|
|
242
|
-
fetchPage: fetchPageWithReaders,
|
|
243
|
-
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage })
|
|
268
|
+
fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
|
|
269
|
+
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage }),
|
|
270
|
+
close
|
|
244
271
|
};
|
|
245
272
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FailureInfo, FailureKind, ToolError } from '../types.js';
|
|
2
|
+
/** Terminal failures never retry, never fall back, never escalate to headless (#55). */
|
|
3
|
+
export declare function isTerminalFailure(failure: FailureInfo | undefined): boolean;
|
|
4
|
+
export declare function shouldFallBack(kind: FailureKind): boolean;
|
|
5
|
+
/** An error result's failure. Unclassified errors are treated as bad_response: fall back, no retry, no state. */
|
|
6
|
+
export declare function failureOf(result: {
|
|
7
|
+
status: string;
|
|
8
|
+
error?: ToolError;
|
|
9
|
+
}): FailureInfo | undefined;
|
|
10
|
+
/** RFC 9110 Retry-After: delay-seconds (non-negative integer) or an HTTP-date. */
|
|
11
|
+
export declare function parseRetryAfter(value: string | null | undefined, now: number): number | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TERMINAL_KINDS = new Set(['config_global', 'guard_refused']);
|
|
2
|
+
const NO_FALLBACK_KINDS = new Set(['bad_request', 'config_global', 'guard_refused']);
|
|
3
|
+
/** Terminal failures never retry, never fall back, never escalate to headless (#55). */
|
|
4
|
+
export function isTerminalFailure(failure) {
|
|
5
|
+
return failure !== undefined && TERMINAL_KINDS.has(failure.kind);
|
|
6
|
+
}
|
|
7
|
+
export function shouldFallBack(kind) {
|
|
8
|
+
return !NO_FALLBACK_KINDS.has(kind);
|
|
9
|
+
}
|
|
10
|
+
/** An error result's failure. Unclassified errors are treated as bad_response: fall back, no retry, no state. */
|
|
11
|
+
export function failureOf(result) {
|
|
12
|
+
if (result.status !== 'error')
|
|
13
|
+
return undefined;
|
|
14
|
+
return result.error?.failure ?? { kind: 'bad_response' };
|
|
15
|
+
}
|
|
16
|
+
/** RFC 9110 Retry-After: delay-seconds (non-negative integer) or an HTTP-date. */
|
|
17
|
+
export function parseRetryAfter(value, now) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return undefined;
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (/^\d+$/.test(trimmed)) {
|
|
22
|
+
const ms = Number(trimmed) * 1000;
|
|
23
|
+
// A huge digit string overflows to Infinity; keep it finite (and JSON-safe) so the
|
|
24
|
+
// cooldown clamps to the maximum instead of falling back to the default.
|
|
25
|
+
return Number.isFinite(ms) ? Math.min(ms, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
|
|
26
|
+
}
|
|
27
|
+
// IMF-fixdate only (RFC 9110 preferred form). V8's Date.parse accepts things like "-5" or "abc 2099".
|
|
28
|
+
if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed))
|
|
29
|
+
return undefined;
|
|
30
|
+
const at = Date.parse(trimmed);
|
|
31
|
+
if (Number.isNaN(at) || at <= now)
|
|
32
|
+
return undefined;
|
|
33
|
+
return at - now;
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
2
|
+
import type { ProviderHealth } from './provider-health.js';
|
|
3
|
+
export declare const RETRY_BASE_MS = 500;
|
|
4
|
+
export declare const RETRY_JITTER_MS = 250;
|
|
5
|
+
export type PolicyDeps = {
|
|
6
|
+
health: ProviderHealth;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
sleep?: (ms: number) => Promise<void>;
|
|
9
|
+
random?: () => number;
|
|
10
|
+
};
|
|
11
|
+
type Search = (input: {
|
|
12
|
+
query: string;
|
|
13
|
+
}) => Promise<WebSearchResponse>;
|
|
14
|
+
type FetchPage = (input: {
|
|
15
|
+
url: string;
|
|
16
|
+
}) => Promise<WebFetchResponse>;
|
|
17
|
+
/**
|
|
18
|
+
* One provider under the #55 policy: skip when cooling down or disabled,
|
|
19
|
+
* retry exactly once on transient, record state. Never falls back itself.
|
|
20
|
+
*/
|
|
21
|
+
export declare function withSearchPolicy(name: SearchProviderName, search: Search, deps: PolicyDeps, healthKey?: string): Search;
|
|
22
|
+
/**
|
|
23
|
+
* Tries providers in order under the precedence in the #55 spec: terminal and
|
|
24
|
+
* bad_request failures stop the chain; a result or a valid empty response is
|
|
25
|
+
* returned; everything else falls back.
|
|
26
|
+
*/
|
|
27
|
+
export declare function chainSearch(providers: Search[], deps: PolicyDeps): Search;
|
|
28
|
+
/**
|
|
29
|
+
* Firecrawl under the policy, with the optional HTTP fallback. The HTTP fetcher
|
|
30
|
+
* talks to the model-chosen site, not a service, so it has no provider health.
|
|
31
|
+
*/
|
|
32
|
+
export declare function withFetchPolicy(primary: FetchPage, fallback: FetchPage | undefined, deps: PolicyDeps, healthKey?: string): FetchPage;
|
|
33
|
+
export {};
|