summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-02 16:18:29 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-02 16:18:29 +0900
commitaf1da765acafec4d7468a79d462427f4245b44db (patch)
tree994c95c506f91d9dd23fd39ed2be69a4d2d79b4e /tests
parent515f96a7779f117a7e37f854de2ded99160d0c76 (diff)
fix(tools): search_and_fetch のページングトークン破損バグを修正
## 背景 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 を削除
Diffstat (limited to 'tests')
-rw-r--r--tests/test_tools.py117
1 files changed, 117 insertions, 0 deletions
diff --git a/tests/test_tools.py b/tests/test_tools.py
index 67c66ef..a94ea52 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -354,6 +354,123 @@ def test_search_and_fetch_pagination_across_files(monkeypatch):
assert seen_titles == ["File1", "File2"]
+def test_make_token_ensure_ascii_true():
+ """Tokens are pure ASCII even with non-ASCII content (ensure_ascii=True)."""
+ token = make_token("scrape_url", "https://example.com/日本語", 0, title="論文–要約")
+ # base64url alphabet is pure ASCII; any non-ASCII byte would break this.
+ assert all(ord(c) < 128 for c in token)
+ payload = parse_token(token, "scrape_url")
+ assert payload["url"] == "https://example.com/日本語"
+ assert payload["title"] == "論文–要約"
+
+
+def test_search_and_fetch_token_excludes_body(monkeypatch):
+ """The continuation token must not embed the search snippet (body)."""
+ from ollama_web.tools import fetch as fetch_mod
+
+ matched = [
+ {
+ "title": "File1",
+ "href": "https://example.com/f1.txt",
+ "body": "X" * 5000, # large snippet that would bloat the token
+ }
+ ]
+ monkeypatch.setattr(fetch_mod, "web_search", lambda q, max_results=None: matched)
+ monkeypatch.setattr(
+ fetch_mod,
+ "fetch_public_bytes",
+ lambda url, timeout=None: (b"A" * 200, "", "text/plain"),
+ )
+ result = fetch_mod.search_and_fetch("query", "txt", max_chars=50)
+ _, _, next_token = parse_paged_result(result)
+ assert next_token is not None
+ payload = parse_token(next_token, "search_and_fetch")
+ embedded = payload.get("matched", [])
+ assert embedded, "matched list should be embedded for resume"
+ for entry in embedded:
+ assert "body" not in entry, "body must be stripped from the token"
+ assert "title" in entry and "href" in entry
+
+
+def test_search_and_fetch_token_size_reduced(monkeypatch):
+ """A large body snippet must not inflate the token."""
+ from ollama_web.tools import fetch as fetch_mod
+
+ matched = [
+ {
+ "title": "File1",
+ "href": "https://example.com/f1.txt",
+ "body": "B" * 10_000,
+ }
+ ]
+ monkeypatch.setattr(fetch_mod, "web_search", lambda q, max_results=None: matched)
+ monkeypatch.setattr(
+ fetch_mod,
+ "fetch_public_bytes",
+ lambda url, timeout=None: (b"A" * 200, "", "text/plain"),
+ )
+ result = fetch_mod.search_and_fetch("query", "txt", max_chars=50)
+ _, _, next_token = parse_paged_result(result)
+ assert next_token is not None
+ # Token should be far smaller than the 10KB body it would otherwise carry.
+ assert len(next_token) < 1000
+
+
+def test_search_and_fetch_continue_with_slim_token(monkeypatch):
+ """Resuming with a slim (body-less) token walks files correctly."""
+ from ollama_web.tools import fetch as fetch_mod
+
+ matched = [
+ {"title": "File1", "href": "https://example.com/f1.txt", "body": "ignored1"},
+ {"title": "File2", "href": "https://example.com/f2.txt", "body": "ignored2"},
+ ]
+ monkeypatch.setattr(fetch_mod, "web_search", lambda q, max_results=None: matched)
+ contents = {
+ "https://example.com/f1.txt": b"FILE1 " + b"A" * 200,
+ "https://example.com/f2.txt": b"FILE2 " + b"B" * 200,
+ }
+ monkeypatch.setattr(
+ fetch_mod,
+ "fetch_public_bytes",
+ lambda url, timeout=None: (contents[url], "", "text/plain"),
+ )
+
+ seen = []
+ token = None
+ for _ in range(20):
+ result = fetch_mod.search_and_fetch("query", "txt", max_chars=50, continue_from=token)
+ body_text, has_more, token = parse_paged_result(result)
+ if "File1" in body_text and "File1" not in seen:
+ seen.append("File1")
+ if "File2" in body_text and "File2" not in seen:
+ seen.append("File2")
+ if not has_more:
+ break
+ assert seen == ["File1", "File2"]
+
+
+def test_search_and_fetch_pagination_with_nonascii_title(monkeypatch):
+ """Non-ASCII titles/URLs survive a full pagination round-trip."""
+ from ollama_web.tools import fetch as fetch_mod
+
+ matched = [
+ {"title": "日本語–タイトル", "href": "https://example.com/論文.txt", "body": ""},
+ ]
+ monkeypatch.setattr(fetch_mod, "web_search", lambda q, max_results=None: matched)
+ monkeypatch.setattr(
+ fetch_mod,
+ "fetch_public_bytes",
+ lambda url, timeout=None: (b"A" * 200, "", "text/plain"),
+ )
+ result = fetch_mod.search_and_fetch("query", "txt", max_chars=50)
+ body_text, has_more, next_token = parse_paged_result(result)
+ assert "日本語–タイトル" in body_text
+ assert has_more is True
+ # Resuming must not raise even though the token carries non-ASCII fields.
+ result2 = fetch_mod.search_and_fetch("query", "txt", max_chars=50, continue_from=next_token)
+ assert isinstance(result2, str)
+
+
# ---------------------------------------------------------------------------
# pagination helper tests
# ---------------------------------------------------------------------------