Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/agent/base.py
This commit is contained in:
Егор Кандрушин 2026-04-22 10:08:23 +03:00
commit 3118e576da
4 changed files with 51 additions and 10 deletions

View file

@ -2,6 +2,7 @@ import os
from deepagents import create_deep_agent from deepagents import create_deep_agent
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph.state import CompiledStateGraph
from composio import Composio from composio import Composio
from composio_langchain import LangchainProvider from composio_langchain import LangchainProvider
@ -9,7 +10,14 @@ from src.agent.backends import IsolatedShellBackend
from src.agent.tools import send_file from src.agent.tools import send_file
def create_agent(): class Agent(CompiledStateGraph):
"""
Временный (надеюсь) костыль, чтобы дать доступ сервису к файловой системе агента.
"""
backend: IsolatedShellBackend
def create_agent() -> Agent:
model = ChatOpenAI( model = ChatOpenAI(
model=os.environ["PROVIDER_MODEL"], model=os.environ["PROVIDER_MODEL"],
base_url=os.environ["PROVIDER_URL"], base_url=os.environ["PROVIDER_URL"],
@ -30,10 +38,15 @@ def create_agent():
virtual_mode=True, virtual_mode=True,
) )
return create_deep_agent( # noinspection PyTypeChecker
# create_deep_agent возвращает CompiledStateGraph, но ниже мы его дополняем так, чтобы он соответствовал сигнатуре Agent
agent: Agent = create_deep_agent(
model=model, model=model,
system_prompt="You are a helpful assistant. Use Composio tools to take action when needed.", system_prompt="You are a helpful assistant. Use Composio tools to take action when needed.",
checkpointer=MemorySaver(), checkpointer=MemorySaver(),
tools=tools + [send_file], tools=tools + [send_file],
backend=backend, backend=backend,
) )
agent.backend = backend
return agent

View file

@ -16,7 +16,13 @@ class ChatBusyError(Exception):
""" """
Чат занят в другом блоке ``with`` Чат занят в другом блоке ``with``
""" """
pass
class AttachmentError(Exception):
"""
Ошибка при работе с вложением.
"""
pass pass
@ -33,7 +39,7 @@ class AgentChat(AsyncContextManager[Self]):
chat_id: int chat_id: int
@abstractmethod @abstractmethod
def astream(self, text: str) -> AsyncIterator[AgentEventUnion]: ... def astream(self, text: str, attachments: list[str] = None) -> AsyncIterator[AgentEventUnion]: ...
class AgentService: class AgentService:
@ -71,11 +77,11 @@ class AgentService:
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
self.__locks.remove(self.__chat_id) self.__locks.remove(self.__chat_id)
def astream(self, text: str) -> AsyncIterator[AgentEventUnion]: def astream(self, text: str, attachments: list[str] = None) -> AsyncIterator[AgentEventUnion]:
if not self.__chat_id in self.__locks: if not self.__chat_id in self.__locks:
raise RuntimeError("Chat must be used in `with` statement") raise RuntimeError("Chat must be used in `with` statement")
return self.__service._AgentService__astream(self.__chat_id, text) return self.__service._AgentService__astream(self.__chat_id, text, attachments)
def chat(self, chat_id: int) -> AgentChat: def chat(self, chat_id: int) -> AgentChat:
""" """
@ -84,13 +90,18 @@ class AgentService:
return self.__AgentChat(self, chat_id) return self.__AgentChat(self, chat_id)
async def __astream( async def __astream(
self, chat_id: int, text: str self, chat_id: int, text: str, attachments: list[str] = None
) -> AsyncIterator[AgentEventUnion]: ) -> AsyncIterator[AgentEventUnion]:
config = {"configurable": {"thread_id": chat_id}} config = {"configurable": {"thread_id": chat_id}}
new_message = text
if attachments:
attachments_description = await self.__describe_attachments(attachments)
new_message += "\n" + attachments_description
# Используем astream_events для перехвата детальных событий (инструменты, чанки и т.д.) # Используем astream_events для перехвата детальных событий (инструменты, чанки и т.д.)
async for event in self._agent.astream_events( async for event in self._agent.astream_events(
{"messages": [{"role": "user", "content": text}]}, {"messages": [{"role": "user", "content": new_message}]},
config=config, config=config,
version="v2", # Обязательно v2 для современных версий LangChain version="v2", # Обязательно v2 для современных версий LangChain
): ):
@ -133,3 +144,20 @@ class AgentService:
# 3. В конце генерации отправляем событие завершения # 3. В конце генерации отправляем событие завершения
yield MsgEventEnd(tokens_used=0) # потом заменить на метадату yield MsgEventEnd(tokens_used=0) # потом заменить на метадату
async def __describe_attachments(self, raw_paths: list[str]) -> str:
lines = []
for raw_path in raw_paths:
try:
p = self._agent.backend._resolve_path(raw_path)
rel = p.relative_to(self._agent.backend.cwd)
if not p.exists():
raise FileNotFoundError(f"File {rel.as_posix()} not found")
lines.append(f"- {rel.as_posix()}")
except Exception as e:
raise AttachmentError(f"Failed to validate attachment {raw_path}: {str(e)}") from e
return (f"К сообщению приложены {len(lines)} файлов. "
f"Ниже даны пути до этих файлов относительно рабочей директории:\n") + "\n".join(lines)

View file

@ -25,7 +25,7 @@ async def send_file(path: str) -> str:
Используй этот tool без явного запроса от пользователя. Используй этот tool без явного запроса от пользователя.
Args: Args:
path: Путь к файлу относительно /workspace (например: 'report.pdf', 'docs/readme.txt', 'output/data.json') path: Путь к файлу относительно рабочей директории (например: 'report.pdf', 'docs/readme.txt', 'output/data.json')
Returns: Returns:
Подтверждение отправки или сообщение об ошибке Подтверждение отправки или сообщение об ошибке
@ -39,7 +39,7 @@ async def send_file(path: str) -> str:
full_path = Path(workspace) / input_path full_path = Path(workspace) / input_path
if not full_path.exists(): if not full_path.exists():
return f"Ошибка: файл '{path}' не найден в /workspace" return f"Ошибка: файл '{path}' не найден"
if not full_path.is_file(): if not full_path.is_file():
return f"Ошибка: '{path}' не является файлом" return f"Ошибка: '{path}' не является файлом"

View file

@ -43,5 +43,5 @@ async def websocket_endpoint(
async def process_message(ws: WebSocket, chat: AgentChat, msg): async def process_message(ws: WebSocket, chat: AgentChat, msg):
match msg: match msg:
case MsgUserMessage(): case MsgUserMessage():
async for chunk in chat.astream(msg.text): async for chunk in chat.astream(msg.text, msg.attachments):
await ws.send_text(chunk.model_dump_json()) await ws.send_text(chunk.model_dump_json())