mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
* feat(web-search): add GLM (ZhipuAI) web search provider - Add GlmProvider class implementing BaseWebSearchProvider using the ZhipuAI Web Search API (https://open.bigmodel.cn/api/paas/v4/web_search) - Support multiple search engines: search_std, search_pro, search_pro_sogou, search_pro_quark - Support optional config: maxResults, searchIntent, searchRecencyFilter, contentSize, searchDomainFilter - Truncate query to 70 characters per API limit - Register 'glm' in the provider discriminated union (types.ts) and createProvider() switch (index.ts) - Add GlmProviderConfig to settingsSchema, ConfigParams, and Config class - Add --glm-api-key CLI flag and GLM_API_KEY env var support in webSearch.ts - Forward GLM_API_KEY in sandbox environment - Update provider priority list: Tavily > Google > GLM > DashScope - Add 17 unit tests for GlmProvider and 4 integration tests in index.test.ts - Update docs/developers/tools/web-search.md with GLM configuration, env vars, CLI args, pricing, and corrected DashScope billing info - Fix stale OAuth/free-tier references in web-search.md Closes #3496 * docs(web-search): fix DashScope note and add GLM server-side limitations * fix(web-search): make DashScope provider work with standard API key, remove qwen-oauth dependency - DashScopeProvider.isAvailable() now checks config.apiKey instead of authType - Remove OAuth credential file reading and resource_url requirement - Use standard DashScope endpoint: dashscope.aliyuncs.com/api/v1/indices/plugin/web_search - Read DASHSCOPE_API_KEY env var and --dashscope-api-key CLI flag - Forward DASHSCOPE_API_KEY into sandbox environment - Update integration test to detect DASHSCOPE_API_KEY - Update docs to reflect new API key based configuration * feat(web-search): remove built-in web search tool The web_search tool and all related provider implementations are removed. Web search functionality will be provided via MCP integrations instead, which is the direction the broader agent ecosystem is moving. Removed: - packages/core/src/tools/web-search/ (entire directory) - packages/cli/src/config/webSearch.ts - integration-tests/cli/web_search.test.ts - ToolNames.WEB_SEARCH, ToolErrorCode.WEB_SEARCH_FAILED - webSearch config in ConfigParams, Config class, settingsSchema - CLI options: --tavily-api-key, --google-api-key, --google-search-engine-id, --glm-api-key, --dashscope-api-key, --web-search-default - Sandbox env forwarding for TAVILY/GLM/DASHSCOPE/GOOGLE search keys - web_search from rule-parser, permission-manager, speculation gate, microcompact tool set, and builtin-agents tool list * fix: remove websearch reference * docs: remove websearch tool * docs: add break change guide * fix review
217 lines
5.8 KiB
TypeScript
217 lines
5.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { Content, Part } from '@google/genai';
|
|
|
|
import type { ClearContextOnIdleSettings } from '../../config/config.js';
|
|
import { ToolNames } from '../../tools/tool-names.js';
|
|
|
|
export const MICROCOMPACT_CLEARED_MESSAGE = '[Old tool result content cleared]';
|
|
|
|
const COMPACTABLE_TOOLS = new Set<string>([
|
|
ToolNames.READ_FILE,
|
|
ToolNames.SHELL,
|
|
ToolNames.GREP,
|
|
ToolNames.GLOB,
|
|
ToolNames.WEB_FETCH,
|
|
ToolNames.EDIT,
|
|
ToolNames.WRITE_FILE,
|
|
]);
|
|
|
|
// --- Trigger evaluation ---
|
|
|
|
/**
|
|
* Check whether the time-based trigger should fire.
|
|
*
|
|
* A toolResultsThresholdMinutes of -1 means disabled (never clear).
|
|
*/
|
|
export function evaluateTimeBasedTrigger(
|
|
lastApiCompletionTimestamp: number | null,
|
|
settings: ClearContextOnIdleSettings,
|
|
): { gapMs: number } | null {
|
|
const thresholdMin = settings.toolResultsThresholdMinutes ?? 60;
|
|
// -1 means disabled
|
|
if (thresholdMin < 0) {
|
|
return null;
|
|
}
|
|
if (lastApiCompletionTimestamp === null) {
|
|
return null;
|
|
}
|
|
const thresholdMs = thresholdMin * 60_000;
|
|
const gapMs = Date.now() - lastApiCompletionTimestamp;
|
|
if (!Number.isFinite(gapMs) || gapMs < thresholdMs) {
|
|
return null;
|
|
}
|
|
return { gapMs };
|
|
}
|
|
|
|
// --- Collection ---
|
|
|
|
/** Pointer to a single compactable functionResponse part. */
|
|
interface PartRef {
|
|
contentIndex: number;
|
|
partIndex: number;
|
|
}
|
|
|
|
/**
|
|
* Collect references to individual compactable functionResponse parts
|
|
* across the history, in encounter order. This counts per-part (not
|
|
* per-Content-entry) so keepRecent applies to individual tool results
|
|
* even when multiple results are batched into one Content message.
|
|
*/
|
|
function collectCompactablePartRefs(history: Content[]): PartRef[] {
|
|
const refs: PartRef[] = [];
|
|
for (let ci = 0; ci < history.length; ci++) {
|
|
const content = history[ci]!;
|
|
if (content.role !== 'user' || !content.parts) continue;
|
|
for (let pi = 0; pi < content.parts.length; pi++) {
|
|
const part = content.parts[pi]!;
|
|
if (
|
|
part.functionResponse?.name &&
|
|
COMPACTABLE_TOOLS.has(part.functionResponse.name)
|
|
) {
|
|
refs.push({ contentIndex: ci, partIndex: pi });
|
|
}
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
/** True when the functionResponse carries an error (not a success output). */
|
|
function isErrorResponse(part: Part): boolean {
|
|
return part.functionResponse?.response?.['error'] !== undefined;
|
|
}
|
|
|
|
function estimatePartTokens(part: Part): number {
|
|
if (!part.functionResponse?.response) return 0;
|
|
const output = part.functionResponse.response['output'];
|
|
if (typeof output !== 'string') return 0;
|
|
return Math.ceil(output.length / 4);
|
|
}
|
|
|
|
function isAlreadyCleared(part: Part): boolean {
|
|
return (
|
|
part.functionResponse?.response?.['output'] === MICROCOMPACT_CLEARED_MESSAGE
|
|
);
|
|
}
|
|
|
|
// --- Main entry point ---
|
|
|
|
export interface MicrocompactMeta {
|
|
gapMinutes: number;
|
|
thresholdMinutes: number;
|
|
toolsCleared: number;
|
|
toolsKept: number;
|
|
keepRecent: number;
|
|
tokensSaved: number;
|
|
}
|
|
|
|
/**
|
|
* Microcompact history: clear old compactable tool results when the
|
|
* time-based trigger fires.
|
|
*
|
|
* Returns the (potentially modified) history and optional metadata
|
|
* about what was cleared (for logging by the caller).
|
|
*/
|
|
export function microcompactHistory(
|
|
history: Content[],
|
|
lastApiCompletionTimestamp: number | null,
|
|
settings: ClearContextOnIdleSettings,
|
|
): { history: Content[]; meta?: MicrocompactMeta } {
|
|
const trigger = evaluateTimeBasedTrigger(
|
|
lastApiCompletionTimestamp,
|
|
settings,
|
|
);
|
|
if (!trigger) {
|
|
return { history };
|
|
}
|
|
const { gapMs } = trigger;
|
|
|
|
const envKeep = process.env['QWEN_MC_KEEP_RECENT'];
|
|
const rawKeepRecent =
|
|
envKeep !== undefined && Number.isFinite(Number(envKeep))
|
|
? Number(envKeep)
|
|
: (settings.toolResultsNumToKeep ?? 5);
|
|
const keepRecent = Number.isFinite(rawKeepRecent)
|
|
? Math.max(1, rawKeepRecent)
|
|
: 5;
|
|
|
|
const allRefs = collectCompactablePartRefs(history);
|
|
const keepRefs = new Set(
|
|
allRefs.slice(-keepRecent).map((r) => `${r.contentIndex}:${r.partIndex}`),
|
|
);
|
|
const clearRefs = allRefs.filter(
|
|
(r) => !keepRefs.has(`${r.contentIndex}:${r.partIndex}`),
|
|
);
|
|
|
|
if (clearRefs.length === 0) {
|
|
return { history };
|
|
}
|
|
|
|
// Build a lookup: contentIndex → Set of partIndices to clear
|
|
const clearMap = new Map<number, Set<number>>();
|
|
for (const ref of clearRefs) {
|
|
let parts = clearMap.get(ref.contentIndex);
|
|
if (!parts) {
|
|
parts = new Set();
|
|
clearMap.set(ref.contentIndex, parts);
|
|
}
|
|
parts.add(ref.partIndex);
|
|
}
|
|
|
|
let tokensSaved = 0;
|
|
let toolsCleared = 0;
|
|
|
|
const result: Content[] = history.map((content, ci) => {
|
|
const partsToClean = clearMap.get(ci);
|
|
if (!partsToClean || !content.parts) return content;
|
|
|
|
let touched = false;
|
|
const newParts = content.parts.map((part, pi) => {
|
|
if (
|
|
partsToClean.has(pi) &&
|
|
part.functionResponse?.name &&
|
|
COMPACTABLE_TOOLS.has(part.functionResponse.name) &&
|
|
!isAlreadyCleared(part) &&
|
|
!isErrorResponse(part)
|
|
) {
|
|
tokensSaved += estimatePartTokens(part);
|
|
toolsCleared++;
|
|
touched = true;
|
|
return {
|
|
functionResponse: {
|
|
...part.functionResponse,
|
|
response: { output: MICROCOMPACT_CLEARED_MESSAGE },
|
|
},
|
|
};
|
|
}
|
|
return part;
|
|
});
|
|
|
|
if (!touched) return content;
|
|
return { ...content, parts: newParts };
|
|
});
|
|
|
|
if (tokensSaved === 0) {
|
|
return { history };
|
|
}
|
|
|
|
const thresholdMinutes = settings.toolResultsThresholdMinutes ?? 60;
|
|
|
|
return {
|
|
history: result,
|
|
meta: {
|
|
gapMinutes: Math.round(gapMs / 60_000),
|
|
thresholdMinutes,
|
|
toolsCleared,
|
|
toolsKept: allRefs.length - clearRefs.length,
|
|
keepRecent,
|
|
tokensSaved,
|
|
},
|
|
};
|
|
}
|