fix(projects): add special logic for internal search tool when no connectors available (#6774)

Co-authored-by: Yuhong Sun <yuhongsun96@gmail.com>
This commit is contained in:
roshanandYuhong Sun authored and GitHub committed 2025-12-17 06:45:03 +00:00
1 parent f1a81d45a1
commit 7f79e34aa4
6 files changed
+162 -43

No files matched your search

+60 -16
View File
@@ -64,10 +64,12 @@ from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.utils import get_json_line
from onyx.tools.constants import SEARCH_TOOL_ID
from onyx.tools.tool import Tool
from onyx.tools.tool_constructor import construct_tools
from onyx.tools.tool_constructor import CustomToolConfig
from onyx.tools.tool_constructor import SearchToolConfig
from onyx.tools.tool_constructor import SearchToolUsage
from onyx.utils.logger import setup_logger
from onyx.utils.long_term_log import LongTermLogger
from onyx.utils.timing import log_function_time
@@ -212,6 +214,46 @@ def _extract_project_file_texts_and_images(
)
def _get_project_search_availability(
project_id: int | None,
persona_id: int | None,
has_project_file_texts: bool,
forced_tool_ids: list[int] | None,
search_tool_id: int | None,
) -> SearchToolUsage:
"""Determine search tool availability based on project context.
Args:
project_id: The project ID if the user is in a project
persona_id: The persona ID to check if it's the default persona
has_project_file_texts: Whether project files are loaded in context
forced_tool_ids: List of forced tool IDs (may be mutated to remove search tool)
search_tool_id: The search tool ID to check against
Returns:
SearchToolUsage setting indicating how search should be used
"""
# There are cases where the internal search tool should be disabled
# If the user is in a project, it should not use other sources / generic search
# If they are in a project but using a custom agent, it should use the agent setup
# (which means it can use search)
# However if in a project and there are more files than can fit in the context,
# it should use the search tool with the project filter on
# If no files are uploaded, search should remain enabled
search_usage_forcing_setting = SearchToolUsage.AUTO
if project_id:
if bool(persona_id is DEFAULT_PERSONA_ID and has_project_file_texts):
search_usage_forcing_setting = SearchToolUsage.DISABLED
# Remove search tool from forced_tool_ids if it's present
if forced_tool_ids and search_tool_id and search_tool_id in forced_tool_ids:
forced_tool_ids[:] = [
tool_id for tool_id in forced_tool_ids if tool_id != search_tool_id
]
elif forced_tool_ids and search_tool_id and search_tool_id in forced_tool_ids:
search_usage_forcing_setting = SearchToolUsage.ENABLED
return search_usage_forcing_setting
def _initialize_chat_session(
message_text: str,
files: list[FileDescriptor],
@@ -405,17 +447,23 @@ def stream_chat_message_objects(
db_session=db_session,
)
# There are cases where the internal search tool should be disabled
# If the user is in a project, it should not use other sources / generic search
# If they are in a project but using a custom agent, it should use the agent setup
# (which means it can use search)
# However if in a project and there are more files than can fit in the context,
# it should use the search tool with the project filter on
# If no files are uploaded, search should remain enabled
disable_internal_search = bool(
chat_session.project_id
and persona.id is DEFAULT_PERSONA_ID
and extracted_project_files.project_file_texts
# Build a mapping of tool_id to tool_name for history reconstruction
all_tools = get_tools(db_session)
tool_id_to_name_map = {tool.id: tool.name for tool in all_tools}
search_tool_id = next(
(tool.id for tool in all_tools if tool.in_code_tool_id == SEARCH_TOOL_ID),
None,
)
# This may also mutate the new_msg_req.forced_tool_ids
# This logic is specifically for projects
search_usage_forcing_setting = _get_project_search_availability(
project_id=chat_session.project_id,
persona_id=persona.id,
has_project_file_texts=bool(extracted_project_files.project_file_texts),
forced_tool_ids=new_msg_req.forced_tool_ids,
search_tool_id=search_tool_id,
)
emitter = get_default_emitter()
@@ -444,7 +492,7 @@ def stream_chat_message_objects(
additional_headers=custom_tool_additional_headers,
),
allowed_tool_ids=new_msg_req.allowed_tool_ids,
disable_internal_search=disable_internal_search,
search_usage_forcing_setting=search_usage_forcing_setting,
)
tools: list[Tool] = []
for tool_list in tool_dict.values():
@@ -469,10 +517,6 @@ def stream_chat_message_objects(
reserved_assistant_message_id=assistant_response.id,
)
# Build a mapping of tool_id to tool_name for history reconstruction
all_tools = get_tools(db_session)
tool_id_to_name_map = {tool.id: tool.name for tool in all_tools}
# Convert the chat history into a simple format that is free of any DB objects
# and is easy to parse for the agent loop
simple_chat_history = convert_chat_history(
+42 -3
View File
@@ -1,3 +1,4 @@
from enum import Enum
from typing import cast
from uuid import UUID
@@ -23,6 +24,7 @@ from onyx.db.models import Persona
from onyx.db.models import User
from onyx.db.oauth_config import get_oauth_config
from onyx.db.search_settings import get_current_search_settings
from onyx.db.tools import get_builtin_tool
from onyx.document_index.factory import get_default_document_index
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMConfig
@@ -65,6 +67,12 @@ class CustomToolConfig(BaseModel):
additional_headers: dict[str, str] | None = None
class SearchToolUsage(str, Enum):
DISABLED = "disabled"
ENABLED = "enabled"
AUTO = "auto"
def _get_image_generation_config(llm: LLM, db_session: Session) -> LLMConfig:
"""Helper function to get image generation LLM config based on available providers"""
if llm and llm.config.api_key and llm.config.model_provider == "openai":
@@ -127,7 +135,7 @@ def construct_tools(
search_tool_config: SearchToolConfig | None = None,
custom_tool_config: CustomToolConfig | None = None,
allowed_tool_ids: list[int] | None = None,
disable_internal_search: bool = False,
search_usage_forcing_setting: SearchToolUsage = SearchToolUsage.AUTO,
) -> dict[int, list[Tool]]:
"""Constructs tools based on persona configuration and available APIs.
@@ -146,6 +154,7 @@ def construct_tools(
if user and user.oauth_accounts:
user_oauth_token = user.oauth_accounts[0].access_token
added_search_tool = False
for db_tool_model in persona.tools:
# If allowed_tool_ids is specified, skip tools not in the allowed list
if allowed_tool_ids is not None and db_tool_model.id not in allowed_tool_ids:
@@ -171,7 +180,8 @@ def construct_tools(
# Handle Internal Search Tool
if tool_cls.__name__ == SearchTool.__name__:
if disable_internal_search:
added_search_tool = True
if search_usage_forcing_setting == SearchToolUsage.DISABLED:
continue
if not search_tool_config:
@@ -180,7 +190,6 @@ def construct_tools(
search_settings = get_current_search_settings(db_session)
document_index = get_default_document_index(search_settings, None)
# TODO concerning passing the db_session here.
search_tool = SearchTool(
tool_id=db_tool_model.id,
db_session=db_session,
@@ -371,6 +380,36 @@ def construct_tools(
f"Tool '{expected_tool_name}' not found in MCP server '{mcp_server.name}'"
)
if (
not added_search_tool
and search_usage_forcing_setting == SearchToolUsage.ENABLED
):
# Get the database tool model for SearchTool
search_tool_db_model = get_builtin_tool(db_session, SearchTool)
# Use the passed-in config if available, otherwise create a new one
if not search_tool_config:
search_tool_config = SearchToolConfig()
search_settings = get_current_search_settings(db_session)
document_index = get_default_document_index(search_settings, None)
search_tool = SearchTool(
tool_id=search_tool_db_model.id,
db_session=db_session,
emitter=emitter,
user=user,
persona=persona,
llm=llm,
fast_llm=fast_llm,
document_index=document_index,
user_selected_filters=search_tool_config.user_selected_filters,
project_id=search_tool_config.project_id,
bypass_acl=search_tool_config.bypass_acl,
slack_context=search_tool_config.slack_context,
)
tool_dict[search_tool_db_model.id] = [search_tool]
tools: list[Tool] = []
for tool_list in tool_dict.values():
tools.extend(tool_list)
+8 -1
View File
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import { useSettingsContext } from "@/components/settings/SettingsProvider";
@@ -110,6 +111,12 @@ export default function ChatPage({ firstMessage, headerData }: ChatPageProps) {
clearLastFailedFiles,
} = useProjectsContext();
// When changing from project chat to main chat (or vice-versa), clear forced tools
const { setForcedToolIds } = useForcedTools();
useEffect(() => {
setForcedToolIds([]);
}, [currentProjectId, setForcedToolIds]);
// handle redirect if chat page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
@@ -666,7 +673,7 @@ export default function ChatPage({ firstMessage, headerData }: ChatPageProps) {
{/* ChatInputBar container */}
<div
ref={inputRef}
className="max-w-[50rem] w-full pointer-events-auto flex flex-col px-4 justify-center items-center"
className="max-w-[50rem] w-full pointer-events-auto flex flex-col justify-center items-center"
>
{(showOnboarding ||
(user?.role !== UserRole.ADMIN &&
+1 -1
View File
@@ -52,7 +52,7 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
side={side}
className={cn(
"z-tooltip rounded-08 px-3 py-2 text-text-inverted-05 animate-in fade-in-0 zoom-in-95 bg-background-neutral-dark-03 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[30001] rounded-08 px-3 py-2 text-text-inverted-05 animate-in fade-in-0 zoom-in-95 bg-background-neutral-dark-03 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
width,
className
)}
@@ -24,6 +24,7 @@ export interface ActionItemProps {
hasNoConnectors?: boolean;
toolAuthStatus?: ToolAuthStatus;
onOAuthAuthenticate?: () => void;
isProjectContext?: boolean;
}
export default function ActionLineItem({
@@ -38,13 +39,20 @@ export default function ActionLineItem({
hasNoConnectors = false,
toolAuthStatus,
onOAuthAuthenticate,
isProjectContext = false,
}: ActionItemProps) {
const Icon = tool ? getIconForAction(tool) : ProvidedIcon!;
const label = tool ? tool.display_name || tool.name : providedLabel!;
const toolName = tool?.name || providedLabel || "";
let label = tool ? tool.display_name || tool.name : providedLabel!;
if (isProjectContext && tool?.in_code_tool_id === SEARCH_TOOL_ID) {
label = "Project Search";
}
const isSearchToolWithNoConnectors =
tool?.in_code_tool_id === SEARCH_TOOL_ID && hasNoConnectors;
!isProjectContext &&
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
hasNoConnectors;
return (
<SimpleTooltip tooltip={tool?.description} className="max-w-[30rem]">
@@ -56,7 +64,7 @@ export default function ActionLineItem({
onForceToggle();
}}
selected={isForced}
strikethrough={disabled}
strikethrough={disabled || isSearchToolWithNoConnectors}
icon={Icon}
rightChildren={
<div className="flex flex-row items-center gap-1">
@@ -92,25 +100,32 @@ export default function ActionLineItem({
tooltip={disabled ? "Enable" : "Disable"}
/>
)}
{tool && tool.in_code_tool_id === SEARCH_TOOL_ID && (
<IconButton
icon={
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
}
onClick={noProp(() => {
if (isSearchToolWithNoConnectors)
window.location.href = "/admin/add-connector";
else onSourceManagementOpen?.();
})}
internal
className={cn(
isSearchToolWithNoConnectors &&
"invisible grouop-hover/LineItem:visible"
)}
tooltip={isSearchToolWithNoConnectors ? "Settings" : "More"}
/>
)}
{tool &&
tool.in_code_tool_id === SEARCH_TOOL_ID &&
!isProjectContext && (
<IconButton
icon={
isSearchToolWithNoConnectors
? SvgSettings
: SvgChevronRight
}
onClick={noProp(() => {
if (isSearchToolWithNoConnectors)
window.location.href = "/admin/add-connector";
else onSourceManagementOpen?.();
})}
internal
className={cn(
isSearchToolWithNoConnectors &&
"invisible group-hover/LineItem:visible"
)}
tooltip={
isSearchToolWithNoConnectors
? "Setup Connectors"
: "Configure Connectors"
}
/>
)}
</div>
}
>
@@ -36,6 +36,7 @@ import ActionLineItem from "@/refresh-components/popovers/ActionsPopover/ActionL
import MCPLineItem, {
MCPServer,
} from "@/refresh-components/popovers/ActionsPopover/MCPLineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import { SvgActions, SvgChevronRight, SvgKey, SvgSliders } from "@opal/icons";
// Get source metadata for configured sources - deduplicated by source type
@@ -142,7 +143,9 @@ export default function ActionsPopover({
const { tools: availableTools } = useAvailableTools();
const { ccPairs } = useCCPairs();
const { currentProjectId, allCurrentProjectFiles } = useProjectsContext();
const availableToolIds = availableTools.map((tool) => tool.id);
const isProjectContext = currentProjectId !== null;
// Check if there are any connectors available
const hasNoConnectors = !ccPairs || ccPairs.length === 0;
@@ -184,6 +187,16 @@ export default function ActionsPopover({
// Filter out tools that are not chat-selectable (visibility set by backend)
if (!tool.chat_selectable) return false;
// Special handling for Project Search
// Ensure Project Search is hidden if no files exist
if (tool.in_code_tool_id === SEARCH_TOOL_ID && isProjectContext) {
if (!allCurrentProjectFiles || allCurrentProjectFiles.length === 0) {
return false;
}
// If files exist, show it (even if backend thinks it's strictly unavailable due to no connectors)
return true;
}
// Advertise to admin/curator users that they can connect an internal search tool
// even if it's not available or has no connectors
if (tool.in_code_tool_id === SEARCH_TOOL_ID && (isAdmin || isCurator)) {
@@ -518,6 +531,7 @@ export default function ActionsPopover({
hasNoConnectors={hasNoConnectors}
toolAuthStatus={getToolAuthStatus(tool)}
onOAuthAuthenticate={() => authenticateTool(tool)}
isProjectContext={isProjectContext}
/>
)),