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
16 changes: 5 additions & 11 deletions src/backend/chat/agents/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,14 @@ def enforce_response_language() -> str:
"""Dynamic instruction function to set the expected language to use."""
return f"Answer in {get_language_name(language).lower()}." if language else ""

def get_web_search_tool_name(self) -> str | None:
def is_web_search_configured(self) -> bool:
"""
Get the name of the web search tool if available.
Return True when a web search backend is configured on this model.
If several are available, return the first one found.
Warning, this says the tool is available, not that
it (the tool/feature) is enabled for the current conversation.
This does not mean web search is enabled for the current conversation
(feature flags and runtime deps still apply).
"""
for toolset in self.toolsets:
for tool in toolset.tools.values():
if tool.name.startswith("web_search_"):
return tool.name
return None
return bool(getattr(self.configuration, "web_search", None))


@dataclasses.dataclass(init=False)
Expand Down
36 changes: 31 additions & 5 deletions src/backend/chat/clients/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
session=session,
web_search_enabled=self._is_web_search_enabled and self._is_smart_search_enabled,
)
self._web_search_tool_registered = False

self.conversation_agent = ConversationAgent(
model_hrid=self.model_hrid,
Expand Down Expand Up @@ -440,8 +441,7 @@
logger.warning("Web search is forced but the feature is disabled, ignoring.")
return False

web_search_tool_name = self.conversation_agent.get_web_search_tool_name()
if not web_search_tool_name:
if not self.conversation_agent.is_web_search_configured():
logger.warning("Web search is forced but no web search tool is available, ignoring.")
return False

Expand All @@ -450,9 +450,7 @@
@self.conversation_agent.instructions
def force_web_search_prompt() -> str:
"""Dynamic system prompt function to force web search."""
return (
f"You must call the {web_search_tool_name} tool before answering the user request."
)
return "You must call the web_search tool before answering the user request."

return True

Expand Down Expand Up @@ -699,6 +697,33 @@
"""Wrap the document_summarize tool to provide context and add the tool."""
return await document_summarize(ctx, *args, **kwargs)

def _setup_web_search_tool(self) -> None:
"""Register model-specific web search tool when configured."""
if self._web_search_tool_registered:
return
configuration = self.conversation_agent.configuration
if not getattr(configuration, "web_search", None):
return

async def only_if_web_search_enabled(ctx, tool_def):

Check warning on line 708 in src/backend/chat/clients/pydantic_ai.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_conversations&issues=AZ0gxwnnVHaA9q91tHSr&open=AZ0gxwnnVHaA9q91tHSr&pullRequest=330
"""Prepare function to include a tool only if web search is enabled in the context."""
return tool_def if ctx.deps.web_search_enabled else None

web_search_impl = import_string(configuration.web_search)

@self.conversation_agent.tool(
name="web_search",
retries=1,
prepare=only_if_web_search_enabled,
description="Search the web for up-to-date information",
)
@functools.wraps(web_search_impl)
async def web_search(ctx: RunContext, *args, **kwargs) -> ToolReturn:
"""Wrap the web_search tool to provide context and add the tool."""
return await web_search_impl(ctx, *args, **kwargs)

self._web_search_tool_registered = True

async def _handle_input_documents(
self,
input_documents: List[BinaryContent | DocumentUrl],
Expand Down Expand Up @@ -1016,6 +1041,7 @@
conversation_has_documents = doc_result.has_documents

await self._agent_stop_streaming(force_cache_check=True)
self._setup_web_search_tool()
self._setup_web_search(force_web_search)

if await self._check_should_enable_rag(conversation_has_documents):
Expand Down
9 changes: 9 additions & 0 deletions src/backend/chat/llm_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class LLModel(BaseModel):
supports_streaming: bool | None = None
system_prompt: SettingEnvValue
tools: list[str]
web_search: SettingEnvValue | None = None

@field_validator("tools", mode="before")
@classmethod
Expand All @@ -134,6 +135,14 @@ def validate_tools(cls, value: list[str] | str) -> list[str]:
return _get_setting_or_env_or_value(value)
return value

@field_validator("web_search", mode="before")
@classmethod
def validate_web_search(cls, value: str | None) -> str | None:
"""Convert web_search path if it's a setting or environment variable."""
if isinstance(value, str):
return _get_setting_or_env_or_value(value)
return value

@model_validator(mode="after")
def check_provider_or_provider_name(self) -> Self:
"""
Expand Down
71 changes: 26 additions & 45 deletions src/backend/chat/tests/agents/test_build_conversation_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
# pylint:disable=protected-access

import pytest
import responses
from freezegun import freeze_time
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.models.test import TestModel

from chat.agents.conversation import ConversationAgent
from chat.clients.pydantic_ai import ContextDeps
from chat.llm_configuration import LLModel, LLMProvider


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -87,47 +85,30 @@ def test_add_dynamic_system_prompt():
assert agent._instructions[2]() == "Answer in french."


def test_agent_get_web_search_tool_name(settings):
"""Test the web_search_available method."""
settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_albert_rag"]
def test_agent_is_web_search_configured():
"""Test whether web search backend is configured on the model."""
agent = ConversationAgent(model_hrid="default-model")
assert agent.get_web_search_tool_name() == "web_search_albert_rag"

settings.AI_AGENT_TOOLS = ["get_current_weather"]
agent = ConversationAgent(model_hrid="default-model")
assert agent.get_web_search_tool_name() is None

settings.AI_AGENT_TOOLS = ["get_current_weather", "web_search_tavily", "web_search_albert_rag"]
agent = ConversationAgent(model_hrid="default-model")
assert agent.get_web_search_tool_name() == "web_search_tavily"


@responses.activate
def test_web_search_tool_avalability(settings):
"""Test the web search tool availability according to context."""
responses.add(
responses.POST,
"https://api.tavily.com/search",
json={"results": []},
status=200,
)
context_deps = ContextDeps(conversation=None, user=None, web_search_enabled=True)

# No tools (context allows web search, but no tool configured)
assert agent.is_web_search_configured() is False


def test_agent_is_web_search_configured_when_defined_in_model_config(settings):
"""Web search is configured when LLModel.web_search is set."""
settings.LLM_CONFIGURATIONS = {
"default-model": LLModel(
hrid="default-model",
model_name="model-123",
human_readable_name="Default Model",
is_active=True,
icon=None,
system_prompt="You are a helpful assistant",
tools=[],
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
provider=LLMProvider(
hrid="default-provider",
base_url="https://api.llm.com/v1/",
api_key="test-key",
),
),
}
agent = ConversationAgent(model_hrid="default-model")
with agent.override(model=TestModel(), deps=context_deps):
response = agent.run_sync("What tools do you have?")
assert response.output == "success (no tool calls)"

# Tool configured, context allows web search
settings.AI_AGENT_TOOLS = ["web_search_tavily"]
agent = ConversationAgent(model_hrid="default-model") # re-init to pick up new settings
with agent.override(model=TestModel(), deps=context_deps):
response = agent.run_sync("What tools do you have?")
assert response.output == '{"web_search_tavily":[]}'

# Tool configured, context disables web search
context_deps.web_search_enabled = False
with agent.override(model=TestModel(), deps=context_deps):
response = agent.run_sync("What tools do you have?")
assert response.output == "success (no tool calls)"
assert agent.is_web_search_configured() is True
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def _llm_config_with_websearch(settings):
is_active=True,
icon=None,
system_prompt="You are an amazing assistant.",
tools=["web_search_brave_with_document_backend"],
tools=[],
web_search="chat.tools.web_search_brave.web_search_brave_llm_context",
provider=LLMProvider(
hrid="unused",
base_url="https://example.com",
Expand All @@ -48,6 +49,7 @@ def test_smart_search_disabled_suppresses_tool_at_runtime(_llm_config_with_webse
if not service._is_smart_search_enabled and service._is_web_search_enabled:
service._context_deps.web_search_enabled = False

service._setup_web_search_tool()
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
response = service.conversation_agent.run_sync("Search the web for something.")

Expand All @@ -65,10 +67,11 @@ def test_smart_search_enabled_tool_is_called(_llm_config_with_websearch):
assert service._is_smart_search_enabled is True
assert service._context_deps.web_search_enabled is True

service._setup_web_search_tool()
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
response = service.conversation_agent.run_sync("Search the web for something.")

assert "web_search_brave_with_document_backend" in response.output
assert "web_search" in response.output


def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websearch):
Expand All @@ -82,14 +85,16 @@ def test_force_websearch_overrides_smart_search_disabled(_llm_config_with_websea
assert service._is_smart_search_enabled is False
assert service._context_deps.web_search_enabled is False

# Match _run_agent: register the tool first, then enable deps + forced prompt.
service._setup_web_search_tool()
service._setup_web_search(force_web_search=True)

web_search_tool_name = service.conversation_agent.get_web_search_tool_name()
assert service.conversation_agent.is_web_search_configured() is True
assert service._context_deps.web_search_enabled is True
assert any(
callable(instr) and web_search_tool_name in instr()
callable(instr) and "web_search" in instr()
for instr in service.conversation_agent._instructions
)
with service.conversation_agent.override(model=TestModel(), deps=service._context_deps):
response = service.conversation_agent.run_sync("Search the web for something.")
assert "web_search_brave_with_document_backend" in response.output
assert "web_search" in response.output
Loading
Loading