Skip to content
Open
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
1 change: 1 addition & 0 deletions content_packs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
56 changes: 56 additions & 0 deletions src/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions src/backend/agents/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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", ""),
Expand All @@ -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()
Expand Down
35 changes: 33 additions & 2 deletions src/backend/agents/agent_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import asyncio
import logging
from contextlib import AsyncExitStack
from typing import Any, AsyncGenerator, Optional, cast
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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),
))
Comment on lines +497 to +500
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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/backend/common/config/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/backend/common/models/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/backend/config/web_search_config.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/backend/services/team_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading