From 33f6b4dbb69e9366e312a8b44dbed9d22e7cba5b Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 23 Aug 2026 17:28:10 +0900 Subject: fix(tools): PDFページングを損失なしカーソル方式へ再設計 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF本文をページ単位で抽出した後に文字数上限で切断しつつ、継続位置だけ次ページへ進めていたため、ページ末尾が復元不能になる問題を修正する。ツール側の本文上限とLLM側の結果上限の不整合、25ページの処理上限、添付経路での継続情報欠落も共通契約で解消する。 - 共通PageResultと、メタデータ込みの結果上限内で本文量を決めるフォーマッターを追加 - v2トークンにPDFのページ番号とページ内文字位置を保持し、v1トークンの読み取り互換性を維持 - pdf_to_text、scrape_url、search_and_fetchを返却済み本文だけカーソルが進む方式へ統一 - max_pdf_pagesを文書全体ではなく1呼び出しの処理上限として扱い、長い単一ページと26ページ目以降の継続取得に対応 - attachment:をセッション限定で解決し、既存添付も保存済みプレビューではなく元PDFから再解析 - Web取得ファイルをセッションとURL単位で重複排除し、HTMLおよびテキスト経路の全文ページングも維持 - 正常なページング結果の後段切断を禁止し、結果上限・コンテキスト予算・ラウンド上限では未取得状態を明示 - 日英プロンプト、UIメッセージ、ツールスキーマ、変更履歴を実装へ同期 - 実PDF、30ページ超、長い単一ページ、旧トークン、セッション分離、コンテキスト境界の回帰テストを追加 検証: - pytest: 130 passed - ruff check src tests: passed - mypy src: passed - c2144.pdf: 19ページ、抽出本文46015文字(ページ区切り込み46051文字)を8回で重複・欠落なく完全再構成 --- src/ollama_web/llm.py | 116 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 39 deletions(-) (limited to 'src/ollama_web/llm.py') diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py index 187b5e7..20485eb 100644 --- a/src/ollama_web/llm.py +++ b/src/ollama_web/llm.py @@ -14,6 +14,7 @@ from .config import settings from .i18n import t from .mcp import redact_secrets from .prompts import get_prompt +from .tools.helper.pagination import parse_paged_result, parse_token from .tools.registry import ToolRegistry, default_registry # Maximum successive tool-call rounds before we force a final answer. @@ -176,29 +177,22 @@ def _token_counts(response: Any) -> dict[str, int] | None: def _trim_tool_result(result: str, limit: int) -> str: """Truncate ``result`` to ``limit`` chars while keeping it readable. - When the result contains a ``---page-info---`` footer (emitted by paged - tools), the footer is preserved and only the body is truncated so the - model can still see the ``next_token`` needed to fetch the continuation. + Paged tools must fit their own complete envelope before advancing a cursor. + An oversized paged result is therefore rejected atomically instead of + being truncated into an unrecoverable content gap. """ if len(result) <= limit: return result - # Protect the page-info footer from truncation. ``split`` keeps the - # marker on the footer side so the ``---page-info---`` line is preserved. + # A paged tool must size its own body before advancing its cursor. Trimming + # it here would create an unrecoverable gap between visible text and the + # next token, so fail the call atomically and let the model repeat it. if _PAGE_INFO_MARKER in result: - body, footer = result.split(_PAGE_INFO_MARKER, 1) - footer = _PAGE_INFO_MARKER + footer - body_limit = limit - len(footer) - if body_limit <= 0: - # The footer alone exceeds the limit; keep it verbatim and drop the - # body entirely so the model can still continue paging. - return "…[tool result truncated]" + footer - if len(body) <= body_limit: - return result - cutoff = body[:body_limit].rsplit("\n\n", 1)[0] - if len(cutoff) < body_limit * 0.5: - cutoff = body[:body_limit].rsplit(" ", 1)[0] - return cutoff + "\n\n…[tool result truncated]" + footer + return ( + "Paged tool result exceeded the context-safe result limit. " + "Repeat the same tool call with a smaller max_chars value; no part " + "of this page was consumed." + ) # No footer: legacy truncation behaviour. cutoff = result[:limit].rsplit("\n\n", 1)[0] @@ -208,6 +202,23 @@ def _trim_tool_result(result: str, limit: int) -> str: return cutoff + "\n\n…[tool result truncated]" +def _paged_source_key(name: str, arguments: Any) -> tuple[str, str]: + """Identify one paged source without exposing its content.""" + if not isinstance(arguments, dict): + return name, "unknown" + if name == "search_and_fetch": + return name, f"{arguments.get('query', '')}|{arguments.get('file_type', '')}" + token = arguments.get("continue_from") + if isinstance(token, str) and token: + try: + payload = parse_token(token, name) + return name, str(payload.get("url", "unknown")) + except ValueError: + pass + source = arguments.get("url_or_path", arguments.get("url", "unknown")) + return name, str(source) + + def stream_chat_with_tools( messages: list[Message], model: str, @@ -242,6 +253,7 @@ def stream_chat_with_tools( messages = [_embed_images_in_content(m) if _is_image_message(m) else m for m in messages] cumulative_tool_chars = 0 + outstanding_paged_sources: set[tuple[str, str]] = set() MAX_EMPTY_RETRIES = 2 empty_retries = 0 @@ -339,6 +351,11 @@ def stream_chat_with_tools( messages.append(msg) if not tool_calls: + if outstanding_paged_sources: + yield { + "type": "warning", + "message": t("status.pagination_incomplete", lang=language), + } if is_empty: # Ran out of tool rounds while still empty; surface a warning. yield { @@ -358,14 +375,41 @@ def stream_chat_with_tools( for call in tool_calls: name = call.get("name", "") args = call.get("arguments", {}) + paged_source = _paged_source_key(name, args) yield {"type": "tool_start", "name": name, "arguments": _safe_tool_arguments(args)} t1 = time.time() - try: - result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args)) - except Exception as exc: # noqa: BLE001 - logger.warning("tool %s execution failed in %.2fs: %s", name, time.time() - t1, exc) - result = f"Tool execution failed: {exc}" + remaining_context = MAX_TOOL_CONTEXT_CHARS - ( + cumulative_tool_chars + total_result_len + ) + is_builtin_paged = name in {"scrape_url", "pdf_to_text", "search_and_fetch"} + if is_builtin_paged and remaining_context < settings.max_tool_result_chars: + result = ( + "Tool context budget exhausted before this paged call was executed. " + "Unread content remains; do not claim that the source was read in full." + ) + outstanding_paged_sources.add(paged_source) + logger.info("paged tool skipped name=%s reason=context_budget", name) + else: + try: + result = reg.execute( + name, args if isinstance(args, dict) else json.dumps(args) + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "tool %s execution failed in %.2fs: %s", name, time.time() - t1, exc + ) + result = f"Tool execution failed: {exc}" + raw_is_paged = _PAGE_INFO_MARKER in result + if raw_is_paged: + _, _, next_token = parse_paged_result(result) + if next_token and isinstance(args, dict): + paged_source = _paged_source_key( + name, {**args, "continue_from": next_token} + ) + outstanding_paged_sources.add(paged_source) + elif is_builtin_paged: + outstanding_paged_sources.discard(paged_source) # Always truncate per-tool result immediately so it does not bloat # the context even before we reach the cumulative limit. result = _safe_tool_result(_trim_tool_result(result, settings.max_tool_result_chars)) @@ -379,21 +423,6 @@ def stream_chat_with_tools( tool_results.append((name, result)) total_result_len += len(result) - # If the accumulated tool results are still too large, truncate each - # one more aggressively to keep ollama's context processing fast. - if total_result_len > MAX_TOOL_CONTEXT_CHARS: - budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200 # reserve footer per tool - max_per_tool = budget // max(1, len(tool_results)) - logger.warning( - "tool results too large (%d chars), truncating to %d chars per tool", - total_result_len, - max_per_tool, - ) - trimmed_results: list[tuple[str, str]] = [] - for name, result in tool_results: - trimmed_results.append((name, _trim_tool_result(result, max_per_tool))) - tool_results = trimmed_results - cumulative_tool_chars += sum(len(r) for _, r in tool_results) for name, result in tool_results: messages.append(Message(role="tool", tool_name=name, content=result)) @@ -410,10 +439,19 @@ def stream_chat_with_tools( "type": "status", "message": t("status.tool_limit_reached", lang=language), } + limit_instruction = get_prompt("tool_limit_instruction", lang=language) + if outstanding_paged_sources: + limit_instruction += "\n\n" + get_prompt( + "pagination_incomplete_instruction", lang=language + ) + yield { + "type": "warning", + "message": t("status.pagination_incomplete", lang=language), + } messages.append( Message( role="user", - content=get_prompt("tool_limit_instruction", lang=language), + content=limit_instruction, ) ) t0 = time.time() -- cgit v1.2.3