/
Watashicuvu
/
agentic-tools
Обзор
Документация
Войти
/
Watashicuvu
/
agentic-tools
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/eval/eval_agent.py
828 строк
32 KB
Your Name
added come roles
28 май 2026, 15:16
28 май 2026, 15:16
745363b
Код
Авторство
О чём код?
"""Eval Agent — использует реальный пайплайн Orchestrator + LLMStreamingClient. EvalAgent тестирует аутентичный агент: - MCP Server как subprocess - ToolOrchestrator с MCP-first fallback - LLMStreamingClient с multi-round tool-calling loop - Трекинг всех вызовов инструментов Это отличается от простого chat_completion — здесь LLM взаимодействует с реальными инструментами (search, read_file, write_file, run_shell_command) перед генерацией финального ответа. """ from __future__ import annotations import json import os import time from dataclasses import dataclass, field from typing import Any, Optional from src.eval.models import EvalTask # ============================================================================ # Configuration # ============================================================================ DEFAULT_MAX_CONTEXT_TOKENS = int(os.getenv("EVAL_MAX_CONTEXT_TOKENS", "5000")) """Максимальный размер контекста для LLM запросов (~4 символа на токен).""" def _truncate_messages_smart( messages: list[dict[str, Any]], max_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS, ) -> list[dict[str, Any]]: """Умная обрезка контекста для LLM. Стратегия (приоритет сохранения): 1. System message — всегда сохраняется полностью 2. Первое user message — сохраняется полностью (задача) 3. Tool results — обрезаются от старых к новым, содержимое сокращается 4. Assistant messages — сохраняются (содержат tool_calls и reasoning) 5. Последние сообщения имеют приоритет — они наиболее релевантны Args: messages: Все сообщения из tool-calling loop. max_tokens: Максимальное количество токенов (~4 char/token). Returns: Обрезанный список сообщений. """ max_chars = max_tokens * 4 # Грубая оценка: 4 символа на токен if not messages: return messages # Оценим общий размер total_chars = sum(len(m.get("content", "")) for m in messages) if total_chars <= max_chars: return messages # В пределах лимита # Разделяем на типы, сохраняя индексы для однозначной идентификации system_msgs = [] user_msgs = [] assistant_msgs = [] tool_msgs = [] for i, m in enumerate(messages): role = m.get("role") if role == "system": system_msgs.append((i, m)) elif role == "user": user_msgs.append((i, m)) elif role == "assistant": assistant_msgs.append((i, m)) elif role == "tool": tool_msgs.append((i, m)) # Сохраняем system и первое user message полностью preserved_indices: set[int] = set() preserved = [] for idx, m in system_msgs: preserved_indices.add(idx) preserved.append(m) if user_msgs: idx, m = user_msgs[0] preserved_indices.add(idx) preserved.append(m) # Оставшийся бюджет preserved_chars = sum(len(m.get("content", "")) for m in preserved) remaining_budget = max(500, max_chars - preserved_chars) # Собираем все остальные сообщения (по индексу, не по ссылке) other_assistant = [(i, m) for i, m in assistant_msgs if i not in preserved_indices] other_tool = [(i, m) for i, m in tool_msgs if i not in preserved_indices] # Обрезаем tool results — самые большие первыми для обрезки tool_results_sorted = sorted( other_tool, key=lambda pair: len(pair[1].get("content", "")), reverse=True, ) truncated_msgs = [] used_chars = 0 # Сначала assistant messages (они важные, содержат tool_calls и reasoning) for idx, m in other_assistant: content_len = len(m.get("content", "")) # Учитываем также размер tool_calls tool_calls = m.get("tool_calls", []) if tool_calls: content_len += sum(len(str(tc)) for tc in tool_calls) if used_chars + content_len <= remaining_budget: truncated_msgs.append(m) used_chars += content_len else: # Обрезаем assistant content remaining = remaining_budget - used_chars if remaining > 100: truncated_content = m.get("content", "")[:remaining] + "...[truncated]" truncated_msgs.append({**m, "content": truncated_content}) used_chars += remaining break # Затем tool results — новые важнее (обратный порядок = более поздние сначала) tool_msgs_by_time = sorted(other_tool, key=lambda pair: pair[0], reverse=True) for idx, m in tool_msgs_by_time: content = m.get("content", "") content_len = len(content) remaining = remaining_budget - used_chars if content_len <= remaining: truncated_msgs.append(m) used_chars += content_len elif remaining > 200: # Сокращаем tool result — сохраняем начало и конец half = remaining // 2 truncated_content = content[:half] + "\n...[truncated " + str(content_len - remaining) + " chars]...\n" + content[-half:] truncated_msgs.append({**m, "content": truncated_content}) used_chars += remaining # Иначе пропускаем tool result # Сохраняем порядок сообщений result = preserved + truncated_msgs telemetry.emit( EventType.STEP_INFO, f"Context truncation: {total_chars} → {sum(len(m.get('content', '')) for m in result)} chars", {"original_chars": total_chars, "truncated_chars": sum(len(m.get("content", "")) for m in result)}, ) return result # ============================================================================ # Imports (должны быть на верхнем уровне) # ============================================================================ from typing import TYPE_CHECKING from pydantic import ValidationError from src.cli_agent.tools.tool_orchestrator import ( ToolOrchestrator, ToolSafety, ) from src.cli_agent.llm_streaming_client import ( LLMStreamingClient, StreamEvent, StreamEventType, ToolCallError, ) from src.eval.models import FinalCodeResponse from src.services.async_smart_client import AsyncSmartOpenAI from src.services.telemetry import telemetry, EventType if TYPE_CHECKING: from src.eval.eval_swarm_shim import EvalSwarmShim @dataclass(frozen=True) class ToolCallRecord: """Запись о вызове инструмента. Attributes: tool_name: Имя вызванного инструмента. args: Аргументы вызова. result: Результат выполнения. success: Успешно ли выполнен. used_fallback: Использовался ли fallback (local vs MCP). round_num: Номер раунда tool-calling. """ tool_name: str args: dict[str, Any] result: Optional[dict[str, Any]] = None success: bool = False used_fallback: bool = False round_num: int = 0 @dataclass class AgentExecutionResult: """Результат выполнения EvalAgent. Attributes: code: Извлечённый Lua-код из финального ответа. full_response: Полный ответ ассистента. tool_calls: Список всех вызовов инструментов. tool_call_count: Общее количество вызовов. execution_time_ms: Время выполнения. rounds_used: Количество раундов tool-calling. error: Ошибка если возникла. """ code: str = "" full_response: str = "" tool_calls: list[ToolCallRecord] = field(default_factory=list) tool_call_count: int = 0 execution_time_ms: float = 0.0 rounds_used: int = 0 error: Optional[str] = None class EvalAgent: """Агент для eval-сессий, использующий реальный пайплайн. Запускает ToolOrchestrator с MCP, затем использует LLMStreamingClient для генерации кода с реальными tool calls. Args: smart_client: AsyncSmartOpenAI для LLM. model: Модель LLM. mcp_url: URL MCP Server (если None — только локальные инструменты). max_tool_call_rounds: Максимум раундов tool-calling. Example: async with EvalAgent(smart_client, mcp_url="http://localhost:8000") as agent: result = await agent.generate_code(task) """ def __init__( self, smart_client: AsyncSmartOpenAI, model: str = "gpt-4o", mcp_url: Optional[str] = None, max_tool_call_rounds: int = 10, use_swarm: bool = False, project_root: Optional[str] = None, eval_dir: Optional[str] = None, ) -> None: self.smart_client = smart_client self.model = model self.mcp_url = mcp_url self.max_tool_call_rounds = max_tool_call_rounds self.use_swarm = use_swarm self.project_root = project_root self.eval_dir = eval_dir self._tool_orchestrator: Optional[ToolOrchestrator] = None self._swarm_shim: Optional[EvalSwarmShim] = None self._tool_calls: list[ToolCallRecord] = [] self._round_num: int = 0 async def initialize(self) -> None: """Инициализировать ToolOrchestrator с MCP.""" self._tool_orchestrator = ToolOrchestrator( mcp_url=self.mcp_url, mcp_timeout=10, ) await self._tool_orchestrator.initialize() if self.use_swarm: from src.eval.eval_swarm_shim import EvalSwarmShim self._swarm_shim = EvalSwarmShim( smart_client=self.smart_client, model=self.model, mcp_url=self.mcp_url, project_root=self.project_root, eval_dir=self.eval_dir, max_tool_call_rounds=self.max_tool_call_rounds, ) tools_available = self._tool_orchestrator.mcp_available telemetry.emit( EventType.STEP_INFO, "EvalAgent initialized", { "model": self.model, "mcp_url": self.mcp_url, "mcp_tools_available": tools_available, "use_swarm": self.use_swarm, }, ) async def generate_code( self, task_description: str, expected_behavior: str, reference_code: Optional[str] = None, context: Optional[dict[str, Any]] = None, iron_user: Optional[Any] = None, max_qa_rounds: int = 3, role: str = "executor", requires_clarification: bool = False, ) -> AgentExecutionResult: """Сгенерировать код через реальный пайплайн. Если use_swarm=True — запускает swarm-пайплайн через EvalSwarmShim. Иначе — legacy pipeline с ToolOrchestrator + LLMStreamingClient. Args: task_description: Описание задачи. expected_behavior: Ожидаемое поведение. reference_code: Опциональный reference код. context: Контекст задачи (входные данные, например wf.vars). iron_user: IronUser для ответов на уточняющие вопросы. max_qa_rounds: Максимум раундов уточняющих вопросов. role: Роль для eval (executor, strategist, reviewer, multi). requires_clarification: Нужна ли clarification loop. Returns: AgentExecutionResult с кодом и трекингом tool calls. """ # Swarm-пайплайн if self.use_swarm and self._swarm_shim is not None: task = EvalTask( id="eval_task", description=task_description, expected_behavior=expected_behavior, reference_code=reference_code, context=context, role=role, requires_clarification=requires_clarification, ) return await self._swarm_shim.run_task(task) # Legacy pipeline return await self._generate_code_legacy( task_description, expected_behavior, reference_code, context, iron_user, max_qa_rounds, ) async def _generate_code_legacy( self, task_description: str, expected_behavior: str, reference_code: Optional[str] = None, context: Optional[dict[str, Any]] = None, iron_user: Optional[Any] = None, max_qa_rounds: int = 3, ) -> AgentExecutionResult: """Legacy pipeline с ToolOrchestrator + LLMStreamingClient.""" start = time.monotonic() self._tool_calls = [] self._round_num = 0 # System prompt system_prompt = ( "Ты — экспертный Lua программист. Твоя задача — написать корректный " "Lua-код, который решает поставленную задачу.\n\n" "Правила:\n" "1. Используй доступные инструменты для поиска похожих реализаций, " "чтения файлов проекта, или анализа архитектуры, если это поможет.\n" "2. Пиши чистый, идиоматичный Lua код с обработкой ошибок.\n" "3. В конце выдай ТОЛЬКО финальный код в блоке ```lua ... ```.\n" "4. Не добавляй объяснений после кода.\n\n" "ВАЖНО: Если задача неоднозначна и тебе нужна дополнительная информация, " "задай вопрос начиная с 'QUESTION: ' и затем сам вопрос. " "Иначе выдай только Lua код." ) # User prompt user_content = f"Задача: {task_description}\n\n" user_content += f"Ожидаемое поведение: {expected_behavior}" if reference_code: user_content += ( f"\n\nReference код (для вдохновения):\n```lua\n" f"{reference_code}\n```\n" ) if context: import json as _json user_content += ( f"\n\nContext (входные данные, доступны в wf.vars):\n" f"```json\n{_json.dumps(context, indent=2, ensure_ascii=False)}\n```\n" ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, ] # Get available tools if self._tool_orchestrator is None: await self.initialize() tools = self._tool_orchestrator.get_available_tools() telemetry.emit( EventType.LLM_REQ, "EvalAgent starting code generation", { "task_description": task_description[:80], "tools_available": len(tools), "has_context": context is not None, "has_iron_user": iron_user is not None, "max_qa_rounds": max_qa_rounds, }, ) try: # Run tool-calling loop with feedback final_messages, qa_rounds = await self._run_with_feedback_loop( messages=messages, tools=tools, iron_user=iron_user, max_qa_rounds=max_qa_rounds, ) # Get final assistant message for full_response assistant_msg = self._get_final_assistant_message(final_messages) full_response = assistant_msg.get("content", "") if assistant_msg else "" # Extract code using structured output code, _reasoning = await self._extract_code_structured( messages=final_messages, task_description=task_description, reference_code=reference_code, ) elapsed = (time.monotonic() - start) * 1000 telemetry.emit( EventType.LLM_RES, "EvalAgent code generation complete", { "tool_calls": len(self._tool_calls), "rounds_used": self._round_num, "qa_rounds": qa_rounds, "code_length": len(code), }, ) return AgentExecutionResult( code=code, full_response=full_response, tool_calls=list(self._tool_calls), tool_call_count=len(self._tool_calls), execution_time_ms=round(elapsed, 2), rounds_used=self._round_num, ) except ToolCallError as e: elapsed = (time.monotonic() - start) * 1000 telemetry.emit( EventType.ERROR, f"Tool call error: {e}", {"tool": e.tool_name}, ) return AgentExecutionResult( error=f"Tool call error: {e}", tool_calls=list(self._tool_calls), tool_call_count=len(self._tool_calls), execution_time_ms=round(elapsed, 2), rounds_used=self._round_num, ) except Exception as e: elapsed = (time.monotonic() - start) * 1000 telemetry.emit( EventType.ERROR, f"Evaluation agent error: {e}", {"error": str(e)}, ) return AgentExecutionResult( error=f"Agent error: {e}", tool_calls=list(self._tool_calls), execution_time_ms=round(elapsed, 2), ) async def _run_with_feedback_loop( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], iron_user: Optional[Any], max_qa_rounds: int, ) -> tuple[list[dict[str, Any]], int]: """Run tool-calling loop with clarification feedback. Args: messages: Initial messages. tools: Available tools. iron_user: IronUser for answering questions. max_qa_rounds: Maximum clarification rounds. Returns: Tuple of (final_messages, qa_rounds completed). """ import re as _re_module qa_round = 0 while qa_round < max_qa_rounds: # Create LLM client with tool registry client = LLMStreamingClient( smart_client=self.smart_client, model=self.model, tool_registry=self._tool_orchestrator, on_event=self._on_stream_event, max_tool_call_rounds=self.max_tool_call_rounds, ) # Run tool-calling loop final_messages = await client.run( messages=messages, tools=tools, enable_prompt_caching=True, ) # Get final assistant message assistant_msg = self._get_final_assistant_message(final_messages) assistant_content = assistant_msg.get("content", "") if assistant_msg else "" # Check if LLM is asking a question # ВАЖНО: re.search вместо re.match — match ищет ТОЛЬКО в начале строки # но после strip() ответ должен начинаться с QUESTION: question_match = _re_module.match( r"^QUESTION:\s*(.+)", assistant_content.strip(), _re_module.DOTALL ) if question_match and iron_user: # Agent asked a question — get answer from IronUser question = question_match.group(1).strip() qa_round += 1 telemetry.emit( EventType.STEP_INFO, f"Clarification question round {qa_round}", {"question": question[:100]}, ) try: iron_response = await iron_user.answer(question) # Add IronUser answer to messages messages = list(final_messages) # Copy messages.append({ "role": "user", "content": f"IronUser answers: {iron_response.answer}", }) telemetry.emit( EventType.STEP_INFO, f"IronUser answer round {qa_round}", {"answer": iron_response.answer[:100]}, ) # Continue loop with updated messages continue except Exception as e: telemetry.emit( EventType.ERROR, f"IronUser answer failed: {e}", {"question": question}, ) # If IronUser fails, continue with current messages break else: # LLM provided code (or asked question but no IronUser) return final_messages, qa_round # Max QA rounds reached telemetry.emit( EventType.STEP_INFO, f"Max QA rounds ({max_qa_rounds}) reached, returning current messages", data={}, ) return final_messages, qa_round async def _on_stream_event(self, event: StreamEvent) -> None: """Callback для стриминг событий.""" if event.type == StreamEventType.TOOL_CALL_START: self._round_num += 1 telemetry.emit( EventType.STEP_INFO, f"Tool call round {self._round_num}", {"tool": event.tool_name, "args_preview": str(event.tool_args)[:100]}, ) elif event.type == StreamEventType.TOOL_RESULT: self._tool_calls.append(ToolCallRecord( tool_name=event.tool_name or "unknown", args=event.tool_args or {}, result=event.tool_result, success=True, round_num=self._round_num, )) telemetry.emit( EventType.STEP_INFO, f"Tool result: {event.tool_name}", {"round": self._round_num, "success": True}, ) elif event.type == StreamEventType.TOOL_ERROR: self._tool_calls.append(ToolCallRecord( tool_name=event.tool_name or "unknown", args={}, success=False, round_num=self._round_num, )) telemetry.emit( EventType.ERROR, f"Tool error: {event.error}", {"tool": event.tool_name, "round": self._round_num}, ) @staticmethod def _get_final_assistant_message(messages: list[dict[str, Any]]) -> dict[str, Any]: """Получить финальное assistant сообщение (без tool_calls). В tool-calling loop может быть несколько assistant сообщений. Финальное — это последнее сообщение без tool_calls (или с пустыми tool_calls). Args: messages: Все сообщения в диалоге. Returns: Финальное assistant сообщение или пустой dict. """ final_msg = {} for msg in reversed(messages): if msg.get("role") == "assistant": tool_calls = msg.get("tool_calls", []) # Финальное сообщение — без tool_calls или с пустым content (значит был tool call) if not tool_calls or msg.get("content", "").strip(): final_msg = msg break return final_msg async def _extract_code_structured( self, messages: list[dict[str, Any]], task_description: str, reference_code: Optional[str], ) -> tuple[str, str]: """Извлечь код через structured output (FinalCodeResponse). Делает отдельный финальный вызов с response_format=FinalCodeResponse, чтобы гарантированно получить код, а не промежуточный ответ. Args: messages: Все сообщения из tool-calling loop. task_description: Описание задачи. reference_code: Reference код для сравнения. Returns: Tuple of (extracted code, full response text). """ # System prompt для финального извлечения кода system_prompt = ( "Ты — экспертный Lua программист. Проанализируй весь диалог и извлеки " "ФИНАЛЬНЫЙ код, который решает задачу. Верни ответ в формате JSON.\n\n" "Правила:\n" "1. Используй код из последнего assistant сообщения без tool_calls.\n" "2. Если код был в ```lua блоке, извлеки его без markdown fences.\n" "3. Убедись что код полный и рабочий.\n" "4. Если в диалоге нет финального кода — верни пустую строку." ) # User prompt с контекстом user_content = f"Задача: {task_description}\n" if reference_code: user_content += f"\nReference код:\n```lua\n{reference_code}\n```\n" user_content += "\nПроанализируй диалог и верни финальный код." extraction_messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, # Добавляем сообщения из tool-calling loop, исключая дублирующий system *[m for m in messages if m.get("role") != "system"], ] # Умная обрезка контекста если превышен лимит extraction_messages = _truncate_messages_smart(extraction_messages) try: response = await self.smart_client.chat_completion( messages=extraction_messages, model=self.model, response_format=FinalCodeResponse, stream=False, enable_prompt_caching=True, ) # response — это FinalCodeResponse (Pydantic модель) code_response: FinalCodeResponse = response telemetry.emit( EventType.LLM_RES, "Structured code extraction success", { "code_length": len(code_response.code), "confidence": code_response.confidence, "reasoning": code_response.reasoning[:100], }, ) return code_response.code, code_response.reasoning except ValidationError as e: telemetry.emit( EventType.ERROR, f"Structured output validation failed: {e}", {"error_details": str(e)}, ) # Fallback: попробовать старый метод code = self._extract_code_fallback(messages) return code, "" except Exception as e: telemetry.emit( EventType.ERROR, f"Structured code extraction failed: {e}", {"error": str(e)}, ) # Fallback: попробовать старый метод code = self._extract_code_fallback(messages) return code, "" @staticmethod def _extract_code(response: str) -> str: """Извлечь Lua код из строки ответа (обратная совместимость). Args: response: Строка ответа ассистента. Returns: Чистый код без markdown fences. """ response = response.strip() # Try ```lua block if "```lua" in response: start_idx = response.index("```lua") + 6 end_idx = response.index("```", start_idx) if "```" in response[start_idx:] else len(response) return response[start_idx:end_idx].strip() # Try generic ``` block if "```" in response: start_idx = response.index("```") + 3 end_idx = response.index("```", start_idx) if "```" in response[start_idx:] else len(response) return response[start_idx:end_idx].strip() # No fences — return as-is return response @staticmethod def _extract_code_fallback(messages: list[dict[str, Any]]) -> str: """Fallback: извлечь код из последнего assistant сообщения. Args: messages: Все сообщения в диалоге. Returns: Извлечённый код или пустая строка. """ # Найти финальное assistant сообщение final_msg = "" for msg in reversed(messages): if msg.get("role") == "assistant" and not msg.get("tool_calls"): final_msg = msg.get("content", "") break if not final_msg: return "" # Использовать обычный извлечение return EvalAgent._extract_code(final_msg) async def close(self) -> None: """Закрыть ToolOrchestrator.""" if self._tool_orchestrator: await self._tool_orchestrator.close() self._tool_orchestrator = None async def __aenter__(self) -> "EvalAgent": await self.initialize() return self async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: await self.close() # Module-level convenience function async def run_eval_agent( smart_client: AsyncSmartOpenAI, task_description: str, expected_behavior: str, reference_code: Optional[str] = None, model: str = "gpt-4o", mcp_url: Optional[str] = None, ) -> AgentExecutionResult: """Run eval agent with full pipeline in a single call. Args: smart_client: AsyncSmartOpenAI. task_description: Task description. expected_behavior: Expected behavior. reference_code: Optional reference code. model: LLM model. mcp_url: MCP Server URL. Returns: AgentExecutionResult. """ async with EvalAgent( smart_client=smart_client, model=model, mcp_url=mcp_url, ) as agent: return await agent.generate_code( task_description=task_description, expected_behavior=expected_behavior, reference_code=reference_code, )