@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +19 -4
  3. package/dist/backends/config.d.ts +40 -0
  4. package/dist/backends/config.js +140 -1
  5. package/dist/backends/factory.d.ts +17 -1
  6. package/dist/backends/factory.js +170 -85
  7. package/dist/backends/failure.d.ts +11 -0
  8. package/dist/backends/failure.js +34 -0
  9. package/dist/backends/fallback-policy.d.ts +33 -0
  10. package/dist/backends/fallback-policy.js +239 -0
  11. package/dist/backends/provider-failure.d.ts +21 -0
  12. package/dist/backends/provider-failure.js +111 -0
  13. package/dist/backends/provider-health.d.ts +29 -0
  14. package/dist/backends/provider-health.js +49 -0
  15. package/dist/commands/web-agent-config.d.ts +17 -1
  16. package/dist/commands/web-agent-config.js +131 -7
  17. package/dist/extension.d.ts +1 -0
  18. package/dist/extension.js +49 -3
  19. package/dist/fetch/destination-policy.d.ts +32 -0
  20. package/dist/fetch/destination-policy.js +24 -0
  21. package/dist/fetch/firecrawl-fetch.js +64 -45
  22. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  23. package/dist/fetch/guard-proxy-fetch.js +82 -0
  24. package/dist/fetch/guard-proxy.d.ts +58 -0
  25. package/dist/fetch/guard-proxy.js +420 -0
  26. package/dist/fetch/guarded-fetch.d.ts +7 -0
  27. package/dist/fetch/guarded-fetch.js +75 -0
  28. package/dist/fetch/headless-fetch.d.ts +17 -2
  29. package/dist/fetch/headless-fetch.js +181 -9
  30. package/dist/fetch/http-fetch.js +16 -1
  31. package/dist/fetch/network-guard.d.ts +82 -0
  32. package/dist/fetch/network-guard.js +275 -0
  33. package/dist/fetch/proxy-fetch.d.ts +22 -0
  34. package/dist/fetch/proxy-fetch.js +46 -0
  35. package/dist/jiti-compat-run.d.ts +1 -0
  36. package/dist/jiti-compat-run.js +9 -0
  37. package/dist/jiti-compat.d.ts +32 -0
  38. package/dist/jiti-compat.js +215 -0
  39. package/dist/orchestration/answer-synthesizer.js +2 -0
  40. package/dist/orchestration/evidence-quality.d.ts +3 -2
  41. package/dist/orchestration/evidence-quality.js +2 -1
  42. package/dist/orchestration/index.d.ts +23 -0
  43. package/dist/orchestration/index.js +9 -2
  44. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  45. package/dist/orchestration/research-orchestrator.js +40 -7
  46. package/dist/orchestration/research-types.d.ts +13 -1
  47. package/dist/orchestration/research-worker.js +38 -3
  48. package/dist/orchestration/stop-decider.js +3 -1
  49. package/dist/presentation/config-store.js +10 -0
  50. package/dist/presentation/explore-presentation.js +3 -1
  51. package/dist/presentation/fetch-presentation.js +16 -9
  52. package/dist/presentation/search-presentation.d.ts +2 -1
  53. package/dist/presentation/search-presentation.js +13 -1
  54. package/dist/readers/youtube-reader.d.ts +3 -1
  55. package/dist/readers/youtube-reader.js +11 -3
  56. package/dist/search/brave.d.ts +1 -2
  57. package/dist/search/brave.js +23 -80
  58. package/dist/search/duckduckgo.d.ts +7 -3
  59. package/dist/search/duckduckgo.js +17 -18
  60. package/dist/search/exa.d.ts +1 -2
  61. package/dist/search/exa.js +15 -76
  62. package/dist/search/fanout.d.ts +12 -0
  63. package/dist/search/fanout.js +86 -47
  64. package/dist/search/json-provider.d.ts +32 -0
  65. package/dist/search/json-provider.js +76 -0
  66. package/dist/search/searxng.d.ts +1 -2
  67. package/dist/search/searxng.js +15 -57
  68. package/dist/search/tavily.d.ts +1 -2
  69. package/dist/search/tavily.js +17 -74
  70. package/dist/search/youcom.d.ts +1 -2
  71. package/dist/search/youcom.js +15 -76
  72. package/dist/tools/web-explore.d.ts +9 -0
  73. package/dist/tools/web-explore.js +16 -2
  74. package/dist/tools/web-search.js +41 -103
  75. package/dist/types.d.ts +40 -0
  76. package/package.json +4 -3
  77. package/scripts/patch-jiti-compat.mjs +52 -8
@@ -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
- function withSearchFallback(primary, fallback, fallbackFrom) {
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
- const first = await primary(input);
49
- if (first.status !== 'error')
50
- return first;
51
- const second = await fallback(input);
52
- const result = {
53
- ...second,
54
- metadata: {
55
- ...second.metadata,
56
- fallbackFrom,
57
- fallbackReason: first.error?.message ?? `${fallbackFrom} search failed.`,
58
- // Keep the primary's fanout provenance (which providers were tried/skipped) even though
59
- // the answer came from the fallback backend.
60
- ...(first.metadata.fanout ? { fanout: first.metadata.fanout } : {})
61
- }
62
- };
63
- return { ...result, presentation: buildSearchPresentation(result) };
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 createDuckDuckGoSearch();
208
+ return createDuckDuckGo();
110
209
  }
111
210
  }
112
- let search = config.search.provider === 'searxng'
113
- ? config.search.baseUrl
114
- ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
115
- : invalidSearxngSearch()
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
- providers: ordered.map((name) => ({ name, search: buildProviderSearch(name) })),
152
- mode: fanoutConfig.mode
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
- search = withSearchFallback(search, createTavilySearch({ keyless: true }), 'duckduckgo');
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
- let fetchPage = config.fetch.provider === 'firecrawl'
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: [createGithubReader(), createPdfReader(), createYoutubeReader()],
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
+ }