Skip to content

Commit 1af363b

Browse files
author
luozihao
committed
fix(agent-core-v2): fall over to local engines when managed search auth fails
WebSearch resolved its provider eagerly: with a managed:kimi-code OAuth entry present but no valid token, construction succeeded and the token lookup only threw at search time — surfacing "No token for kimi-code" and never reaching the always-available local engine chain (bing/baidu/ sogou/zhihu/csdn/juejin/linuxdo/startpage/brave/exa/duckduckgo). Compose the candidates into a failover provider instead: auth/network failures and empty result sets cascade to the next candidate, caller aborts propagate immediately, and a sole candidate keeps its exact behavior.
1 parent 65e2314 commit 1af363b

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@ export class WebSearchProviderService implements IWebSearchProviderService {
3232
) {}
3333

3434
getWebSearchProvider(): WebSearchProvider | undefined {
35-
return this.fromServicesConfig() ?? this.fromManagedOAuth() ?? this.localProvider();
35+
const candidates: WebSearchProvider[] = [];
36+
const services = this.fromServicesConfig();
37+
if (services !== undefined) candidates.push(services);
38+
let managed: WebSearchProvider | undefined;
39+
try {
40+
managed = this.fromManagedOAuth();
41+
} catch {
42+
managed = undefined;
43+
}
44+
if (managed !== undefined) candidates.push(managed);
45+
candidates.push(this.localProvider());
46+
return createFailoverWebSearchProvider(candidates);
3647
}
3748

3849
hasWebSearchProvider(): boolean {
@@ -100,6 +111,33 @@ function nonEmptyString(value: string | undefined): string | undefined {
100111
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
101112
}
102113

114+
/**
115+
* Try each provider in order; an auth failure (missing/expired managed
116+
* token) falls through to the next candidate instead of surfacing a
117+
* dead-end, while caller aborts propagate immediately. Empty result sets
118+
* also cascade — engines can legitimately miss a query.
119+
*/
120+
export function createFailoverWebSearchProvider(
121+
candidates: readonly WebSearchProvider[],
122+
): WebSearchProvider {
123+
return {
124+
async search(query, options) {
125+
let lastError: unknown = new Error('no search provider configured');
126+
for (const candidate of candidates) {
127+
try {
128+
const results = await candidate.search(query, options);
129+
if (results.length > 0 || candidates.length === 1) return results;
130+
lastError = new Error('no search results');
131+
} catch (error) {
132+
if (options?.signal?.aborted) throw error;
133+
lastError = error;
134+
}
135+
}
136+
throw lastError;
137+
},
138+
};
139+
}
140+
103141
registerScopedService(
104142
LifecycleScope.App,
105143
IWebSearchProviderService,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createFailoverWebSearchProvider } from '#/app/auth/webSearch/webSearchService';
4+
import type { WebSearchProvider } from '#/agent/tools/web-search/web-search';
5+
6+
function provider(
7+
results: string[],
8+
impl?: (query: string) => Promise<never> | void,
9+
): WebSearchProvider {
10+
return {
11+
async search(query) {
12+
if (impl) await impl(query);
13+
return results.map((title) => ({ title, url: `https://x/${title}`, snippet: '' }));
14+
},
15+
};
16+
}
17+
18+
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
19+
20+
describe('createFailoverWebSearchProvider', () => {
21+
it('falls through to the next provider when the primary throws an auth failure', async () => {
22+
const primary = provider([], () => {
23+
throw new Error('No token for "kimi-code". Run /login to authenticate.');
24+
});
25+
const fallback = provider(['hit']);
26+
const p = createFailoverWebSearchProvider([primary, fallback]);
27+
const r = await p.search('q');
28+
expect(r.map((x) => x.title)).toEqual(['hit']);
29+
});
30+
31+
it('cascades when a candidate returns an empty result set', async () => {
32+
const empty = provider([]);
33+
const next = provider(['found']);
34+
const p = createFailoverWebSearchProvider([empty, next]);
35+
const r = await p.search('q');
36+
expect(r.map((x) => x.title)).toEqual(['found']);
37+
});
38+
39+
it('surfaces the last error when every candidate fails', async () => {
40+
const boom = provider([], () => {
41+
throw new Error('network down');
42+
});
43+
const p = createFailoverWebSearchProvider([boom, boom]);
44+
await expect(p.search('q')).rejects.toThrow('network down');
45+
});
46+
47+
it('rethrows caller aborts immediately without trying remaining providers', async () => {
48+
let secondCalled = false;
49+
const controller = new AbortController();
50+
const slow = provider([], () => {
51+
controller.abort();
52+
return Promise.reject(abortError);
53+
});
54+
const second = {
55+
async search() {
56+
secondCalled = true;
57+
return [];
58+
},
59+
};
60+
const p = createFailoverWebSearchProvider([slow, second]);
61+
await expect(p.search('q', { signal: controller.signal })).rejects.toBe(abortError);
62+
expect(secondCalled).toBe(false);
63+
});
64+
65+
it('returns the single candidate as-is behaviorally', async () => {
66+
const only = provider(['solo']);
67+
const p = createFailoverWebSearchProvider([only]);
68+
const r = await p.search('q');
69+
expect(r.map((x) => x.title)).toEqual(['solo']);
70+
});
71+
});

0 commit comments

Comments
 (0)