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
12 changes: 8 additions & 4 deletions routes/chat_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
from src.tool_policy import (
WEB_TOOL_NAMES,
build_effective_tool_policy,
is_web_search_explicitly_denied,
disabled_web_tools_for_turn,
web_search_enabled_for_turn,
)

Expand Down Expand Up @@ -780,6 +780,11 @@ async def chat_stream(request: Request) -> StreamingResponse:
)

_research_flags = {"do": do_research} # Mutable container for generator scope
_disabled_web_tools = disabled_web_tools_for_turn(
allow_web_search,
use_web,
do_research,
)

# Query active document — prefer explicit ID from frontend, fall back to session lookup
active_doc = None
Expand Down Expand Up @@ -880,8 +885,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
if allow_bash is not None and str(allow_bash).lower() != "true":
disabled_tools.add("bash")
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
disabled_tools.update(WEB_TOOL_NAMES)
disabled_tools.update(_disabled_web_tools)
if _explicit_web_intent:
# A direct lookup/search request should not drift into personal
# tools or shell fallbacks. It can only use web_search/web_fetch
Expand All @@ -898,7 +902,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
if _search_enabled:
disabled_tools.difference_update(WEB_TOOL_NAMES)
else:
disabled_tools.update(WEB_TOOL_NAMES)
disabled_tools.update(_disabled_web_tools)
elif _search_enabled:
disabled_tools.difference_update(WEB_TOOL_NAMES)

Expand Down
21 changes: 21 additions & 0 deletions src/tool_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ def web_search_enabled_for_turn(allow_web_search: object, use_web: object = None
return tool_toggle_enabled(allow_web_search) or tool_toggle_enabled(use_web)


def disabled_web_tools_for_turn(
allow_web_search: object,
use_web: object = None,
use_research: object = None,
) -> Set[str]:
"""Return network-backed tools denied by the request's web controls.

Deep research performs live retrieval too, so an agent must not be able to
use ``trigger_research`` as a fallback when web access is off. An explicit
research request remains allowed while the quick-search tools stay off.
"""

if web_search_enabled_for_turn(allow_web_search, use_web):
return set()

disabled = set(WEB_TOOL_NAMES)
if not tool_toggle_enabled(use_research):
disabled.add("trigger_research")
return disabled


_COMMON_TOOL_NAMES = {
"api_call",
"app_api",
Expand Down
33 changes: 27 additions & 6 deletions tests/test_chat_route_tool_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from src.action_intents import classify_tool_intent
from src.tool_policy import (
WEB_TOOL_NAMES,
is_web_search_explicitly_denied,
disabled_web_tools_for_turn,
web_search_enabled_for_turn,
)

Expand Down Expand Up @@ -94,8 +94,11 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
assert "web_search_enabled_for_turn(allow_web_search, use_web)" in source, (
"web tools must be gated through the explicit per-turn web setting"
)
assert "disabled_tools.update(WEB_TOOL_NAMES)" in source, (
"disabled_tools must add web_search/web_fetch when web is not explicitly enabled"
assert "disabled_web_tools_for_turn(" in source, (
"web access must be gated through the shared per-turn denylist helper"
)
assert "disabled_tools.update(_disabled_web_tools)" in source, (
"disabled_tools must add all denied network-backed tools"
)
assert "_forced_tools = set(WEB_TOOL_NAMES)" in source, (
"web tools should only be forced visible from the explicit web setting"
Expand All @@ -109,6 +112,7 @@ def _build_disabled_tools(
allow_bash=None,
allow_web_search=None,
use_web=None,
use_research=None,
can_use_bash=True,
can_use_browser=True,
explicit_web_intent=False,
Expand All @@ -124,8 +128,12 @@ def _build_disabled_tools(
if allow_bash is not None and str(allow_bash).lower() != "true":
disabled_tools.add("bash")
search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
if is_web_search_explicitly_denied(allow_web_search) or not search_enabled:
disabled_tools.update(WEB_TOOL_NAMES)
disabled_web_tools = disabled_web_tools_for_turn(
allow_web_search,
use_web,
use_research,
)
disabled_tools.update(disabled_web_tools)
if explicit_web_intent:
disabled_tools.update({
"bash", "python",
Expand All @@ -139,7 +147,7 @@ def _build_disabled_tools(
if search_enabled:
disabled_tools.difference_update(WEB_TOOL_NAMES)
else:
disabled_tools.update(WEB_TOOL_NAMES)
disabled_tools.update(disabled_web_tools)
elif search_enabled:
disabled_tools.difference_update(WEB_TOOL_NAMES)

Expand Down Expand Up @@ -178,6 +186,18 @@ def test_json_body_allow_web_search_false_disables_web():
disabled = _build_disabled_tools(allow_web_search="false")
assert "web_search" in disabled
assert "web_fetch" in disabled
assert "trigger_research" in disabled


def test_explicit_research_toggle_allows_research_with_web_search_off():
disabled = _build_disabled_tools(
allow_web_search="false",
use_research="true",
)

assert "web_search" in disabled
assert "web_fetch" in disabled
assert "trigger_research" not in disabled


def test_chat_mode_use_web_true_enables_web():
Expand Down Expand Up @@ -245,6 +265,7 @@ def test_web_search_disabled_by_default_without_explicit_turn_setting():
disabled = _build_disabled_tools(allow_web_search=None)
assert "web_search" in disabled
assert "web_fetch" in disabled
assert "trigger_research" in disabled


def test_non_privileged_user_without_explicit_flag_still_disabled():
Expand Down
Loading