refactor: use thin upstream transport adapter

This commit is contained in:
Mikhail Putilovskij 2026-04-22 01:25:11 +03:00
parent 569824ead1
commit 0c2884c2b1
8 changed files with 420 additions and 255 deletions

View file

@ -70,7 +70,7 @@ surfaces-bot/
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта - **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
- **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота - **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
- **Backend selection**`MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/` - **Backend selection**`MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/`
- **Ограничения real backend** — локальный runtime использует shared `/workspace`, а файлы передаются как относительные пути в `attachments` - **Ограничения real backend** — локальный runtime использует shared `/workspace`, файлы передаются как относительные пути в `attachments`, а transport layer со стороны `surfaces` использует pinned upstream `platform-agent_api.AgentApi` почти без локальной stream-логики; текущая реализация рабочая, но после tool/file flow остаётся подтверждённый upstream streaming bug, из-за которого начало ответа может пропадать
--- ---
@ -122,6 +122,8 @@ MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=...
MATRIX_PLATFORM_BACKEND=real MATRIX_PLATFORM_BACKEND=real
# compose runtime: platform-agent service name + shared /workspace # compose runtime: platform-agent service name + shared /workspace
# значение передаётся в thin wrapper как base URL; wrapper сам нормализует его
# до upstream WS route /v1/agent_ws/{chat_id}/
AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/ AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/
AGENT_BASE_URL=http://platform-agent:8000 AGENT_BASE_URL=http://platform-agent:8000
SURFACES_WORKSPACE_DIR=/workspace SURFACES_WORKSPACE_DIR=/workspace
@ -245,7 +247,8 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot
| Функция | Почему не работает | | Функция | Почему не работает |
|---|---| |---|---|
| `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. | | `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. |
| Счётчик токенов в `!context` | platform-agent отдаёт `tokens_used=0` хардкодом в `MsgEventEnd`. Наш код перехватывает значение корректно. | | Стриминг после tool/file flow | В текущем upstream `platform-agent` первый `MsgEventTextChunk` иногда рождается уже обрезанным до попадания в websocket-клиент. Наш transport layer после cleanup максимально близок к upstream и больше не пытается локально “лечить” этот поток. Подробности и raw evidence: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`. |
| Счётчик токенов в `!context` | pinned `platform-agent_api.AgentApi` потребляет `MsgEventEnd` внутри клиента и не публикует `tokens_used` наружу. Сейчас `surfaces` честно показывает `0`, пока upstream не добавит поддержанный способ получить это значение. |
| `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. | | `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. |
| Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. | | Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. |
| E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. | | E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. |
@ -269,6 +272,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot
| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы | | [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы |
| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey | | [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey |
| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code | | [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code |
| [`docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`](docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md) | Финальный аудит platform streaming bug после cleanup transport layer |
--- ---

View file

@ -110,7 +110,7 @@ def _build_platform_from_env() -> PlatformClient:
if backend == "real": if backend == "real":
ws_url = os.environ["AGENT_WS_URL"] ws_url = os.environ["AGENT_WS_URL"]
return RealPlatformClient( return RealPlatformClient(
agent_api=AgentApiWrapper(agent_id="matrix-bot", url=ws_url), agent_api=AgentApiWrapper(agent_id="matrix-bot", base_url=ws_url),
prototype_state=PrototypeStateStore(), prototype_state=PrototypeStateStore(),
platform="matrix", platform="matrix",
) )

View file

@ -0,0 +1,294 @@
# Финальный баг-репорт: потеря начала ответа и сбои streaming/image path в `platform-agent`
## Статус
Это финальный отчёт после полного аудита интеграции `surfaces -> platform-agent_api -> platform-agent`.
Итог:
- текущая реализация `surfaces` рабочая, но проблемная из-за upstream-дефектов платформы
- баг с пропадающим началом ответа на текущем состоянии **не локализуется в `surfaces`**
- в воспроизведённом сценарии повреждённый первый текстовый chunk рождается уже внутри `platform-agent`
- помимо этого подтверждены ещё два независимых platform-side дефекта:
- duplicate `END`
- некорректная обработка больших изображений (`data-uri > 10 MB`, `WS 1009`)
## Версии и состояние кода
Рантайм воспроизводился на vendored upstream-репозиториях без локальных platform patchей:
- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
Со стороны `surfaces` transport layer был предварительно очищен:
- убрана локальная stream-semantics из `sdk/agent_api_wrapper.py`
- `sdk/real.py` переведён на pinned upstream `platform-agent_api.AgentApi`
- больше нет локального post-END drain, custom listener и wrapper-owned reclassification events
Это важно: баг воспроизводился **после** удаления наших транспортных костылей.
## Контекст интеграции
- поверхность: Matrix
- транспорт к платформе: WebSocket через upstream `platform-agent_api.AgentApi`
- `chat_id` на платформу: стабильный числовой surrogate id, выдаваемый со стороны `surfaces`
- файловый контракт: shared `/workspace`, вложения передаются как относительные пути в `attachments`
## Пользовательские симптомы
Наблюдались несколько классов сбоев:
1. Начало ответа может пропасть
- ожидалось: `Моя ошибка: ...`
- фактически: `оя ошибка: ...`
- ожидалось: `На двух изображениях: ...`
- фактически: ` двух изображениях: ...`
2. После tool/file flow ответы могут вести себя нестабильно
- следующий ответ стартует с середины фразы
- в некоторых сценариях после image/tool path платформа отвечает ошибкой или замолкает
3. На больших изображениях image path падает совсем
- provider error `Exceeded limit on max bytes per data-uri item : 10485760`
- websocket закрывается с `1009 (message too big)`
## Что было проверено на стороне `surfaces`
Ниже перечислено, что именно было перепроверено в нашем коде и почему это не выглядит корнем проблемы.
### 1. Мы больше не режем и не переклассифицируем stream локально
В текущем `surfaces`:
- `sdk/agent_api_wrapper.py` — thin construction/factory shim над upstream `AgentApi`
- `sdk/real.py` — просто итерирует upstream events и склеивает `MsgEventTextChunk.text`
- `adapter/matrix/bot.py` — отправляет `OutgoingMessage.text` в Matrix без `strip/lstrip`
Наблюдение:
- в текущем коде не осталось места, где строка могла бы превратиться из `Моя ошибка` в `оя ошибка` через локальный slicing
### 2. Сборка ответа у нас линейная и тупая
`sdk/real.py` делает только следующее:
- если пришёл `MsgEventTextChunk` — добавляет `event.text` в `response_parts`
- если пришёл `MsgEventSendFile` — превращает его в `Attachment`
- не пытается “восстанавливать” поток после `END`
Следствие:
- если начало уже отсутствует в `event.text`, мы его не можем ни потерять, ни вернуть
### 3. Matrix sender не модифицирует текст
`adapter/matrix/bot.py` передаёт текст дальше как есть.
Следствие:
- Matrix renderer не является объяснением пропажи первого куска
## Что было проверено в `platform-agent_api`
Upstream client всё ещё имеет спорную queue-архитектуру:
- одна активная `_current_queue`
- `MsgEventEnd` съедается внутри `send_message()`
- в `finally` очередь отвязывается и дренится orphan messages
Это архитектурно хрупко и может быть источником других boundary bugs.
Но в конкретном воспроизведении этот слой не был точкой порчи текста.
Почему:
- в raw logs клиент получил **ровно тот же** первый text chunk, который сервер уже отправил
- queue/dequeue не изменили его содержимое
## Что удалось доказать по raw logs
Для финальной проверки была временно добавлена точечная диагностика в:
- `external/platform-agent/src/agent/service.py`
- `external/platform-agent/src/api/external.py`
- `external/platform-agent_api/lambda_agent_api/agent_api.py`
Эта диагностика **не входила** в рабочую реализацию и использовалась только для локализации бага.
### Ключевое наблюдение
На проблемном запросе после tool/file flow сервер сам yieldил уже обрезанный первый chunk:
```text
platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text=' двух изображениях:\n\n**Первое изображение'
platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' path=None
matrix-bot-1 | [raw-stream][client-listen] agent=matrix-bot chat=1 queue_active=True AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
matrix-bot-1 | [raw-stream][client-dequeue] agent=matrix-bot chat=1 request=2 AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
```
Это означает:
- порча произошла **до** websocket-клиента
- `surfaces` transport layer не является источником именно этого дефекта
- `platform-agent_api` не исказил этот конкретный chunk по дороге
Дополнительно тот же паттерн виден и вне image-сценария:
```text
platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text='сё работает напрямую'
...
matrix-bot-1 | [raw-stream][client-dequeue] ... text='сё работает напрямую'
```
То есть сервер уже выдаёт `сё`, а не `Всё`.
## Наиболее вероятный root cause
Главный подозреваемый — `external/platform-agent/src/agent/service.py`.
Сейчас он делает следующее:
- читает `self._agent.astream_events(...)`
- обрабатывает только `kind == "on_chat_model_stream"`
- берёт `chunk = event["data"]["chunk"]`
- если `chunk.content`, форвардит `MsgEventTextChunk(text=chunk.content)`
Проблема в том, что это очень грубое преобразование raw event stream в пользовательский текст.
### Почему именно это место выглядит корнем
1. Первый битый chunk уже рождается на server-side
- это подтверждено логами выше
2. Код берёт только `chunk.content`
- если начало ответа приходит в другой форме, поле или raw event, оно просто теряется
3. Код не учитывает `ns` / `source`
- после tool/vision flow у Deep Agents / LangChain может быть более сложная структура потока
- текущий adapter flattenит её слишком агрессивно
4. Код никак не валидирует, что наружу уходит именно main assistant output
Итоговая гипотеза:
> После tool/file/vision flow `platform-agent` неправильно адаптирует `astream_events()` в `MsgEventTextChunk`. Начало итогового пользовательского ответа может находиться не в том raw event, который сейчас читается через `chunk.content`, либо теряться из-за упрощённой фильтрации потока.
## Подтверждённый отдельный баг: duplicate `END`
Это отдельный platform-side дефект.
Сейчас:
- `external/platform-agent/src/agent/service.py` уже делает `yield MsgEventEnd(...)`
- `external/platform-agent/src/api/external.py` после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
По логам это выглядит так:
```text
platform-agent-1 | [raw-stream][server-yield] chat=1 event=END
platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END text=None path=None
platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END duplicate_end=true
matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
```
Независимая оценка:
- duplicate `END` — реальный баг платформы
- он делает границу ответа менее надёжной
- но в текущем воспроизведении он **не объясняет** сам факт потери первого text chunk
То есть это важный, но вторичный дефект.
## Подтверждённый отдельный баг: большие изображения ломают image path
В отдельном воспроизведении платформа падала на анализе изображений с provider error:
```text
Exceeded limit on max bytes per data-uri item : 10485760
```
И параллельно websocket рвался с:
```text
received 1009 (message too big); then sent 1009 (message too big)
```
Это означает:
- image path отправляет в provider oversized `data:` URI
- безопасной предвалидации / деградации нет
- failure scenario сопровождается разрывом websocket-соединения
Независимая оценка:
- это отдельный platform-side bug
- он не объясняет потерю первого чанка в текстовом сценарии напрямую
- но подтверждает, что path `tool/file/image -> platform stream` в целом сейчас нестабилен
## Что мы считаем исключённым
С достаточной уверенностью можно исключить:
1. Локальный slicing текста в `surfaces`
2. Локальную “умную” реконструкцию потока, потому что она была удалена
3. Matrix sender как источник потери первого чанка
4. `platform-agent_api` queue/dequeue как primary root cause именно в этом воспроизведении
## Финальная независимая оценка
Текущая оценка вероятностей:
- `75%` — ошибка в `platform-agent`, в адаптере `astream_events() -> MsgEventTextChunk`
- `15%` — provider/model stream приносит начало ответа в другой raw event/field, а `platform-agent` его некорректно интерпретирует
- `10%` — вторичные platform-side boundary defects (`duplicate END`, queue semantics и т.д.)
- `~0-5%` — ошибка в `surfaces`
Итоговый вывод:
> На текущем состоянии кода баг с пропадающим началом ответа следует считать platform-side дефектом. Воспроизведение после cleanup transport layer показывает, что первый повреждённый text chunk формируется уже внутри `platform-agent` до отправки в websocket.
## Что нужно исправить в платформе
### Обязательно
1. Убрать duplicate `END`
- один ответ должен завершаться ровно одним `MsgEventEnd`
2. Перепроверить адаптацию `astream_events()` в `service.py`
- логировать и проанализировать raw `event["event"]`
- проверить `event.get("name")`
- смотреть `event.get("ns")`
- сравнить `chunk.content` с тем, что реально лежит в `chunk.text` / raw chunk repr
3. Форвардить наружу только финальный main assistant output
- не flattenить весь поток без учёта `ns/source`
### Желательно
4. Сделать image path устойчивым к oversized payload
- preflight check размера
- resize/compress или controlled error без разрыва WS
5. Улучшить client/server protocol boundary
- более строгая корреляция запроса и ответа
- более однозначная semantics конца ответа
## Что мы сделали со своей стороны
Со стороны `surfaces` уже выполнено следующее:
- transport layer очищен до thin adapter над upstream `AgentApi`
- локальные stream-workaroundы удалены
- рабочая интеграция сохранена
- known issue задокументирован
То есть текущая интеграция не “идеальна”, но её поведение теперь достаточно чистое, чтобы platform bug было можно локализовать без смешения ответственности.
## Приложение: короткий диагноз
Если нужна самая короткая формулировка для issue tracker:
> После cleanup transport layer в `surfaces` и воспроизведения на clean upstream platform repos видно, что `platform-agent` иногда сам порождает первый `MsgEventTextChunk` уже обрезанным, особенно после tool/file flow. Дополнительно подтверждены duplicate `END` и отдельный image-path failure на больших `data:` URI.

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import inspect
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -27,11 +26,6 @@ class AgentApiWrapper(AgentApi):
self._base_url = self._normalize_base_url(base_url) self._base_url = self._normalize_base_url(base_url)
self._init_kwargs = dict(kwargs) self._init_kwargs = dict(kwargs)
self.chat_id = chat_id self.chat_id = chat_id
if not self._supports_modern_constructor():
raise RuntimeError(
"Pinned platform-agent_api is expected to support base_url + chat_id"
)
super().__init__( super().__init__(
agent_id=agent_id, agent_id=agent_id,
base_url=self._base_url, base_url=self._base_url,
@ -39,21 +33,13 @@ class AgentApiWrapper(AgentApi):
**kwargs, **kwargs,
) )
@staticmethod
def _supports_modern_constructor() -> bool:
try:
parameters = inspect.signature(AgentApi.__init__).parameters
except (TypeError, ValueError):
return False
return "base_url" in parameters and "chat_id" in parameters
@staticmethod @staticmethod
def _normalize_base_url(base_url: str) -> str: def _normalize_base_url(base_url: str) -> str:
parsed = urlsplit(base_url) parsed = urlsplit(base_url)
path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/")) path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
def for_chat(self, chat_id: int | str) -> "AgentApiWrapper": def for_chat(self, chat_id: int | str) -> AgentApiWrapper:
return type(self)( return type(self)(
agent_id=self.id, agent_id=self.id,
base_url=self._base_url, base_url=self._base_url,

View file

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import inspect
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk
from sdk.agent_api_wrapper import AgentApiWrapper from sdk.agent_api_wrapper import AgentApiWrapper
from sdk.interface import ( from sdk.interface import (
Attachment, Attachment,
@ -40,14 +41,10 @@ class RealPlatformClient(PlatformClient):
chat_key = str(chat_id) chat_key = str(chat_id)
chat_api = self._chat_apis.get(chat_key) chat_api = self._chat_apis.get(chat_key)
if chat_api is None: if chat_api is None:
chat_api_factory = getattr(self._agent_api, "for_chat", None)
if not callable(chat_api_factory):
return self._agent_api
async with self._chat_api_lock: async with self._chat_api_lock:
chat_api = self._chat_apis.get(chat_key) chat_api = self._chat_apis.get(chat_key)
if chat_api is None: if chat_api is None:
chat_api = chat_api_factory(chat_key) chat_api = self._agent_api.for_chat(chat_key)
await chat_api.connect() await chat_api.connect()
self._chat_apis[chat_key] = chat_api self._chat_apis[chat_key] = chat_api
return chat_api return chat_api
@ -80,48 +77,36 @@ class RealPlatformClient(PlatformClient):
attachments: list[Attachment] | None = None, attachments: list[Attachment] | None = None,
) -> MessageResponse: ) -> MessageResponse:
response_parts: list[str] = [] response_parts: list[str] = []
tokens_used = 0
sent_attachments: list[Attachment] = [] sent_attachments: list[Attachment] = []
message_id = user_id message_id = user_id
saw_end_event = False
lock = self._get_chat_send_lock(chat_id) lock = self._get_chat_send_lock(chat_id)
async with lock: async with lock:
chat_api = await self._get_chat_api(chat_id) chat_api = await self._get_chat_api(chat_id)
if hasattr(chat_api, "last_tokens_used"):
chat_api.last_tokens_used = 0
try: try:
async for event in self._stream_agent_events( async for event in self._stream_agent_events(
chat_api, text, attachments=attachments chat_api, text, attachments=attachments
): ):
message_id = user_id message_id = user_id
if self._is_text_event(event): if isinstance(event, MsgEventTextChunk) and event.text:
chunk_text = getattr(event, "text", "") response_parts.append(event.text)
if chunk_text: elif isinstance(event, MsgEventSendFile):
response_parts.append(chunk_text)
elif self._is_end_event(event):
tokens_used = getattr(event, "tokens_used", tokens_used)
saw_end_event = True
elif self._is_send_file_event(event):
attachment = self._attachment_from_send_file_event(event) attachment = self._attachment_from_send_file_event(event)
if attachment is not None: if attachment is not None:
sent_attachments.append(attachment) sent_attachments.append(attachment)
except Exception as exc: except Exception as exc:
await self._handle_chat_api_failure(chat_id, exc) await self._handle_chat_api_failure(chat_id, exc)
if not saw_end_event: await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
tokens_used = getattr(chat_api, "last_tokens_used", tokens_used)
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
response_kwargs = { response_kwargs = {
"message_id": message_id, "message_id": message_id,
"response": "".join(response_parts), "response": "".join(response_parts),
"tokens_used": tokens_used, "tokens_used": 0,
"finished": True, "finished": True,
"attachments": sent_attachments,
} }
if self._message_response_accepts_attachments():
response_kwargs["attachments"] = sent_attachments
return MessageResponse(**response_kwargs) return MessageResponse(**response_kwargs)
async def stream_message( async def stream_message(
@ -134,43 +119,26 @@ class RealPlatformClient(PlatformClient):
lock = self._get_chat_send_lock(chat_id) lock = self._get_chat_send_lock(chat_id)
async with lock: async with lock:
chat_api = await self._get_chat_api(chat_id) chat_api = await self._get_chat_api(chat_id)
if hasattr(chat_api, "last_tokens_used"):
chat_api.last_tokens_used = 0
saw_end_event = False
try: try:
async for event in self._stream_agent_events( async for event in self._stream_agent_events(
chat_api, text, attachments=attachments chat_api, text, attachments=attachments
): ):
if self._is_text_event(event): if isinstance(event, MsgEventTextChunk):
yield MessageChunk( yield MessageChunk(
message_id=user_id, message_id=user_id,
delta=getattr(event, "text", ""), delta=event.text,
finished=False, finished=False,
) )
elif self._is_end_event(event): elif isinstance(event, MsgEventSendFile):
tokens_used = getattr(event, "tokens_used", 0)
saw_end_event = True
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
yield MessageChunk(
message_id=user_id,
delta="",
finished=True,
tokens_used=tokens_used,
)
elif self._is_send_file_event(event):
continue
else:
continue continue
except Exception as exc: except Exception as exc:
await self._handle_chat_api_failure(chat_id, exc) await self._handle_chat_api_failure(chat_id, exc)
if not saw_end_event: await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
tokens_used = getattr(chat_api, "last_tokens_used", 0)
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
yield MessageChunk( yield MessageChunk(
message_id=user_id, message_id=user_id,
delta="", delta="",
finished=True, finished=True,
tokens_used=tokens_used, tokens_used=0,
) )
async def get_settings(self, user_id: str) -> UserSettings: async def get_settings(self, user_id: str) -> UserSettings:
@ -195,10 +163,6 @@ class RealPlatformClient(PlatformClient):
await close() await close()
self._chat_apis.clear() self._chat_apis.clear()
self._chat_send_locks.clear() self._chat_send_locks.clear()
if not callable(getattr(self._agent_api, "for_chat", None)):
close = getattr(self._agent_api, "close", None)
if callable(close):
await close()
async def _stream_agent_events( async def _stream_agent_events(
self, self,
@ -206,12 +170,8 @@ class RealPlatformClient(PlatformClient):
text: str, text: str,
attachments: list[Attachment] | None = None, attachments: list[Attachment] | None = None,
) -> AsyncIterator[object]: ) -> AsyncIterator[object]:
send_message = chat_api.send_message
attachment_paths = self._attachment_paths(attachments) attachment_paths = self._attachment_paths(attachments)
if attachment_paths and self._send_message_accepts_attachments(send_message): event_stream = chat_api.send_message(text, attachments=attachment_paths or None)
event_stream = send_message(text, attachments=attachment_paths)
else:
event_stream = send_message(text)
async for event in event_stream: async for event in event_stream:
yield event yield event
@ -231,61 +191,9 @@ class RealPlatformClient(PlatformClient):
return paths return paths
@staticmethod @staticmethod
def _send_message_accepts_attachments(send_message) -> bool: def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment:
try: location = str(event.path)
parameters = inspect.signature(send_message).parameters filename = Path(location).name or None
except (TypeError, ValueError):
return False
return "attachments" in parameters or any(
parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
)
@staticmethod
def _event_kind(event: object) -> str:
raw_kind = getattr(event, "type", None)
if hasattr(raw_kind, "value"):
raw_kind = raw_kind.value
if raw_kind is None:
raw_kind = event.__class__.__name__
kind = str(raw_kind).replace("-", "_")
if "_" in kind:
return kind.upper()
normalized = []
for index, char in enumerate(kind):
if index and char.isupper() and not kind[index - 1].isupper():
normalized.append("_")
normalized.append(char)
return "".join(normalized).upper()
@classmethod
def _is_text_event(cls, event: object) -> bool:
return hasattr(event, "text") or "TEXT_CHUNK" in cls._event_kind(event)
@classmethod
def _is_end_event(cls, event: object) -> bool:
kind = cls._event_kind(event)
return kind == "END" or kind.endswith("_END")
@classmethod
def _is_send_file_event(cls, event: object) -> bool:
kind = cls._event_kind(event)
return "SEND_FILE" in kind
@staticmethod
def _attachment_from_send_file_event(event: object) -> Attachment | None:
location = None
for attr in ("url", "workspace_path", "path", "file_path", "uri"):
value = getattr(event, attr, None)
if value:
location = str(value)
break
if location is None:
return None
mime_type = getattr(event, "mime_type", None) or "application/octet-stream"
filename = getattr(event, "filename", None) or Path(location).name or None
size = getattr(event, "size", None)
workspace_path = location workspace_path = location
if workspace_path.startswith("/workspace/"): if workspace_path.startswith("/workspace/"):
workspace_path = workspace_path[len("/workspace/") :] workspace_path = workspace_path[len("/workspace/") :]
@ -293,18 +201,8 @@ class RealPlatformClient(PlatformClient):
workspace_path = "" workspace_path = ""
return Attachment( return Attachment(
url=location, url=location,
mime_type=mime_type, mime_type="application/octet-stream",
size=size, size=None,
filename=filename, filename=filename,
workspace_path=workspace_path or None, workspace_path=workspace_path or None,
) )
@staticmethod
def _message_response_accepts_attachments() -> bool:
fields = getattr(MessageResponse, "model_fields", None)
if isinstance(fields, dict):
return "attachments" in fields
try:
return "attachments" in inspect.signature(MessageResponse).parameters
except (TypeError, ValueError):
return False

View file

@ -911,9 +911,9 @@ async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monk
bot_module = importlib.import_module("adapter.matrix.bot") bot_module = importlib.import_module("adapter.matrix.bot")
class FakeAgentApiWrapper: class FakeAgentApiWrapper:
def __init__(self, agent_id: str, url: str) -> None: def __init__(self, agent_id: str, base_url: str) -> None:
self.agent_id = agent_id self.agent_id = agent_id
self.url = url self.base_url = base_url
monkeypatch.setattr(bot_module, "AgentApiWrapper", FakeAgentApiWrapper) monkeypatch.setattr(bot_module, "AgentApiWrapper", FakeAgentApiWrapper)
monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
@ -922,7 +922,7 @@ async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monk
runtime = build_runtime() runtime = build_runtime()
assert isinstance(runtime.platform, RealPlatformClient) assert isinstance(runtime.platform, RealPlatformClient)
assert runtime.platform.agent_api.url == "ws://agent.example/agent_ws/" assert runtime.platform.agent_api.base_url == "ws://agent.example/agent_ws/"
async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch): async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):

View file

@ -4,32 +4,55 @@ Smoke test: полный цикл через dispatcher + реальные manag
Имитирует что делает адаптер (Telegram или Matrix) при получении события. Имитирует что делает адаптер (Telegram или Matrix) при получении события.
""" """
import pytest import pytest
from sdk.mock import MockPlatformClient from lambda_agent_api.server import MsgEventTextChunk
from sdk.interface import MessageChunk, MessageResponse
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager from core.auth import AuthManager
from core.settings import SettingsManager from core.chat import ChatManager
from core.handler import EventDispatcher from core.handler import EventDispatcher
from core.handlers import register_all from core.handlers import register_all
from core.protocol import ( from core.protocol import (
IncomingCommand, IncomingMessage, IncomingCallback, Attachment,
OutgoingMessage, OutgoingUI, IncomingCallback,
Attachment, SettingsAction, IncomingCommand,
IncomingMessage,
OutgoingMessage,
OutgoingUI,
) )
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
class FakeAgentApi: class FakeAgentApi:
def __init__(self) -> None: def __init__(self, chat_id: str) -> None:
self.chat_id = chat_id
self.calls: list[tuple[str, list[str]]] = [] self.calls: list[tuple[str, list[str]]] = []
self.last_tokens_used = 0 self.connect_calls = 0
self.close_calls = 0
async def connect(self) -> None:
self.connect_calls += 1
async def close(self) -> None:
self.close_calls += 1
async def send_message(self, text: str, attachments: list[str] | None = None): async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append((text, attachments or [])) self.calls.append((text, attachments or []))
yield type("Chunk", (), {"text": f"[REAL] {text}"})() yield MsgEventTextChunk(text=f"[REAL] {text}")
self.last_tokens_used = 5
class FakeAgentApiFactory:
def __init__(self) -> None:
self.created_chat_ids: list[str] = []
self.instances: dict[str, FakeAgentApi] = {}
def for_chat(self, chat_id: str) -> FakeAgentApi:
chat_api = FakeAgentApi(chat_id)
self.created_chat_ids.append(chat_id)
self.instances[chat_id] = chat_api
return chat_api
@pytest.fixture @pytest.fixture
@ -48,7 +71,7 @@ def dispatcher():
@pytest.fixture @pytest.fixture
def real_dispatcher(): def real_dispatcher():
agent_api = FakeAgentApi() agent_api = FakeAgentApiFactory()
platform = RealPlatformClient( platform = RealPlatformClient(
agent_api=agent_api, agent_api=agent_api,
prototype_state=PrototypeStateStore(), prototype_state=PrototypeStateStore(),
@ -80,7 +103,13 @@ async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start) await dispatcher.dispatch(start)
new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"]) new = IncomingCommand(
user_id="u1",
platform="matrix",
chat_id="C2",
command="new",
args=["Анализ"],
)
result = await dispatcher.dispatch(new) result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage)) assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
@ -130,7 +159,8 @@ async def test_full_flow_with_real_platform_uses_shared_agent_api(real_dispatche
texts = [r.text for r in result if isinstance(r, OutgoingMessage)] texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert texts == ["[REAL] Привет!"] assert texts == ["[REAL] Привет!"]
assert agent_api.calls == [("Привет!", [])] assert agent_api.created_chat_ids == ["C1"]
assert agent_api.instances["C1"].calls == [("Привет!", [])]
async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher): async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
@ -155,6 +185,6 @@ async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_d
) )
await dispatcher.dispatch(msg) await dispatcher.dispatch(msg)
assert agent_api.calls == [ assert agent_api.instances["C1"].calls == [
("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"]) ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])
] ]

View file

@ -1,6 +1,8 @@
import asyncio import asyncio
import pytest import pytest
from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk
from pydantic import Field
import sdk.agent_api_wrapper as agent_api_wrapper_module import sdk.agent_api_wrapper as agent_api_wrapper_module
from core.protocol import SettingsAction from core.protocol import SettingsAction
@ -10,18 +12,12 @@ from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient from sdk.real import RealPlatformClient
class FakeChunk:
def __init__(self, text: str) -> None:
self.text = text
class FakeChatAgentApi: class FakeChatAgentApi:
def __init__(self, chat_id: str) -> None: def __init__(self, chat_id: str) -> None:
self.chat_id = chat_id self.chat_id = chat_id
self.calls: list[str] = [] self.calls: list[str] = []
self.connect_calls = 0 self.connect_calls = 0
self.close_calls = 0 self.close_calls = 0
self.last_tokens_used = 0
async def connect(self) -> None: async def connect(self) -> None:
self.connect_calls += 1 self.connect_calls += 1
@ -29,12 +25,11 @@ class FakeChatAgentApi:
async def close(self) -> None: async def close(self) -> None:
self.close_calls += 1 self.close_calls += 1
async def send_message(self, text: str): async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append(text) self.calls.append(text)
midpoint = len(text) // 2 midpoint = len(text) // 2
yield FakeChunk(text[:midpoint]) yield MsgEventTextChunk(text=text[:midpoint])
yield FakeChunk(text[midpoint:]) yield MsgEventTextChunk(text=text[midpoint:])
self.last_tokens_used = 3
class FakeAgentApiFactory: class FakeAgentApiFactory:
@ -49,25 +44,12 @@ class FakeAgentApiFactory:
return chat_api return chat_api
class LegacyAgentApi:
def __init__(self) -> None:
self.calls: list[str] = []
self.last_tokens_used = 0
async def send_message(self, text: str):
self.calls.append(text)
yield FakeChunk(text[:2])
yield FakeChunk(text[2:])
self.last_tokens_used = 7
class BlockingChatAgentApi: class BlockingChatAgentApi:
def __init__(self, chat_id: str) -> None: def __init__(self, chat_id: str) -> None:
self.chat_id = chat_id self.chat_id = chat_id
self.calls: list[str] = [] self.calls: list[str] = []
self.connect_calls = 0 self.connect_calls = 0
self.close_calls = 0 self.close_calls = 0
self.last_tokens_used = 0
self.active_calls = 0 self.active_calls = 0
self.max_active_calls = 0 self.max_active_calls = 0
self.started = asyncio.Event() self.started = asyncio.Event()
@ -79,15 +61,14 @@ class BlockingChatAgentApi:
async def close(self) -> None: async def close(self) -> None:
self.close_calls += 1 self.close_calls += 1
async def send_message(self, text: str): async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append(text) self.calls.append(text)
self.active_calls += 1 self.active_calls += 1
self.max_active_calls = max(self.max_active_calls, self.active_calls) self.max_active_calls = max(self.max_active_calls, self.active_calls)
self.started.set() self.started.set()
await self.release.wait() await self.release.wait()
self.active_calls -= 1 self.active_calls -= 1
yield FakeChunk(text) yield MsgEventTextChunk(text=text)
self.last_tokens_used = len(text)
class AttachmentTrackingChatAgentApi: class AttachmentTrackingChatAgentApi:
@ -96,7 +77,6 @@ class AttachmentTrackingChatAgentApi:
self.calls: list[tuple[str, list[str] | None]] = [] self.calls: list[tuple[str, list[str] | None]] = []
self.connect_calls = 0 self.connect_calls = 0
self.close_calls = 0 self.close_calls = 0
self.last_tokens_used = 0
async def connect(self) -> None: async def connect(self) -> None:
self.connect_calls += 1 self.connect_calls += 1
@ -106,8 +86,20 @@ class AttachmentTrackingChatAgentApi:
async def send_message(self, text: str, attachments: list[str] | None = None): async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append((text, attachments)) self.calls.append((text, attachments))
yield FakeChunk(text) yield MsgEventTextChunk(text=text)
self.last_tokens_used = 5
class AttachmentTrackingAgentApiFactory:
def __init__(self, chat_api_cls=AttachmentTrackingChatAgentApi) -> None:
self.chat_api_cls = chat_api_cls
self.created_chat_ids: list[str] = []
self.instances: dict[str, AttachmentTrackingChatAgentApi] = {}
def for_chat(self, chat_id: str) -> AttachmentTrackingChatAgentApi:
chat_api = self.chat_api_cls(chat_id)
self.created_chat_ids.append(chat_id)
self.instances[chat_id] = chat_api
return chat_api
class FlakyChatAgentApi: class FlakyChatAgentApi:
@ -127,22 +119,8 @@ class FlakyChatAgentApi:
yield yield
class SendFileEvent:
def __init__(self, *, workspace_path: str, mime_type: str, filename: str, size: int) -> None:
self.type = "AGENT_EVENT_SEND_FILE"
self.workspace_path = workspace_path
self.mime_type = mime_type
self.filename = filename
self.size = size
class TextChunkEvent:
def __init__(self, text: str) -> None:
self.type = "AGENT_EVENT_TEXT_CHUNK"
self.text = text
class MessageResponseWithAttachments(MessageResponse): class MessageResponseWithAttachments(MessageResponse):
attachments: list[Attachment] = [] attachments: list[Attachment] = Field(default_factory=list)
def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch): def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch):
@ -230,19 +208,19 @@ async def test_real_platform_client_send_message_uses_chat_bound_client():
assert result == MessageResponse( assert result == MessageResponse(
message_id="@alice:example.org", message_id="@alice:example.org",
response="hello", response="hello",
tokens_used=3, tokens_used=0,
finished=True, finished=True,
) )
assert agent_api.created_chat_ids == ["chat-7"] assert agent_api.created_chat_ids == ["chat-7"]
assert agent_api.instances["chat-7"].chat_id == "chat-7" assert agent_api.instances["chat-7"].chat_id == "chat-7"
assert agent_api.instances["chat-7"].calls == ["hello"] assert agent_api.instances["chat-7"].calls == ["hello"]
assert agent_api.instances["chat-7"].connect_calls == 1 assert agent_api.instances["chat-7"].connect_calls == 1
assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 3 assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_real_platform_client_forwards_attachments_to_chat_api(): async def test_real_platform_client_forwards_attachments_to_chat_api():
agent_api = AttachmentTrackingChatAgentApi("chat-7") agent_api = AttachmentTrackingAgentApiFactory()
client = RealPlatformClient( client = RealPlatformClient(
agent_api=agent_api, agent_api=agent_api,
prototype_state=PrototypeStateStore(), prototype_state=PrototypeStateStore(),
@ -262,74 +240,49 @@ async def test_real_platform_client_forwards_attachments_to_chat_api():
attachments=[attachment], attachments=[attachment],
) )
assert agent_api.calls == [("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"])] assert agent_api.instances["chat-7"].calls == [
("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"])
]
assert result.response == "hello" assert result.response == "hello"
assert result.tokens_used == 5 assert result.tokens_used == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch): async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch):
agent_api = AttachmentTrackingChatAgentApi("chat-7") class FileEventAgentApi(AttachmentTrackingChatAgentApi):
async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append((text, attachments))
yield MsgEventTextChunk(text="he")
yield MsgEventSendFile(path="report.pdf")
yield MsgEventTextChunk(text="llo")
agent_api = AttachmentTrackingAgentApiFactory(chat_api_cls=FileEventAgentApi)
client = RealPlatformClient( client = RealPlatformClient(
agent_api=agent_api, agent_api=agent_api,
prototype_state=PrototypeStateStore(), prototype_state=PrototypeStateStore(),
platform="matrix", platform="matrix",
) )
class FileEventAgentApi(AttachmentTrackingChatAgentApi):
async def send_message(self, text: str, attachments: list[str] | None = None):
self.calls.append((text, attachments))
yield TextChunkEvent("he")
yield SendFileEvent(
workspace_path="/workspace/report.pdf",
mime_type="application/pdf",
filename="report.pdf",
size=123,
)
yield TextChunkEvent("llo")
self.last_tokens_used = 9
monkeypatch.setattr( monkeypatch.setattr(
"sdk.real.MessageResponse", "sdk.real.MessageResponse",
MessageResponseWithAttachments, MessageResponseWithAttachments,
) )
client._agent_api = FileEventAgentApi("chat-7")
result = await client.send_message("@alice:example.org", "chat-7", "hello") result = await client.send_message("@alice:example.org", "chat-7", "hello")
assert result.response == "hello" assert result.response == "hello"
assert result.tokens_used == 9 assert result.tokens_used == 0
assert result.attachments == [ assert result.attachments == [
Attachment( Attachment(
url="/workspace/report.pdf", url="report.pdf",
mime_type="application/pdf", mime_type="application/octet-stream",
filename="report.pdf", filename="report.pdf",
size=123, size=None,
workspace_path="report.pdf", workspace_path="report.pdf",
) )
] ]
@pytest.mark.asyncio
async def test_real_platform_client_works_with_legacy_agent_api_without_for_chat():
legacy_api = LegacyAgentApi()
client = RealPlatformClient(
agent_api=legacy_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
result = await client.send_message("@alice:example.org", "chat-legacy", "hello")
assert result == MessageResponse(
message_id="@alice:example.org",
response="hello",
tokens_used=7,
finished=True,
)
assert legacy_api.calls == ["hello"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_real_platform_client_reuses_cached_chat_client(): async def test_real_platform_client_reuses_cached_chat_client():
agent_api = FakeAgentApiFactory() agent_api = FakeAgentApiFactory()
@ -505,7 +458,7 @@ async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
message_id="@alice:example.org", message_id="@alice:example.org",
delta="", delta="",
finished=True, finished=True,
tokens_used=3, tokens_used=0,
), ),
] ]
assert agent_api.created_chat_ids == ["chat-1"] assert agent_api.created_chat_ids == ["chat-1"]