@demigodmode/pi-web-agent 1.10.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 +27 -0
- package/README.md +19 -4
- package/dist/backends/config.d.ts +40 -0
- package/dist/backends/config.js +140 -1
- package/dist/backends/factory.d.ts +17 -1
- package/dist/backends/factory.js +170 -85
- 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 +17 -1
- package/dist/commands/web-agent-config.js +131 -7
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +49 -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 +17 -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/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -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 +10 -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/readers/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- 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 +4 -3
- package/scripts/patch-jiti-compat.mjs +52 -8
package/dist/backends/factory.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
2
|
+
import { createHttpFetcher } from '../fetch/http-fetch.js';
|
|
3
|
+
import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
|
|
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';
|
|
2
9
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
3
10
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
11
|
+
import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
|
|
4
12
|
import { createExaSearchTool } from '../search/exa.js';
|
|
5
13
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
6
14
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
7
|
-
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';
|
|
8
18
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
9
19
|
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
10
20
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
11
21
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
12
22
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
13
|
-
import { DEFAULT_BACKEND_CONFIG, usableSearchProviders } from './config.js';
|
|
23
|
+
import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, stripProxyCredentials, usableSearchProviders } from './config.js';
|
|
14
24
|
import { createSpecialContentResolver } from '../readers/resolver.js';
|
|
15
25
|
import { createGithubReader } from '../readers/github-reader.js';
|
|
16
26
|
import { createPdfReader } from '../readers/pdf-reader.js';
|
|
@@ -23,7 +33,8 @@ function invalidSearxngSearch() {
|
|
|
23
33
|
metadata: { backend: 'searxng', cacheHit: false },
|
|
24
34
|
error: {
|
|
25
35
|
code: 'BACKEND_CONFIG_INVALID',
|
|
26
|
-
message: 'SearXNG search requires backends.search.baseUrl.'
|
|
36
|
+
message: 'SearXNG search requires backends.search.baseUrl.',
|
|
37
|
+
failure: { kind: 'not_configured' }
|
|
27
38
|
}
|
|
28
39
|
};
|
|
29
40
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
@@ -37,47 +48,36 @@ function invalidFirecrawlFetch() {
|
|
|
37
48
|
metadata: { method: 'firecrawl', cacheHit: false },
|
|
38
49
|
error: {
|
|
39
50
|
code: 'BACKEND_CONFIG_INVALID',
|
|
40
|
-
message: 'Firecrawl fetch requires backends.fetch.baseUrl.'
|
|
51
|
+
message: 'Firecrawl fetch requires backends.fetch.baseUrl.',
|
|
52
|
+
failure: { kind: 'not_configured' }
|
|
41
53
|
}
|
|
42
54
|
};
|
|
43
55
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
44
56
|
};
|
|
45
57
|
}
|
|
46
|
-
|
|
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) {
|
|
47
64
|
return async (input) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
function withFetchFallback(primary, fallback) {
|
|
67
|
-
return async (input) => {
|
|
68
|
-
const first = await primary(input);
|
|
69
|
-
if (first.status !== 'error' && first.status !== 'needs_headless')
|
|
70
|
-
return first;
|
|
71
|
-
const second = await fallback(input);
|
|
72
|
-
const result = {
|
|
73
|
-
...second,
|
|
74
|
-
metadata: {
|
|
75
|
-
...second.metadata,
|
|
76
|
-
fallbackFrom: 'firecrawl',
|
|
77
|
-
fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
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);
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
@@ -90,53 +90,130 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
90
90
|
const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
|
|
91
91
|
const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
|
|
92
92
|
const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
|
|
93
|
+
const makeProxyFetch = deps.createProxyFetch ?? createProxyFetch;
|
|
94
|
+
// A blank url is the "disable proxy" marker: treat it as no proxy at all.
|
|
95
|
+
const proxy = config.proxy && config.proxy.url.trim() !== '' ? config.proxy : undefined;
|
|
96
|
+
// A configured proxy whose url fails validation must not be silently ignored
|
|
97
|
+
// — that would send traffic direct to the websites. Every request errors out
|
|
98
|
+
// instead. No connectivity check is needed: the url itself is the problem.
|
|
99
|
+
if (proxy && !isValidProxyUrl(proxy.url)) {
|
|
100
|
+
const message = `backends.proxy.url (${proxy.url}) is not a valid http or https URL. ` +
|
|
101
|
+
'Web requests are blocked until it is fixed; set backends.proxy.url to "" to disable the proxy.';
|
|
102
|
+
return {
|
|
103
|
+
search: async () => {
|
|
104
|
+
const result = {
|
|
105
|
+
status: 'error',
|
|
106
|
+
results: [],
|
|
107
|
+
metadata: { backend: config.search.provider, cacheHit: false },
|
|
108
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
109
|
+
};
|
|
110
|
+
return { ...result, presentation: buildSearchPresentation(result) };
|
|
111
|
+
},
|
|
112
|
+
fetchPage: async ({ url }) => {
|
|
113
|
+
const result = {
|
|
114
|
+
status: 'error',
|
|
115
|
+
url,
|
|
116
|
+
metadata: { method: 'http', cacheHit: false },
|
|
117
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
118
|
+
};
|
|
119
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
120
|
+
},
|
|
121
|
+
headlessFetch: async ({ url }) => {
|
|
122
|
+
const result = {
|
|
123
|
+
status: 'error',
|
|
124
|
+
url,
|
|
125
|
+
metadata: { method: 'headless', cacheHit: false },
|
|
126
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
127
|
+
};
|
|
128
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
129
|
+
},
|
|
130
|
+
close: async () => undefined
|
|
131
|
+
};
|
|
132
|
+
}
|
|
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.
|
|
136
|
+
const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
|
|
137
|
+
const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
|
|
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);
|
|
186
|
+
}
|
|
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);
|
|
191
|
+
const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
|
|
93
192
|
function buildProviderSearch(name) {
|
|
94
193
|
switch (name) {
|
|
95
194
|
case 'searxng':
|
|
96
195
|
return config.search.baseUrl
|
|
97
|
-
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
|
|
196
|
+
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
|
|
98
197
|
: invalidSearxngSearch();
|
|
99
198
|
case 'brave':
|
|
100
|
-
return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY });
|
|
199
|
+
return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl });
|
|
101
200
|
case 'youcom':
|
|
102
|
-
return createYouComSearch({ apiKey: process.env.YDC_API_KEY });
|
|
201
|
+
return createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl });
|
|
103
202
|
case 'exa':
|
|
104
|
-
return createExaSearch({ apiKey: process.env.EXA_API_KEY });
|
|
203
|
+
return createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl });
|
|
105
204
|
case 'tavily':
|
|
106
|
-
return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
|
|
205
|
+
return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl });
|
|
107
206
|
case 'duckduckgo':
|
|
108
207
|
default:
|
|
109
|
-
return
|
|
208
|
+
return createDuckDuckGo();
|
|
110
209
|
}
|
|
111
210
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
: config.search.provider === 'brave'
|
|
117
|
-
? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY })
|
|
118
|
-
: config.search.provider === 'youcom'
|
|
119
|
-
? createYouComSearch({ apiKey: process.env.YDC_API_KEY })
|
|
120
|
-
: config.search.provider === 'exa'
|
|
121
|
-
? createExaSearch({ apiKey: process.env.EXA_API_KEY })
|
|
122
|
-
: config.search.provider === 'tavily'
|
|
123
|
-
? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY })
|
|
124
|
-
: createDuckDuckGoSearch();
|
|
125
|
-
if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
|
|
126
|
-
search = withSearchFallback(search, createDuckDuckGoSearch(), 'searxng');
|
|
127
|
-
}
|
|
128
|
-
if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
|
|
129
|
-
search = withSearchFallback(search, createDuckDuckGoSearch(), 'brave');
|
|
130
|
-
}
|
|
131
|
-
if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
|
|
132
|
-
search = withSearchFallback(search, createDuckDuckGoSearch(), 'youcom');
|
|
133
|
-
}
|
|
134
|
-
if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
|
|
135
|
-
search = withSearchFallback(search, createDuckDuckGoSearch(), 'exa');
|
|
136
|
-
}
|
|
137
|
-
if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
|
|
138
|
-
search = withSearchFallback(search, createDuckDuckGoSearch(), '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()));
|
|
139
215
|
}
|
|
216
|
+
let search = chainSearch(chain, policyDeps);
|
|
140
217
|
const fanoutConfig = config.search.fanout;
|
|
141
218
|
if (fanoutConfig && fanoutConfig.mode !== 'off') {
|
|
142
219
|
const baseNames = fanoutConfig.providers && fanoutConfig.providers.length > 0
|
|
@@ -147,9 +224,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
147
224
|
? [...baseNames, 'duckduckgo']
|
|
148
225
|
: baseNames;
|
|
149
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;
|
|
150
228
|
search = createFanoutSearch({
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
153
233
|
});
|
|
154
234
|
}
|
|
155
235
|
// Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
|
|
@@ -158,30 +238,35 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
158
238
|
const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
|
|
159
239
|
const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
|
|
160
240
|
if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
|
|
161
|
-
|
|
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);
|
|
162
243
|
}
|
|
163
|
-
const httpFetch = createHttpFetch();
|
|
164
|
-
|
|
165
|
-
? config.fetch.baseUrl
|
|
244
|
+
const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl: targetFetch }) });
|
|
245
|
+
const fetchPage = config.fetch.provider === 'firecrawl'
|
|
246
|
+
? withFetchPolicy(config.fetch.baseUrl
|
|
166
247
|
? createHttpFetch({
|
|
167
248
|
fetchPage: createFirecrawlFetch({
|
|
168
249
|
baseUrl: config.fetch.baseUrl,
|
|
169
250
|
apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
|
|
170
|
-
options: config.fetch.options
|
|
251
|
+
options: config.fetch.options,
|
|
252
|
+
fetchImpl
|
|
171
253
|
})
|
|
172
254
|
})
|
|
173
|
-
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
|
|
255
|
+
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
|
|
174
256
|
: httpFetch;
|
|
175
|
-
if (config.fetch.provider === 'firecrawl' && config.fetch.fallback === 'http') {
|
|
176
|
-
fetchPage = withFetchFallback(fetchPage, httpFetch);
|
|
177
|
-
}
|
|
178
257
|
const fetchPageWithReaders = createSpecialContentResolver({
|
|
179
|
-
readers: [
|
|
258
|
+
readers: [
|
|
259
|
+
createGithubReader({ fetchImpl: targetFetch }),
|
|
260
|
+
createPdfReader({ fetchImpl: targetFetch }),
|
|
261
|
+
createYoutubeReader({ fetchImpl: targetFetch })
|
|
262
|
+
],
|
|
180
263
|
fallback: fetchPage
|
|
181
264
|
});
|
|
265
|
+
const headlessPage = (url) => headlessFetch(url, { guard: networkGuard, guardProxy: getGuardProxy });
|
|
182
266
|
return {
|
|
183
267
|
search,
|
|
184
|
-
fetchPage: fetchPageWithReaders,
|
|
185
|
-
headlessFetch: createHeadlessFetch()
|
|
268
|
+
fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
|
|
269
|
+
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage }),
|
|
270
|
+
close
|
|
186
271
|
};
|
|
187
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 {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
2
|
+
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
3
|
+
import { failureOf, shouldFallBack } from './failure.js';
|
|
4
|
+
export const RETRY_BASE_MS = 500;
|
|
5
|
+
export const RETRY_JITTER_MS = 250;
|
|
6
|
+
const USER_FIXABLE_KINDS = new Set(['not_configured', 'auth_failed', 'quota_exhausted']);
|
|
7
|
+
/** Only user-fixable failures keep the provider's message; everything else stays message-free. */
|
|
8
|
+
function detailFor(failure, message) {
|
|
9
|
+
return message && USER_FIXABLE_KINDS.has(failure.kind) ? message : undefined;
|
|
10
|
+
}
|
|
11
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
function retryDelay(deps) {
|
|
13
|
+
return RETRY_BASE_MS + Math.floor((deps.random ?? Math.random)() * RETRY_JITTER_MS);
|
|
14
|
+
}
|
|
15
|
+
function skipAttempt(backend, state) {
|
|
16
|
+
return {
|
|
17
|
+
backend,
|
|
18
|
+
outcome: 'skipped',
|
|
19
|
+
failure: state.failure,
|
|
20
|
+
skipReason: state.state,
|
|
21
|
+
...(state.state === 'cooling_down' ? { cooldownUntil: state.until } : {}),
|
|
22
|
+
...(state.detail ? { detail: state.detail } : {})
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function skipMessage(backend, state) {
|
|
26
|
+
const base = state.state === 'cooling_down'
|
|
27
|
+
? `${backend} is cooling down after ${state.failure.kind} until ${new Date(state.until).toISOString()}.`
|
|
28
|
+
: `${backend} is disabled for this session after ${state.failure.kind}.`;
|
|
29
|
+
return state.detail ? `${base} ${state.detail}` : base;
|
|
30
|
+
}
|
|
31
|
+
function failedAttempt(backend, failure, state, detail) {
|
|
32
|
+
return {
|
|
33
|
+
backend,
|
|
34
|
+
outcome: 'failed',
|
|
35
|
+
failure,
|
|
36
|
+
...(state.state === 'cooling_down' ? { cooldownUntil: state.until } : {}),
|
|
37
|
+
...(detail ? { detail } : {})
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One provider under the #55 policy: skip when cooling down or disabled,
|
|
42
|
+
* retry exactly once on transient, record state. Never falls back itself.
|
|
43
|
+
*/
|
|
44
|
+
export function withSearchPolicy(name, search, deps, healthKey = name) {
|
|
45
|
+
return async (input) => {
|
|
46
|
+
const state = deps.health.get(healthKey);
|
|
47
|
+
if (state.state !== 'available') {
|
|
48
|
+
return {
|
|
49
|
+
status: 'error',
|
|
50
|
+
results: [],
|
|
51
|
+
metadata: { backend: name, cacheHit: false, attempts: [skipAttempt(name, state)] },
|
|
52
|
+
error: { code: 'BACKEND_UNAVAILABLE', message: skipMessage(name, state), failure: state.failure }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const attempts = [];
|
|
56
|
+
let result = await search(input);
|
|
57
|
+
let failure = failureOf(result);
|
|
58
|
+
if (failure?.kind === 'transient') {
|
|
59
|
+
attempts.push({ backend: name, outcome: 'retried', failure });
|
|
60
|
+
await (deps.sleep ?? defaultSleep)(retryDelay(deps));
|
|
61
|
+
result = await search(input);
|
|
62
|
+
failure = failureOf(result);
|
|
63
|
+
}
|
|
64
|
+
if (failure) {
|
|
65
|
+
const detail = detailFor(failure, result.error?.message);
|
|
66
|
+
attempts.push(failedAttempt(name, failure, deps.health.record(healthKey, failure, detail), detail));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
attempts.push({ backend: name, outcome: result.results.length > 0 ? 'results' : 'empty' });
|
|
70
|
+
}
|
|
71
|
+
return { ...result, metadata: { ...result.metadata, attempts: [...(result.metadata.attempts ?? []), ...attempts] } };
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function unavailableMessage(attempts, messages = new Map()) {
|
|
75
|
+
const byProvider = new Map();
|
|
76
|
+
for (const attempt of attempts) {
|
|
77
|
+
if (attempt.outcome === 'failed' || attempt.outcome === 'skipped')
|
|
78
|
+
byProvider.set(attempt.backend, attempt);
|
|
79
|
+
}
|
|
80
|
+
const entries = [...byProvider.values()].sort((a, b) => {
|
|
81
|
+
const aUntil = a.cooldownUntil ?? Number.POSITIVE_INFINITY;
|
|
82
|
+
const bUntil = b.cooldownUntil ?? Number.POSITIVE_INFINITY;
|
|
83
|
+
return aUntil - bUntil;
|
|
84
|
+
});
|
|
85
|
+
const parts = entries.map((attempt) => {
|
|
86
|
+
const kind = attempt.failure?.kind ?? 'bad_response';
|
|
87
|
+
if (attempt.cooldownUntil !== undefined) {
|
|
88
|
+
return `${attempt.backend} ${kind} (available again at ${new Date(attempt.cooldownUntil).toISOString()})`;
|
|
89
|
+
}
|
|
90
|
+
// Keep the provider's own hint for problems the user has to fix, e.g. a missing key or base URL.
|
|
91
|
+
const hint = USER_FIXABLE_KINDS.has(kind)
|
|
92
|
+
? attempt.detail ?? (attempt.outcome === 'failed' ? messages.get(attempt.backend) : undefined)
|
|
93
|
+
: undefined;
|
|
94
|
+
return hint ? `${attempt.backend} ${kind} (${hint})` : `${attempt.backend} ${kind}`;
|
|
95
|
+
});
|
|
96
|
+
return `No search backend is available: ${parts.join(', ')}.`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A failed link's unavailable providers, from its own attempts: the last failed or skipped
|
|
100
|
+
* attempt per backend with that attempt's kind. A fanout link reports each provider, not the
|
|
101
|
+
* aggregate. Falls back to the aggregate only when the link carries no failed or skipped attempts.
|
|
102
|
+
*/
|
|
103
|
+
function unavailableFor(result, failure) {
|
|
104
|
+
const message = result.error?.message ?? failure.kind;
|
|
105
|
+
const byProvider = new Map();
|
|
106
|
+
for (const entry of result.metadata.coverage?.unavailable ?? []) {
|
|
107
|
+
byProvider.set(entry.provider, { ...entry, message });
|
|
108
|
+
}
|
|
109
|
+
const attempts = result.metadata.attempts ?? [];
|
|
110
|
+
const relevant = attempts.filter((a) => a.outcome === 'failed' || a.outcome === 'skipped');
|
|
111
|
+
if (relevant.length === 0) {
|
|
112
|
+
byProvider.set(result.metadata.backend, { provider: result.metadata.backend, kind: failure.kind, message });
|
|
113
|
+
}
|
|
114
|
+
for (const attempt of relevant) {
|
|
115
|
+
byProvider.delete(attempt.backend); // keep insertion order at the latest attempt
|
|
116
|
+
byProvider.set(attempt.backend, {
|
|
117
|
+
provider: attempt.backend,
|
|
118
|
+
kind: attempt.failure?.kind ?? failure.kind,
|
|
119
|
+
message: attempt.detail ?? message
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return [...byProvider.values()];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Tries providers in order under the precedence in the #55 spec: terminal and
|
|
126
|
+
* bad_request failures stop the chain; a result or a valid empty response is
|
|
127
|
+
* returned; everything else falls back.
|
|
128
|
+
*/
|
|
129
|
+
export function chainSearch(providers, deps) {
|
|
130
|
+
return async (input) => {
|
|
131
|
+
const attempts = [];
|
|
132
|
+
const unavailable = [];
|
|
133
|
+
let lastFailure;
|
|
134
|
+
let firstBackend;
|
|
135
|
+
// Keep an earlier fanout's provenance (which providers were tried) when a later link answers.
|
|
136
|
+
let fanout;
|
|
137
|
+
for (const provider of providers) {
|
|
138
|
+
const result = await provider(input);
|
|
139
|
+
firstBackend ??= result.metadata.backend;
|
|
140
|
+
attempts.push(...(result.metadata.attempts ?? []));
|
|
141
|
+
fanout ??= result.metadata.fanout;
|
|
142
|
+
const failure = failureOf(result);
|
|
143
|
+
if (!failure) {
|
|
144
|
+
const first = unavailable[0];
|
|
145
|
+
const merged = {
|
|
146
|
+
...result,
|
|
147
|
+
metadata: {
|
|
148
|
+
...result.metadata,
|
|
149
|
+
...(fanout && !result.metadata.fanout ? { fanout } : {}),
|
|
150
|
+
attempts,
|
|
151
|
+
...(first
|
|
152
|
+
? {
|
|
153
|
+
fallbackFrom: first.provider,
|
|
154
|
+
fallbackReason: first.message,
|
|
155
|
+
coverage: { partial: true, unavailable: unavailable.map(({ provider, kind }) => ({ provider, kind })) }
|
|
156
|
+
}
|
|
157
|
+
: {})
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
return { ...merged, presentation: buildSearchPresentation(merged) };
|
|
161
|
+
}
|
|
162
|
+
if (!shouldFallBack(failure.kind)) {
|
|
163
|
+
const stopped = { ...result, metadata: { ...result.metadata, attempts } };
|
|
164
|
+
return { ...stopped, presentation: buildSearchPresentation(stopped) };
|
|
165
|
+
}
|
|
166
|
+
for (const entry of unavailableFor(result, failure)) {
|
|
167
|
+
const existing = unavailable.findIndex((u) => u.provider === entry.provider);
|
|
168
|
+
if (existing >= 0)
|
|
169
|
+
unavailable.splice(existing, 1);
|
|
170
|
+
unavailable.push(entry);
|
|
171
|
+
}
|
|
172
|
+
lastFailure = failure;
|
|
173
|
+
}
|
|
174
|
+
const exhausted = {
|
|
175
|
+
status: 'error',
|
|
176
|
+
results: [],
|
|
177
|
+
metadata: { backend: firstBackend ?? 'duckduckgo', cacheHit: false, attempts },
|
|
178
|
+
error: {
|
|
179
|
+
code: 'SEARCH_BACKENDS_UNAVAILABLE',
|
|
180
|
+
message: unavailableMessage(attempts, new Map(unavailable.map((entry) => [entry.provider, entry.message]))),
|
|
181
|
+
failure: lastFailure ?? { kind: 'bad_response' }
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return { ...exhausted, presentation: buildSearchPresentation(exhausted) };
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Firecrawl under the policy, with the optional HTTP fallback. The HTTP fetcher
|
|
189
|
+
* talks to the model-chosen site, not a service, so it has no provider health.
|
|
190
|
+
*/
|
|
191
|
+
export function withFetchPolicy(primary, fallback, deps, healthKey = 'firecrawl') {
|
|
192
|
+
const finish = (result) => ({ ...result, presentation: buildFetchPresentation(result) });
|
|
193
|
+
return async (input) => {
|
|
194
|
+
const attempts = [];
|
|
195
|
+
const state = deps.health.get(healthKey);
|
|
196
|
+
let first;
|
|
197
|
+
if (state.state !== 'available') {
|
|
198
|
+
attempts.push(skipAttempt('firecrawl', state));
|
|
199
|
+
first = {
|
|
200
|
+
status: 'error',
|
|
201
|
+
url: input.url,
|
|
202
|
+
metadata: { method: 'firecrawl', cacheHit: false },
|
|
203
|
+
error: { code: 'BACKEND_UNAVAILABLE', message: skipMessage('firecrawl', state), failure: state.failure }
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
first = await primary(input);
|
|
208
|
+
let failure = failureOf(first);
|
|
209
|
+
if (failure?.kind === 'transient') {
|
|
210
|
+
attempts.push({ backend: 'firecrawl', outcome: 'retried', failure });
|
|
211
|
+
await (deps.sleep ?? defaultSleep)(retryDelay(deps));
|
|
212
|
+
first = await primary(input);
|
|
213
|
+
failure = failureOf(first);
|
|
214
|
+
}
|
|
215
|
+
if (failure) {
|
|
216
|
+
const detail = detailFor(failure, first.error?.message);
|
|
217
|
+
attempts.push(failedAttempt('firecrawl', failure, deps.health.record(healthKey, failure, detail), detail));
|
|
218
|
+
}
|
|
219
|
+
else
|
|
220
|
+
attempts.push({ backend: 'firecrawl', outcome: first.status === 'ok' ? 'results' : 'empty' });
|
|
221
|
+
}
|
|
222
|
+
const failure = failureOf(first);
|
|
223
|
+
const fallBack = first.status === 'needs_headless' || (failure !== undefined && shouldFallBack(failure.kind));
|
|
224
|
+
if (!fallback || !fallBack) {
|
|
225
|
+
return finish({ ...first, metadata: { ...first.metadata, attempts } });
|
|
226
|
+
}
|
|
227
|
+
const second = await fallback(input);
|
|
228
|
+
attempts.push({ backend: 'http', outcome: second.status === 'ok' ? 'results' : 'failed', ...(second.error?.failure ? { failure: second.error.failure } : {}) });
|
|
229
|
+
return finish({
|
|
230
|
+
...second,
|
|
231
|
+
metadata: {
|
|
232
|
+
...second.metadata,
|
|
233
|
+
attempts,
|
|
234
|
+
fallbackFrom: 'firecrawl',
|
|
235
|
+
fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
}
|