feat: finalize matrix platform audit and docs
This commit is contained in:
parent
6422c7db58
commit
4524a6abc8
30 changed files with 3093 additions and 176 deletions
|
|
@ -3,10 +3,12 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
|
@ -14,16 +16,18 @@ _api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_a
|
|||
if str(_api_root) not in sys.path:
|
||||
sys.path.insert(0, str(_api_root))
|
||||
|
||||
from lambda_agent_api.agent_api import AgentApi, AgentException
|
||||
from lambda_agent_api.server import (
|
||||
MsgError,
|
||||
MsgEventEnd,
|
||||
MsgEventTextChunk,
|
||||
MsgGracefulDisconnect,
|
||||
ServerMessage,
|
||||
)
|
||||
from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402
|
||||
from lambda_agent_api.client import EClientMessage, MsgUserMessage # noqa: E402
|
||||
from lambda_agent_api.server import AgentEventUnion, MsgEventEnd, ServerMessage # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DEBUG_STREAM = os.environ.get("SURFACES_AGENT_DEBUG_STREAM", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
_POST_END_DRAIN_MS = int(os.environ.get("SURFACES_AGENT_POST_END_DRAIN_MS", "120"))
|
||||
_STREAM_IDLE_TIMEOUT_MS = int(os.environ.get("SURFACES_AGENT_IDLE_TIMEOUT_MS", "60000"))
|
||||
|
||||
|
||||
class AgentApiWrapper(AgentApi):
|
||||
|
|
@ -78,7 +82,7 @@ class AgentApiWrapper(AgentApi):
|
|||
def _build_ws_url(base_url: str, chat_id: int | str) -> str:
|
||||
return base_url.rstrip("/") + f"/agent_ws/?thread_id={chat_id}"
|
||||
|
||||
def for_chat(self, chat_id: int | str) -> "AgentApiWrapper":
|
||||
def for_chat(self, chat_id: int | str) -> AgentApiWrapper:
|
||||
return type(self)(
|
||||
agent_id=self.id,
|
||||
base_url=self._base_url,
|
||||
|
|
@ -133,7 +137,7 @@ class AgentApiWrapper(AgentApi):
|
|||
if self.callback:
|
||||
self.callback(event)
|
||||
if self._current_queue and hasattr(event, "code") and hasattr(event, "details"):
|
||||
await self._current_queue.put(AgentException(getattr(event, "code"), getattr(event, "details")))
|
||||
await self._current_queue.put(AgentException(event.code, event.details))
|
||||
|
||||
async def _listen(self):
|
||||
try:
|
||||
|
|
@ -143,6 +147,13 @@ class AgentApiWrapper(AgentApi):
|
|||
outgoing_msg = ServerMessage.validate_json(msg.data)
|
||||
|
||||
if self._is_text_event(outgoing_msg):
|
||||
if _DEBUG_STREAM:
|
||||
logger.warning(
|
||||
"[%s] text chunk queue=%s text=%r",
|
||||
self.id,
|
||||
self._current_queue is not None,
|
||||
getattr(outgoing_msg, "text", "")[:80],
|
||||
)
|
||||
if self._current_queue:
|
||||
await self._current_queue.put(outgoing_msg)
|
||||
elif self.callback:
|
||||
|
|
@ -152,6 +163,13 @@ class AgentApiWrapper(AgentApi):
|
|||
|
||||
elif self._is_end_event(outgoing_msg):
|
||||
self.last_tokens_used = outgoing_msg.tokens_used
|
||||
if _DEBUG_STREAM:
|
||||
logger.warning(
|
||||
"[%s] end event queue=%s tokens=%s",
|
||||
self.id,
|
||||
self._current_queue is not None,
|
||||
getattr(outgoing_msg, "tokens_used", None),
|
||||
)
|
||||
await self._publish_event(outgoing_msg)
|
||||
|
||||
elif self._is_kind(outgoing_msg, "ERROR"):
|
||||
|
|
@ -184,3 +202,114 @@ class AgentApiWrapper(AgentApi):
|
|||
logger.error("[%s] Error in listen loop: %s", self.id, exc)
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def send_message(
|
||||
self, text: str, attachments: list[str] | None = None
|
||||
) -> AsyncIterator[AgentEventUnion]:
|
||||
if not self._connected or not self._ws:
|
||||
raise AgentException(
|
||||
code="NOT_CONNECTED", details="Not connected. Call connect() first."
|
||||
)
|
||||
|
||||
if self._request_lock.locked():
|
||||
raise AgentBusyException("Agent is currently processing another request")
|
||||
|
||||
await self._request_lock.acquire()
|
||||
try:
|
||||
self._current_queue = asyncio.Queue()
|
||||
|
||||
message = MsgUserMessage(
|
||||
type=EClientMessage.USER_MESSAGE,
|
||||
text=text,
|
||||
attachments=attachments or [],
|
||||
)
|
||||
|
||||
await self._ws.send_str(message.model_dump_json())
|
||||
logger.debug("[%s] Sent message: %s...", self.id, text[:50])
|
||||
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
self._current_queue.get(),
|
||||
timeout=max(_STREAM_IDLE_TIMEOUT_MS, 0) / 1000,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise AgentException(
|
||||
"TIMEOUT",
|
||||
(
|
||||
"Timed out waiting for the next agent stream event "
|
||||
f"after {max(_STREAM_IDLE_TIMEOUT_MS, 0)}ms"
|
||||
),
|
||||
) from exc
|
||||
|
||||
if isinstance(chunk, Exception):
|
||||
raise chunk
|
||||
|
||||
if isinstance(chunk, MsgEventEnd):
|
||||
self.last_tokens_used = chunk.tokens_used
|
||||
async for late_chunk in self._drain_post_end_events():
|
||||
yield late_chunk
|
||||
break
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
if self._current_queue:
|
||||
orphan_queue = self._current_queue
|
||||
self._current_queue = None
|
||||
|
||||
while not orphan_queue.empty():
|
||||
try:
|
||||
orphan_msg = orphan_queue.get_nowait()
|
||||
if isinstance(orphan_msg, Exception):
|
||||
logger.debug(
|
||||
"[%s] Dropped exception from queue during cleanup: %s",
|
||||
self.id,
|
||||
orphan_msg,
|
||||
)
|
||||
continue
|
||||
|
||||
if self.callback:
|
||||
self.callback(orphan_msg)
|
||||
else:
|
||||
logger.debug("[%s] Dropped orphaned message during cleanup", self.id)
|
||||
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
if self._request_lock.locked():
|
||||
self._request_lock.release()
|
||||
|
||||
async def _drain_post_end_events(self) -> AsyncIterator[AgentEventUnion]:
|
||||
if self._current_queue is None:
|
||||
return
|
||||
|
||||
timeout_s = max(_POST_END_DRAIN_MS, 0) / 1000
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(self._current_queue.get(), timeout=timeout_s)
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
if isinstance(chunk, Exception):
|
||||
logger.warning("[%s] dropping post-END exception: %s", self.id, chunk)
|
||||
continue
|
||||
|
||||
if isinstance(chunk, MsgEventEnd):
|
||||
self.last_tokens_used = chunk.tokens_used
|
||||
if _DEBUG_STREAM:
|
||||
logger.warning(
|
||||
"[%s] dropped duplicate END tokens=%s",
|
||||
self.id,
|
||||
chunk.tokens_used,
|
||||
)
|
||||
continue
|
||||
|
||||
if _DEBUG_STREAM and self._is_text_event(chunk):
|
||||
logger.warning(
|
||||
"[%s] recovered post-END text chunk=%r",
|
||||
self.id,
|
||||
getattr(chunk, "text", "")[:80],
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# platform/interface.py
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncIterator, Literal, Protocol
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ class MessageResponse(BaseModel):
|
|||
|
||||
class MessageChunk(BaseModel):
|
||||
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
|
||||
|
||||
message_id: str
|
||||
delta: str
|
||||
finished: bool
|
||||
|
|
@ -50,6 +52,7 @@ class UserSettings(BaseModel):
|
|||
|
||||
class AgentEvent(BaseModel):
|
||||
"""Webhook-уведомление от платформы — агент закончил долгую задачу."""
|
||||
|
||||
event_id: str
|
||||
user_id: str
|
||||
chat_id: str
|
||||
|
|
@ -96,4 +99,5 @@ class PlatformClient(Protocol):
|
|||
|
||||
class WebhookReceiver(Protocol):
|
||||
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
|
||||
|
||||
async def on_agent_event(self, event: AgentEvent) -> None: ...
|
||||
|
|
|
|||
21
sdk/mock.py
21
sdk/mock.py
|
|
@ -4,8 +4,9 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import random
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, AsyncIterator, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import structlog
|
||||
|
||||
|
|
@ -222,14 +223,16 @@ class MockPlatformClient:
|
|||
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
|
||||
tokens = len(text.split()) * 2
|
||||
|
||||
self._messages[key].append({
|
||||
"message_id": message_id,
|
||||
"user_text": text,
|
||||
"response": response,
|
||||
"tokens_used": tokens,
|
||||
"finished": True,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
})
|
||||
self._messages[key].append(
|
||||
{
|
||||
"message_id": message_id,
|
||||
"user_text": text,
|
||||
"response": response,
|
||||
"tokens_used": tokens,
|
||||
"finished": True,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
return message_id, response, tokens
|
||||
|
||||
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
|
||||
|
|
|
|||
97
sdk/real.py
97
sdk/real.py
|
|
@ -2,11 +2,19 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
from sdk.agent_api_wrapper import AgentApiWrapper
|
||||
from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
|
||||
from sdk.interface import (
|
||||
Attachment,
|
||||
MessageChunk,
|
||||
MessageResponse,
|
||||
PlatformClient,
|
||||
PlatformError,
|
||||
User,
|
||||
UserSettings,
|
||||
)
|
||||
from sdk.prototype_state import PrototypeStateStore
|
||||
|
||||
|
||||
|
|
@ -83,19 +91,24 @@ class RealPlatformClient(PlatformClient):
|
|||
if hasattr(chat_api, "last_tokens_used"):
|
||||
chat_api.last_tokens_used = 0
|
||||
|
||||
async for event in self._stream_agent_events(chat_api, text, attachments=attachments):
|
||||
message_id = user_id
|
||||
if self._is_text_event(event):
|
||||
chunk_text = getattr(event, "text", "")
|
||||
if chunk_text:
|
||||
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)
|
||||
if attachment is not None:
|
||||
sent_attachments.append(attachment)
|
||||
try:
|
||||
async for event in self._stream_agent_events(
|
||||
chat_api, text, attachments=attachments
|
||||
):
|
||||
message_id = user_id
|
||||
if self._is_text_event(event):
|
||||
chunk_text = getattr(event, "text", "")
|
||||
if chunk_text:
|
||||
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)
|
||||
if attachment is not None:
|
||||
sent_attachments.append(attachment)
|
||||
except Exception as exc:
|
||||
await self._handle_chat_api_failure(chat_id, exc)
|
||||
|
||||
if not saw_end_event:
|
||||
tokens_used = getattr(chat_api, "last_tokens_used", tokens_used)
|
||||
|
|
@ -124,27 +137,32 @@ class RealPlatformClient(PlatformClient):
|
|||
if hasattr(chat_api, "last_tokens_used"):
|
||||
chat_api.last_tokens_used = 0
|
||||
saw_end_event = False
|
||||
async for event in self._stream_agent_events(chat_api, text, attachments=attachments):
|
||||
if self._is_text_event(event):
|
||||
yield MessageChunk(
|
||||
message_id=user_id,
|
||||
delta=getattr(event, "text", ""),
|
||||
finished=False,
|
||||
)
|
||||
elif self._is_end_event(event):
|
||||
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
|
||||
try:
|
||||
async for event in self._stream_agent_events(
|
||||
chat_api, text, attachments=attachments
|
||||
):
|
||||
if self._is_text_event(event):
|
||||
yield MessageChunk(
|
||||
message_id=user_id,
|
||||
delta=getattr(event, "text", ""),
|
||||
finished=False,
|
||||
)
|
||||
elif self._is_end_event(event):
|
||||
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
|
||||
except Exception as exc:
|
||||
await self._handle_chat_api_failure(chat_id, exc)
|
||||
if not saw_end_event:
|
||||
tokens_used = getattr(chat_api, "last_tokens_used", 0)
|
||||
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
|
||||
|
|
@ -197,6 +215,11 @@ class RealPlatformClient(PlatformClient):
|
|||
async for event in event_stream:
|
||||
yield event
|
||||
|
||||
async def _handle_chat_api_failure(self, chat_id: str, exc: Exception) -> None:
|
||||
await self.disconnect_chat(chat_id)
|
||||
code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
|
||||
raise PlatformError(str(exc), code=code) from exc
|
||||
|
||||
@staticmethod
|
||||
def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
|
||||
if not attachments:
|
||||
|
|
@ -265,7 +288,7 @@ class RealPlatformClient(PlatformClient):
|
|||
size = getattr(event, "size", None)
|
||||
workspace_path = location
|
||||
if workspace_path.startswith("/workspace/"):
|
||||
workspace_path = workspace_path[len("/workspace/"):]
|
||||
workspace_path = workspace_path[len("/workspace/") :]
|
||||
elif workspace_path == "/workspace":
|
||||
workspace_path = ""
|
||||
return Attachment(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue