From b23455441e862f5d06dd4540d0ff61ad5cde3dd7 Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:24:19 +0000 Subject: [PATCH] feat!: add default Parallel web research tools for agents --- content_packs/README.md | 1 + src/backend/README.md | 56 +++ src/backend/agents/agent_factory.py | 9 + src/backend/agents/agent_template.py | 35 +- src/backend/common/config/app_config.py | 4 + src/backend/common/models/messages.py | 4 + src/backend/config/web_search_config.py | 50 +++ src/backend/services/team_service.py | 2 + src/tests/agents/test_parallel_search.py | 373 ++++++++++++++++++ .../backend/agents/test_agent_factory.py | 2 + .../backend/services/test_team_service.py | 2 + 11 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 src/backend/config/web_search_config.py create mode 100644 src/tests/agents/test_parallel_search.py diff --git a/content_packs/README.md b/content_packs/README.md index aa71ce096..dd7e8b5e3 100644 --- a/content_packs/README.md +++ b/content_packs/README.md @@ -39,6 +39,7 @@ perform, not around a template. Ask yourself: | Question | Design decision | |----------|----------------| | Does the team need to look up data? | Add a **ResearchAgent** with `use_knowledge_base: true` | +| Does it need current public web sources? | Agents inherit Parallel web search by default; use `web_search_provider: "none"` for internal-only agents. See [web research controls](../src/backend/README.md#public-web-research). | | Does it need to generate images? | Add an agent with `use_toolbox: true` and the appropriate `toolbox_filter` | | Should it ask the user clarifying questions? | Set `user_responses: true` on the relevant agent | | Are there multiple distinct tasks? | Add specialist agents and a **TriageAgent** to route between them | diff --git a/src/backend/README.md b/src/backend/README.md index 1de280fa0..395bf0f91 100644 --- a/src/backend/README.md +++ b/src/backend/README.md @@ -3,3 +3,59 @@ ```shell uv run uvicorn app:app --port 8000 ``` + +## Public web research + +Agents now receive `parallel_web_search` and `parallel_web_fetch` from +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) when +web search is unconfigured. Anonymous search uses Fast mode and needs no Parallel +account or API key. The application's Azure model/project setup is still required. +Existing team configurations without web-search settings also receive these tools. + +Search objectives, queries, and fetched URLs are sent to Parallel. Use public +information in these requests. To keep a deployment offline from this service, +set `WEB_SEARCH_PROVIDER=none` in the backend environment before starting it. +This operator restriction denies access for every agent, even one explicitly +configured with `web_search_provider: "parallel"`. + +Team JSON supports these **per-agent** fields; they are retained on upload and +when a saved team is loaded: + +| Field | Default when omitted | Behavior | +|-------|----------------------|----------| +| `web_search_provider` | `null` | Inherit `WEB_SEARCH_PROVIDER` (`parallel` by default). Use `"none"` to disable web access for this agent or `"parallel"` to select Parallel explicitly. | +| `web_search_tools` | `["web_search", "web_fetch"]` | Limit the tools exposed to this agent. Use `["web_search"]` for search only, `["web_fetch"]` for page fetching only, or `[]` to deny both. | + +For example, an agent restricted to internal documents can include: + +```json +"web_search_provider": "none" +``` + +These settings are independent of `use_toolbox`, its domain filter and Azure +credentials, Foundry IQ, and Code Interpreter. Web tools run through the same +client-side Agent Framework tool dispatcher as other MCP tools. They do not add +tools to the Foundry portal definition. Existing domain/knowledge-base choices +and credentials keep their own connections. +The `parallel_` prefix keeps model-facing function names distinct from domain +tools; the permission list uses the server's `web_search` and `web_fetch` names. + +Optionally set `PARALLEL_API_KEY` to a dedicated Parallel key for higher limits. +Only this key is sent as a Bearer token to `https://search.parallel.ai/mcp`; +Azure credentials are never reused for web research. Without the key, the +connection is anonymous. Free access has service limits; it is not unlimited. +Connection or tool errors are reported through the existing agent error path, +without switching providers. MCP requests have a 60-second timeout (10 seconds +to connect), and agent cleanup closes the MCP session and its HTTP client. + +With the backend dependencies installed, run the deterministic dispatcher tests +from the repository root: + +```shell +PYTHONPATH=src/backend python -m pytest src/tests/agents/test_parallel_search.py -q +``` + +These tests use a scripted model, the real Agent Framework/MCP client, and HTTP +fixtures. They need no Azure, model, or Parallel credentials and make no external +requests. Run them separately from the backend unit suite, which replaces the +framework with module stubs. diff --git a/src/backend/agents/agent_factory.py b/src/backend/agents/agent_factory.py index cd7de8a90..08900772f 100644 --- a/src/backend/agents/agent_factory.py +++ b/src/backend/agents/agent_factory.py @@ -16,6 +16,7 @@ from common.database.database_base import DatabaseBase from common.models.messages import TeamConfiguration from config.mcp_config import KnowledgeBaseConfig, MCPConfig, VectorStoreConfig +from config.web_search_config import WebSearchConfig from tools.clarification_tool import request_user_clarification @@ -176,6 +177,13 @@ async def create_agent_from_config( # Agents with user_responses=true get the clarification tool directly. extra_tools = [request_user_clarification] if user_responses else None + web_search_config = WebSearchConfig.resolve( + getattr(agent_obj, "web_search_provider", None), + getattr(agent_obj, "web_search_tools", None), + default_provider=config.WEB_SEARCH_PROVIDER, + api_key=config.PARALLEL_API_KEY, + ) + agent = AgentTemplate( agent_name=agent_obj.name, agent_description=getattr(agent_obj, "description", ""), @@ -190,6 +198,7 @@ async def create_agent_from_config( team_config=team_config, memory_store=memory_store, extra_tools=extra_tools, + web_search_config=web_search_config, ) await agent.open() diff --git a/src/backend/agents/agent_template.py b/src/backend/agents/agent_template.py index 8b4b2aac2..756550017 100644 --- a/src/backend/agents/agent_template.py +++ b/src/backend/agents/agent_template.py @@ -12,6 +12,7 @@ from __future__ import annotations +import asyncio import logging from contextlib import AsyncExitStack from typing import Any, AsyncGenerator, Optional, cast @@ -31,6 +32,8 @@ from common.utils.agent_utils import get_database_team_agent_id from config.agent_registry import agent_registry from config.mcp_config import KnowledgeBaseConfig, MCPConfig, VectorStoreConfig +from config.web_search_config import PARALLEL_SEARCH_URL, WebSearchConfig +from httpx import AsyncClient, Timeout class AgentTemplate: @@ -56,6 +59,7 @@ def __init__( team_config: TeamConfiguration | None = None, memory_store: DatabaseBase | None = None, extra_tools: list | None = None, + web_search_config: WebSearchConfig | None = None, ) -> None: self.agent_name = agent_name self.agent_description = agent_description @@ -70,6 +74,7 @@ def __init__( self.team_config = team_config self.memory_store = memory_store self.extra_tools = extra_tools or [] + self.web_search_config = web_search_config self.logger = logging.getLogger(__name__) @@ -485,12 +490,36 @@ def _is_domain_mcp_tool(t) -> bool: [f.name for f in mcp_tool.functions], ) - # Combine Toolbox tools (Search, CodeInterpreter) + client-side MCP + extra tools. + # A separate HTTP client prevents Azure/toolbox credentials from reaching + # public web search. The stack closes the MCP session before its client. + web_search_tool = None + if self.web_search_config: + web_client = await self._stack.enter_async_context(AsyncClient( + headers=self.web_search_config.get_headers(), + timeout=Timeout(60.0, connect=10.0), + )) + web_search_tool = MCPStreamableHTTPTool( + name="parallel_search", + tool_name_prefix="parallel", + url=PARALLEL_SEARCH_URL, + http_client=web_client, + allowed_tools=self.web_search_config.allowed_tools, + load_prompts=False, + request_timeout=60, + ) + # Register cleanup before discovery so a failed/cancelled handshake + # also releases the framework's MCP lifecycle task. + self._stack.push_async_callback(web_search_tool.close) + await web_search_tool.connect() + + # Combine Toolbox tools, independent client-side MCPs and extra tools. all_tools: list = [] if maf_tools: all_tools.extend(maf_tools) if mcp_tool: all_tools.append(mcp_tool) + if web_search_tool: + all_tools.append(web_search_tool) # Extra tools (e.g. approval-gated clarification tool) injected by # AgentFactory for agents with user_responses=true. if self.extra_tools: @@ -582,12 +611,14 @@ def _tool_signature(tools): ) self._agent = await self._stack.enter_async_context(agent) - except Exception as exc: + except (Exception, asyncio.CancelledError) as exc: self.logger.error( "Failed to initialize agent '%s': %s", self.agent_name, exc ) await self._stack.aclose() self._stack = None + self._agent = None + self._credential = None raise # Register (best-effort) diff --git a/src/backend/common/config/app_config.py b/src/backend/common/config/app_config.py index 749b406d6..0c8734a3d 100644 --- a/src/backend/common/config/app_config.py +++ b/src/backend/common/config/app_config.py @@ -95,6 +95,10 @@ def __init__(self): # Foundry project connection ID for the MCP server (optional — only needed # when the MCP server requires a managed-connection credential in Foundry). self.MCP_SERVER_CONNECTION_ID = self._get_optional("MCP_SERVER_CONNECTION_ID") + # Public web research is separate from the authenticated domain toolbox. + # 'none' is an operator-wide deny, including agents explicitly using Parallel. + self.WEB_SEARCH_PROVIDER = self._get_optional("WEB_SEARCH_PROVIDER", "parallel") + self.PARALLEL_API_KEY = self._get_optional("PARALLEL_API_KEY") self.TENANT_ID = self._get_optional("AZURE_TENANT_ID") self.CLIENT_ID = self._get_optional("AZURE_CLIENT_ID") self.AZURE_AI_SEARCH_CONNECTION_NAME = self._get_optional( diff --git a/src/backend/common/models/messages.py b/src/backend/common/models/messages.py index 36fbe8ac4..0434feb33 100644 --- a/src/backend/common/models/messages.py +++ b/src/backend/common/models/messages.py @@ -175,6 +175,10 @@ class TeamAgent(BaseModel): knowledge_base_name: str | None = None use_toolbox: bool = False toolbox_filter: str | None = None + web_search_provider: Literal["parallel", "none"] | None = None + web_search_tools: List[Literal["web_search", "web_fetch"]] = Field( + default_factory=lambda: ["web_search", "web_fetch"] + ) user_responses: bool = False coding_tools: bool = False temperature: float | None = None diff --git a/src/backend/config/web_search_config.py b/src/backend/config/web_search_config.py new file mode 100644 index 000000000..322ccaa10 --- /dev/null +++ b/src/backend/config/web_search_config.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Resolve web-search access independently of domain MCP and Azure credentials.""" + +from dataclasses import dataclass, field +from typing import Sequence + + +PARALLEL_SEARCH_URL = "https://search.parallel.ai/mcp" +WEB_SEARCH_TOOLS = ("web_search", "web_fetch") +WEB_SEARCH_USER_AGENT = "macae/0.1.0 (+https://github.com/microsoft/Multi-Agent-Custom-Automation-Engine-Solution-Accelerator)" + + +@dataclass(frozen=True, slots=True) +class WebSearchConfig: + """A resolved Parallel connection; ``None`` from resolve means no access.""" + + allowed_tools: tuple[str, ...] = WEB_SEARCH_TOOLS + api_key: str | None = field(default=None, repr=False) + + @classmethod + def resolve( + cls, + provider: str | None = None, + allowed_tools: Sequence[str] | None = None, + *, + default_provider: str = "parallel", + api_key: str | None = None, + ) -> "WebSearchConfig | None": + """Default to Parallel, respecting global denial and per-agent choices. + + Unknown providers/permissions are errors, never a reason to fall back. + A global ``none`` is an operator restriction that agents cannot override. + """ + if default_provider not in ("parallel", "none"): + raise ValueError("WEB_SEARCH_PROVIDER must be 'parallel' or 'none'") + if provider not in (None, "parallel", "none"): + raise ValueError("web_search_provider must be 'parallel' or 'none'") + tools = WEB_SEARCH_TOOLS if allowed_tools is None else tuple(allowed_tools) + if any(name not in WEB_SEARCH_TOOLS for name in tools): + raise ValueError("web_search_tools may contain only web_search and web_fetch") + if default_provider == "none" or provider == "none" or not tools: + return None + return cls(tuple(dict.fromkeys(tools)), api_key=api_key or None) + + def get_headers(self) -> dict[str, str]: + """Only dedicated Parallel credentials may leave on this connection.""" + headers = {"User-Agent": WEB_SEARCH_USER_AGENT} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers diff --git a/src/backend/services/team_service.py b/src/backend/services/team_service.py index c7dea543d..663d597dc 100644 --- a/src/backend/services/team_service.py +++ b/src/backend/services/team_service.py @@ -139,6 +139,8 @@ def _validate_and_parse_agent(self, agent_data: Dict[str, Any]) -> TeamAgent: knowledge_base_name=agent_data.get("knowledge_base_name"), use_toolbox=agent_data.get("use_toolbox", False), toolbox_filter=agent_data.get("toolbox_filter"), + web_search_provider=agent_data.get("web_search_provider"), + web_search_tools=agent_data.get("web_search_tools", ["web_search", "web_fetch"]), user_responses=agent_data.get("user_responses", False), coding_tools=agent_data.get("coding_tools", False), temperature=agent_data.get("temperature"), diff --git a/src/tests/agents/test_parallel_search.py b/src/tests/agents/test_parallel_search.py new file mode 100644 index 000000000..ae312bdda --- /dev/null +++ b/src/tests/agents/test_parallel_search.py @@ -0,0 +1,373 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Real Agent Framework/MCP dispatch with a scripted model and HTTP fixtures. + +Run separately from backend unit tests, whose module stubs replace the framework: + PYTHONPATH=src/backend pytest src/tests/agents/test_parallel_search.py -q +No Azure, model, or Parallel credentials/network access are needed. +""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from agent_framework import BaseChatClient, ChatResponseUpdate, Content +from agent_framework._tools import FunctionInvocationLayer + + +SEARCH_URL = "https://example.org/source" +TOOLS = [ + { + "name": "web_search", + "description": "Search the public web", + "inputSchema": { + "type": "object", + "properties": { + "objective": {"type": "string"}, + "search_queries": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["objective", "search_queries"], + }, + }, + { + "name": "web_fetch", + "description": "Read a public URL", + "inputSchema": { + "type": "object", + "properties": {"urls": {"type": "array", "items": {"type": "string"}}}, + "required": ["urls"], + }, + }, + {"name": "unrelated_tool", "inputSchema": {"type": "object"}}, +] + + +class ScriptedResearchClient(FunctionInvocationLayer, BaseChatClient): + """Deterministic tool requests; proves dispatch, not model research quality.""" + + def __init__(self, calls=None): + super().__init__() + self.calls = calls if calls is not None else [ + ("web_search", {"objective": "Find a source", "search_queries": ["public source information"]}), + ("web_fetch", {"urls": [SEARCH_URL]}), + ] + self.round = 0 + self.consumed = [] + self.offered = [] + + def _inner_get_response(self, *, messages, stream, options, **kwargs): + assert stream + + async def updates(): + self.offered.append([t.name for t in options.get("tools", [])]) + results = [c for m in messages for c in m.contents if c.type == "function_result"] + if results: + self.consumed.append(str(results[-1].result)) + index = self.round + self.round += 1 + if index < len(self.calls): + name, arguments = self.calls[index] + yield ChatResponseUpdate( + role="assistant", message_id=f"round-{index}", + contents=[Content.from_function_call(f"call-{index}", f"parallel_{name}", arguments=arguments)], + ) + else: + text = "Research complete: " + (self.consumed[-1] if self.consumed else "no web tools") + yield ChatResponseUpdate(role="assistant", message_id="final", contents=[Content.from_text(text)]) + + return self._build_response_stream(updates()) + + +@pytest.fixture +def runtime(monkeypatch): + """Import the real application with inert Azure settings and no search key.""" + import dotenv + + monkeypatch.setattr(dotenv, "load_dotenv", lambda **kw: None) + for key in ("AZURE_OPENAI_ENDPOINT", "AZURE_AI_AGENT_ENDPOINT"): + monkeypatch.setenv(key, "https://unused.example") + for key in ("AZURE_AI_SUBSCRIPTION_ID", "AZURE_AI_RESOURCE_GROUP", "AZURE_AI_PROJECT_NAME"): + monkeypatch.setenv(key, "unused") + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + monkeypatch.delenv("WEB_SEARCH_PROVIDER", raising=False) + + from agents import agent_factory, agent_template + from common.config.app_config import AppConfig + from common.models.messages import TeamAgent + from config.web_search_config import WebSearchConfig + from services.team_service import TeamService + + # Avoid loading a developer .env when checking the fresh environment default. + settings = AppConfig() + assert settings.WEB_SEARCH_PROVIDER == "parallel" + assert not settings.PARALLEL_API_KEY + settings.SUPPORTED_MODELS = '["test-model"]' + settings.AZURE_AI_PROJECT_ENDPOINT = "https://unused.example/project" + monkeypatch.setattr(agent_factory, "config", settings) + + credential = AsyncMock() + credential.__aenter__.return_value = credential + monkeypatch.setattr(agent_template, "AzureCliCredential", lambda: credential) + monkeypatch.setattr(agent_template, "ManagedIdentityCredential", lambda **kw: credential) + + from azure.ai.projects.models import PromptAgentDefinition + definition = PromptAgentDefinition(model="test-model", instructions="Research public information.") + project = AsyncMock() + project.__aenter__.return_value = project + project.agents.get.return_value = SimpleNamespace( + versions=SimpleNamespace(latest=SimpleNamespace(definition=definition)) + ) + monkeypatch.setattr(agent_template, "AIProjectClient", lambda **kw: project) + + requests = [] + clients = [] + started = asyncio.Event() + failure = {"kind": None} + + async def respond(request): + if request.method != "POST": + return httpx.Response(405) + body = json.loads(request.content) + requests.append((request, body)) + method = body["method"] + if "id" not in body: + return httpx.Response(202) + if method == "initialize": + if failure["kind"] == "discovery_error": + return httpx.Response(503) + if failure["kind"] == "discovery_wait": + started.set() + await asyncio.Event().wait() + result = {"protocolVersion": body["params"]["protocolVersion"], "capabilities": {"tools": {}}, + "serverInfo": {"name": "fixture", "version": "1"}} + elif method == "tools/list": + result = {"tools": TOOLS} + elif method == "ping": + result = {} + elif method == "tools/call": + started.set() + if failure["kind"] == "timeout": + raise httpx.ReadTimeout("fixture read timeout", request=request) + if failure["kind"] == "wait": + await asyncio.Event().wait() + if failure["kind"] == "error": + result = {"isError": True, "content": [{"type": "text", "text": "Search unavailable"}]} + else: + text = (json.dumps({"results": [{"url": SEARCH_URL, "excerpts": ["Useful search excerpt"]}]}) + if body["params"]["name"] == "web_search" else "Fetched source detail: " + SEARCH_URL) + result = {"content": [{"type": "text", "text": text}]} + else: + pytest.fail(f"Unexpected MCP method: {method}") + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + def http_client(**kwargs): + client = httpx.AsyncClient(transport=httpx.MockTransport(respond), **kwargs) + clients.append(client) + return client + + monkeypatch.setattr(agent_template, "AsyncClient", http_client) + real_mcp_tool = agent_template.MCPStreamableHTTPTool + + def mcp_tool(**kwargs): + # Keep production's bounded timeout assertion, but make fixture timeouts fast. + assert kwargs["request_timeout"] == 60 + kwargs["request_timeout"] = 0.1 + return real_mcp_tool(**kwargs) + + monkeypatch.setattr(agent_template, "MCPStreamableHTTPTool", mcp_tool) + + async def make(overrides=None, calls=None): + raw = {"input_key": "research", "type": "", "name": "ResearchAgent", + "deployment_name": "test-model", "system_message": definition.instructions, **(overrides or {})} + # Use the upload parser, then the same serialization used for saved teams. + parsed = TeamService._validate_and_parse_agent(TeamService.__new__(TeamService), raw) + saved = TeamAgent.model_validate_json(parsed.model_dump_json()) + model = ScriptedResearchClient(calls) + monkeypatch.setattr(agent_template, "FoundryChatClient", lambda **kw: model) + agent = await agent_factory.AgentFactory().create_agent_from_config("user", saved, None, None) + return agent, model + + return SimpleNamespace(make=make, settings=settings, requests=requests, clients=clients, + failure=failure, started=started, config_cls=WebSearchConfig, + template_module=agent_template, project=project, credential=credential) + + +@pytest.mark.asyncio +async def test_default_dispatch_search_fetch_and_consume(runtime): + from config.web_search_config import PARALLEL_SEARCH_URL, WEB_SEARCH_USER_AGENT + + agent, model = await runtime.make() + try: + updates = [u async for u in agent.invoke("Research a public source and read it.")] + assert SEARCH_URL in "".join(u.text for u in updates) + assert "Useful search excerpt" in model.consumed[0] + assert "Fetched source detail" in model.consumed[1] + assert set(model.offered[0]) == {"parallel_web_search", "parallel_web_fetch"} + calls = [body for _, body in runtime.requests if body["method"] == "tools/call"] + assert [b["params"]["name"] for b in calls] == ["web_search", "web_fetch"] + assert calls[1]["params"]["arguments"]["urls"] == [SEARCH_URL] + assert {b["method"] for _, b in runtime.requests} >= {"initialize", "tools/list", "tools/call"} + for request, _ in runtime.requests: + assert str(request.url) == PARALLEL_SEARCH_URL + assert request.headers["User-Agent"] == WEB_SEARCH_USER_AGENT + assert "Authorization" not in request.headers + finally: + await agent.close() + assert all(c.is_closed for c in runtime.clients) + assert agent._agent is None + runtime.credential.__aexit__.assert_awaited_once() + + +@pytest.mark.parametrize("overrides,global_provider", [ + ({"web_search_provider": "none"}, "parallel"), + ({"web_search_tools": []}, "parallel"), + ({"web_search_provider": "parallel"}, "none"), +]) +@pytest.mark.asyncio +async def test_saved_disables_and_global_deny_make_no_requests(runtime, overrides, global_provider): + runtime.settings.WEB_SEARCH_PROVIDER = global_provider + agent, model = await runtime.make(overrides, calls=[]) + try: + assert [u async for u in agent.invoke("Do an offline task.")] + assert not runtime.requests + assert not runtime.clients + assert model.offered == [[]] + finally: + await agent.close() + + +@pytest.mark.asyncio +async def test_saved_selection_permissions_and_dedicated_credentials(runtime): + runtime.settings.PARALLEL_API_KEY = "dedicated-test-key" + runtime.settings.AZURE_CLIENT_SECRET = "never-forward-this" + agent, model = await runtime.make({"web_search_provider": "parallel", "web_search_tools": ["web_search"]}, + calls=[("web_search", {"objective": "Source", "search_queries": ["public source information"]})]) + try: + assert [u async for u in agent.invoke("Find a source.")] + assert model.offered[0] == ["parallel_web_search"] + for request, _ in runtime.requests: + assert request.headers["Authorization"] == "Bearer dedicated-test-key" + assert "never-forward-this" not in str(request.headers) + assert "dedicated-test-key" not in repr(agent.web_search_config) + finally: + await agent.close() + + +@pytest.mark.asyncio +async def test_model_cannot_dispatch_disallowed_fetch(runtime): + agent, model = await runtime.make({"web_search_tools": ["web_search"]}, + calls=[("web_fetch", {"urls": [SEARCH_URL]})]) + try: + assert [u async for u in agent.invoke("Read a source.")] + assert not [b for _, b in runtime.requests if b["method"] == "tools/call"] + assert model.consumed + finally: + await agent.close() + + +@pytest.mark.parametrize("kind", ["error", "timeout"]) +@pytest.mark.asyncio +async def test_errors_reach_model_without_provider_fallback(runtime, kind): + runtime.failure["kind"] = kind + agent, model = await runtime.make(calls=[("web_search", {"objective": "Source", "search_queries": ["public source information"]})]) + try: + assert [u async for u in agent.invoke("Find a source.")] + assert model.consumed + calls = [b for _, b in runtime.requests if b["method"] == "tools/call"] + assert len(calls) == 1 + assert "Useful search excerpt" not in model.consumed[0] + finally: + await agent.close() + assert all(c.is_closed for c in runtime.clients) + + +@pytest.mark.asyncio +async def test_cancel_releases_transport(runtime): + runtime.failure["kind"] = "wait" + agent, model = await runtime.make() + + async def consume(): + return [u async for u in agent.invoke("Find a source.")] + + task = asyncio.create_task(consume()) + try: + await asyncio.wait_for(runtime.started.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not model.consumed + finally: + await agent.close() + assert all(c.is_closed for c in runtime.clients) + + +def test_unknown_provider_and_permission_do_not_fall_back(runtime): + with pytest.raises(ValueError, match="web_search_provider"): + runtime.config_cls.resolve("unconfigured-provider") + with pytest.raises(ValueError, match="WEB_SEARCH_PROVIDER"): + runtime.config_cls.resolve(default_provider="unconfigured-provider") + with pytest.raises(ValueError, match="web_search_tools"): + runtime.config_cls.resolve(allowed_tools=["unrelated_tool"]) + + +@pytest.mark.asyncio +async def test_factory_preserves_domain_knowledge_and_other_tool_choices(runtime, monkeypatch): + from agents import agent_factory + from config import mcp_config + + settings = runtime.settings + settings.MCP_SERVER_ENDPOINT = "https://domain.example/mcp" + settings.MCP_SERVER_NAME = "domain-tools" + settings.MCP_SERVER_DESCRIPTION = "Existing domain tools" + settings.AZURE_TENANT_ID = "existing-tenant" + settings.AZURE_CLIENT_ID = "existing-client" + settings.MCP_SERVER_CONNECTION_ID = "existing-domain-connection" + settings.AZURE_AI_SEARCH_ENDPOINT = "https://knowledge.example" + settings.AZURE_AI_SEARCH_CONNECTION_NAME = "existing-knowledge-connection" + monkeypatch.setattr(mcp_config, "config", settings) + captured = {} + + def template(**kwargs): + captured.update(kwargs) + return SimpleNamespace(open=AsyncMock()) + + monkeypatch.setattr(agent_factory, "AgentTemplate", template) + await runtime.make({"use_toolbox": True, "toolbox_filter": "hr", "use_file_search": True, + "vector_store_name": "existing-files", "use_knowledge_base": True, + "knowledge_base_name": "existing-knowledge", "coding_tools": True, + "user_responses": True}) + assert captured["mcp_config"].url == "https://domain.example/hr/mcp" + assert captured["mcp_config"].connection_id == "existing-domain-connection" + assert captured["mcp_config"].allowed_tools == mcp_config.DOMAIN_ALLOWED_TOOLS["hr"] + assert captured["kb_config"].search_connection_name == "existing-knowledge-connection" + assert captured["vector_store_config"].vector_store_name == "existing-files" + assert captured["enable_code_interpreter"] is True + assert captured["extra_tools"] + assert captured["web_search_config"] is not None + assert captured["web_search_config"].get_headers().get("Authorization") is None + + +@pytest.mark.asyncio +async def test_failed_discovery_closes_clients_and_lifecycle_tasks(runtime): + runtime.failure["kind"] = "discovery_error" + with pytest.raises(ExceptionGroup) as error: + await runtime.make() + assert "503" in str(error.value.exceptions[0]) + assert all(c.is_closed for c in runtime.clients) + assert not [t for t in asyncio.all_tasks() if t.get_name() == "mcp-lifecycle:parallel_search"] + runtime.credential.__aexit__.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cancelled_discovery_closes_clients_and_lifecycle_tasks(runtime): + runtime.failure["kind"] = "discovery_wait" + task = asyncio.create_task(runtime.make()) + await asyncio.wait_for(runtime.started.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert all(c.is_closed for c in runtime.clients) + assert not [t for t in asyncio.all_tasks() if t.get_name() == "mcp-lifecycle:parallel_search"] + runtime.credential.__aexit__.assert_awaited_once() diff --git a/src/tests/backend/agents/test_agent_factory.py b/src/tests/backend/agents/test_agent_factory.py index e483e5054..894a992f1 100644 --- a/src/tests/backend/agents/test_agent_factory.py +++ b/src/tests/backend/agents/test_agent_factory.py @@ -39,6 +39,8 @@ mock_config = Mock() mock_config.SUPPORTED_MODELS = '["gpt-4", "gpt-4-32k", "gpt-35-turbo"]' mock_config.AZURE_AI_PROJECT_ENDPOINT = "https://test-endpoint.com" +mock_config.WEB_SEARCH_PROVIDER = "none" +mock_config.PARALLEL_API_KEY = "" _mock_app_config_mod.config = mock_config _mock_db_base_mod.DatabaseBase = Mock() _mock_messages_mod.TeamConfiguration = Mock() diff --git a/src/tests/backend/services/test_team_service.py b/src/tests/backend/services/test_team_service.py index 90bef1834..2e7ea5d2e 100644 --- a/src/tests/backend/services/test_team_service.py +++ b/src/tests/backend/services/test_team_service.py @@ -83,6 +83,8 @@ class MockTeamAgent: knowledge_base_name: str = None use_toolbox: bool = False toolbox_filter: str = None + web_search_provider: str = None + web_search_tools: List[str] = field(default_factory=lambda: ["web_search", "web_fetch"]) user_responses: bool = False coding_tools: bool = False temperature: float = None