Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 77 additions & 14 deletions docs/developers/tools/web-search.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,84 @@
# Web Search

Qwen Code supports web search capabilities through **MCP (Model Context Protocol)** integrations. Rather than a built-in search tool, web search is provided by connecting to external MCP servers, giving you full flexibility to choose the search service that best fits your needs.
Qwen Code provides web search two ways:

## ⚠️ Breaking Change: Built-in `web_search` Tool Removed
1. **Built-in `web_search` tool** (opt-in) — backed by the DashScope Responses API server-side search. Works with a standard Bailian (DashScope) API key; no extra provider or MCP setup.
2. **MCP (Model Context Protocol) integrations** — connect any external search service (Tavily, GLM, and others). Use this when you don't have a DashScope key.

> **Affected versions:** `V0.0.7+` through the last release with built-in web search support.
## Built-in `web_search` (opt-in)

The built-in `web_search` tool and all its associated configuration have been **removed**. If you were using any of the following, you should migrate to the MCP-based approach described in this document:
The built-in tool issues a self-contained search request to a small auxiliary model with DashScope's server-side `web_search` (and `web_extractor`) tools, and returns the narrated findings plus source URLs. It never activates implicitly — two settings are required:

| Removed | What to do |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) |
| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) |
| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) |
| `DASHSCOPE_API_KEY` for web search | Use the [Alibaba Cloud Bailian WebSearch MCP](#alibaba-cloud-bailian-websearch-recommended) |
| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) |
| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` |
```json
{
"modelProviders": {
"openai": [
{
"id": "qwen3.6-plus",
"envKey": "DASHSCOPE_API_KEY",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1"
}
]
},
"tools": {
"webSearch": {
"enabled": true,
"model": "qwen3.6-plus"
}
}
}
```

| Setting | Env override | Meaning |
| ------------------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.webSearch.enabled` | `ENABLE_WEB_SEARCH` | Opt-in flag. Required. |
| `tools.webSearch.model` | `WEB_SEARCH_MODEL` | Search model selector, resolved against `modelProviders` like `fastModel` (`modelId` or `authType:modelId`). Required — no default. Recommended: `qwen3.6-plus`. |
| `tools.webSearch.webExtractor` | `WEB_SEARCH_EXTRACTOR` | Let the search agent open result pages for better-grounded answers (default `true`; billed separately by DashScope). |

### Env-only configuration (no settings.json)

For environments where you cannot write a settings file (locked-down containers, CI
with env injection only), the tool can be configured entirely through environment
variables — no `modelProviders` entry needed:

```bash
export ENABLE_WEB_SEARCH=true
export WEB_SEARCH_MODEL=qwen3.6-plus
export WEB_SEARCH_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export DASHSCOPE_API_KEY=sk-... # or set WEB_SEARCH_API_KEY instead
```

`WEB_SEARCH_BASE_URL` mirrors a `modelProviders` entry's `baseUrl` and must be a
DashScope-compatible endpoint; when it is set, it takes precedence over
`modelProviders` resolution and `WEB_SEARCH_MODEL` is used as the plain DashScope
model id. The API key is read from `WEB_SEARCH_API_KEY` if set, otherwise from
`DASHSCOPE_API_KEY`. Misconfiguration still surfaces as a startup notice.

Notes:

- The selector must resolve to a DashScope-compatible `modelProviders` entry carrying a direct API key via `envKey`. Your main model can be any provider — only the search side request needs a DashScope entry. Qwen OAuth cannot back the tool.
- If enabled but misconfigured, the tool stays off and a startup notice explains which condition failed.
- Searches bill your DashScope key (`usage.x_tools` counts). The tool asks for confirmation by default; approving with "always allow" persists a standard `WebSearch` permission rule, like other tools.
- There is no client-side model allowlist; a model the Responses endpoint does not serve fails loudly on first use.

## MCP alternatives

If you don't have a DashScope key, web search is available by connecting an external MCP server — see the services below.

## ⚠️ Historical Breaking Change: original built-in `web_search` removed

