From 74cf028e8f903a756e3918aca4734f23f227aac7 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Fri, 24 Apr 2026 13:54:25 +0300 Subject: [PATCH] feat: add !agent command and durable user agent selection Users can now list available agents with !agent and select one by number. Selection persists in user metadata (selected_agent_id). If the current room has no agent binding yet, selecting an agent binds it immediately so the user can start messaging without !new. Also updates the dispatcher test to reflect that real-mode platform is now RoutedPlatformClient, not a bare RealPlatformClient. --- adapter/matrix/handlers/__init__.py | 4 + adapter/matrix/handlers/agent.py | 78 +++++++++ adapter/matrix/handlers/settings.py | 3 + adapter/matrix/store.py | 21 +++ pyproject.toml | 2 +- tests/adapter/matrix/test_agent_handler.py | 175 +++++++++++++++++++++ tests/adapter/matrix/test_dispatcher.py | 15 +- 7 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 adapter/matrix/handlers/agent.py create mode 100644 tests/adapter/matrix/test_agent_handler.py 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):