From af1da765acafec4d7468a79d462427f4245b44db Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 2 Aug 2026 16:18:29 +0900 Subject: fix(tools): search_and_fetch のページングトークン破損バグを修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 search_and_fetch の next_token / continue_from によるページング継続が PDF 形式で間欠的に失敗するバグが報告された。発生エラー: 1. payload is not valid JSON: Expecting ':' delimiter 2. 'utf-8' codec can't decode byte 0xda in position 278 1ページ目の取得自体は成功し、next_token 返却まで正常だが、 next_token を continue_from に渡した2ページ目でエラーが発生。 txt/md/csv/json では安定動作し、PDF で間欠的・再現性なし。 ## 根本原因 コード精査の結果、報告の「非UTF-8バイト混入」仮説に対し、より有力な 主因として「トークン巨大化による末尾欠損」を特定: - _render_file_page() は matched 配列(title/href/body 全部入り)を トークンに埋め込んでいたが、レンダリングで使うのは title と href のみ。body(DuckDuckGo のスニペット、PDF 論文要約で数KB)は未使用 - 候補複数件でトークンが数KB〜十数KBに膨張 - LLM が continue_from にコピーする際、コンテキスト長や _trim_tool_result の切り詰めでトークン末尾が欠損 - base64 デコード結果が不正になり JSONDecodeError(JSON途中切断) と UnicodeDecodeError(マルチバイト境界切断)の両エラーが発生 - PDF で間欠的・再現性なしは、検索結果セットごとにスニペット長が 変動しトークンサイズが破損閾値を前後するためと説明がつく 補助要因: make_token() の ensure_ascii=False が、末尾欠損時に マルチバイト文字境界で切れる確率を上げていた。 ## 修正内容(案A: ユーザー承認済み) 1. pagination.py の make_token() を ensure_ascii=True 化 - トークンを純ASCII化し、末尾欠損でも UnicodeDecodeError を防止 - 非ASCII文字は \uXXXX エスケープ(6バイト固定)で安定化 2. fetch.py に _slim_matched() ヘルパーを追加 - matched から body を除外し title+href のみ埋め込み - 3箇所の make_token 呼び出し(fetch失敗時・ファイル内継続時・ 次ファイル移行時)を matched=_slim_matched(matched) に変更 - トークンサイズを数KB〜十数KB → 数百バイトに削減 3. トークン形式バージョン v は 1維持(構造不変・後方互換) - 旧トークン(body入り・ensure_ascii=False)も parse_token で 正常デコード可能 - 再検索回避機能も維持 ## テスト tests/test_tools.py に新規テスト5件を追加: - test_make_token_ensure_ascii_true - test_search_and_fetch_token_excludes_body - test_search_and_fetch_token_size_reduced - test_search_and_fetch_continue_with_slim_token - test_search_and_fetch_pagination_with_nonascii_title ## 検証結果 - pytest: 113 passed (新規5件含む、既存回帰なし) - ruff check src/ tests/: All checks passed - mypy src/: Success: no issues found in 30 source files ## ドキュメント docs/tools-schema.md に実装ベースの差分を反映: - make_token() の説明に ensure_ascii=True 化の旨を追記 - search_and_fetch 処理フローの注記に _slim_matched() による body 除外とトークンサイズ削減の旨を追記 - トークンペイロード構造の matched 例から body を削除 --- src/ollama_web/tools/fetch.py | 20 +++++++++++++++++--- src/ollama_web/tools/helper/pagination.py | 5 ++++- 2 files changed, 21 insertions(+), 4 deletions(-) (limited to 'src/ollama_web/tools') diff --git a/src/ollama_web/tools/fetch.py b/src/ollama_web/tools/fetch.py index a9a92f8..65ec511 100644 --- a/src/ollama_web/tools/fetch.py +++ b/src/ollama_web/tools/fetch.py @@ -39,6 +39,20 @@ _FILETYPE_SUFFIXES: dict[str, str] = { _TOOL_NAME = "search_and_fetch" +def _slim_matched(matched: list[dict[str, str]]) -> list[dict[str, str]]: + """Return a token-embeddable view of ``matched`` without the ``body`` field. + + ``search_and_fetch`` embeds the candidate list in its continuation token + so subsequent pages can resume without re-running the web search. The + rendering layer only uses ``title`` and ``href``; the search-engine + snippet (``body``) is unused and can be large (multi-KB for PDF + abstracts). Including it inflates the token to the point where the + model or tool-result trimming may truncate it, corrupting the base64 + payload. Stripping ``body`` keeps the token to a few hundred bytes. + """ + return [{"title": m.get("title", ""), "href": m.get("href", "")} for m in matched] + + def set_session_context(session_id: str | None) -> None: """Set the active session id so fetched files can be persisted later.""" # No-op marker kept for future hooks; storage is keyed by session_id directly. @@ -295,7 +309,7 @@ def _render_file_page( _TOOL_NAME, url, 0, - matched=matched, + matched=_slim_matched(matched), file_idx=file_idx + 1, file_offset=0, ) @@ -328,7 +342,7 @@ def _render_file_page( _TOOL_NAME, url, file_offset + per_file_chars, - matched=matched, + matched=_slim_matched(matched), file_idx=file_idx, file_offset=file_offset + per_file_chars, ) @@ -340,7 +354,7 @@ def _render_file_page( _TOOL_NAME, url, 0, - matched=matched, + matched=_slim_matched(matched), file_idx=file_idx + 1, file_offset=0, ) diff --git a/src/ollama_web/tools/helper/pagination.py b/src/ollama_web/tools/helper/pagination.py index 7f18ac8..748338b 100644 --- a/src/ollama_web/tools/helper/pagination.py +++ b/src/ollama_web/tools/helper/pagination.py @@ -64,7 +64,10 @@ def make_token(tool: str, url: str, offset: int, **extra: Any) -> str: "offset": int(offset), } payload.update(extra) - raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + # ensure_ascii=True keeps the token pure ASCII so truncation of the + # token by the model or tool-result trimming cannot split a multibyte + # UTF-8 sequence (which would raise UnicodeDecodeError on decode). + raw = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("ascii") return _b64url_encode(raw) -- cgit v1.2.3