> **Affected versions:** `V0.0.7+` through the last release with the original multi-provider built-in web search.

The original built-in `web_search` tool (Tavily/Google/GLM/DashScope multi-provider) and its configuration were **removed**. The new opt-in built-in tool above is a different implementation with different configuration. If you were using any of the following, migrate either to the new built-in tool (DashScope) or to MCP:

| Removed | What to do |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `webSearch` block in `settings.json` | Configure an MCP server in `mcpServers` instead (see below) |
| `advanced.tavilyApiKey` in `settings.json` | Use the [Tavily MCP server](#tavily-websearch) |
| `TAVILY_API_KEY` environment variable | Use the [Tavily MCP server](#tavily-websearch) |
| `DASHSCOPE_API_KEY` for web search | Use the [built-in `web_search` tool](#built-in-web_search-opt-in) |
| `GLM_API_KEY` for web search | Use the [GLM WebSearch Prime MCP](#glm-websearch-prime-zhipuai) |
| `--tavily-api-key` / `--glm-api-key` / `--dashscope-api-key` CLI flags | Configure via `mcpServers` in `settings.json` |

### Migration Examples

Expand Down Expand Up @@ -74,9 +137,9 @@ The built-in `web_search` tool and all its associated configuration have been **

## Supported MCP Web Search Services

### Alibaba Cloud Bailian WebSearch (Recommended)
### Alibaba Cloud Bailian WebSearch

The official web search MCP service provided by Alibaba Cloud Bailian platform, powered by DashScope.
The official web search MCP service provided by Alibaba Cloud Bailian platform, powered by DashScope. If you have a DashScope key, prefer the built-in `web_search` tool above — it uses a stronger search path than this MCP service.

- **MCP Marketplace:** https://bailian.console.aliyun.com/cn-beijing?tab=mcp#/mcp-market/detail/WebSearch
- **Cost:** Paid (billed via Alibaba Cloud DashScope)
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

# Debug-log directories leaked by CLI unit tests that resolve paths relative
# to the package dir instead of a temp dir (settings/config suites).
/custom/
/first/
/from-env/
/workspace/
Comment on lines +4 to +7

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The gitignore entries /custom/, /first/, /from-env/, /workspace/ use generic directory names anchored to the packages/cli/ root. — Concrete cost: if a developer later creates packages/cli/workspace/ (or custom/) as a real source or fixture directory, git add . silently skips it; the omission is invisible until a fresh clone hits a missing-module error. The names workspace and custom are common enough for this to be plausible.

Consider fixing the root cause — the tests that resolve paths relative to the package dir instead of a temp dir (as the comment acknowledges) — or at minimum narrow the patterns to the test-specific stems that create them.

— qwen3.8-max-preview via Qwen Code /review

23 changes: 23 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,29 @@ describe('runAcpAgent shutdown cleanup', () => {
expect(dispose).toHaveBeenCalledTimes(1);
});

it('writes config startup warnings to stderr for the ACP client log', async () => {
// The ACP path exits gemini.tsx before its startup-warning printing
// runs; runAcpAgent must emit config warnings (e.g. the WebSearch
// enablement notices) itself or they vanish.
(mockConfig as unknown as { getWarnings: () => string[] }).getWarnings =
() => ['WebSearch is enabled but no search model is configured.'];
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv);
await vi.waitFor(() => {
expect(stderrWriteSpy).toHaveBeenCalledWith(
'WebSearch is enabled but no search model is configured.\n',
);
});
mockConnectionState.resolve();
await agentPromise;
} finally {
stderrWriteSpy.mockRestore();
}
});

it('disposes the event loop monitor when connection setup fails', async () => {
const dispose = vi.fn();
vi.mocked(startEventLoopLagMonitor).mockReturnValueOnce({
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2573,6 +2573,16 @@ export async function runAcpAgent(
} finally {
endAcpBootstrapConfigProfiling();
}
// The ACP path exits gemini.tsx before its startup-warning printing runs,
// so config warnings (including initialize-time ones like the WebSearch
// enablement notice) would otherwise vanish. stderr lands in the client's
// logs without interfering with the ACP protocol on stdout.
// Defensive `typeof` for tests that stub Config without getWarnings.
const startupWarnings =
typeof config.getWarnings === 'function' ? config.getWarnings() : [];
for (const warning of startupWarnings) {
process.stderr.write(`${warning}\n`);
}
Comment thread
tanzhenxin marked this conversation as resolved.
const eventLoopMonitor = startEventLoopLagMonitor({
onNewMaxStall: (maxMs) => {
console.error(`[perf] acp agent event loop stall: max=${maxMs}ms`);
Expand Down
110 changes: 110 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,116 @@ describe('loadCliConfig', () => {
expect(config.getProxy()).toBe('http://localhost:7890');
});
});

describe('web search settings resolution', () => {
const loadWithSettings = async (settings: Settings) => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
return loadCliConfig(settings, argv);
};

it('returns undefined when neither settings nor env configure web search', async () => {
const config = await loadWithSettings({});
expect(config.getWebSearchSettings()).toBeUndefined();
});

it('passes tools.webSearch through from settings', async () => {
const config = await loadWithSettings({
tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } },
});
expect(config.getWebSearchSettings()).toEqual({
enabled: true,
model: 'qwen3.6-plus',
});
});

it('lets ENABLE_WEB_SEARCH override the settings flag', async () => {
vi.stubEnv('ENABLE_WEB_SEARCH', 'false');
const config = await loadWithSettings({
tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } },
});
expect(config.getWebSearchSettings()?.enabled).toBe(false);
});

it('treats a set-but-empty ENABLE_WEB_SEARCH as unset', async () => {
vi.stubEnv('ENABLE_WEB_SEARCH', '');
const config = await loadWithSettings({
tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } },
});
expect(config.getWebSearchSettings()?.enabled).toBe(true);
});

it('lets WEB_SEARCH_MODEL and WEB_SEARCH_EXTRACTOR override settings', async () => {
vi.stubEnv('WEB_SEARCH_MODEL', 'env-model');
vi.stubEnv('WEB_SEARCH_EXTRACTOR', 'false');
const config = await loadWithSettings({
tools: {
webSearch: { enabled: true, model: 'settings-model' },
},
});
expect(config.getWebSearchSettings()).toEqual({
enabled: true,
model: 'env-model',
webExtractor: false,
});
});

it('resolves WEB_SEARCH_BASE_URL with the DASHSCOPE_API_KEY fallback', async () => {
vi.stubEnv(
'WEB_SEARCH_BASE_URL',
'https://dashscope.aliyuncs.com/api/v2',
);
const config = await loadWithSettings({});
expect(config.getWebSearchSettings()).toEqual({
baseUrl: 'https://dashscope.aliyuncs.com/api/v2',
apiKeyEnv: 'DASHSCOPE_API_KEY',
});
});

it('selects WEB_SEARCH_API_KEY when it is non-empty', async () => {
vi.stubEnv(
'WEB_SEARCH_BASE_URL',
'https://dashscope.aliyuncs.com/api/v2',
);
vi.stubEnv('WEB_SEARCH_API_KEY', 'sk-live');
const config = await loadWithSettings({});
expect(config.getWebSearchSettings()?.apiKeyEnv).toBe(
'WEB_SEARCH_API_KEY',
);
});

it('treats a whitespace-only WEB_SEARCH_API_KEY as unset', async () => {
vi.stubEnv(
'WEB_SEARCH_BASE_URL',
'https://dashscope.aliyuncs.com/api/v2',
);
vi.stubEnv('WEB_SEARCH_API_KEY', ' ');
const config = await loadWithSettings({});
expect(config.getWebSearchSettings()?.apiKeyEnv).toBe(
'DASHSCOPE_API_KEY',
);
});

it('disables web search in safe mode', async () => {
process.argv = ['node', 'script.js', '--safe-mode'];
const argv = await parseArguments();
const config = await loadCliConfig(
{ tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } } },
argv,
);
expect(config.getWebSearchSettings()).toBeUndefined();
});

