Compare commits

..

3 Commits

Author SHA1 Message Date
koalazf.99
172c05be26 Merge branch 'main' into fix/trimend 2025-08-14 19:17:27 +08:00
koalazf.99
04415bd19d tmp 2025-08-14 19:16:40 +08:00
koalazf.99
f9d3fe6fad fix: generate random tool call id when serving API does not have one 2025-08-14 19:09:02 +08:00
25 changed files with 470 additions and 697 deletions

View File

@@ -18,11 +18,7 @@ jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
@@ -53,9 +49,9 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request_target & workflow_dispatch)
- name: Get PR details (pull_request & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

View File

@@ -268,11 +268,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"loadMemoryFromIncludeDirectories": true
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
### Example `settings.json`:
```json
@@ -281,7 +276,6 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"sandbox": "docker",
"toolDiscoveryCommand": "bin/get_tools",
"toolCallCommand": "bin/call_tool",
"tavilyApiKey": "$TAVILY_API_KEY",
"mcpServers": {
"mainServer": {
"command": "bin/mcp_server.py"
@@ -379,11 +373,6 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- **`CODE_ASSIST_ENDPOINT`**:
- Specifies the endpoint for the code assist server.
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -441,9 +430,6 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `gemini --tavily-api-key tvly-your-api-key-here`
## Context Files (Hierarchical Instructional Context)

View File

@@ -28,7 +28,7 @@ This documentation is organized into the following sections:
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the `read_many_files` tool.
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command` tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `google_web_search` tool.
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
- **[NPM Workspaces and Publishing](./npm.md):** Details on how the project's packages are managed and published.

View File

@@ -4,25 +4,24 @@ This document describes the `web_fetch` tool for the Gemini CLI.
## Description
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
### Arguments
`web_fetch` takes two arguments:
`web_fetch` takes one argument:
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
## How to use `web_fetch` with the Gemini CLI
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
Usage:
```
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
```
## `web_fetch` examples
@@ -30,25 +29,16 @@ web_fetch(url="https://example.com", prompt="Summarize the main points of this a
Summarize a single article:
```
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
```
Extract specific information:
Compare two articles:
```
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
```
Analyze GitHub documentation:
```
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
```
## Important notes
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.

View File

@@ -1,43 +1,36 @@
# Web Search Tool (`web_search`)
# Web Search Tool (`google_web_search`)
This document describes the `web_search` tool.
This document describes the `google_web_search` tool.
## Description
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
Use `google_web_search` to perform a web search using Google Search via the Gemini API. The `google_web_search` tool returns a summary of web results with sources.
### Arguments
`web_search` takes one argument:
`google_web_search` takes one argument:
- `query` (string, required): The search query.
## How to use `web_search`
## How to use `google_web_search` with the Gemini CLI
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
If the key is not configured, the tool will be disabled and skipped.
The `google_web_search` tool sends a query to the Gemini API, which then performs a web search. `google_web_search` will return a generated response based on the search results, including citations and sources.
Usage:
```
web_search(query="Your query goes here.")
google_web_search(query="Your query goes here.")
```
## `web_search` examples
## `google_web_search` examples
Get information on a topic:
```
web_search(query="latest advancements in AI-powered code generation")
google_web_search(query="latest advancements in AI-powered code generation")
```
## Important notes
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
- **Response returned:** The `google_web_search` tool returns a processed summary, not a raw list of search results.
- **Citations:** The response includes citations to the sources used to generate the summary.

View File

@@ -9,11 +9,6 @@ import { strict as assert } from 'assert';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env.TAVILY_API_KEY) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
@@ -32,7 +27,7 @@ test('should be able to search the web', async () => {
throw error; // Re-throw if not a network error
}
const foundToolCall = await rig.waitForToolCall('web_search');
const foundToolCall = await rig.waitForToolCall('google_web_search');
// Add debugging information
if (!foundToolCall) {
@@ -40,11 +35,12 @@ test('should be able to search the web', async () => {
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
(t) =>
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'web_search tool was called but failed, possibly due to network issues',
'google_web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
@@ -54,20 +50,20 @@ test('should be able to search the web', async () => {
}
}
assert.ok(foundToolCall, 'Expected to find a call to web_search');
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Web search test',
'Google web search test',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'web_search');
.filter((t) => t.toolRequest.name === 'google_web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.7",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.7",
"version": "0.0.6",
"workspaces": [
"packages/*"
],
@@ -7421,15 +7421,6 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsonrepair": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.0.tgz",
"integrity": "sha512-5YRzlAQ7tuzV1nAJu3LvDlrKtBFIALHN2+a+I1MGJCt3ldRDBF/bZuvIPzae8Epot6KBXd0awRZZcuoeAsZ/mw==",
"license": "ISC",
"bin": {
"jsonrepair": "bin/cli.js"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -11700,7 +11691,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.7",
"version": "0.0.6",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -11904,7 +11895,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.7",
"version": "0.0.6",
"dependencies": {
"@google/genai": "1.9.0",
"@modelcontextprotocol/sdk": "^1.11.0",
@@ -11925,7 +11916,6 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"jsonrepair": "^3.13.0",
"marked": "^15.0.12",
"micromatch": "^4.0.8",
"open": "^10.1.2",
@@ -12052,7 +12042,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.0.7",
"version": "0.1.18",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -12063,7 +12053,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.7",
"version": "0.0.6",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.7",
"version": "0.0.6",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
},
"scripts": {
"start": "node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.7",
"version": "0.0.6",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.6"
},
"dependencies": {
"@google/genai": "1.9.0",

View File

@@ -69,7 +69,6 @@ export interface CliArgs {
proxy: string | undefined;
includeDirectories: string[] | undefined;
loadMemoryFromIncludeDirectories: boolean | undefined;
tavilyApiKey: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -216,10 +215,6 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.option('proxy', {
type: 'string',
description:
@@ -339,11 +334,6 @@ export async function loadCliConfig(
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Handle Tavily API key from command line
if (argv.tavilyApiKey) {
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -523,8 +513,6 @@ export async function loadCliConfig(
],
contentGenerator: settings.contentGenerator,
cliVersion,
tavilyApiKey:
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
});
}

View File

@@ -147,9 +147,6 @@ export interface Settings {
includeDirectories?: string[];
loadMemoryFromIncludeDirectories?: boolean;
// Web search API keys
tavilyApiKey?: string;
}
export interface SettingsError {

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput, Static } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
@@ -26,93 +26,17 @@ interface QwenOAuthProgressProps {
authMessage?: string | null;
}
/**
* Static QR Code Display Component
* Renders the QR code and URL once and doesn't re-render unless the URL changes
*/
function QrCodeDisplay({
verificationUrl,
qrCodeData,
}: {
verificationUrl: string;
qrCodeData: string | null;
}): React.JSX.Element | null {
if (!qrCodeData) {
return null;
}
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={verificationUrl} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{verificationUrl}
</Text>
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</Box>
);
}
/**
* Dynamic Status Display Component
* Shows the loading spinner, timer, and status messages
*/
function StatusDisplay({
timeRemaining,
dots,
}: {
timeRemaining: number;
dots: string;
}): React.JSX.Element {
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
);
interface StaticItem {
key: string;
type:
| 'title'
| 'instructions'
| 'url'
| 'qr-instructions'
| 'qr-code'
| 'auth-content';
url?: string;
qrCode?: string;
}
export function QwenOAuthProgress({
@@ -136,29 +60,33 @@ export function QwenOAuthProgress({
}
});
// Generate QR code once when device auth is available
// Generate QR code when device auth is available
useEffect(() => {
if (!deviceAuth?.verification_uri_complete) {
if (!deviceAuth) {
setQrCodeData(null);
return;
}
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
// Only generate QR code if we don't have one yet for this URL
if (qrCodeData === null) {
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
generateQR();
}, [deviceAuth?.verification_uri_complete]);
generateQR();
}
}, [deviceAuth, qrCodeData]);
// Countdown timer
useEffect(() => {
@@ -187,17 +115,11 @@ export function QwenOAuthProgress({
return () => clearInterval(dotsTimer);
}, []);
// Memoize the QR code display to prevent unnecessary re-renders
const qrCodeDisplay = useMemo(() => {
if (!deviceAuth?.verification_uri_complete) return null;
return (
<QrCodeDisplay
verificationUrl={deviceAuth.verification_uri_complete}
qrCodeData={qrCodeData}
/>
);
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// Handle timeout state
if (authStatus === 'timeout') {
@@ -229,7 +151,6 @@ export function QwenOAuthProgress({
);
}
// Show loading state when no device auth is available yet
if (!deviceAuth) {
return (
<Box
@@ -246,8 +167,7 @@ export function QwenOAuthProgress({
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {Math.floor(timeRemaining / 60)}:
{(timeRemaining % 60).toString().padStart(2, '0')}
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
@@ -256,12 +176,77 @@ export function QwenOAuthProgress({
}
return (
<Box flexDirection="column" width="100%">
{/* Static QR Code Display */}
{qrCodeDisplay}
<>
{qrCodeData && (
<Static
items={
[
{
key: 'auth-content',
type: 'auth-content' as const,
url: deviceAuth.verification_uri_complete,
qrCode: qrCodeData,
},
] as StaticItem[]
}
style={{
width: '100%',
}}
>
{(item: StaticItem) => (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
key={item.key}
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
{/* Dynamic Status Display */}
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
</Box>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={item.url || ''} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{item.url || ''}
</Text>
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{item.qrCode || ''}</Text>
</Box>
</Box>
)}
</Static>
)}
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
</>
);
}

View File

@@ -562,10 +562,6 @@ export async function start_sandbox(
if (process.env.OPENAI_API_KEY) {
args.push('--env', `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`);
}
// copy TAVILY_API_KEY for web search tool
if (process.env.TAVILY_API_KEY) {
args.push('--env', `TAVILY_API_KEY=${process.env.TAVILY_API_KEY}`);
}
if (process.env.OPENAI_BASE_URL) {
args.push('--env', `OPENAI_BASE_URL=${process.env.OPENAI_BASE_URL}`);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.7",
"version": "0.0.6",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -39,7 +39,6 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"jsonrepair": "^3.13.0",
"marked": "^15.0.12",
"micromatch": "^4.0.8",
"open": "^10.1.2",

View File

@@ -211,8 +211,6 @@ export interface ConfigParameters {
};
cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean;
// Web search providers
tavilyApiKey?: string;
}
export class Config {
@@ -288,7 +286,6 @@ export class Config {
};
private readonly cliVersion?: string;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly tavilyApiKey?: string;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -366,9 +363,6 @@ export class Config {
this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false;
// Web search
this.tavilyApiKey = params.tavilyApiKey;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
}
@@ -701,11 +695,6 @@ export class Config {
return this.summarizeToolOutput;
}
// Web search provider configuration
getTavilyApiKey(): string | undefined {
return this.tavilyApiKey;
}
getIdeModeFeature(): boolean {
return this.ideModeFeature;
}
@@ -816,10 +805,7 @@ export class Config {
registerCoreTool(ReadManyFilesTool, this);
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
// Conditionally register web search tool only if Tavily API key is set
if (this.getTavilyApiKey()) {
registerCoreTool(WebSearchTool, this);
}
registerCoreTool(WebSearchTool, this);
await registry.discoverAllTools();
return registry;

View File

@@ -1160,90 +1160,6 @@ describe('OpenAIContentGenerator', () => {
);
});
it('should handle MCP tools with parametersJsonSchema', async () => {
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Test' }] }],
model: 'gpt-4',
config: {
tools: [
{
callTool: vi.fn(),
tool: () =>
Promise.resolve({
functionDeclarations: [
{
name: 'list-items',
description: 'Get a list of items',
parametersJsonSchema: {
type: 'object',
properties: {
page_number: {
type: 'number',
description: 'Page number',
},
page_size: {
type: 'number',
description: 'Number of items per page',
},
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
}),
} as unknown as CallableTool,
],
},
};
await generator.generateContent(request, 'test-prompt-id');
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
type: 'function',
function: {
name: 'list-items',
description: 'Get a list of items',
parameters: {
type: 'object',
properties: {
page_number: {
type: 'number',
description: 'Page number',
},
page_size: {
type: 'number',
description: 'Number of items per page',
},
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
},
],
}),
);
});
it('should handle nested parameter objects', async () => {
const mockResponse = {
id: 'chatcmpl-123',

View File

@@ -26,7 +26,6 @@ import { logApiError, logApiResponse } from '../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
import { Config } from '../config/config.js';
import { openaiLogger } from '../utils/openaiLogger.js';
import { safeJsonParse } from '../utils/safeJsonParse.js';
// OpenAI API type definitions for logging
interface OpenAIToolCall {
@@ -366,6 +365,8 @@ export class OpenAIContentGenerator implements ContentGenerator {
);
}
// console.log('createParams', createParams);
const stream = (await this.client.chat.completions.create(
createParams,
)) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>;
@@ -740,16 +741,6 @@ export class OpenAIContentGenerator implements ContentGenerator {
return convertTypes(converted) as Record<string, unknown> | undefined;
}
/**
* Converts Gemini tools to OpenAI format for API compatibility.
* Handles both Gemini tools (using 'parameters' field) and MCP tools (using 'parametersJsonSchema' field).
*
* Gemini tools use a custom parameter format that needs conversion to OpenAI JSON Schema format.
* MCP tools already use JSON Schema format in the parametersJsonSchema field and can be used directly.
*
* @param geminiTools - Array of Gemini tools to convert
* @returns Promise resolving to array of OpenAI-compatible tools
*/
private async convertGeminiToolsToOpenAI(
geminiTools: ToolListUnion,
): Promise<OpenAI.Chat.ChatCompletionTool[]> {
@@ -770,31 +761,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (actualTool.functionDeclarations) {
for (const func of actualTool.functionDeclarations) {
if (func.name && func.description) {
let parameters: Record<string, unknown> | undefined;
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiParametersToOpenAI(
func.parameters as Record<string, unknown>,
);
}
openAITools.push({
type: 'function',
function: {
name: func.name,
description: func.description,
parameters,
parameters: this.convertGeminiParametersToOpenAI(
(func.parameters || {}) as Record<string, unknown>,
),
},
});
}
@@ -1177,7 +1151,12 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (toolCall.function) {
let args: Record<string, unknown> = {};
if (toolCall.function.arguments) {
args = safeJsonParse(toolCall.function.arguments, {});
try {
args = JSON.parse(toolCall.function.arguments);
} catch (error) {
console.error('Failed to parse function arguments:', error);
args = {};
}
}
parts.push({
@@ -1293,7 +1272,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (accumulatedCall.name) {
let args: Record<string, unknown> = {};
if (accumulatedCall.arguments) {
args = safeJsonParse(accumulatedCall.arguments, {});
try {
args = JSON.parse(accumulatedCall.arguments);
} catch (error) {
console.error(
'Failed to parse final tool call arguments:',
error,
);
}
}
parts.push({

View File

@@ -82,12 +82,10 @@ export class QwenLogger {
return undefined;
if (!QwenLogger.instance) {
QwenLogger.instance = new QwenLogger(config);
process.on(
'exit',
QwenLogger.instance.shutdown.bind(QwenLogger.instance),
);
}
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
return QwenLogger.instance;
}

View File

@@ -19,27 +19,23 @@ describe('WebFetchTool', () => {
describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const params = { prompt: 'fetch https://example.com' };
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'Fetch content from https://example.com and process with: summarize this page',
prompt: 'fetch https://example.com',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should return github urls as-is in confirmation details', async () => {
it('should convert github urls to raw format', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
url: 'https://github.com/google/gemini-react/blob/main/README.md',
prompt: 'summarize the README',
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
@@ -47,8 +43,10 @@ describe('WebFetchTool', () => {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',
urls: ['https://github.com/google/gemini-react/blob/main/README.md'],
'fetch https://github.com/google/gemini-react/blob/main/README.md',
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
onConfirm: expect.any(Function),
});
});
@@ -58,10 +56,7 @@ describe('WebFetchTool', () => {
...mockConfig,
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
} as unknown as Config);
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const params = { prompt: 'fetch https://example.com' };
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toBe(false);
@@ -73,10 +68,7 @@ describe('WebFetchTool', () => {
...mockConfig,
setApprovalMode,
} as unknown as Config);
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const params = { prompt: 'fetch https://example.com' };
const confirmationDetails = await tool.shouldConfirmExecute(params);
if (

View File

@@ -13,25 +13,49 @@ import {
Icon,
} from './tools.js';
import { Type } from '@google/genai';
import { getErrorMessage } from '../utils/errors.js';
import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout } from '../utils/fetch.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
// Helper function to extract URLs from a string
function extractUrls(text: string): string[] {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
}
// Interfaces for grounding metadata (similar to web-search.ts)
interface GroundingChunkWeb {
uri?: string;
title?: string;
}
interface GroundingChunkItem {
web?: GroundingChunkWeb;
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string;
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
}
/**
* Parameters for the WebFetch tool
*/
export interface WebFetchToolParams {
/**
* The URL to fetch content from
*/
url: string;
/**
* The prompt to run on the fetched content
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
*/
prompt: string;
}
@@ -46,20 +70,17 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
super(
WebFetchTool.Name,
'WebFetch',
'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large',
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
Icon.Globe,
{
properties: {
url: {
description: 'The URL to fetch content from',
type: Type.STRING,
},
prompt: {
description: 'The prompt to run on the fetched content',
description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
type: Type.STRING,
},
},
required: ['url', 'prompt'],
required: ['prompt'],
type: Type.OBJECT,
},
);
@@ -69,11 +90,19 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
}
}
private async executeFetch(
private async executeFallback(
params: WebFetchToolParams,
signal: AbortSignal,
): Promise<ToolResult> {
let url = params.url;
const urls = extractUrls(params.prompt);
if (urls.length === 0) {
return {
llmContent: 'Error: No URL found in the prompt for fallback.',
returnDisplay: 'Error: No URL found in the prompt for fallback.',
};
}
// For now, we only support one URL for fallback
let url = urls[0];
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
@@ -101,7 +130,7 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${params.prompt}".
I have fetched the content from ${params.url}. Please use the following content to answer the user's request.
I was unable to access the URL directly. Instead, I have fetched the raw content of the page. Please use the following content to answer the user's request. Do not attempt to access the URL again.
---
${textContent}
@@ -114,11 +143,11 @@ ${textContent}
const resultText = getResponseText(result) || '';
return {
llmContent: resultText,
returnDisplay: `Content from ${params.url} processed successfully.`,
returnDisplay: `Content for ${url} processed using fallback fetch.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
@@ -131,17 +160,14 @@ ${textContent}
if (errors) {
return errors;
}
if (!params.url || params.url.trim() === '') {
return "The 'url' parameter cannot be empty.";
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
}
if (
!params.url.startsWith('http://') &&
!params.url.startsWith('https://')
!params.prompt.includes('http://') &&
!params.prompt.includes('https://')
) {
return "The 'url' must be a valid URL starting with http:// or https://.";
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty.";
return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
}
return null;
}
@@ -151,7 +177,7 @@ ${textContent}
params.prompt.length > 100
? params.prompt.substring(0, 97) + '...'
: params.prompt;
return `Fetching content from ${params.url} and processing with prompt: "${displayPrompt}"`;
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
}
async shouldConfirmExecute(
@@ -166,11 +192,22 @@ ${textContent}
return false;
}
// Perform GitHub URL conversion here to differentiate between user-provided
// URL and the actual URL to be fetched.
const urls = extractUrls(params.prompt).map((url) => {
if (url.includes('github.com') && url.includes('/blob/')) {
return url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
return url;
});
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: `Fetch content from ${params.url} and process with: ${params.prompt}`,
urls: [params.url],
prompt: params.prompt,
urls,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
@@ -192,6 +229,132 @@ ${textContent}
};
}
return this.executeFetch(params, signal);
const userPrompt = params.prompt;
const urls = extractUrls(userPrompt);
const url = urls[0];
const isPrivate = isPrivateIp(url);
if (isPrivate) {
return this.executeFallback(params, signal);
}
const geminiClient = this.config.getGeminiClient();
try {
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: userPrompt }] }],
{ tools: [{ urlContext: {} }] },
signal, // Pass signal
);
console.debug(
`[WebFetchTool] Full response for prompt "${userPrompt.substring(
0,
50,
)}...":`,
JSON.stringify(response, null, 2),
);
let responseText = getResponseText(response) || '';
const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
// Error Handling
let processingError = false;
if (
urlContextMeta?.urlMetadata &&
urlContextMeta.urlMetadata.length > 0
) {
const allStatuses = urlContextMeta.urlMetadata.map(
(m) => m.urlRetrievalStatus,
);
if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
processingError = true;
}
} else if (!responseText.trim() && !sources?.length) {
// No URL metadata and no content/sources
processingError = true;
}
if (
!processingError &&
!responseText.trim() &&
(!sources || sources.length === 0)
) {
// Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
processingError = true;
}
if (processingError) {
return this.executeFallback(params, signal);
}
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
insertions.sort((a, b) => b.index - a.index);
const responseChars = responseText.split('');
insertions.forEach((insertion) => {
responseChars.splice(insertion.index, 0, insertion.marker);
});
responseText = responseChars.join('');
}
if (sourceListFormatted.length > 0) {
responseText += `
Sources:
${sourceListFormatted.join('\n')}`;
}
}
const llmContent = responseText;
console.debug(
`[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
llmContent,
);
return {
llmContent,
returnDisplay: `Content processed from prompt.`,
};
} catch (error: unknown) {
const errorMessage = `Error processing web content for prompt "${userPrompt.substring(
0,
50,
)}...": ${getErrorMessage(error)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
}
}

View File

@@ -4,24 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GroundingMetadata } from '@google/genai';
import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js';
import { Config } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
interface TavilyResultItem {
title: string;
url: string;
content?: string;
score?: number;
published_date?: string;
interface GroundingChunkWeb {
uri?: string;
title?: string;
}
interface TavilySearchResponse {
query: string;
answer?: string;
results: TavilyResultItem[];
interface GroundingChunkItem {
web?: GroundingChunkWeb;
// Other properties might exist if needed in the future
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string; // text is optional as per the example
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
confidenceScores?: number[]; // Optional as per example
}
/**
@@ -31,6 +42,7 @@ export interface WebSearchToolParams {
/**
* The search query.
*/
query: string;
}
@@ -38,23 +50,25 @@ export interface WebSearchToolParams {
* Extends ToolResult to include sources for web search.
*/
export interface WebSearchToolResult extends ToolResult {
sources?: Array<{ title: string; url: string }>;
sources?: GroundingMetadata extends { groundingChunks: GroundingChunkItem[] }
? GroundingMetadata['groundingChunks']
: GroundingChunkItem[];
}
/**
* A tool to perform web searches using Tavily API.
* A tool to perform web searches using Google Search via the Gemini API.
*/
export class WebSearchTool extends BaseTool<
WebSearchToolParams,
WebSearchToolResult
> {
static readonly Name: string = 'web_search';
static readonly Name: string = 'google_web_search';
constructor(private readonly config: Config) {
super(
WebSearchTool.Name,
'TavilySearch',
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
'GoogleSearch',
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
Icon.Globe,
{
type: Type.OBJECT,
@@ -92,7 +106,7 @@ export class WebSearchTool extends BaseTool<
async execute(
params: WebSearchToolParams,
_signal: AbortSignal,
signal: AbortSignal,
): Promise<WebSearchToolResult> {
const validationError = this.validateToolParams(params);
if (validationError) {
@@ -101,83 +115,79 @@ export class WebSearchTool extends BaseTool<
returnDisplay: validationError,
};
}
const apiKey = this.config.getTavilyApiKey() || process.env.TAVILY_API_KEY;
if (!apiKey) {
return {
llmContent:
'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.',
returnDisplay:
'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.',
};
}
const geminiClient = this.config.getGeminiClient();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
query: params.query,
search_depth: 'advanced',
max_results: 5,
include_answer: true,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as TavilySearchResponse;
const sources = (data.results || []).map((r) => ({
title: r.title,
url: r.url,
}));
const sourceListFormatted = sources.map(
(s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`,
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: params.query }] }],
{ tools: [{ googleSearch: {} }] },
signal,
);
let content = data.answer?.trim() || '';
if (!content) {
// Fallback: build a concise summary from top results
content = sources
.slice(0, 3)
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
.join('\n');
}
const responseText = getResponseText(response);
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
if (sourceListFormatted.length > 0) {
content += `\n\nSources:\n${sourceListFormatted.join('\n')}`;
}
if (!content.trim()) {
if (!responseText || !responseText.trim()) {
return {
llmContent: `No search results or information found for query: "${params.query}"`,
returnDisplay: 'No information found.',
};
}
let modifiedResponseText = responseText;
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'No URI';
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
// Sort insertions by index in descending order to avoid shifting subsequent indices
insertions.sort((a, b) => b.index - a.index);
const responseChars = modifiedResponseText.split(''); // Use new variable
insertions.forEach((insertion) => {
// Fixed arrow function syntax
responseChars.splice(insertion.index, 0, insertion.marker);
});
modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
}
if (sourceListFormatted.length > 0) {
modifiedResponseText +=
'\n\nSources:\n' + sourceListFormatted.join('\n'); // Fixed string concatenation
}
}
return {
llmContent: `Web search results for "${params.query}":\n\n${content}`,
llmContent: `Web search results for "${params.query}":\n\n${modifiedResponseText}`,
returnDisplay: `Search results for "${params.query}" returned.`,
sources,
};
} catch (error: unknown) {
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(
error,
)}`;
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(error)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,

View File

@@ -1,149 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonParse } from './safeJsonParse.js';
describe('safeJsonParse', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('valid JSON parsing', () => {
it('should parse valid JSON correctly', () => {
const validJson = '{"name": "test", "value": 123}';
const result = safeJsonParse(validJson);
expect(result).toEqual({ name: 'test', value: 123 });
});
it('should parse valid JSON arrays', () => {
const validArray = '["item1", "item2", "item3"]';
const result = safeJsonParse(validArray);
expect(result).toEqual(['item1', 'item2', 'item3']);
});
it('should parse valid JSON with nested objects', () => {
const validNested =
'{"config": {"paths": ["testlogs/*.py"], "options": {"recursive": true}}}';
const result = safeJsonParse(validNested);
expect(result).toEqual({
config: {
paths: ['testlogs/*.py'],
options: { recursive: true },
},
});
});
});
describe('malformed JSON with jsonrepair fallback', () => {
it('should handle malformed JSON with single quotes', () => {
const malformedJson = "{'name': 'test', 'value': 123}";
const result = safeJsonParse(malformedJson);
expect(result).toEqual({ name: 'test', value: 123 });
});
it('should handle malformed JSON with unquoted keys', () => {
const malformedJson = '{name: "test", value: 123}';
const result = safeJsonParse(malformedJson);
expect(result).toEqual({ name: 'test', value: 123 });
});
it('should handle malformed JSON with trailing commas', () => {
const malformedJson = '{"name": "test", "value": 123,}';
const result = safeJsonParse(malformedJson);
expect(result).toEqual({ name: 'test', value: 123 });
});
it('should handle malformed JSON with comments', () => {
const malformedJson = '{"name": "test", // comment\n "value": 123}';
const result = safeJsonParse(malformedJson);
expect(result).toEqual({ name: 'test', value: 123 });
});
});
describe('fallback behavior', () => {
it('should return fallback value for empty string', () => {
const emptyString = '';
const fallback = { default: 'value' };
const result = safeJsonParse(emptyString, fallback);
expect(result).toEqual(fallback);
});
it('should return fallback value for null input', () => {
const nullInput = null as unknown as string;
const fallback = { default: 'value' };
const result = safeJsonParse(nullInput, fallback);
expect(result).toEqual(fallback);
});
it('should return fallback value for undefined input', () => {
const undefinedInput = undefined as unknown as string;
const fallback = { default: 'value' };
const result = safeJsonParse(undefinedInput, fallback);
expect(result).toEqual(fallback);
});
it('should return empty object as default fallback', () => {
const invalidJson = 'invalid json';
const result = safeJsonParse(invalidJson);
// jsonrepair returns the original string for completely invalid JSON
expect(result).toEqual('invalid json');
});
it('should return custom fallback when parsing fails', () => {
const invalidJson = 'invalid json';
const customFallback = { error: 'parsing failed', data: null };
const result = safeJsonParse(invalidJson, customFallback);
// jsonrepair returns the original string for completely invalid JSON
expect(result).toEqual('invalid json');
});
});
describe('type safety', () => {
it('should preserve generic type when parsing valid JSON', () => {
const validJson = '{"name": "test", "value": 123}';
const result = safeJsonParse<{ name: string; value: number }>(validJson);
expect(result).toEqual({ name: 'test', value: 123 });
// TypeScript should infer the correct type
expect(typeof result.name).toBe('string');
expect(typeof result.value).toBe('number');
});
it('should return fallback type when parsing fails', () => {
const invalidJson = 'invalid json';
const fallback = { error: 'fallback' } as const;
const result = safeJsonParse(invalidJson, fallback);
// jsonrepair returns the original string for completely invalid JSON
expect(result).toEqual('invalid json');
// TypeScript should preserve the fallback type
expect(typeof result).toBe('string');
});
});
});

View File

@@ -1,45 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { jsonrepair } from 'jsonrepair';
/**
* Safely parse JSON string with jsonrepair fallback for malformed JSON.
* This function attempts to parse JSON normally first, and if that fails,
* it uses jsonrepair to fix common JSON formatting issues before parsing.
*
* @param jsonString - The JSON string to parse
* @param fallbackValue - The value to return if parsing fails completely
* @returns The parsed object or the fallback value
*/
export function safeJsonParse<T = Record<string, unknown>>(
jsonString: string,
fallbackValue: T = {} as T,
): T {
if (!jsonString || typeof jsonString !== 'string') {
return fallbackValue;
}
try {
// First attempt: try normal JSON.parse
return JSON.parse(jsonString) as T;
} catch (error) {
try {
// Second attempt: use jsonrepair to fix common JSON issues
const repairedJson = jsonrepair(jsonString);
// jsonrepair always returns a string, so we need to parse it
return JSON.parse(repairedJson) as T;
} catch (repairError) {
console.error('Failed to parse JSON even with jsonrepair:', {
originalError: error,
repairError,
jsonString,
});
return fallbackValue;
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.0.7",
"version": "0.1.18",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.0.7",
"version": "0.0.6",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {