diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index 66819f02..fb1b6aba 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.20.1 + version: 5.20.3 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/pyproject.toml b/pyproject.toml index 87f3e011..a19e62fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.20.1" +version = "5.20.3" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/di/di.py b/src/di/di.py index fa3f5fac..742f283b 100644 --- a/src/di/di.py +++ b/src/di/di.py @@ -591,10 +591,10 @@ def chat_langchain_model( configured_tool: ConfiguredTool, ) -> "ChatModelUsageTrackingDecorator": from features.accounting.usage.decorators.chat_model_usage_tracking_decorator import ChatModelUsageTrackingDecorator - from features.llm import langchain_creator + from features.llm import langchain_factory resolved_max_tokens = self.__resolve_max_output_tokens(configured_tool) - base_model = langchain_creator.create(configured_tool, resolved_max_tokens) + base_model = langchain_factory.create(configured_tool, resolved_max_tokens) return ChatModelUsageTrackingDecorator( base_model, self.usage_tracking_service, diff --git a/src/features/chat/domain_langchain_mapper.py b/src/features/chat/domain_langchain_mapper.py index 20367ea7..60276e25 100644 --- a/src/features/chat/domain_langchain_mapper.py +++ b/src/features/chat/domain_langchain_mapper.py @@ -142,6 +142,11 @@ def pretty_print(raw_dict): def extract_text_from_dict(item: dict) -> str: # Handle LangChain content block format: {'type': 'text', 'text': '...', ...} + if item.get("type") == "thinking": + thinking = item.get("thinking", "") + return DomainLangchainMapper._format_thinking(thinking) if thinking else "" + if item.get("type") == "redacted_thinking": + return "" if "text" in item: return item["text"] # Fallback to pretty print for other dict formats @@ -166,10 +171,15 @@ def extract_text_from_dict(item: dict) -> str: messages.append(extract_text_from_dict(item)) else: messages.append(str(item)) - return CHAT_MESSAGE_DELIMITER.join(messages) + return CHAT_MESSAGE_DELIMITER.join(m for m in messages if m) # noinspection PyUnreachableCode return str(message.content) + @staticmethod + def _format_thinking(thinking: str) -> str: + lines = "\n".join(f"> {line}" for line in thinking.splitlines()) + return f"💭\n{lines}" + @staticmethod def __construct_bot_message_id(chat_id: UUID, sent_at: datetime) -> str: random_seed = str(random.randint(1000, 9999)) diff --git a/src/features/llm/langchain_creator.py b/src/features/llm/langchain_factory.py similarity index 80% rename from src/features/llm/langchain_creator.py rename to src/features/llm/langchain_factory.py index f8ba1043..736914fb 100644 --- a/src/features/llm/langchain_creator.py +++ b/src/features/llm/langchain_factory.py @@ -6,7 +6,8 @@ from langchain_xai import ChatXAI from features.external_tools.configured_tool import ConfiguredTool -from features.external_tools.external_tool import ExternalTool, ExternalToolProvider, ToolType +from features.external_tools.external_tool import ExternalTool, ToolType +from features.external_tools.external_tool_library import CLAUDE_5_FABLE, CLAUDE_5_SONNET from features.external_tools.external_tool_provider_library import ( ANTHROPIC, GOOGLE_AI, @@ -18,19 +19,26 @@ from util.error_codes import UNSUPPORTED_PROVIDER from util.errors import ConfigurationError +NO_TEMPERATURE_MODELS = { + CLAUDE_5_SONNET.id, + CLAUDE_5_FABLE.id, +} + def create(configured_tool: ConfiguredTool, max_tokens: int) -> BaseChatModel: definition = configured_tool.definition purpose = configured_tool.purpose + temperature = __normalize_temperature(purpose.temperature_percent, definition) model_args = { "model": definition.id, - "temperature": __normalize_temperature(purpose.temperature_percent, definition.provider), "max_tokens": max_tokens, "timeout": __get_timeout(purpose, definition), "max_retries": config.web_retries, "api_key": configured_tool.token, } + if temperature is not None: + model_args["temperature"] = temperature match definition.provider.id: case OPEN_AI.id: @@ -46,8 +54,10 @@ def create(configured_tool: ConfiguredTool, max_tokens: int) -> BaseChatModel: raise ConfigurationError(f"{definition.provider.name}/{definition.name} does not support LLMs", UNSUPPORTED_PROVIDER) -def __normalize_temperature(temperature_percent: float, provider: ExternalToolProvider) -> float: - match provider.id: +def __normalize_temperature(temperature_percent: float, tool: ExternalTool) -> float | None: + if tool.id in NO_TEMPERATURE_MODELS: + return None + match tool.provider.id: case OPEN_AI.id: return temperature_percent * 2 case ANTHROPIC.id: @@ -58,7 +68,7 @@ def __normalize_temperature(temperature_percent: float, provider: ExternalToolPr return temperature_percent * 2 case XAI.id: return temperature_percent * 2 - raise ConfigurationError(f"{provider.name}/{provider.id} does not support temperature", UNSUPPORTED_PROVIDER) + raise ConfigurationError(f"{tool.provider.name}/{tool.provider.id} does not support temperature", UNSUPPORTED_PROVIDER) def __get_timeout(tool_type: ToolType, tool: ExternalTool) -> float: diff --git a/test/features/chat/test_domain_langchain_mapper.py b/test/features/chat/test_domain_langchain_mapper.py index 9c75eebf..21da3cc0 100644 --- a/test/features/chat/test_domain_langchain_mapper.py +++ b/test/features/chat/test_domain_langchain_mapper.py @@ -174,6 +174,59 @@ def test_map_bot_message_to_storage_closes_unclosed_code_block(self): self.assertEqual(result[0].text, "Here:") self.assertEqual(result[1].text, "```python\nprint('hi')\n```") + def test_map_bot_message_to_storage_formats_thinking_block(self): + message = AIMessage(content = [ + {"type": "thinking", "thinking": "some reasoning", "signature": "EqwH..."}, + {"type": "text", "text": "Hello!"}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].text, "💭\n> some reasoning") + self.assertEqual(result[1].text, "Hello!") + + def test_map_bot_message_to_storage_formats_multiline_thinking_block(self): + message = AIMessage(content = [ + {"type": "thinking", "thinking": "line one\nline two\nline three", "signature": "EqwH..."}, + {"type": "text", "text": "Answer."}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].text, "💭\n> line one\n> line two\n> line three") + self.assertEqual(result[1].text, "Answer.") + + def test_map_bot_message_to_storage_skips_empty_thinking_block(self): + message = AIMessage(content = [ + {"type": "thinking", "thinking": "", "signature": "EqwH..."}, + {"type": "text", "text": "Hello!"}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].text, "Hello!") + + def test_map_bot_message_to_storage_only_thinking_block(self): + message = AIMessage(content = [ + {"type": "thinking", "thinking": "some reasoning", "signature": "EqwH..."}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].text, "💭\n> some reasoning") + + def test_map_bot_message_to_storage_only_empty_thinking_block(self): + message = AIMessage(content = [ + {"type": "thinking", "thinking": "", "signature": "EqwH..."}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 0) + + def test_map_bot_message_to_storage_skips_redacted_thinking_block(self): + message = AIMessage(content = [ + {"type": "redacted_thinking", "data": "opaque-data"}, + {"type": "text", "text": "Hello!"}, + ]) + result = self.mapper.map_bot_message_to_storage(self.chat, message) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].text, "Hello!") + def test_map_bot_message_to_storage_closes_unclosed_tilde_fence(self): content = "~~~\nsome code" message = AIMessage(content = content) @@ -182,6 +235,23 @@ def test_map_bot_message_to_storage_closes_unclosed_tilde_fence(self): self.assertEqual(result[0].text, "~~~\nsome code\n~~~") +class FormatThinkingTest(unittest.TestCase): + + def test_single_line(self): + self.assertEqual(DomainLangchainMapper._format_thinking("I think therefore I am"), "💭\n> I think therefore I am") + + def test_multiple_lines(self): + result = DomainLangchainMapper._format_thinking("line one\nline two\nline three") + self.assertEqual(result, "💭\n> line one\n> line two\n> line three") + + def test_single_line_with_special_chars(self): + self.assertEqual(DomainLangchainMapper._format_thinking("hello *world* `code`"), "💭\n> hello *world* `code`") + + def test_preserves_empty_lines_in_middle(self): + result = DomainLangchainMapper._format_thinking("first\n\nthird") + self.assertEqual(result, "💭\n> first\n> \n> third") + + class SplitPreservingBlocksTest(unittest.TestCase): D = CHAT_MESSAGE_DELIMITER diff --git a/test/features/llm/test_langchain_creator.py b/test/features/llm/test_langchain_factory.py similarity index 94% rename from test/features/llm/test_langchain_creator.py rename to test/features/llm/test_langchain_factory.py index 99feae82..b3095e66 100644 --- a/test/features/llm/test_langchain_creator.py +++ b/test/features/llm/test_langchain_factory.py @@ -11,11 +11,11 @@ from features.external_tools.configured_tool import ConfiguredTool from features.external_tools.external_tool import CostEstimate, ExternalTool, ExternalToolProvider, ToolType from features.external_tools.external_tool_provider_library import ANTHROPIC, GOOGLE_AI, OPEN_AI, PERPLEXITY -from features.llm.langchain_creator import create +from features.llm.langchain_factory import create from util.errors import ConfigurationError -class LangchainCreatorTest(unittest.TestCase): +class LangchainFactoryTest(unittest.TestCase): def setUp(self): self.mock_openai_provider = OPEN_AI @@ -65,7 +65,7 @@ def _make_configured_tool(self, tool: ExternalTool, api_key: SecretStr, purpose: uses_credits = False, ) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_openai_chat_model(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -77,7 +77,7 @@ def test_create_openai_chat_model(self, mock_config): self.assertIsInstance(result, ChatOpenAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_anthropic_reasoning_model(self, mock_config): mock_config.web_retries = 5 mock_config.web_timeout_s = 15 @@ -89,7 +89,7 @@ def test_create_anthropic_reasoning_model(self, mock_config): self.assertIsInstance(result, ChatAnthropic) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_perplexity_search_model(self, mock_config): mock_config.web_retries = 2 mock_config.web_timeout_s = 20 @@ -101,7 +101,7 @@ def test_create_perplexity_search_model(self, mock_config): self.assertIsInstance(result, ChatPerplexity) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_google_ai_chat_model(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -113,7 +113,7 @@ def test_create_google_ai_chat_model(self, mock_config): self.assertIsInstance(result, ChatGoogleGenerativeAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_copywriting_model(self, mock_config): mock_config.web_retries = 1 mock_config.web_timeout_s = 30 @@ -125,7 +125,7 @@ def test_create_copywriting_model(self, mock_config): self.assertIsInstance(result, ChatOpenAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_create_vision_model(self, mock_config): mock_config.web_retries = 4 mock_config.web_timeout_s = 25 @@ -214,7 +214,7 @@ def test_unsupported_provider_temperature_normalization(self): self.assertIn("does not support temperature", str(context.exception)) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_all_supported_tool_types_with_openai(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -236,7 +236,7 @@ def test_all_supported_tool_types_with_openai(self, mock_config): result = create(configured_tool, 4096) self.assertIsInstance(result, ChatOpenAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_all_supported_tool_types_with_anthropic(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -257,7 +257,7 @@ def test_all_supported_tool_types_with_anthropic(self, mock_config): result = create(configured_tool, 4096) self.assertIsInstance(result, ChatAnthropic) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_all_supported_tool_types_with_perplexity(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -278,7 +278,7 @@ def test_all_supported_tool_types_with_perplexity(self, mock_config): result = create(configured_tool, 4096) self.assertIsInstance(result, ChatPerplexity) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_all_supported_tool_types_with_google_ai(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -299,7 +299,7 @@ def test_all_supported_tool_types_with_google_ai(self, mock_config): result = create(configured_tool, 4096) self.assertIsInstance(result, ChatGoogleGenerativeAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_config_values_are_used(self, mock_config): """Test that config values are properly passed to model creation""" mock_config.web_retries = 7 @@ -316,7 +316,7 @@ def test_temperature_calculation_logic(self): """Test that different tool types result in different model instances""" api_key = SecretStr("test-key") - with patch("features.llm.langchain_creator.config") as mock_config: + with patch("features.llm.langchain_factory.config") as mock_config: mock_config.web_retries = 3 mock_config.web_timeout_s = 10 @@ -333,7 +333,7 @@ def test_temperature_calculation_logic(self): self.assertIsInstance(reasoning_result, ChatOpenAI) self.assertIsInstance(copywriting_result, ChatOpenAI) - @patch("features.llm.langchain_creator.config") + @patch("features.llm.langchain_factory.config") def test_reasoning_tool_has_longer_timeout(self, mock_config): mock_config.web_retries = 3 mock_config.web_timeout_s = 10