2025/8/10 GeminiCLI の WebSearch Tool が挿入するマーカーの位置がおかしい
マーカー位置はバイト位置なのに文字 index として加工している
これ marker 入れていったら index ずれていくな...と思ったけど末尾から処理することで回避していて賢い
code:instruction.md
タスク: WebFetch ツールのマーカー挿入箇所のバグの修正及び Issue, PullRequest の作成
WebFetchTool (packages/core/src/tools/web-search.ts) の実装には潜在的なバグが存在します。
これを修正し、取り込んでもらうための Issue と PullRequest を作成したいです。
実際に Issue, PullRequest は作成せず、以下の出力を行ってください。
必要に応じて gh コマンドでリポジトリを検索し、他の Issue, PullRequest を参考にすること。
## 期待する出力
- issue.md の記述
- .github/ISSUE_TEMPLATE/bug_report.yml に従う
- pull_request.md の記述
- .github/pull_request_template.md に従う
- issue で十分説明されていれば簡潔でよい
- web-search.ts の修正
- 可能であれば web-search.test.ts にテストケースを実装
- モックが複雑になる可能性があります、シンプルな記述を目指してください
## バグの内容
WebFetchTool には、出典を示すマーカー (例: [1][2] など)を挿入する機能があります。
ただし、API のレスポンスでは、startIndex, endIndex が byte 位置で返るのに対し、
WebFetchTool の実装では、これを文字位置として扱っています。
そのためマルチバイト文字を含む出力では、マーカーが意図しない位置に挿入されます
再現コードは repr-marker/marker-pos.js です。
$ GEMINI_API_KEY=****** node marker-pos.js で実行できます。
## 修正の方針
- 既存のコードを極力尊重する
- index を編集対象文字列の byte 位置として扱ってマーカーを挿入する
- 長い文字列を複数回編集するため実行時のパフォーマンスに配慮する、ただし元の実装より悪化しなければ構わない
- コードは、Node.js 環境でもブラウザ環境でも動作するように実装する
- byte 位置に文字を挿入する際、マルチバイト文字の途中で挿入する可能性を避ける
- API レスポンスである segment の endIndex がマルチバイト文字の途中を指さないものとして信用してよい
- ただし、もしマルチバイト文字の途中を指す index が含まれる際、文字列全体が破壊されず部分的な文字化け程度にとどまるように実装する
- テストケースは最小限でよい、web-fetch.test.ts を参考にモックしてもよい
まあだるい感じのコードになるしモックもカスだからほぼ書き直すわな、英作文は最高です
送った
なんかもっと簡単に書けないか版
code:search.ts
import { GoogleGenAI } from "@google/genai";
import { env } from "./env.js";
const genai = new GoogleGenAI({
vertexai: true,
project: env.GEMINI_PROJECT,
location: env.GEMINI_LOCATION,
});
export type WebSearchResult = {
text: string;
};
export async function webSearch(
query: string,
signal: AbortSignal
): Promise<WebSearchResult> {
const res = await genai.models.generateContent({
model: env.GEMINI_MODEL,
config: {
tools: googleSearch: {} },
abortSignal: signal,
},
});
const text = (res.candidates?.0?.content?.parts || []) .map((p) => p.text || "")
.join("")
.trim();
if (!text) {
return {
text: No search results or information found for query: "${query}",
};
}
const { groundingSupports, groundingChunks } =
res.candidates?.0?.groundingMetadata || {}; const insertions: { pos: number; marker: string }[] = (
groundingSupports || []
)
.flatMap((support) => {
const { segment, groundingChunkIndices } = support;
if (segment?.endIndex && groundingChunkIndices) {
const marker = groundingChunkIndices.map((i) => [${i + 1}]).join("");
return { pos: segment.endIndex, marker };
} else {
return [];
}
})
.sort((a, b) => b.pos - a.pos);
let textBytes = new TextEncoder().encode(text);
for (const { pos, marker } of insertions) {
const markerBytes = new TextEncoder().encode(marker);
textBytes = new Uint8Array([
...textBytes.slice(0, pos),
...markerBytes,
...textBytes.slice(pos),
]);
}
const modifiedText = new TextDecoder().decode(textBytes);
const sourceList = (groundingChunks || []).map(
(s, i) =>
[${i + 1}] ${s.web?.title || "Untitled"} (${s.web?.uri || "No URL"})
);
const sourceListPart =
sourceList.length > 0 ? \n\nSources:\n${sourceList.join("\n")} : "";
return {
text: [
Web search results for "${query}":\n\n,
modifiedText,
sourceListPart,
].join(""),
};
}