From 2d4a5d73c78dfdd74f27e6e77edf78aca2c4ee56 Mon Sep 17 00:00:00 2001 From: MrKan Date: Sun, 19 Apr 2026 21:02:56 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/base.py | 2 ++ src/agent/service.py | 35 +++++++++++++++++++++++------------ src/agent/tools.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 src/agent/tools.py diff --git a/src/agent/base.py b/src/agent/base.py index fe08072..d85775f 100644 --- a/src/agent/base.py +++ b/src/agent/base.py @@ -5,6 +5,7 @@ from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from src.agent.backends import IsolatedShellBackend +from src.agent.tools import send_file def create_agent(): @@ -25,6 +26,7 @@ def create_agent(): return create_deep_agent( model=model, + tools=[send_file], system_prompt="You are a helpful assistant.", checkpointer=MemorySaver(), backend=backend, diff --git a/src/agent/service.py b/src/agent/service.py index 170bb53..90c4663 100644 --- a/src/agent/service.py +++ b/src/agent/service.py @@ -3,8 +3,12 @@ from abc import abstractmethod from src.agent.base import create_agent from lambda_agent_api.server import ( - AgentEventUnion, MsgEventTextChunk, MsgEventToolCallChunk, - MsgEventToolResult, MsgEventEnd + AgentEventUnion, + MsgEventTextChunk, + MsgEventToolCallChunk, + MsgEventToolResult, + MsgEventSendFile, + MsgEventEnd, ) @@ -12,6 +16,7 @@ class ChatBusyError(Exception): """ Чат занят в другом блоке ``with`` """ + pass @@ -24,15 +29,15 @@ class AgentChat(AsyncContextManager[Self]): Перед вызовом любых методов (``astream`` и т. д.) необходимо войти в блок ``with``. Объект получается из AgentService.chat(). """ + chat_id: int @abstractmethod - def astream(self, text: str) -> AsyncIterator[AgentEventUnion]: - ... + def astream(self, text: str) -> AsyncIterator[AgentEventUnion]: ... class AgentService: - _instance = None # синглтон + _instance = None # синглтон def __new__(cls): if cls._instance is None: @@ -45,7 +50,7 @@ class AgentService: Своеобразная реализация Mutex'а. Служит прослойкой до методов AgentService, но подставляет в них 'захваченный' chat_id. """ - __locks: set[int] = set() # чаты, которые уже "взяты" + __locks: set[int] = set() # чаты, которые уже "взяты" def __init__(self, service: AgentService, chat_id: int) -> None: self.__chat_id = chat_id @@ -78,21 +83,23 @@ class AgentService: """ return self.__AgentChat(self, chat_id) - async def __astream(self, chat_id: int, text: str) -> AsyncIterator[AgentEventUnion]: + async def __astream( + self, chat_id: int, text: str + ) -> AsyncIterator[AgentEventUnion]: config = {"configurable": {"thread_id": chat_id}} # Используем astream_events для перехвата детальных событий (инструменты, чанки и т.д.) async for event in self._agent.astream_events( {"messages": [{"role": "user", "content": text}]}, config=config, - version="v2" # Обязательно v2 для современных версий LangChain + version="v2", # Обязательно v2 для современных версий LangChain ): kind = event["event"] # 1. Агент генерирует токены (текст или аргументы для инструмента) if kind == "on_chat_model_stream": chunk = event["data"]["chunk"] - + # Если генерируется обычный текст if chunk.content: yield MsgEventTextChunk(text=chunk.content) @@ -102,15 +109,19 @@ class AgentService: for tool_chunk in chunk.tool_call_chunks: yield MsgEventToolCallChunk( tool_name=tool_chunk.get("name"), - args_chunk=tool_chunk.get("args") + args_chunk=tool_chunk.get("args"), ) # 2. Инструмент завершил работу и вернул результат elif kind == "on_tool_end": yield MsgEventToolResult( - tool_name=event["name"], - result=event["data"].get("output") + tool_name=event["name"], result=event["data"].get("output") ) + # 3. Кастомные события (send_file и др.) + elif kind == "on_custom_event": + if event["name"] == "send_file": + yield MsgEventSendFile(path=event["data"]["path"]) + # 3. В конце генерации отправляем событие завершения yield MsgEventEnd(tokens_used=0) # потом заменить на метадату diff --git a/src/agent/tools.py b/src/agent/tools.py new file mode 100644 index 0000000..b87aa9e --- /dev/null +++ b/src/agent/tools.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +from langchain_core.callbacks import adispatch_custom_event +from langchain_core.tools import tool + + +@tool +async def send_file(path: str) -> str: + """Отправить файл пользователю. + + Используй этот инструмент, когда пользователь просит: + - скачать файл + - отправить файл + - прислать документ + - получить файл + - открыть/показать файл в чате + + Этот инструмент НЕ используется когда: + - пользователь просто хочет прочитать содержимое файла (используй read_file) + - нужно создать или изменить файл (используй write_file/edit_file) + + Args: + path: Путь к файлу относительно /workspace (например: 'report.pdf', 'docs/readme.txt', 'output/data.json') + + Returns: + Подтверждение отправки или сообщение об ошибке + """ + workspace = os.environ.get("WORKSPACE_DIR", "/workspace") + full_path = Path(workspace) / path + + if not full_path.exists(): + return f"Ошибка: файл '{path}' не найден в /workspace" + + if not full_path.is_file(): + return f"Ошибка: '{path}' не является файлом" + + await adispatch_custom_event(name="send_file", data={"path": path}) + + return f"Файл '{path}' отправлен пользователю" From 62de4ff36ce326b7acd8e9d20fc000ac898ec2ad Mon Sep 17 00:00:00 2001 From: MrKan Date: Sun, 19 Apr 2026 21:56:17 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=83=D1=82=D0=B5=D0=B9=20=D0=B2=20send?= =?UTF-8?q?=5Ffile=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B5=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/tools.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/agent/tools.py b/src/agent/tools.py index b87aa9e..4afd6e0 100644 --- a/src/agent/tools.py +++ b/src/agent/tools.py @@ -20,6 +20,10 @@ async def send_file(path: str) -> str: - пользователь просто хочет прочитать содержимое файла (используй read_file) - нужно создать или изменить файл (используй write_file/edit_file) + Пользователь не имеет доступа к файлам напрямую, ты ОБЯЗАН ему их отправлять. + Если пользователь просил сформировать файл - скорее всего, нужно его отправить. + Используй этот tool без явного запроса от пользователя. + Args: path: Путь к файлу относительно /workspace (например: 'report.pdf', 'docs/readme.txt', 'output/data.json') @@ -27,7 +31,12 @@ async def send_file(path: str) -> str: Подтверждение отправки или сообщение об ошибке """ workspace = os.environ.get("WORKSPACE_DIR", "/workspace") - full_path = Path(workspace) / path + + input_path = Path(path).as_posix().lstrip("/") + if input_path.startswith("workspace/"): + input_path = input_path[len("workspace/"):] + + full_path = Path(workspace) / input_path if not full_path.exists(): return f"Ошибка: файл '{path}' не найден в /workspace"