Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/open-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
4 changes: 2 additions & 2 deletions src/di/di.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/features/chat/domain_langchain_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions test/features/chat/test_domain_langchain_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
Loading