it('disables web search in bare mode', async () => {
process.argv = ['node', 'script.js', '--bare'];
const argv = await parseArguments();
const config = await loadCliConfig(
{ tools: { webSearch: { enabled: true, model: 'qwen3.6-plus' } } },
argv,
);
expect(config.getWebSearchSettings()).toBeUndefined();
});
});
});

describe('loadCliConfig telemetry', () => {
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ import {
createDebugLogger,
NativeLspService,
isBareMode,
isTruthy,
isSafeModeEnv,
isToolEnabled,
isTlsVerificationDisabled,
SchemaValidator,
type ConfigParameters,
type MCPServerConfig,
type WebSearchSettings,
MAX_SUBAGENT_DEPTH_LIMIT,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
Expand Down Expand Up @@ -1226,6 +1228,50 @@ function resolveModelFallbacks(
return undefined;
}

/**
* Resolve the built-in WebSearch tool settings, with env overrides taking
* precedence over `tools.webSearch` (mirroring the QWEN_SANDBOX_IMAGE
* pattern): ENABLE_WEB_SEARCH for the flag, WEB_SEARCH_MODEL for the model
* selector, WEB_SEARCH_EXTRACTOR for page reading.
*
* Env-only backend: WEB_SEARCH_BASE_URL mirrors a modelProviders entry's
* baseUrl for environments that cannot write settings.json; the API key
* comes from WEB_SEARCH_API_KEY, falling back to DASHSCOPE_API_KEY. When
* set, it takes precedence over modelProviders resolution in the gate.
*/
function resolveWebSearchSettings(
settings: Settings,
): WebSearchSettings | undefined {
Comment thread
tanzhenxin marked this conversation as resolved.
const webSearch = settings.tools?.webSearch;
// A set-but-empty env var is "unset", not an override: dotenv templates and
// CI wrappers export empty values, which must not clobber a valid
// settings.json config (same rule as WEB_SEARCH_BASE_URL below).
const envEnabled = process.env['ENABLE_WEB_SEARCH']?.trim() || undefined;
const enabled =
envEnabled !== undefined ? isTruthy(envEnabled) : webSearch?.enabled;
const model = process.env['WEB_SEARCH_MODEL']?.trim() || webSearch?.model;
const envExtractor = process.env['WEB_SEARCH_EXTRACTOR']?.trim() || undefined;
const webExtractor =
envExtractor !== undefined
? isTruthy(envExtractor)
: webSearch?.webExtractor;
const baseUrl = process.env['WEB_SEARCH_BASE_URL']?.trim() || undefined;
const apiKeyEnv = baseUrl
? process.env['WEB_SEARCH_API_KEY']?.trim()
? 'WEB_SEARCH_API_KEY'
: 'DASHSCOPE_API_KEY'
: undefined;
Comment thread
tanzhenxin marked this conversation as resolved.
if (
enabled === undefined &&
model === undefined &&
webExtractor === undefined &&
baseUrl === undefined
) {
return undefined;
}
return { enabled, model, webExtractor, baseUrl, apiKeyEnv };
}

/**
* Resolves the wall-clock budget for a run. Returns seconds (`-1` =
* unlimited). Order of precedence: `--max-wall-time` flag, then
Expand Down Expand Up @@ -2212,6 +2258,8 @@ export async function loadCliConfig(
: (settings.memory?.autoSkillConfirm ?? true),
memoryAgentTimeoutMinutes: settings.memory?.agentTimeoutMinutes,
fastModel: settings.fastModel || undefined,
webSearch:
bareMode || safeMode ? undefined : resolveWebSearchSettings(settings),
Comment thread
tanzhenxin marked this conversation as resolved.
visionModel: settings.visionModel || undefined,
visionBridgeTimeoutMs: settings.visionBridgeTimeoutMs,
modelFallbacks: resolveModelFallbacks(
Expand Down
Loading
Loading