refactor: use thin upstream transport adapter
This commit is contained in:
parent
569824ead1
commit
0c2884c2b1
8 changed files with 420 additions and 255 deletions
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
152
sdk/real.py
152
sdk/real.py
|
|
@ -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,44 +119,27 @@ 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)
|
yield MessageChunk(
|
||||||
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
|
message_id=user_id,
|
||||||
yield MessageChunk(
|
delta="",
|
||||||
message_id=user_id,
|
finished=True,
|
||||||
delta="",
|
tokens_used=0,
|
||||||
finished=True,
|
)
|
||||||
tokens_used=tokens_used,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_settings(self, user_id: str) -> UserSettings:
|
async def get_settings(self, user_id: str) -> UserSettings:
|
||||||
return await self._prototype_state.get_settings(user_id)
|
return await self._prototype_state.get_settings(user_id)
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue