summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-24 18:11:10 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-24 18:11:10 +0900
commitf7e2894856c10d8a1e2d429a7858d9f143c42404 (patch)
treefc916d63947acead39868968db26ecd80854579c /src
parent374ba05fe2a4022d95ed45322a13e9eef3ed59de (diff)
Web検索などのツール呼び出しによる応答不能を回避
Diffstat (limited to 'src')
-rw-r--r--src/ollama_web/config.py1
-rw-r--r--src/ollama_web/llm.py166
-rw-r--r--src/ollama_web/routes/chat.py15
-rw-r--r--src/ollama_web/static/app.js32
-rw-r--r--src/ollama_web/tools/pdf.py3
5 files changed, 211 insertions, 6 deletions
diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py
index 44947dd..7b6d8f9 100644
--- a/src/ollama_web/config.py
+++ b/src/ollama_web/config.py
@@ -28,6 +28,7 @@ class Settings:
search_max_results: int = 5
fetch_max_results: int = 3
fetch_max_chars: int = 8000
+ max_tool_result_chars: int = 3000
settings = Settings()
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
index 92f4ce5..40af2d4 100644
--- a/src/ollama_web/llm.py
+++ b/src/ollama_web/llm.py
@@ -13,15 +13,31 @@ from .config import settings
from .tools.registry import ToolRegistry, default_registry
# Maximum successive tool-call rounds before we force a final answer.
+# Some models request many tools per round; we keep this bounded but large
+# enough to let iterative research complete (e.g. search, then fetch).
MAX_TOOL_ROUNDS = 5
+# Upper bound on the cumulative size of tool results sent back to ollama.
+# Larger values can cause ollama to spend a very long time processing the
+# context between rounds.
+MAX_TOOL_CONTEXT_CHARS = 24000
+
# Type alias matching ollama's expected tools signature.
ToolCallable = Callable[..., Any]
def get_client(host: str | None = None) -> ollama.Client:
"""Return an ``ollama.Client`` configured for the configured host."""
- return ollama.Client(host=host or settings.ollama_host, timeout=settings.ollama_timeout)
+ import httpx
+
+ timeout = httpx.Timeout(
+ settings.ollama_timeout,
+ connect=10.0,
+ read=settings.ollama_timeout,
+ write=10.0,
+ pool=10.0,
+ )
+ return ollama.Client(host=host or settings.ollama_host, timeout=timeout)
def list_models(host: str | None = None) -> list[str]:
@@ -106,13 +122,27 @@ def chat_with_tools(
The final assistant message is streamed token-by-token via ``delta`` events.
"""
+ import logging
+
+ logger = logging.getLogger("ollama_web.llm")
+
raw_client = get_client(host)
reg = registry or default_registry()
tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+ cumulative_tool_chars = 0
+
rounds = 0
while rounds < MAX_TOOL_ROUNDS:
rounds += 1
+ logger.info(
+ "tool round %d/%d start messages=%d model=%s cumulative_tool_chars=%d",
+ rounds,
+ MAX_TOOL_ROUNDS,
+ len(messages),
+ model,
+ cumulative_tool_chars,
+ )
try:
resp = cast(Any, raw_client).chat(
model=model,
@@ -122,6 +152,7 @@ def chat_with_tools(
stream=False,
)
except Exception as exc: # noqa: BLE001
+ logger.warning("ollama chat call failed: %s", exc)
yield {"type": "error", "message": str(exc)}
return
@@ -141,15 +172,48 @@ def chat_with_tools(
messages.append(_assistant_message(resp))
# Execute each requested tool and append tool-role messages.
+ tool_results: list[tuple[str, str]] = []
for call in calls:
name = call.get("name", "")
args = call.get("arguments", {})
yield {"type": "tool_start", "name": name, "arguments": args}
result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args))
+ result = _trim_tool_result(result, settings.max_tool_result_chars)
yield {"type": "tool_end", "name": name, "result": result}
+ tool_results.append((name, result))
+
+ if sum(len(r) for _, r in tool_results) > MAX_TOOL_CONTEXT_CHARS:
+ budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200
+ max_per_tool = budget // max(1, len(tool_results))
+ logger.warning(
+ "tool results too large (%d chars), truncating to %d chars per tool",
+ sum(len(r) for _, r in tool_results),
+ max_per_tool,
+ )
+ tool_results = [(n, _trim_tool_result(r, max_per_tool)) for n, r in tool_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))
# Exceeded tool rounds: ask for a final answer without tools.
+ logger.info(
+ "max tool rounds exceeded, requesting final answer without tools (cumulative_tool_chars=%d)",
+ cumulative_tool_chars,
+ )
+ yield {
+ "type": "status",
+ "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…",
+ }
+ messages.append(
+ Message(
+ role="user",
+ content=(
+ "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、"
+ "ユーザーが求めた回答を最終的にまとめてください。"
+ ),
+ )
+ )
try:
resp = cast(Any, raw_client).chat(
model=model,
@@ -158,6 +222,7 @@ def chat_with_tools(
stream=False,
)
except Exception as exc: # noqa: BLE001
+ logger.warning("final ollama chat call failed: %s", exc)
yield {"type": "error", "message": str(exc)}
return
content = _assistant_content(resp)
@@ -169,6 +234,18 @@ def chat_with_tools(
yield {"type": "done"}
+def _trim_tool_result(result: str, limit: int) -> str:
+ """Truncate ``result`` to ``limit`` chars while keeping it readable."""
+ if len(result) <= limit:
+ return result
+ # Prefer cutting at a paragraph boundary.
+ cutoff = result[:limit].rsplit("\n\n", 1)[0]
+ if len(cutoff) < limit * 0.5:
+ # No good paragraph boundary; cut at a space instead.
+ cutoff = result[:limit].rsplit(" ", 1)[0]
+ return cutoff + "\n\n…[tool result truncated]"
+
+
def stream_chat_with_tools(
messages: list[Message],
model: str,
@@ -183,15 +260,31 @@ def stream_chat_with_tools(
events are emitted inline so the UI can render them inside the assistant
message.
"""
+ import logging
+ import time
+
+ logger = logging.getLogger("ollama_web.llm")
+
raw_client = get_client(host)
reg = registry or default_registry()
tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+ cumulative_tool_chars = 0
+
rounds = 0
while rounds < MAX_TOOL_ROUNDS:
rounds += 1
full_message: dict[str, Any] = {"role": "assistant", "content": "", "thinking": ""}
tool_calls: list[dict[str, Any]] = []
+ logger.info(
+ "tool round %d/%d start messages=%d model=%s cumulative_tool_chars=%d",
+ rounds,
+ MAX_TOOL_ROUNDS,
+ len(messages),
+ model,
+ cumulative_tool_chars,
+ )
+ t0 = time.time()
try:
stream = cast(Any, raw_client).chat(
model=model,
@@ -201,10 +294,13 @@ def stream_chat_with_tools(
stream=True,
)
except Exception as exc: # noqa: BLE001
+ logger.warning("ollama chat call failed in %.2fs: %s", time.time() - t0, exc)
yield {"type": "error", "message": str(exc)}
return
+ chunk_count = 0
for chunk in stream:
+ chunk_count += 1
thinking = _assistant_thinking(chunk)
if thinking:
full_message.setdefault("thinking", "")
@@ -221,6 +317,13 @@ def stream_chat_with_tools(
if c not in tool_calls:
tool_calls.append(c)
+ logger.info(
+ "ollama stream finished round=%d chunks=%d elapsed=%.2fs",
+ rounds,
+ chunk_count,
+ time.time() - t0,
+ )
+
# Reconstruct the assistant message including any tool_calls so the
# next round can see them.
if tool_calls:
@@ -233,17 +336,67 @@ def stream_chat_with_tools(
yield {"type": "done"}
return
+ logger.info("executing %d tool call(s) round=%d", len(tool_calls), rounds)
+ tool_results: list[tuple[str, str]] = []
+ total_result_len = 0
for call in tool_calls:
name = call.get("name", "")
args = call.get("arguments", {})
yield {"type": "tool_start", "name": name, "arguments": args}
- result = reg.execute(
- name, args if isinstance(args, dict) else json.dumps(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}"
+ # Always truncate per-tool result immediately so it does not bloat
+ # the context even before we reach the cumulative limit.
+ result = _trim_tool_result(result, settings.max_tool_result_chars)
+ logger.info("tool %s executed len=%d elapsed=%.2fs", name, len(result), time.time() - t1)
yield {"type": "tool_end", "name": name, "result": result}
+ 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))
# Exceeded tool rounds: ask for a final answer without tools.
+ logger.info(
+ "max tool rounds exceeded, requesting final answer without tools (cumulative_tool_chars=%d)",
+ cumulative_tool_chars,
+ )
+ yield {
+ "type": "status",
+ "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…",
+ }
+ messages.append(
+ Message(
+ role="user",
+ content=(
+ "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、"
+ "ユーザーが求めた回答を最終的にまとめてください。"
+ ),
+ )
+ )
+ t0 = time.time()
try:
stream = cast(Any, raw_client).chat(
model=model,
@@ -252,9 +405,11 @@ def stream_chat_with_tools(
stream=True,
)
except Exception as exc: # noqa: BLE001
+ logger.warning("final ollama chat call failed in %.2fs: %s", time.time() - t0, exc)
yield {"type": "error", "message": str(exc)}
return
+ logger.info("streaming final answer")
for chunk in stream:
thinking = _assistant_thinking(chunk)
if thinking:
@@ -262,6 +417,7 @@ def stream_chat_with_tools(
content = _assistant_content(chunk)
if content:
yield {"type": "delta", "content": content}
+ logger.info("final answer stream finished elapsed=%.2fs", time.time() - t0)
yield {"type": "done"}
@@ -285,7 +441,7 @@ async def astream_chat_with_tools(
return None
while True:
- event = await anyio.to_thread.run_sync(_next)
+ event = await anyio.to_thread.run_sync(_next, abandon_on_cancel=True)
if event is None:
break
yield event
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py
index 08b330a..eb7a555 100644
--- a/src/ollama_web/routes/chat.py
+++ b/src/ollama_web/routes/chat.py
@@ -22,6 +22,15 @@ from ..tools.registry import ToolRegistry, default_registry
logger = logging.getLogger("ollama_web.chat")
+# System prompt injected when tools are enabled. Encourages the model to use
+# tools iteratively rather than firing many searches at once.
+_TOOL_SYSTEM_PROMPT = (
+ "あなたはWeb検索・スクレイピング・ファイル取得ツールを利用できます。"
+ "一度の回答で必要以上に多くの検索を同時に行わず、結果を確認してから次の調査を行ってください。"
+ "各ツール結果は要約されて提供されるため、重要なポイントを把握して活用してください。"
+)
+
+
def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]:
"""Convert raw JSON message dicts into ``ollama.Message`` objects."""
out: list[Message] = []
@@ -155,6 +164,12 @@ async def _chat_event_stream(
if session_messages:
messages = session_messages + messages
+ # Prepend a system prompt to encourage efficient tool usage when tools
+ # are available. Avoid duplicate injection if the session already has
+ # a system message.
+ if not any(getattr(m, "role", None) == "system" for m in messages):
+ messages.insert(0, Message(role="system", content=_TOOL_SYSTEM_PROMPT))
+
host = request.app.state.settings.ollama_host
from ollama_web import llm # local import to avoid circular at module load
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
index 22fac53..f7a46a2 100644
--- a/src/ollama_web/static/app.js
+++ b/src/ollama_web/static/app.js
@@ -436,6 +436,11 @@ async function send() {
assistantBody.innerHTML = "";
placeholderCleared = true;
}
+ // Remove the "waiting" placeholder once actual content starts arriving.
+ const waiting = assistantBody.querySelector(".waiting-msg");
+ if (waiting) {
+ waiting.remove();
+ }
assistantText += content;
assistantBody.innerHTML = renderMarkdown(assistantText);
highlightIn(assistantBody);
@@ -472,6 +477,33 @@ async function send() {
b.result.textContent = (ev.result || "").slice(0, 2000);
chatEl.scrollTop = chatEl.scrollHeight;
}
+ // Show a placeholder while waiting for the next ollama response so
+ // the UI does not look frozen between tool execution and deltas.
+ if (!placeholderCleared) {
+ assistantBody.innerHTML = "";
+ placeholderCleared = true;
+ }
+ if (assistantBody.querySelector(".waiting-msg") === null) {
+ const waiting = document.createElement("span");
+ waiting.className = "waiting-msg";
+ waiting.style.color = "var(--muted)";
+ waiting.style.fontStyle = "italic";
+ waiting.textContent = "ollama が回答を生成中…";
+ assistantBody.appendChild(waiting);
+ }
+ } else if (ev.type === "status") {
+ if (!placeholderCleared) {
+ assistantBody.innerHTML = "";
+ placeholderCleared = true;
+ }
+ if (assistantBody.querySelector(".waiting-msg") === null) {
+ const waiting = document.createElement("span");
+ waiting.className = "waiting-msg";
+ waiting.style.color = "var(--muted)";
+ waiting.style.fontStyle = "italic";
+ waiting.textContent = ev.message || "処理中…";
+ assistantBody.appendChild(waiting);
+ }
} else if (ev.type === "thinking") {
if (!thinkingBody) thinkingBody = addThinkingBubble(assistantExtras);
thinkingBody.textContent += ev.content;
diff --git a/src/ollama_web/tools/pdf.py b/src/ollama_web/tools/pdf.py
index 8dc5abb..815c053 100644
--- a/src/ollama_web/tools/pdf.py
+++ b/src/ollama_web/tools/pdf.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import io
from pathlib import Path
from pypdf import PdfReader
@@ -25,7 +26,7 @@ def extract_pdf_text(data: bytes, max_chars: int | None = None) -> str:
limit = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars
try:
- reader = PdfReader(stream=data)
+ reader = PdfReader(stream=io.BytesIO(data))
except Exception as exc: # noqa: BLE001
return f"Failed to parse PDF: {exc}"