diff --git a/libs/partners/ollama/langchain_ollama/chat_models.py b/libs/partners/ollama/langchain_ollama/chat_models.py index 24d059573c..f39c697d65 100644 --- a/libs/partners/ollama/langchain_ollama/chat_models.py +++ b/libs/partners/ollama/langchain_ollama/chat_models.py @@ -83,7 +83,7 @@ from langchain_core.utils.function_calling import ( convert_to_openai_tool, ) from langchain_core.utils.pydantic import TypeBaseModel, is_basemodel_subclass -from ollama import AsyncClient, Client, Message +from ollama import AsyncClient, Client, Message, ResponseError from pydantic import BaseModel, PrivateAttr, field_validator, model_validator from pydantic.json_schema import JsonSchemaValue from pydantic.v1 import BaseModel as BaseModelV1 @@ -117,6 +117,30 @@ def _get_usage_metadata_from_generation_info( return None +def _reraise_if_malformed_tool_call_response(exc: ResponseError) -> None: + """Re-raise an Ollama tool-call parsing failure as an `OutputParserException`. + + The `ollama` client parses streamed tool-call arguments internally and + raises a bare `ResponseError` (e.g. `"error parsing tool call: raw='...', + err=..."`) when the model produces malformed JSON. That error never + reaches `_parse_json_string`, so it otherwise propagates as an opaque, + non-`langchain_core` exception that crashes the entire agent run instead + of being recognized as a tool-call parsing issue. + + Args: + exc: The `ResponseError` raised by the `ollama` client. + + Raises: + OutputParserException: If `exc` is a tool-call parsing failure. + """ + if "error parsing tool call" in str(exc.error): + msg = ( + "Ollama returned a tool call with malformed JSON arguments that " + f"could not be parsed: {exc.error}" + ) + raise OutputParserException(msg) from exc + + def _parse_json_string( json_string: str, *, @@ -1109,11 +1133,15 @@ class ChatOllama(BaseChatModel): raise RuntimeError(msg) chat_params = self._chat_params(messages, stop, **kwargs) - if chat_params["stream"]: - async for part in await self._async_client.chat(**chat_params): - yield part - else: - yield await self._async_client.chat(**chat_params) + try: + if chat_params["stream"]: + async for part in await self._async_client.chat(**chat_params): + yield part + else: + yield await self._async_client.chat(**chat_params) + except ResponseError as e: + _reraise_if_malformed_tool_call_response(e) + raise def _create_chat_stream( self, @@ -1129,10 +1157,14 @@ class ChatOllama(BaseChatModel): raise RuntimeError(msg) chat_params = self._chat_params(messages, stop, **kwargs) - if chat_params["stream"]: - yield from self._client.chat(**chat_params) - else: - yield self._client.chat(**chat_params) + try: + if chat_params["stream"]: + yield from self._client.chat(**chat_params) + else: + yield self._client.chat(**chat_params) + except ResponseError as e: + _reraise_if_malformed_tool_call_response(e) + raise def _chat_stream_with_aggregation( self, diff --git a/libs/partners/ollama/tests/unit_tests/test_chat_models.py b/libs/partners/ollama/tests/unit_tests/test_chat_models.py index 7efaa8e9cb..11d759555e 100644 --- a/libs/partners/ollama/tests/unit_tests/test_chat_models.py +++ b/libs/partners/ollama/tests/unit_tests/test_chat_models.py @@ -10,6 +10,7 @@ import pytest from langchain_core.exceptions import OutputParserException from langchain_core.messages import AIMessage, BaseMessage, ChatMessage, HumanMessage from langchain_tests.unit_tests import ChatModelUnitTests +from ollama import ResponseError from langchain_ollama.chat_models import ( ChatOllama, @@ -788,6 +789,55 @@ def test_invoke_raises_when_client_none() -> None: llm.invoke([HumanMessage("Hello")]) +def test_malformed_tool_call_response_error_is_output_parser_exception() -> None: + """A malformed tool-call `ResponseError` from `ollama` becomes an + `OutputParserException` instead of propagating as an opaque error. + + See issue #34746: Ollama's client raises a bare `ResponseError` when the + model streams invalid JSON tool-call arguments, crashing the agent with + a confusing error instead of a recognizable parsing failure. + """ + with patch("langchain_ollama.chat_models.Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.chat.side_effect = ResponseError( + "error parsing tool call: raw='{\"foo\": ' err=unexpected end of JSON input" + ) + + llm = ChatOllama(model=MODEL_NAME) + with pytest.raises(OutputParserException, match="error parsing tool call"): + llm.invoke([HumanMessage("Hello")]) + + +async def test_malformed_tool_call_response_error_output_parser_async() -> None: + """Async counterpart of the malformed tool-call `ResponseError` handling.""" + with patch("langchain_ollama.chat_models.AsyncClient") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.chat = AsyncMock( + side_effect=ResponseError( + "error parsing tool call: raw='{\"foo\": ' err=unexpected end of " + "JSON input" + ) + ) + + llm = ChatOllama(model=MODEL_NAME) + with pytest.raises(OutputParserException, match="error parsing tool call"): + await llm.ainvoke([HumanMessage("Hello")]) + + +def test_unrelated_response_error_is_not_wrapped() -> None: + """A `ResponseError` unrelated to tool-call parsing propagates unchanged.""" + with patch("langchain_ollama.chat_models.Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.chat.side_effect = ResponseError("model not found", 404) + + llm = ChatOllama(model=MODEL_NAME) + with pytest.raises(ResponseError, match="model not found"): + llm.invoke([HumanMessage("Hello")]) + + def test_chat_ollama_ignores_strict_arg() -> None: """Test that ChatOllama ignores the 'strict' argument.""" response = [