diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py index c028735..7484a37 100644 --- a/adapter/matrix/handlers/__init__.py +++ b/adapter/matrix/handlers/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from adapter.matrix.handlers.agent import make_handle_agent from adapter.matrix.handlers.chat import ( handle_list_chats, make_handle_archive, @@ -34,9 +35,12 @@ def register_matrix_handlers( dispatcher: EventDispatcher, client=None, store=None, + registry=None, prototype_state=None, agent_base_url: str = "http://127.0.0.1:8000", ) -> None: + if store is not None and registry is not None: + dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) dispatcher.register(IncomingCommand, "chats", handle_list_chats) dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) diff --git a/adapter/matrix/handlers/agent.py b/adapter/matrix/handlers/agent.py new file mode 100644 index 0000000..f9bf804 --- /dev/null +++ b/adapter/matrix/handlers/agent.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from adapter.matrix.agent_registry import AgentRegistry +from adapter.matrix.store import ( + get_platform_chat_id, + get_selected_agent_id, + get_room_meta, + next_platform_chat_id, + set_platform_chat_id, + set_room_agent_id, + set_selected_agent_id, +) +from core.protocol import IncomingCommand, OutgoingMessage + + +def make_handle_agent(store, registry: AgentRegistry) -> Callable[..., Awaitable[list]]: + async def handle_agent( + event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr + ) -> list: + if not event.args: + selected_agent_id = await get_selected_agent_id(store, event.user_id) + lines = ["Доступные агенты:"] + for index, agent in enumerate(registry.agents, start=1): + suffix = " [текущий]" if agent.agent_id == selected_agent_id else "" + lines.append(f"{index}. {agent.label}{suffix}") + lines.extend(["", "Выбери агент: !agent <номер>"]) + return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] + + try: + selected_index = int(event.args[0]) + except ValueError: + return [ + OutgoingMessage( + chat_id=event.chat_id, + text="Укажи номер агента из списка: !agent <номер>.", + ) + ] + + if selected_index < 1 or selected_index > len(registry.agents): + return [ + OutgoingMessage( + chat_id=event.chat_id, + text="Такого агента нет. Открой список через !agent.", + ) + ] + + agent = registry.agents[selected_index - 1] + await set_selected_agent_id(store, event.user_id, agent.agent_id) + + current_chat = await chat_mgr.get(event.chat_id, user_id=event.user_id) + if current_chat is not None and current_chat.surface_ref: + room_id = current_chat.surface_ref + room_meta = await get_room_meta(store, room_id) + if room_meta is not None and not room_meta.get("agent_id"): + await set_room_agent_id(store, room_id, agent.agent_id) + if await get_platform_chat_id(store, room_id) is None: + await set_platform_chat_id( + store, + room_id, + await next_platform_chat_id(store), + ) + return [ + OutgoingMessage( + chat_id=event.chat_id, + text=f"Агент {agent.label} выбран. Текущий чат готов к работе.", + ) + ] + + return [ + OutgoingMessage( + chat_id=event.chat_id, + text=f"Агент переключен на {agent.label}. Продолжай через !new.", + ) + ] + + return handle_agent diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py index 07e64c0..e6a740c 100644 --- a/adapter/matrix/handlers/settings.py +++ b/adapter/matrix/handlers/settings.py @@ -14,6 +14,9 @@ HELP_TEXT = "\n".join( "!save [имя] сохранить текущий контекст", "!load показать сохранённые контексты", "", + "!agent показать доступных агентов", + "!agent <номер> выбрать агента для следующих чатов", + "", "Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.", ] ) diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py index e835ace..b78d4b5 100644 --- a/adapter/matrix/store.py +++ b/adapter/matrix/store.py @@ -45,6 +45,27 @@ async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> N await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta) +async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: + meta = await get_user_meta(store, matrix_user_id) + return meta.get("selected_agent_id") if meta else None + + +async def set_selected_agent_id( + store: StateStore, + matrix_user_id: str, + agent_id: str, +) -> None: + meta = dict(await get_user_meta(store, matrix_user_id) or {}) + meta["selected_agent_id"] = agent_id + await set_user_meta(store, matrix_user_id, meta) + + +async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: + meta = dict(await get_room_meta(store, room_id) or {}) + meta["agent_id"] = agent_id + await set_room_meta(store, room_id, meta) + + async def get_room_state(store: StateStore, room_id: str) -> str: data = await store.get(f"{ROOM_STATE_PREFIX}{room_id}") return data["state"] if data else "idle" diff --git a/pyproject.toml b/pyproject.toml index f2fc338..73dfbd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "python-dotenv>=1.0", "httpx>=0.27", "aiohttp>=3.9", - "PyYAML>=6.0", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/tests/adapter/matrix/test_agent_handler.py b/tests/adapter/matrix/test_agent_handler.py new file mode 100644 index 0000000..dd101a1 --- /dev/null +++ b/tests/adapter/matrix/test_agent_handler.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from adapter.matrix.bot import build_runtime +from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry +from adapter.matrix.handlers.agent import make_handle_agent +from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_selected_agent_id, set_room_meta +from core.chat import ChatManager +from core.protocol import IncomingCommand, OutgoingMessage +from core.settings import SettingsManager +from core.store import InMemoryStore +from sdk.mock import MockPlatformClient + + +def _registry() -> AgentRegistry: + return AgentRegistry( + [ + AgentDefinition(agent_id="agent-1", label="Analyst"), + AgentDefinition(agent_id="agent-2", label="Research"), + ] + ) + + +async def test_agent_command_lists_available_agents_with_selected_marker(): + store = InMemoryStore() + await set_selected_agent_id(store, "@alice:example.org", "agent-2") + handler = make_handle_agent(store, _registry()) + + result = await handler( + event=IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="agent", + ), + auth_mgr=None, + platform=MockPlatformClient(), + chat_mgr=ChatManager(None, store), + settings_mgr=SettingsManager(MockPlatformClient(), store), + ) + + assert result == [ + OutgoingMessage( + chat_id="C1", + text=( + "Доступные агенты:\n" + "1. Analyst\n" + "2. Research [текущий]\n" + "\n" + "Выбери агент: !agent <номер>" + ), + ) + ] + + +async def test_agent_command_persists_selected_agent_id(): + store = InMemoryStore() + handler = make_handle_agent(store, _registry()) + + result = await handler( + event=IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="agent", + args=["2"], + ), + auth_mgr=None, + platform=MockPlatformClient(), + chat_mgr=ChatManager(None, store), + settings_mgr=SettingsManager(MockPlatformClient(), store), + ) + + assert await get_selected_agent_id(store, "@alice:example.org") == "agent-2" + assert result == [ + OutgoingMessage( + chat_id="C1", + text="Агент переключен на Research. Продолжай через !new.", + ) + ] + + +async def test_agent_command_binds_existing_unbound_room_to_selected_agent(): + store = InMemoryStore() + chat_mgr = ChatManager(None, store) + await chat_mgr.get_or_create( + user_id="@alice:example.org", + chat_id="C1", + platform="matrix", + surface_ref="!room:example.org", + name="Research", + ) + await set_room_meta( + store, + "!room:example.org", + { + "chat_id": "C1", + "matrix_user_id": "@alice:example.org", + "display_name": "Research", + }, + ) + handler = make_handle_agent(store, _registry()) + + result = await handler( + event=IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="agent", + args=["1"], + ), + auth_mgr=None, + platform=MockPlatformClient(), + chat_mgr=chat_mgr, + settings_mgr=SettingsManager(MockPlatformClient(), store), + ) + + assert await get_selected_agent_id(store, "@alice:example.org") == "agent-1" + assert await get_room_meta(store, "!room:example.org") == { + "chat_id": "C1", + "matrix_user_id": "@alice:example.org", + "display_name": "Research", + "agent_id": "agent-1", + "platform_chat_id": "1", + } + assert result == [ + OutgoingMessage( + chat_id="C1", + text="Агент Analyst выбран. Текущий чат готов к работе.", + ) + ] + + +@pytest.mark.asyncio +async def test_build_runtime_registers_agent_handler_when_registry_is_configured( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + registry_path = tmp_path / "matrix-agents.yaml" + registry_path.write_text( + "agents:\n" + " - id: agent-1\n" + " label: Analyst\n" + " - id: agent-2\n" + " label: Research\n", + encoding="utf-8", + ) + monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) + + runtime = build_runtime(platform=MockPlatformClient()) + + result = await runtime.dispatcher.dispatch( + IncomingCommand( + user_id="@alice:example.org", + platform="matrix", + chat_id="C1", + command="agent", + ) + ) + + assert result == [ + OutgoingMessage( + chat_id="C1", + text=( + "Доступные агенты:\n" + "1. Analyst\n" + "2. Research\n" + "\n" + "Выбери агент: !agent <номер>" + ), + ) + ] diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index f338495..f9d8c14 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -36,7 +36,7 @@ from core.protocol import ( ) from sdk.interface import PlatformError from sdk.mock import MockPlatformClient -from sdk.real import RealPlatformClient +from adapter.matrix.routed_platform import RoutedPlatformClient async def test_matrix_dispatcher_registers_custom_handlers(): @@ -907,15 +907,20 @@ async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync(): assert since == "s123" -async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): +async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real( + monkeypatch, tmp_path +): + registry_path = tmp_path / "agents.yaml" + registry_path.write_text( + "agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8" + ) monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") + monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) runtime = build_runtime() - assert isinstance(runtime.platform, RealPlatformClient) - assert runtime.platform.agent_base_url == "http://agent.example" - assert runtime.platform.agent_id == "matrix-bot" + assert isinstance(runtime.platform, RoutedPlatformClient) async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):