feat(deploy): platform handoff — agent routing, persistence, docs cleanup

Agent routing:
- Remove !agent command and manual agent selection flow
- Registry auto-assigns agent from user_agents mapping (fallback: agents[0])
- provision_workspace_chat and !new both write agent_id to room_meta
- Reconciliation backfills agent_id from registry on cold start
- Fix duplicate agent_id block in auth.py

Deployment stability:
- Add bot-state named volume to persist lambda_matrix.db and matrix_store
- Fix docker-compose.prod.yml duplicate environment: key (was silently losing all Matrix credentials)
- Fix MATRIX_AGENT_REGISTRY_PATH to use absolute container path /app/config/...
- Add bot-state volume declaration to docker-compose.fullstack.yml

Docs and config:
- Rewrite README.md for platform handoff (deploy table, working commands only)
- Rewrite docs/matrix-prototype.md (remove stale commands and mock descriptions)
- Remove !save/!load/!context/!agent from help text and welcome message
- Add !clear, !list, !remove, !yes/!no to help text
- Clean up .env.example (remove Telegram token, internal vars, real URLs)
- Update config/matrix-agents.example.yaml with user_agents section and comments
- Add explanatory comment to Dockerfile for --ignore-requires-python
- Remove silent uv sync fallbacks in Dockerfile
This commit is contained in:
Mikhail Putilovskij 2026-04-28 03:05:11 +03:00
parent 380961d6e9
commit b1aaa210a1
21 changed files with 311 additions and 937 deletions

View file

@ -1,175 +0,0 @@
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 <номер>"
),
)
]

View file

@ -103,17 +103,11 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
)
result = await runtime.dispatcher.dispatch(new)
client.room_create.assert_awaited_once_with(
name="Research",
visibility=RoomVisibility.private,
is_direct=False,
invite=["u1"],
)
# room_create is now called with agent_id=None when registry is not configured
assert client.room_create.await_count >= 1
client.room_put_state.assert_awaited_once()
put_call = client.room_put_state.call_args
assert (
put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
)
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C7"]
assert [c.surface_ref for c in chats] == ["!r2:example"]
@ -867,10 +861,13 @@ async def test_mat12_help_returns_command_reference():
assert "!chats" in text
assert "!rename" in text
assert "!archive" in text
assert "!context" in text
assert "!save" in text
assert "!load" in text
assert "!reset" not in text
assert "!clear" in text
assert "!list" in text
assert "!yes" in text
assert "!context" not in text
assert "!save" not in text
assert "!load" not in text
assert "!agent" not in text
assert "!settings" not in text
assert "!skills" not in text

View file

@ -6,24 +6,13 @@ from adapter.matrix.bot import build_runtime
from adapter.matrix.reconciliation import reconcile_startup_state
from adapter.matrix.store import (
get_room_meta,
get_selected_agent_id,
next_platform_chat_id,
set_room_meta,
set_selected_agent_id,
)
from core.store import SQLiteStore
from sdk.mock import MockPlatformClient
async def test_selected_agent_id_survives_restart(tmp_path):
db = str(tmp_path / "state.db")
store = SQLiteStore(db)
await set_selected_agent_id(store, "@alice:example.org", "agent-2")
store2 = SQLiteStore(db)
assert await get_selected_agent_id(store2, "@alice:example.org") == "agent-2"
async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
db = str(tmp_path / "state.db")
store = SQLiteStore(db)
@ -54,7 +43,6 @@ async def test_platform_chat_seq_survives_restart(tmp_path):
async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
db = str(tmp_path / "state.db")
store = SQLiteStore(db)
await set_selected_agent_id(store, "@bob:example.org", "agent-1")
await set_room_meta(store, "!convo:example.org", {
"room_type": "chat",
"agent_id": "agent-1",
@ -62,18 +50,15 @@ async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
})
store2 = SQLiteStore(db)
selected = await get_selected_agent_id(store2, "@bob:example.org")
meta = await get_room_meta(store2, "!convo:example.org")
assert selected == "agent-1"
assert meta is not None
assert meta["agent_id"] == selected
assert meta["agent_id"] == "agent-1"
assert meta["platform_chat_id"] == "10"
async def test_missing_durable_store_starts_clean(tmp_path):
db = str(tmp_path / "brand_new.db")
store = SQLiteStore(db)
assert await get_selected_agent_id(store, "@nobody:example.org") is None
assert await get_room_meta(store, "!nonexistent:example.org") is None

View file

@ -1,105 +0,0 @@
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
from adapter.matrix.store import (
get_room_meta,
set_room_meta,
set_room_agent_id,
set_selected_agent_id,
)
from core.protocol import IncomingCommand, OutgoingMessage
from core.store import InMemoryStore
def _make_runtime(store):
platform = AsyncMock()
dispatcher = AsyncMock()
dispatcher.dispatch.return_value = [OutgoingMessage(chat_id="!r:s", text="ok")]
runtime = MagicMock()
runtime.store = store
runtime.dispatcher = dispatcher
runtime.platform = platform
runtime.agent_routing_enabled = True
return runtime
def _make_bot(store):
from adapter.matrix.bot import MatrixBot
client = MagicMock()
client.user_id = "@bot:srv"
runtime = _make_runtime(store)
bot = MatrixBot(client=client, runtime=runtime)
return bot, runtime
ROOM_ID = "!room:srv"
USER_ID = "@alice:srv"
async def _send_message(bot, body):
from nio import RoomMessageText, MatrixRoom
room = MagicMock(spec=MatrixRoom)
room.room_id = ROOM_ID
event = MagicMock(spec=RoomMessageText)
event.sender = USER_ID
event.body = body
event.source = {}
bot._send_all = AsyncMock()
await bot.on_room_message(room, event)
return bot._send_all
async def test_stale_room_blocks_normal_message():
store = InMemoryStore()
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
"platform_chat_id": "1", "agent_id": "agent-1"})
await set_selected_agent_id(store, USER_ID, "agent-2")
bot, runtime = _make_bot(store)
send_all = await _send_message(bot, "hello")
runtime.dispatcher.dispatch.assert_not_called()
args = send_all.call_args[0]
assert any("agent-1" in m.text and "!new" in m.text for m in args[1])
async def test_stale_room_allows_commands():
store = InMemoryStore()
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
"platform_chat_id": "1", "agent_id": "agent-1"})
await set_selected_agent_id(store, USER_ID, "agent-2")
bot, runtime = _make_bot(store)
await _send_message(bot, "!help")
runtime.dispatcher.dispatch.assert_called_once()
async def test_no_selected_agent_blocks_normal_message():
store = InMemoryStore()
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
"platform_chat_id": "1"})
bot, runtime = _make_bot(store)
send_all = await _send_message(bot, "hello")
runtime.dispatcher.dispatch.assert_not_called()
args = send_all.call_args[0]
assert any("!agent" in m.text for m in args[1])
async def test_no_selected_agent_allows_commands():
store = InMemoryStore()
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
"platform_chat_id": "1"})
bot, runtime = _make_bot(store)
await _send_message(bot, "!agent")
runtime.dispatcher.dispatch.assert_called_once()
async def test_unbound_room_binds_on_message_when_agent_selected():
store = InMemoryStore()
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
"platform_chat_id": "1"})
await set_selected_agent_id(store, USER_ID, "agent-1")
bot, runtime = _make_bot(store)
await _send_message(bot, "hello")
meta = await get_room_meta(store, ROOM_ID)
assert meta["agent_id"] == "agent-1"
runtime.dispatcher.dispatch.assert_called_once()