Files
privateGPT/tests/engines/test_async_chat_engine.py
Javier Martinez 091d5f7020 fix: random bugs (#2301)
* fix: celery callbacks

* fix: s3 + skill creator

* fix: resumable when there's params

* fix: add distributed cache

* fix: do durable context stack

* fix: mcp tools

* fix: present server tool

* fix: add cache to the skills

* fix: mcp

* fix: mypy
2026-07-16 09:14:43 +02:00

547 lines
17 KiB
Python

import asyncio
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock
import pytest
from llama_index.core.base.llms.types import ChatMessage, MessageRole
from llama_index.core.llms.llm import ToolSelection
from private_gpt.components.chat.models.chat_config_models import (
ResolvedChatRequest,
ResolvedSystemConfig,
ResolvedToolConfig,
ToolSpec,
)
from private_gpt.components.engines.chat.async_chat_engine import (
AsyncChatCheckpoint,
AsyncChatEngine,
IterationCheckpointPayload,
LocalEventChannel,
)
from private_gpt.components.engines.chat.chat_engine import ChatLoopEngine
from private_gpt.components.engines.chat.models.chat_state import (
ChatInputState,
ChatState,
ChatStatus,
)
from private_gpt.components.llm.llm_component import LLMComponent
from private_gpt.components.tools.remote_execution import (
ToolExecutionRequest,
ToolExecutionResponse,
build_rebuild_metadata,
execute_tool_request,
)
from private_gpt.components.tools.tool_scheduler import (
BaseToolScheduler,
LocalToolScheduler,
)
from private_gpt.events.models import (
RawContentBlockStartEvent,
TextBlock,
ToolResultBlock,
)
from tests.fixtures.mock_function_llm import get_mock_function_calling_llm
async def _noop_tool(value: str) -> str:
await asyncio.sleep(0.01)
return f"ok:{value}"
def _client_tool(name: str) -> ToolSpec:
return ToolSpec.from_defaults(
name=name,
type=name,
runtime="client",
input_schema={"type": "object", "properties": {"value": {"type": "string"}}},
)
def _rebuild_server_tool(name: str) -> ToolSpec:
return ToolSpec.from_defaults(
name=name,
type=name,
runtime="server",
async_fn=_noop_tool,
)
def _server_tool(name: str) -> ToolSpec:
return ToolSpec.from_defaults(
name=name,
type=name,
runtime="server",
async_fn=_noop_tool,
execution_metadata=build_rebuild_metadata(
_rebuild_server_tool,
{"name": name},
),
)
@pytest.fixture
def base_request() -> ResolvedChatRequest:
return ResolvedChatRequest(
messages=[ChatMessage(role=MessageRole.USER, content="hello")],
system=ResolvedSystemConfig(prompt="test"),
)
class _FakeAsyncToolScheduler(BaseToolScheduler):
def __init__(self) -> None:
self.pending: dict[str, tuple[ToolExecutionRequest, str]] = {}
self.cancelled: list[str] = []
self._next = 0
@property
def is_async(self) -> bool:
return True
async def execute(
self,
request: ToolExecutionRequest,
state_ctx=None,
interceptors=None,
) -> ToolExecutionResponse:
del request, state_ctx, interceptors
raise NotImplementedError
async def async_execute(
self,
request: ToolExecutionRequest,
state_ctx=None,
interceptors=None,
) -> str:
del state_ctx, interceptors
self._next += 1
handle = f"handle-{self._next}"
self.pending[request.tool_id] = (request, handle)
return handle
async def cancel(
self,
request: ToolExecutionRequest,
task_id: str | None = None,
) -> bool:
del request
if task_id is None:
return False
self.cancelled.append(task_id)
return True
async def complete_pending(self) -> list[ToolExecutionResponse]:
responses: list[ToolExecutionResponse] = []
for tool_id in list(self.pending):
request, _ = self.pending.pop(tool_id)
responses.append(await execute_tool_request(request))
return responses
class _FakeChatScheduler:
def __init__(self) -> None:
self.cancelled: list[str] = []
async def cancel(self, correlation_id: str) -> bool:
self.cancelled.append(correlation_id)
return True
@dataclass
class _AsyncRunResult:
events: list[Any]
states: list[ChatState]
def _make_llm_component(mock_llm: Any) -> MagicMock:
llm_component = MagicMock(spec=LLMComponent)
llm_component.get_llm.return_value = mock_llm
return llm_component
async def _collect_events(events: AsyncGenerator[Any, None]) -> list[Any]:
return [event async for event in events]
def _normalize(value: Any) -> Any:
if hasattr(value, "model_dump"):
value = value.model_dump(mode="json", exclude_none=True)
if isinstance(value, dict):
return {
key: _normalize(item)
for key, item in value.items()
if key
not in {
"block_id",
"id",
"start_timestamp",
"stop_timestamp",
"expires_at",
"tool_use_id",
}
}
if isinstance(value, list):
return [_normalize(item) for item in value]
return value
def _normalize_events(events: list[Any]) -> list[Any]:
return [
{
"type": event.__class__.__name__,
"payload": _normalize(event),
}
for event in events
]
def _tool_result_texts(events: list[Any]) -> list[str]:
texts: list[str] = []
for event in events:
if isinstance(event, RawContentBlockStartEvent) and isinstance(
event.content_block, ToolResultBlock
):
for block in event.content_block.content:
if isinstance(block, TextBlock):
texts.append(block.text)
return texts
async def _run_sync_engine(
request: ResolvedChatRequest,
mock_llm: Any,
tool_scheduler: BaseToolScheduler | None = None,
) -> list[Any]:
engine = ChatLoopEngine(
llm_component=_make_llm_component(mock_llm),
request_interceptors=[],
response_interceptors=[],
max_iterations=6,
tool_scheduler=tool_scheduler or LocalToolScheduler(),
)
execution = await engine.run(request)
events = await _collect_events(execution.events)
await execution.final_state_task
return events
async def _drain(channel: LocalEventChannel) -> list[Any]:
"""Drain all events from a closed LocalEventChannel."""
return [e async for e in channel.stream()]
async def _run_async_engine(
request: ResolvedChatRequest,
mock_llm: Any,
tool_scheduler: BaseToolScheduler,
) -> _AsyncRunResult:
engine = AsyncChatEngine(
llm_component=_make_llm_component(mock_llm),
request_interceptors=[],
response_interceptors=[],
max_iterations=6,
tool_scheduler=tool_scheduler,
chat_scheduler=_FakeChatScheduler(),
)
all_events: list[Any] = []
states: list[ChatState] = []
channel = LocalEventChannel()
state = await engine.execute(request, channel=channel)
await channel.close()
all_events.extend(await _drain(channel))
states.append(state)
while state.output.status == ChatStatus.WAITING:
assert isinstance(tool_scheduler, _FakeAsyncToolScheduler)
responses = await tool_scheduler.complete_pending()
resumed_request = state.input.request.model_copy(deep=True)
resumed_request.messages = [
*resumed_request.messages,
*(response.tool_message for response in responses),
]
channel2 = LocalEventChannel()
state = await engine.resume(
AsyncChatCheckpoint(
checkpoint=state.output.pause_type,
input=ChatInputState(
request=resumed_request,
context_stack=state.input.context_stack,
),
iteration=state.runtime.iteration,
next_block_count=state.runtime.next_block_count,
payload=IterationCheckpointPayload(
pending_async_tools=state.output.pending_async_tools,
tool_responses=responses,
pending_external_tool_calls=state.output.pending_external_tool_calls,
total_input_tokens=state.runtime.total_input_tokens,
total_output_tokens=state.runtime.total_output_tokens,
has_input_usage=state.runtime.has_input_usage,
has_output_usage=state.runtime.has_output_usage,
),
),
channel=channel2,
)
await channel2.close()
all_events.extend(await _drain(channel2))
states.append(state)
return _AsyncRunResult(events=all_events, states=states)
@pytest.mark.asyncio
async def test_async_engine_matches_sync_simple_message(
base_request: ResolvedChatRequest,
) -> None:
sync_events = await _run_sync_engine(
base_request.model_copy(deep=True),
get_mock_function_calling_llm(["hello", " world"]),
)
async_result = await _run_async_engine(
base_request.model_copy(deep=True),
get_mock_function_calling_llm(["hello", " world"]),
tool_scheduler=_FakeAsyncToolScheduler(),
)
assert _normalize_events(async_result.events) == _normalize_events(sync_events)
assert [state.output.status for state in async_result.states] == [
ChatStatus.COMPLETED,
]
@pytest.mark.asyncio
async def test_async_engine_matches_sync_one_client_tool_and_stops_first_iteration(
base_request: ResolvedChatRequest,
) -> None:
request = base_request.model_copy(deep=True)
request.tool_config = ResolvedToolConfig(tools=[_client_tool("browser")])
sync_events = await _run_sync_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1",
tool_name="browser",
tool_kwargs={"value": "x"},
)
]
]
),
)
async_result = await _run_async_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1",
tool_name="browser",
tool_kwargs={"value": "x"},
)
]
]
),
tool_scheduler=_FakeAsyncToolScheduler(),
)
assert _normalize_events(async_result.events) == _normalize_events(sync_events)
assert async_result.states[0].output.status == ChatStatus.COMPLETED
assert async_result.states[0].output.stop_reason == "tool_use"
assert len(async_result.states[0].output.pending_external_tool_calls) == 1
@pytest.mark.asyncio
async def test_async_engine_matches_sync_one_server_tool_and_resumes_same_point(
base_request: ResolvedChatRequest,
) -> None:
request = base_request.model_copy(deep=True)
request.tool_config = ResolvedToolConfig(tools=[_server_tool("echo")])
sync_events = await _run_sync_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "x"}
)
],
["done"],
]
),
)
async_result = await _run_async_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "x"}
)
],
["done"],
]
),
tool_scheduler=_FakeAsyncToolScheduler(),
)
assert [state.output.status for state in async_result.states] == [
ChatStatus.WAITING,
ChatStatus.COMPLETED,
]
assert async_result.states[0].output.pause_type == "tools"
assert _tool_result_texts(async_result.events) == ["ok:x"]
assert _tool_result_texts(sync_events) == ["ok:x"]
assert _normalize_events(async_result.events) == _normalize_events(sync_events)
@pytest.mark.asyncio
async def test_async_engine_matches_sync_two_server_tools_plus_one_client_tool(
base_request: ResolvedChatRequest,
) -> None:
request = base_request.model_copy(deep=True)
request.tool_config = ResolvedToolConfig(
tools=[
_server_tool("echo"),
_server_tool("echo2"),
_client_tool("browser"),
]
)
sync_events = await _run_sync_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "a"}
),
ToolSelection(
tool_id="tool_2", tool_name="echo2", tool_kwargs={"value": "b"}
),
ToolSelection(
tool_id="tool_3",
tool_name="browser",
tool_kwargs={"value": "c"},
),
]
]
),
)
async_result = await _run_async_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "a"}
),
ToolSelection(
tool_id="tool_2", tool_name="echo2", tool_kwargs={"value": "b"}
),
ToolSelection(
tool_id="tool_3",
tool_name="browser",
tool_kwargs={"value": "c"},
),
]
]
),
tool_scheduler=_FakeAsyncToolScheduler(),
)
assert async_result.states[0].output.status == ChatStatus.WAITING
assert async_result.states[1].output.status == ChatStatus.COMPLETED
assert async_result.states[1].output.stop_reason == "tool_use"
assert len(async_result.states[1].output.pending_external_tool_calls) == 1
assert sorted(_tool_result_texts(async_result.events)) == ["ok:a", "ok:b"]
assert sorted(_tool_result_texts(sync_events)) == ["ok:a", "ok:b"]
assert _normalize_events(async_result.events) == _normalize_events(sync_events)
@pytest.mark.asyncio
async def test_async_engine_matches_sync_across_multiple_server_tool_iterations(
base_request: ResolvedChatRequest,
) -> None:
request = base_request.model_copy(deep=True)
request.tool_config = ResolvedToolConfig(tools=[_server_tool("echo")])
deltas = [
[ToolSelection(tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "a"})],
[ToolSelection(tool_id="tool_2", tool_name="echo", tool_kwargs={"value": "b"})],
["all", " done"],
]
sync_events = await _run_sync_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(deltas),
)
async_result = await _run_async_engine(
request.model_copy(deep=True),
get_mock_function_calling_llm(deltas),
tool_scheduler=_FakeAsyncToolScheduler(),
)
assert [state.output.status for state in async_result.states] == [
ChatStatus.WAITING,
ChatStatus.WAITING,
ChatStatus.COMPLETED,
]
assert _tool_result_texts(async_result.events) == ["ok:a", "ok:b"]
assert _normalize_events(async_result.events) == _normalize_events(sync_events)
@pytest.mark.asyncio
async def test_async_engine_cancel_schedules_chat_cancellation(
base_request: ResolvedChatRequest,
) -> None:
request = base_request.model_copy(deep=True)
request.context.correlation_id = "msg-cancel-1"
request.tool_config = ResolvedToolConfig(tools=[_server_tool("echo")])
mock_llm = get_mock_function_calling_llm(
[
[
ToolSelection(
tool_id="tool_1", tool_name="echo", tool_kwargs={"value": "x"}
)
]
]
)
tool_scheduler = _FakeAsyncToolScheduler()
chat_scheduler = _FakeChatScheduler()
engine = AsyncChatEngine(
llm_component=_make_llm_component(mock_llm),
request_interceptors=[],
response_interceptors=[],
max_iterations=4,
tool_scheduler=tool_scheduler,
chat_scheduler=chat_scheduler,
)
channel = LocalEventChannel()
state = await engine.execute(request, channel=channel)
await channel.close()
await _collect_events(channel.stream())
assert state.output.status == ChatStatus.WAITING
assert list(state.output.pending_async_tools.values()) == ["handle-1"]
cancelled = await engine.cancel("msg-cancel-1")
assert cancelled is True
assert chat_scheduler.cancelled == ["msg-cancel-1"]
@pytest.mark.asyncio
async def test_async_engine_cancel_without_scheduler_returns_false() -> None:
engine = AsyncChatEngine(
llm_component=_make_llm_component(get_mock_function_calling_llm(["ok"])),
request_interceptors=[],
response_interceptors=[],
max_iterations=2,
tool_scheduler=_FakeAsyncToolScheduler(),
chat_scheduler=_FakeChatScheduler(),
)
# _FakeChatScheduler returns True for any correlation_id
assert await engine.cancel("msg-no-scheduler") is True