@gotza02/sequential-thinking 2026.2.1 → 2026.2.2
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/dist/tools/web.js +79 -63
- package/dist/web_fallback.test.js +103 -0
- package/package.json +1 -1
package/dist/tools/web.js
CHANGED
|
@@ -9,74 +9,90 @@ export function registerWebTools(server) {
|
|
|
9
9
|
query: z.string().min(1).describe("The search query"),
|
|
10
10
|
provider: z.enum(['brave', 'exa', 'google']).optional().describe("Preferred search provider")
|
|
11
11
|
}, async ({ query, provider }) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
const errors = [];
|
|
13
|
+
// 1. Identify available providers
|
|
14
|
+
const availableProviders = [];
|
|
15
|
+
if (process.env.BRAVE_API_KEY)
|
|
16
|
+
availableProviders.push('brave');
|
|
17
|
+
if (process.env.EXA_API_KEY)
|
|
18
|
+
availableProviders.push('exa');
|
|
19
|
+
if (process.env.GOOGLE_SEARCH_API_KEY && process.env.GOOGLE_SEARCH_CX)
|
|
20
|
+
availableProviders.push('google');
|
|
21
|
+
if (availableProviders.length === 0) {
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text: "Error: No search provider configured. Please set BRAVE_API_KEY, EXA_API_KEY, or GOOGLE_SEARCH_API_KEY." }],
|
|
24
|
+
isError: true
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// 2. Determine execution order
|
|
28
|
+
let attemptOrder = [...availableProviders];
|
|
29
|
+
if (provider) {
|
|
30
|
+
if (availableProviders.includes(provider)) {
|
|
31
|
+
// Move requested provider to the front
|
|
32
|
+
attemptOrder = [provider, ...availableProviders.filter(p => p !== provider)];
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'X-Subscription-Token': process.env.BRAVE_API_KEY,
|
|
31
|
-
'Accept': 'application/json'
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
if (!response.ok)
|
|
35
|
-
throw new Error(`Brave API error: ${response.statusText}`);
|
|
36
|
-
const data = await response.json();
|
|
37
|
-
return { content: [{ type: "text", text: JSON.stringify(data.web?.results || data, null, 2) }] };
|
|
34
|
+
else {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text: `Error: Requested provider '${provider}' is not configured or unavailable.` }],
|
|
37
|
+
isError: true
|
|
38
|
+
};
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
40
|
+
}
|
|
41
|
+
// Default priority if no preference: Brave > Exa > Google (Already respected by push order if mapped correctly, but let's be explicit if needed. Here they are added in that order.)
|
|
42
|
+
// 3. Try providers sequentially
|
|
43
|
+
for (const currentProvider of attemptOrder) {
|
|
44
|
+
try {
|
|
45
|
+
if (currentProvider === 'brave') {
|
|
46
|
+
const response = await fetchWithRetry(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`, {
|
|
47
|
+
headers: {
|
|
48
|
+
'X-Subscription-Token': process.env.BRAVE_API_KEY,
|
|
49
|
+
'Accept': 'application/json'
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
return { content: [{ type: "text", text: JSON.stringify(data.web?.results || data, null, 2) }] };
|
|
56
|
+
}
|
|
57
|
+
if (currentProvider === 'exa') {
|
|
58
|
+
const response = await fetchWithRetry('https://api.exa.ai/search', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'x-api-key': process.env.EXA_API_KEY,
|
|
62
|
+
'Content-Type': 'application/json'
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({ query, numResults: 5 })
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
throw new Error(`Exa API error: ${response.status} ${response.statusText}`);
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
return { content: [{ type: "text", text: JSON.stringify(data.results || data, null, 2) }] };
|
|
70
|
+
}
|
|
71
|
+
if (currentProvider === 'google') {
|
|
72
|
+
const response = await fetchWithRetry(`https://www.googleapis.com/customsearch/v1?key=${process.env.GOOGLE_SEARCH_API_KEY}&cx=${process.env.GOOGLE_SEARCH_CX}&q=${encodeURIComponent(query)}&num=5`);
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
throw new Error(`Google API error: ${response.status} ${response.statusText}`);
|
|
75
|
+
const data = await response.json();
|
|
76
|
+
// Extract relevant fields to keep output clean
|
|
77
|
+
const results = data.items?.map((item) => ({
|
|
78
|
+
title: item.title,
|
|
79
|
+
link: item.link,
|
|
80
|
+
snippet: item.snippet
|
|
81
|
+
})) || [];
|
|
82
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
83
|
+
}
|
|
54
84
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
throw new Error("GOOGLE_SEARCH_CX (Search Engine ID) not found");
|
|
60
|
-
const response = await fetchWithRetry(`https://www.googleapis.com/customsearch/v1?key=${process.env.GOOGLE_SEARCH_API_KEY}&cx=${process.env.GOOGLE_SEARCH_CX}&q=${encodeURIComponent(query)}&num=5`);
|
|
61
|
-
if (!response.ok)
|
|
62
|
-
throw new Error(`Google API error: ${response.statusText}`);
|
|
63
|
-
const data = await response.json();
|
|
64
|
-
// Extract relevant fields to keep output clean
|
|
65
|
-
const results = data.items?.map((item) => ({
|
|
66
|
-
title: item.title,
|
|
67
|
-
link: item.link,
|
|
68
|
-
snippet: item.snippet
|
|
69
|
-
})) || [];
|
|
70
|
-
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
85
|
+
catch (error) {
|
|
86
|
+
const errorMsg = `[${currentProvider}] ${error instanceof Error ? error.message : String(error)}`;
|
|
87
|
+
errors.push(errorMsg);
|
|
88
|
+
// Continue to next provider
|
|
71
89
|
}
|
|
72
|
-
return { content: [{ type: "text", text: "Error: Unsupported or unconfigured provider." }], isError: true };
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
return {
|
|
76
|
-
content: [{ type: "text", text: `Search Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
77
|
-
isError: true
|
|
78
|
-
};
|
|
79
90
|
}
|
|
91
|
+
// 4. All failed
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: `All search providers failed:\n${errors.join('\n')}` }],
|
|
94
|
+
isError: true
|
|
95
|
+
};
|
|
80
96
|
});
|
|
81
97
|
// 2. fetch
|
|
82
98
|
server.tool("fetch", "Perform an HTTP request to a specific URL.", {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerWebTools } from './tools/web.js';
|
|
3
|
+
import * as utils from './utils.js';
|
|
4
|
+
// Mock utils
|
|
5
|
+
vi.mock('./utils.js', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal();
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
fetchWithRetry: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
describe('web_search fallback', () => {
|
|
13
|
+
let mockToolCallback;
|
|
14
|
+
const mockServer = {
|
|
15
|
+
tool: vi.fn((name, desc, schema, callback) => {
|
|
16
|
+
if (name === 'web_search') {
|
|
17
|
+
mockToolCallback = callback;
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
const originalEnv = process.env;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
process.env = { ...originalEnv };
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.env = originalEnv;
|
|
28
|
+
});
|
|
29
|
+
it('should use Brave if configured and no provider specified', async () => {
|
|
30
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
31
|
+
delete process.env.EXA_API_KEY;
|
|
32
|
+
delete process.env.GOOGLE_SEARCH_API_KEY;
|
|
33
|
+
registerWebTools(mockServer);
|
|
34
|
+
const mockResponse = {
|
|
35
|
+
ok: true,
|
|
36
|
+
json: async () => ({ web: { results: ['brave result'] } })
|
|
37
|
+
};
|
|
38
|
+
utils.fetchWithRetry.mockResolvedValue(mockResponse);
|
|
39
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
40
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledWith(expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
41
|
+
expect(result.isError).toBeUndefined();
|
|
42
|
+
expect(JSON.parse(result.content[0].text)).toEqual(['brave result']);
|
|
43
|
+
});
|
|
44
|
+
it('should fallback to Exa if Brave fails', async () => {
|
|
45
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
46
|
+
process.env.EXA_API_KEY = 'test-exa-key';
|
|
47
|
+
registerWebTools(mockServer);
|
|
48
|
+
// First call (Brave) fails
|
|
49
|
+
utils.fetchWithRetry
|
|
50
|
+
.mockResolvedValueOnce({ ok: false, statusText: 'Brave Error', status: 500 })
|
|
51
|
+
// Second call (Exa) succeeds
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: async () => ({ results: ['exa result'] })
|
|
55
|
+
});
|
|
56
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
57
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
|
|
58
|
+
// 1. Brave
|
|
59
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
60
|
+
// 2. Exa
|
|
61
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.exa.ai'), expect.anything());
|
|
62
|
+
expect(result.isError).toBeUndefined();
|
|
63
|
+
expect(JSON.parse(result.content[0].text)).toEqual(['exa result']);
|
|
64
|
+
});
|
|
65
|
+
it('should respect requested provider and verify its availability', async () => {
|
|
66
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
67
|
+
// Exa not configured
|
|
68
|
+
delete process.env.EXA_API_KEY;
|
|
69
|
+
registerWebTools(mockServer);
|
|
70
|
+
const result = await mockToolCallback({ query: 'test', provider: 'exa' });
|
|
71
|
+
expect(result.isError).toBe(true);
|
|
72
|
+
expect(result.content[0].text).toContain("Requested provider 'exa' is not configured");
|
|
73
|
+
});
|
|
74
|
+
it('should try requested provider first, then fallback', async () => {
|
|
75
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
76
|
+
process.env.EXA_API_KEY = 'test-exa-key';
|
|
77
|
+
registerWebTools(mockServer);
|
|
78
|
+
// Request Exa
|
|
79
|
+
// Mock Exa fail, Brave success
|
|
80
|
+
utils.fetchWithRetry
|
|
81
|
+
.mockResolvedValueOnce({ ok: false, statusText: 'Exa Error', status: 500 })
|
|
82
|
+
.mockResolvedValueOnce({
|
|
83
|
+
ok: true,
|
|
84
|
+
json: async () => ({ web: { results: ['brave result'] } })
|
|
85
|
+
});
|
|
86
|
+
const result = await mockToolCallback({ query: 'test', provider: 'exa' });
|
|
87
|
+
expect(utils.fetchWithRetry).toHaveBeenCalledTimes(2);
|
|
88
|
+
// 1. Exa (requested)
|
|
89
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(1, expect.stringContaining('api.exa.ai'), expect.anything());
|
|
90
|
+
// 2. Brave (fallback)
|
|
91
|
+
expect(utils.fetchWithRetry).toHaveBeenNthCalledWith(2, expect.stringContaining('api.search.brave.com'), expect.anything());
|
|
92
|
+
expect(result.isError).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
it('should return error if all fail', async () => {
|
|
95
|
+
process.env.BRAVE_API_KEY = 'test-brave-key';
|
|
96
|
+
registerWebTools(mockServer);
|
|
97
|
+
utils.fetchWithRetry.mockResolvedValue({ ok: false, statusText: 'Some Error', status: 500 });
|
|
98
|
+
const result = await mockToolCallback({ query: 'test' });
|
|
99
|
+
expect(result.isError).toBe(true);
|
|
100
|
+
expect(result.content[0].text).toContain("All search providers failed");
|
|
101
|
+
expect(result.content[0].text).toContain("Brave API error");
|
|
102
|
+
});
|
|
103
|
+
});
|