qwen-code/packages/core/src/services/microcompaction/microcompact.ts
顾盼 aeeb2976d6
feat(web-search): remove built-in web_search tool, replace with MCP-based approach (#3502)
* 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
2026-04-24 11:29:02 +08:00

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,
},
};
}