mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 01:48:03 +00:00
* fix: avoid to block the loop * fix: blocks in expansion * fix: remove maximum concurrent users ... * fix: multiplexer * fix: readers * fix: more fixes ... * fix: impl * feat: tool scheduler * feat: add adaptative * feat: add chat worker * fix: max * feat: add chat/tools workers * fix: mypy * feat: add generic scheduler * fix: get result * feat: do serializable the tool executor * fix: tools * fix: config * fix: config * fix: args * fix: config * fix: serializer * Revert "fix: blocks in expansion" This reverts commita2110f94a8. * fix: unify all logic * feat: add ingestion scheduler * fix: settings * fix: config * feat: add arq worker to chat * fix: arq worker * fix: add nest * fix: mypy * fix: await * fix: script stress * fix: tokenizer * fix: chat scheduler * fix: mypy * fix: add async tokenizer * fix: improve condense * fix: tool scheduler * feat: add initial real async chat worker * fix: mypy * fix: do resumable local executor ... ... ... fix: revert usleess changes fix: remove parent chat job fix: refactor fix: loop ref: rename models fix: chat engine fix: mypy ... ... ... fix: fix deps * fix: tests * fix: tests * ... * fix: stream * fix: config * fix: scheduler * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Handle PGPT_WORKER_MODE=celery in health check worker status * fix: cancel * fix: arch * fix: test ingestion * fix: deserialization of chat messages * fix: broken results * fix: mypy * fix: test * fix: config * fix: remove arq tool worker * fix: output cls * fix: preserve early resumable tool callbacks * fix: preserve async tool result order * refactor: address worker PR review comments * fix: mypy * test: colocate ARQ chat enqueue coverage * fix: remove redis from tests * test: isolate chat mocks and cancellation timing * fix: tests (cherry picked from commit218b599c66) # Conflicts: # tests/server/chat/anthropic/test_anthropic_client.py # tests/server/chat/anthropic/test_langchain_anthropic.py # tests/server/chat/test_chat_knowledge_revamp.py # tests/server/chat/test_chat_routes.py # tests/server/chat/test_chat_routes_skills_integration.py * fix: tests (cherry picked from commitfc5ec0f72a) * fix: ruff * fix: test * fix: worker config (cherry picked from commit1371c275a1) * fix: principal * test: remove flaky chat cancellation assertion --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
396 lines
13 KiB
Python
396 lines
13 KiB
Python
import uuid
|
|
from typing import Any
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from llama_index.core.base.llms.types import ChatMessage
|
|
from llama_index.core.llms.llm import ToolSelection
|
|
|
|
from private_gpt.chat.extensions.context_filter import ContextFilter
|
|
from private_gpt.chat.input_models import (
|
|
Citations,
|
|
MessageInput,
|
|
PromptConfig,
|
|
System,
|
|
SystemExtensions,
|
|
ToolSpecBody,
|
|
)
|
|
from private_gpt.components.llm.llm_component import LLMComponent
|
|
from private_gpt.events.models import Message, ToolUseBlock
|
|
from private_gpt.server.chat.chat_router import ChatBody
|
|
from private_gpt.server.utils.artifact_input import IngestedArtifact
|
|
from tests.fixtures.mock_function_llm import get_mock_function_calling_llm
|
|
from tests.fixtures.mock_injector import MockInjector
|
|
|
|
|
|
class PromptCapture:
|
|
"""Helper class to capture system prompts sent to the LLM."""
|
|
|
|
def __init__(self) -> None:
|
|
self.captured_messages: list[ChatMessage] = []
|
|
self.system_prompt: str = ""
|
|
|
|
|
|
async def mock_llm_with_capture(
|
|
injector: MockInjector,
|
|
prompt_capture: PromptCapture,
|
|
deltas: list[list[str | ToolSelection]] | None = None,
|
|
) -> None:
|
|
"""Configure mock LLM and capture the messages sent to it."""
|
|
deltas = deltas or [["Default response"]]
|
|
mock_llm_instance = get_mock_function_calling_llm(deltas)
|
|
|
|
# Wrap astream_chat_with_tools to capture messages
|
|
original_astream = mock_llm_instance.astream_chat_with_tools
|
|
|
|
async def capturing_astream(
|
|
tools: Any,
|
|
user_msg: Any = None,
|
|
chat_history: list[ChatMessage] | None = None,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
# Capture the chat history (which includes system messages)
|
|
if chat_history:
|
|
prompt_capture.captured_messages.extend(chat_history)
|
|
# Extract system prompt from messages
|
|
system_messages = [
|
|
msg for msg in chat_history if msg.role.value == "system"
|
|
]
|
|
if system_messages:
|
|
prompt_capture.system_prompt = "\n".join(
|
|
[msg.content or "" for msg in system_messages]
|
|
)
|
|
|
|
# Call original - await the coroutine to get the async generator
|
|
gen = await original_astream(tools, user_msg, chat_history, **kwargs)
|
|
async for response in gen:
|
|
yield response
|
|
|
|
async def coro(*args, **kwargs):
|
|
return capturing_astream(*args, **kwargs)
|
|
|
|
mock_llm_instance.astream_chat_with_tools = coro
|
|
|
|
llm_component = injector.get(LLMComponent)
|
|
llm_component.get_llm = Mock(return_value=mock_llm_instance)
|
|
injector.bind_mock(LLMComponent, llm_component)
|
|
|
|
|
|
def create_tool_definition(
|
|
name: str,
|
|
tool_type: str | None = None,
|
|
description: str | None = None,
|
|
input_schema: dict[str, Any] | None = None,
|
|
) -> ToolSpecBody:
|
|
"""Create a tool definition with proper structure."""
|
|
return ToolSpecBody(
|
|
name=name,
|
|
type=tool_type,
|
|
description=description,
|
|
input_schema=input_schema,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_no_tools_no_system_prompt(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""Test that when no tools are provided."""
|
|
prompt_capture = PromptCapture()
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Hello", role="user")],
|
|
system=System(),
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
has_tool_content = any(
|
|
keyword in system_prompt_lower
|
|
for keyword in [
|
|
"list_files",
|
|
"get_content",
|
|
"knowledge_search",
|
|
"semantic_search",
|
|
]
|
|
)
|
|
assert not has_tool_content
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_default_prompt_flag_is_noop_in_context_stack(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""use_default_prompt is deprecated; no legacy templates injected."""
|
|
prompt_capture = PromptCapture()
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Hello", role="user")],
|
|
system=System(text="Be concise.", use_default_prompt=True),
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
assert "be concise." in system_prompt_lower
|
|
assert "current date:" in system_prompt_lower
|
|
assert "<tool_restrictions>" not in system_prompt_lower
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_online_search_tool_system_prompt_injection(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""Test online_search injects instructions when tool_instructions=True."""
|
|
prompt_capture = PromptCapture()
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Search the web", role="user")],
|
|
system=System(prompt=PromptConfig(tools=True)),
|
|
tools=[
|
|
create_tool_definition(
|
|
name="online_search",
|
|
tool_type="web_search_v1",
|
|
)
|
|
],
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
# web_search template should be injected when tool_instructions=True
|
|
assert "online_search" in system_prompt_lower or "web" in system_prompt_lower
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_prompt_features_disabled_no_guidelines(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""PromptConfig defaults to all False — no platform prompts injected by default."""
|
|
prompt_capture = PromptCapture()
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Hello", role="user")],
|
|
# prompt features default to all False
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
# No platform prompts should appear
|
|
assert "response_formatting" not in system_prompt_lower
|
|
assert "knowledge_base_operations" not in system_prompt_lower
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_citations_enabled_system_prompt_injection_without_sources(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""Test that citations enabled adds citation prompt."""
|
|
collection = str(uuid.uuid4())
|
|
prompt_capture = PromptCapture()
|
|
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Search for information", role="user")],
|
|
system=System(
|
|
citations=Citations(enabled=True),
|
|
extensions={SystemExtensions.ZYLON},
|
|
),
|
|
tools=[
|
|
create_tool_definition(
|
|
name="knowledge_search",
|
|
tool_type="semantic_search_v1",
|
|
)
|
|
],
|
|
tool_context=[
|
|
IngestedArtifact(context_filter=ContextFilter(collection=collection))
|
|
],
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
assert "citation" not in system_prompt_lower
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_autonomous_selection_without_tool_choice(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""Test that agent can autonomously select tools without explicit tool_choice."""
|
|
collection = str(uuid.uuid4())
|
|
artifact = str(uuid.uuid4())
|
|
|
|
# Ingest test data
|
|
ingest_response = await async_test_client.post(
|
|
"/v1/artifacts/ingest",
|
|
json={
|
|
"metadata": {},
|
|
"input": {
|
|
"type": "text",
|
|
"value": "Test document content",
|
|
},
|
|
"collection": collection,
|
|
"artifact": artifact,
|
|
},
|
|
)
|
|
assert ingest_response.status_code == 200
|
|
|
|
# Mock LLM to return tool selection
|
|
prompt_capture = PromptCapture()
|
|
tool_deltas: list[list[str | ToolSelection]] = [
|
|
[
|
|
ToolSelection(
|
|
tool_id="list_files",
|
|
tool_name="list_files",
|
|
tool_kwargs={"query": "documents"},
|
|
)
|
|
],
|
|
["Found documents"],
|
|
]
|
|
await mock_llm_with_capture(injector, prompt_capture, deltas=tool_deltas)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="What files do I have?", role="user")],
|
|
tools=[
|
|
create_tool_definition(
|
|
name="list_files",
|
|
description="List files",
|
|
input_schema={
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
},
|
|
)
|
|
],
|
|
tool_context=[
|
|
IngestedArtifact(context_filter=ContextFilter(collection=collection))
|
|
],
|
|
# No tool_choice specified - agent should autonomously select
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
|
|
assert response.status_code == 200
|
|
completion: Message = Message.model_validate(response.json())
|
|
assert any(isinstance(block, ToolUseBlock) for block in completion.content)
|
|
|
|
# Cleanup
|
|
await async_test_client.post(
|
|
"/v1/artifacts/delete",
|
|
json={"collection": collection, "artifact": artifact},
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_knowledge_search_tool_instructions_injected(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""With tools=True, tool instructions are injected under the caller-given name."""
|
|
collection = str(uuid.uuid4())
|
|
prompt_capture = PromptCapture()
|
|
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Tell me about the documents", role="user")],
|
|
system=System(prompt=PromptConfig(tools=True)),
|
|
tools=[
|
|
create_tool_definition(
|
|
name="knowledge_search",
|
|
tool_type="semantic_search_v1",
|
|
)
|
|
],
|
|
tool_context=[
|
|
IngestedArtifact(context_filter=ContextFilter(collection=collection))
|
|
],
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
|
|
assert response.status_code == 200
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
# Instructions are injected under the caller-assigned name, not the internal type.
|
|
assert "knowledge_search" in system_prompt_lower
|
|
assert "semantic_search_v1" not in system_prompt_lower
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_external_tool_without_tool_context_succeeds(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""Test that external (non-internal) tools work without tool_context."""
|
|
prompt_capture = PromptCapture()
|
|
tool_deltas: list[list[str | ToolSelection]] = [
|
|
[
|
|
ToolSelection(
|
|
tool_id="custom_tool",
|
|
tool_name="custom_tool",
|
|
tool_kwargs={"param": "value"},
|
|
)
|
|
],
|
|
["Tool executed successfully"],
|
|
]
|
|
await mock_llm_with_capture(injector, prompt_capture, deltas=tool_deltas)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Use custom tool", role="user")],
|
|
tools=[
|
|
create_tool_definition(
|
|
name="custom_tool",
|
|
description="A custom external tool",
|
|
input_schema={
|
|
"type": "object",
|
|
"properties": {"param": {"type": "string"}},
|
|
},
|
|
)
|
|
],
|
|
# No tool_context - external tool doesn't need it
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
completion: Message = Message.model_validate(response.json())
|
|
assert any(isinstance(block, ToolUseBlock) for block in completion.content)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_instructions_external_tool_with_explicit_instructions(
|
|
async_test_client: AsyncClient,
|
|
injector: MockInjector,
|
|
) -> None:
|
|
"""External tool with explicit instructions field gets them injected."""
|
|
prompt_capture = PromptCapture()
|
|
await mock_llm_with_capture(injector, prompt_capture)
|
|
|
|
tool_with_instructions = ToolSpecBody(
|
|
name="my_custom_tool",
|
|
description="A tool with custom instructions",
|
|
input_schema={"type": "object", "properties": {"q": {"type": "string"}}},
|
|
instructions="Always use this tool when the user asks about custom topics.",
|
|
)
|
|
|
|
body = ChatBody(
|
|
messages=[MessageInput(content="Use my tool", role="user")],
|
|
system=System(prompt=PromptConfig(tools=True)),
|
|
tools=[tool_with_instructions],
|
|
)
|
|
response = await async_test_client.post("/v1/messages", json=body.model_dump())
|
|
assert response.status_code == 200
|
|
|
|
system_prompt_lower = prompt_capture.system_prompt.lower()
|
|
assert "always use this tool" in system_prompt_lower
|