From 77ff26094d05b97fa254be855c2ba299f9aa8db1 Mon Sep 17 00:00:00 2001 From: asypost Date: Tue, 3 Nov 2026 16:50:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?1=E3=80=81=E9=87=8D=E6=9E=84Agent=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=202=E3=80=81=E5=AE=9E=E7=8E=B0WebSocket=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 39 ++++ src/main.py | 8 - src/mini_agent/agent/__init__.py | 0 src/mini_agent/agent/context.py | 22 ++ src/mini_agent/agent/message.py | 47 +++++ src/mini_agent/agent/react_agent.py | 247 ++++++++++++++++++++++ src/mini_agent/api/__init__.py | 0 src/mini_agent/api/websocket.py | 39 ++++ src/mini_agent/main.py | 60 ++++++ src/mini_agent/react_agent.py | 153 -------------- src/mini_agent/tools/tavily_search.py | 48 +++-- src/mini_agent/tools/tool.py | 34 +-- src/mini_agent/tools/tool_manager.py | 80 -------- src/mini_agent/tools/tool_repository.py | 111 ++++++++++ src/mini_agent/tools/tool_search.py | 43 ---- uv.lock | 262 ++++++++++++++++++++++++ 16 files changed, 871 insertions(+), 322 deletions(-) delete mode 100644 src/main.py create mode 100644 src/mini_agent/agent/__init__.py create mode 100644 src/mini_agent/agent/context.py create mode 100644 src/mini_agent/agent/message.py create mode 100644 src/mini_agent/agent/react_agent.py create mode 100644 src/mini_agent/api/__init__.py create mode 100644 src/mini_agent/api/websocket.py create mode 100644 src/mini_agent/main.py delete mode 100644 src/mini_agent/react_agent.py delete mode 100644 src/mini_agent/tools/tool_manager.py create mode 100644 src/mini_agent/tools/tool_repository.py delete mode 100644 src/mini_agent/tools/tool_search.py diff --git a/pyproject.toml b/pyproject.toml index 0cb1f85..38979dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,14 @@ authors = [ ] requires-python = ">=3.15" dependencies = [ + "fastapi>=0.162.0", "openai>=3.42.0", "pydantic>=2.14.2", + "pyodbc>=5.3.0", "python-dotenv>=1.2.4", + "sqlalchemy>=2.1.6", "tavily-python>=0.8.5", + "uvicorn[standard]>=0.54.0", ] [project.scripts] @@ -20,3 +24,38 @@ mini-agent = "mini_agent:main" [build-system] requires = ["uv_build>=0.13.6,<0.14.0"] build-backend = "uv_build" + +[tool.ruff] +line-length = 100 +indent-width = 4 +target-version = "py315" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" + +# 多行参数尽量保留尾随逗号 +skip-magic-trailing-comma = false + +# 自动格式化 docstring +docstring-code-format = true +docstring-code-line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # Pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify +] + +ignore = [ + "E501", # 行长度交给 formatter +] + +[tool.ruff.lint.isort] +combine-as-imports = true +force-sort-within-sections = true diff --git a/src/main.py b/src/main.py deleted file mode 100644 index b45712b..0000000 --- a/src/main.py +++ /dev/null @@ -1,8 +0,0 @@ -from mini_agent.react_agent import ReActAgent -from mini_agent.tools.tool_manager import ToolManager - -if __name__ == "__main__": - agent = ReActAgent(tool_manager=ToolManager()) - while True: - question = input("提问:") - print(agent.run(question)) diff --git a/src/mini_agent/agent/__init__.py b/src/mini_agent/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mini_agent/agent/context.py b/src/mini_agent/agent/context.py new file mode 100644 index 0000000..c3f3a53 --- /dev/null +++ b/src/mini_agent/agent/context.py @@ -0,0 +1,22 @@ +from openai.types.chat import ChatCompletionMessageParam +from pydantic import BaseModel, Field, PrivateAttr + +from mini_agent.tools.tool import Tool + + +class ConversationContext(BaseModel): + conversation_id: str = Field(description="会话ID") + messages: list[ChatCompletionMessageParam] = Field(description="消息历史", default_factory=list) + + _discovered_tools: list[Tool] = PrivateAttr(default_factory=list) + + discovered_tool_names: list[str] = Field(description="已发现的工具名称", default_factory=list) + + @property + def discovered_tools(self): + return self._discovered_tools + + def add_tool(self, tool: Tool): + if tool.name not in self.discovered_tool_names: + self.discovered_tool_names.append(tool.name) + self.discovered_tools.append(tool) diff --git a/src/mini_agent/agent/message.py b/src/mini_agent/agent/message.py new file mode 100644 index 0000000..977cdec --- /dev/null +++ b/src/mini_agent/agent/message.py @@ -0,0 +1,47 @@ +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class MessageRole(StrEnum): + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class MessageType(StrEnum): + TEXT = "text" + THINK = "think" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + + +class MessageStatus(StrEnum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class Message(BaseModel): + id: str = Field(default_factory=lambda: str(uuid4())) + conversation_id: str + run_id: str | None = None + + role: MessageRole + type: MessageType + + content: Any = None + status: MessageStatus = MessageStatus.COMPLETED + + tool_call_id: str | None = None + + tool_name: str | None = None + + arguments: dict[str, Any] | None = None + + tool_call_result:Any |None = None + + created_at: datetime = Field(default_factory=lambda: datetime.now()) diff --git a/src/mini_agent/agent/react_agent.py b/src/mini_agent/agent/react_agent.py new file mode 100644 index 0000000..4beb696 --- /dev/null +++ b/src/mini_agent/agent/react_agent.py @@ -0,0 +1,247 @@ +from collections.abc import Iterator +import json +import os +from typing import Any +from uuid import uuid7 + +from openai import OpenAI +from openai.types.chat import ChatCompletionMessage, ChatCompletionMessageToolCallUnion +from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam +from pydantic import BaseModel, TypeAdapter + +from mini_agent.agent.context import ConversationContext +from mini_agent.agent.message import Message, MessageRole, MessageStatus, MessageType +from mini_agent.tools.tavily_search import TavilySearch +from mini_agent.tools.tool_repository import ToolRepository + + +class ReActAgent: + SYSTEM_PROMPT = """ + 你是一个能够调用外部工具的智能助手。 + + 工作规则: + + 1. 首先理解用户的问题,判断是否需要外部工具。 + 2. 如果需要工具,调用工具搜索接口搜索合适的工具。 + 3. 根据搜索结果选择合适的工具。 + 4. 工具执行后,根据返回结果判断下一步行动。 + 5. 当已经获得足够的信息,能够完整回答用户问题时,直接给出最终答案。 + + 注意事项: + + - 不允许编造工具执行结果。 + - 工具搜索没有结果时,可以尝试调整关键词重新搜索。 + - 如果工具无法完成任务,明确告知用户。 + - 不要为了调用工具而调用工具。 + - 只有在任务已经完成或明确无法继续时才结束回答。 + """ + + def __init__( + self, + tool_repository: ToolRepository, + model: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + timeout: int | None = None, + max_steps: int = 10, + ) -> None: + + self.model = model or os.getenv("LLM_MODEL_ID") + self.base_url = base_url or os.getenv("LLM_BASE_URL") + self.api_key = api_key or os.getenv("LLM_API_KEY") + self.timeout = timeout if timeout is not None else int(os.getenv("LLM_TIMEOUT", "60")) + + if not all([self.model, self.base_url, self.api_key]): + raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。") + + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=self.timeout, + ) + + self.tool_repos = tool_repository + self.max_steps = max_steps + web_search_tool = TavilySearch() + web_search_tool.config(api_key=os.getenv("TAVILY_KEY", "")) + self.tool_repos.register(web_search_tool) + + def serialize_json(self, data: Any) -> str: + if isinstance(data, BaseModel): + return data.model_dump_json() + return json.dumps(data, ensure_ascii=False, default=str) + + def _initialize_context(self, context: ConversationContext, question: str) -> None: + """ + 初始化会话context + """ + if len(context.messages) == 0: + context.messages.append({"role": "system", "content": self.SYSTEM_PROMPT}) + if len(context.discovered_tools) == 0: + context.add_tool(self.tool_repos) + context.messages.append({"role": "user", "content": question}) + + def _build_message_param(self, message: ChatCompletionMessage) -> ChatCompletionMessageParam: + """ + 将ChatCompletionMessage转换成ChatCompletionMessageParam + """ + message_param: ChatCompletionMessageParam = { + "role": message.role, + "content": message.content, + } + + if message.tool_calls: + adapter = TypeAdapter(list[ChatCompletionMessageToolCallUnion]) + message_param["tool_calls"] = adapter.dump_python(message.tool_calls, exclude_none=True) + return message_param + + def _parse_tool_call( + self, tool_call: ChatCompletionMessageToolCallUnion + ) -> tuple[str, dict[str, Any]]: + """ + 解析Tool call,返回(tool_name, arguments) + """ + if tool_call.type != "function" and tool_call.type != "custom": + raise ValueError(f"不支持的工具调用类型{tool_call.type}") + if tool_call.type == "function": + tool_name = tool_call.function.name + raw_arguments = tool_call.function.arguments + else: + tool_name = tool_call.custom.name + raw_arguments = tool_call.custom.input + arguments = json.loads(raw_arguments) + if not isinstance(arguments, dict): + raise ValueError("工具参数必须是 JSON 对象") + return (tool_name, arguments) + + def _execute_tool_call( + self, tool_name: str, arguments: dict[str, Any], context: ConversationContext + ) -> Any: + """ + 执行tool call + """ + observation = self.tool_repos.call_tool(tool_name, arguments) + # 更新当前可用工具 + if tool_name == self.tool_repos.name: + available_tools = [] + for item in observation: + if item.name not in context.discovered_tool_names: + context.add_tool(item) + available_tools.append(self.tool_repos.tool_to_openai(item)) + observation = available_tools + return observation + + def run(self, question: str, context: ConversationContext) -> Iterator[Message]: + self._initialize_context(context, question) + run_id = str(uuid7()) + + yield Message( + conversation_id=context.conversation_id, + role=MessageRole.USER, + run_id=run_id, + status=MessageStatus.RUNNING, + type=MessageType.TEXT, + content=question, + ) + for _ in range(self.max_steps): + tools = [self.tool_repos.tool_to_openai(tool) for tool in context.discovered_tools] + + assert self.model is not None + response = self.client.chat.completions.create( + model=self.model, + messages=context.messages, + tools=tools, + tool_choice="auto", + ) + + message = response.choices[0].message + + # 保存模型返回消息 + message_param = self._build_message_param(message) + context.messages.append(message_param) + + # 模型没有调用工具,说明它选择直接回答 + if not message.tool_calls: + if message.content: + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.ASSISTANT, + content=message.content, + status=MessageStatus.COMPLETED, + type=MessageType.TEXT, + ) + return + elif reasoning_content := getattr(message, "reasoning_content", None): + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.ASSISTANT, + type=MessageType.THINK, + content=reasoning_content, + status=MessageStatus.RUNNING, + ) + else: + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.ASSISTANT, + type=MessageType.TEXT, + status=MessageStatus.FAILED, + content="模型没有返回有效内容", + ) + return + + if message.tool_calls: + for tool_call in message.tool_calls: + tool_name = None + arguments = {} + tool_call_status = MessageStatus.RUNNING + try: + tool_name, arguments = self._parse_tool_call(tool_call) + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.ASSISTANT, + status=MessageStatus.RUNNING, + type=MessageType.TOOL_CALL, + content=f"call tool:{tool_name}({arguments})", + tool_name=tool_name, + arguments=arguments, + ) + + observation = self._execute_tool_call(tool_name, arguments, context) + + except Exception as e: + tool_call_status = MessageStatus.FAILED + observation = {"error": str(e)} + + # 统一记录工具执行结果 + context.messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": self.serialize_json(observation), + } + ) + + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.TOOL, + type=MessageType.TOOL_RESULT, + status=tool_call_status, + content=f"call {tool_name or 'unknown'} result:{self.serialize_json(observation)}", + tool_name=tool_name, + arguments=arguments, + tool_call_result=observation, + ) + + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + role=MessageRole.ASSISTANT, + status=MessageStatus.FAILED, + type=MessageType.TEXT, + content=f"已达到最大执行步数 ({self.max_steps}),任务未完成", + ) diff --git a/src/mini_agent/api/__init__.py b/src/mini_agent/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mini_agent/api/websocket.py b/src/mini_agent/api/websocket.py new file mode 100644 index 0000000..04c1fe8 --- /dev/null +++ b/src/mini_agent/api/websocket.py @@ -0,0 +1,39 @@ +from uuid import uuid7 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from mini_agent.agent.context import ConversationContext +from mini_agent.agent.message import Message, MessageRole, MessageStatus, MessageType + +router = APIRouter() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + agent = websocket.app.state.agent + context = ConversationContext(conversation_id=str(uuid7())) + + try: + while True: + request = await websocket.receive_json() + question = request.get("question") + + if not question: + await websocket.send_json( + Message( + conversation_id=context.conversation_id, + role=MessageRole.ASSISTANT, + run_id="", + type=MessageType.TEXT, + status=MessageStatus.FAILED, + content="请输入问题", + ).model_dump(mode="json") + ) + continue + + for status in agent.run(question, context): + await websocket.send_json(status.model_dump(mode="json")) + + except WebSocketDisconnect: + print("客户端断开连接") diff --git a/src/mini_agent/main.py b/src/mini_agent/main.py new file mode 100644 index 0000000..f348f5d --- /dev/null +++ b/src/mini_agent/main.py @@ -0,0 +1,60 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI + +from mini_agent.agent.context import ConversationContext +from mini_agent.agent.message import MessageStatus, MessageType +from mini_agent.agent.react_agent import ReActAgent +from mini_agent.api.websocket import router +from mini_agent.tools.tool_repository import ToolRepository + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +print(PROJECT_ROOT) +load_dotenv(PROJECT_ROOT / ".env") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.agent = ReActAgent( + tool_repository=ToolRepository(), + ) + + yield + + +app = FastAPI(title="Mini Agent", lifespan=lifespan) + +app.include_router(router) + +@app.get("/health") +async def health(): + return { + "status": "ok", + "agent": hasattr(app.state, "agent"), + } + + +if __name__ == "__main__": + agent = ReActAgent( + tool_repository=ToolRepository(), + ) + context = ConversationContext(conversation_id="1") + while True: + question = input("提问:") + try: + for message in agent.run(question=question, context=context): + status_type = message.type + match status_type: + case MessageType.TOOL_CALL: + print(f"🔧调用{message.tool_name} 参数 {message.arguments}") + case MessageType.TOOL_RESULT: + print(f"🔧调用{message.tool_name}结束") + case MessageType.TEXT: + if message.status == MessageStatus.FAILED: + print(f"❌{message.content}") + else: + print(f"ℹ{message.content}") + except Exception as e: + print(f"❌{e}") diff --git a/src/mini_agent/react_agent.py b/src/mini_agent/react_agent.py deleted file mode 100644 index af9f8b7..0000000 --- a/src/mini_agent/react_agent.py +++ /dev/null @@ -1,153 +0,0 @@ -import json -import os -from typing import Any - -from mini_agent.tools.tavily_search import TavilySearch -from openai import OpenAI - -from mini_agent.tools.tool_search import ToolSearch -from mini_agent.tools.tool_manager import ToolManager -from dotenv import load_dotenv - -load_dotenv() - -class ReActAgent: - SYSTEM_PROMPT = """ - 你是一个能够调用外部工具的智能助手。 - - 工作规则: - - 1. 首先理解用户的问题,判断是否需要外部工具。 - 2. 如果需要工具,调用 search_tools 搜索合适的工具。 - 3. 根据搜索结果选择合适的工具。 - 4. 工具执行后,根据返回结果判断下一步行动。 - 5. 如果需要其他工具,可以再次调用 search_tools。 - 6. 当已经获得足够的信息,能够完整回答用户问题时,直接给出最终答案。 - - 注意事项: - - - 不允许编造工具执行结果。 - - 不允许调用尚未通过 search_tools 发现的业务工具。 - - 工具搜索没有结果时,可以尝试调整关键词重新搜索。 - - 如果工具无法完成任务,明确告知用户。 - - 不要为了调用工具而调用工具。 - - 只有在任务已经完成或明确无法继续时才结束回答。 - """ - - def __init__( - self, - tool_manager: ToolManager, - model: str | None = None, - api_key: str | None = None, - base_url: str | None = None, - timeout: int | None = None, - max_steps: int = 10, - max_tool_calls: int = 10, - ) -> None: - - self.model = model or os.getenv("LLM_MODEL_ID") - self.base_url = base_url or os.getenv("LLM_BASE_URL") - self.api_key = api_key or os.getenv("LLM_API_KEY") - self.timeout = timeout or int(os.getenv("LLM_TIMEOUT", 60)) - - if not all([self.model, self.base_url, self.api_key]): - raise ValueError( - "模型ID、API密钥和服务地址必须被提供或在.env文件中定义。" - ) - - self.client = OpenAI( - api_key=self.api_key, - base_url=self.base_url, - timeout=self.timeout, - ) - - self.tool_manager = tool_manager - self.max_steps = max_steps - self.max_tool_calls = max_tool_calls - self.search_tool = ToolSearch(self.tool_manager) - self.tool_manager.register(self.search_tool) - web_search_tool = TavilySearch() - web_search_tool.config(**{"api_key":os.getenv("TAVILY_KEY","")}) - self.tool_manager.register(web_search_tool) - - def run(self, question: str) -> str: - - # 已发现的业务工具 - - # 初始只开放工具发现能力 - available_tools = [self.search_tool] - tool_calls_count = 0 - - messages = [ - {"role":"user","content":question} - ] - - for step in range(self.max_steps): - - print(f"\n========== Step {step + 1} ==========") - - tools = [self.tool_manager.tool_to_openai(tool) for tool in available_tools] - - response = self.client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools, - tool_choice="auto", - ) - - message = response.choices[0].message - - # 模型没有调用工具,说明它选择直接回答 - if not message.tool_calls: - if message.content: - print(f"最终回答: {message.content}") - return message.content - raise RuntimeError("模型没有返回有效内容") - - # 保存模型的工具调用消息 - messages.append( - message.model_dump(exclude_none=True) - ) - - for tool_call in message.tool_calls: - tool_name = tool_call.function.name - try: - arguments = json.loads( - tool_call.function.arguments or "{}" - ) - except json.JSONDecodeError: - observation = json.dumps( - {"error": "工具参数不是合法的 JSON"}, - ensure_ascii=False, - ) - - messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": observation, - }) - continue - - print(f"调用工具: {tool_name}") - print(f"参数: {arguments}") - - - observation = self.tool_manager.call_tool(tool_name,arguments) - # 更新当前可用工具 - if tool_name == self.search_tool.name: - new_tools = [] - for item in observation: - if not any(tool.name == item.name for tool in available_tools): - available_tools.append(item) - new_tools.append(item) - observation = [self.tool_manager.tool_to_openai(tool) for tool in new_tools] - - messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": str(observation), - }) - - raise RuntimeError( - f"Agent 已达到最大执行步数 ({self.max_steps}),任务未完成" - ) diff --git a/src/mini_agent/tools/tavily_search.py b/src/mini_agent/tools/tavily_search.py index 320c974..aca9f8a 100644 --- a/src/mini_agent/tools/tavily_search.py +++ b/src/mini_agent/tools/tavily_search.py @@ -1,27 +1,29 @@ -from mini_agent.tools.tool import Configuration, Tool,Parameter -from pydantic import BaseModel, Field +from pydantic import BaseModel from tavily import TavilyClient +from mini_agent.tools.tool import Configuration, Parameter, Tool + + class ResultItem(BaseModel): - url:str - title:str - content:str - score:float - raw_content:str | None = None - id:str + url: str + title: str + content: str + score: float + raw_content: str | None = None + id: str class SearchResult(BaseModel): - query:str - answer:str | None - images:list[str] = [] + query: str + answer: str | None + images: list[str] = [] results: list[ResultItem] -class TavilySearch(Tool[SearchResult]): +class TavilySearch(Tool[SearchResult]): @property - def name(self)->str: - return "Tavily网络搜索工具 " + def name(self) -> str: + return "Tavily网络搜索工具" @property def description(self) -> str: @@ -36,25 +38,27 @@ class TavilySearch(Tool[SearchResult]): @property def configurations(self) -> list[Configuration]: - return [ - Configuration(name="api_key",type="str",required=True) - ] + return [Configuration(name="api_key", type="str", required=True)] @property def parameters(self) -> list[Parameter]: return [ - Parameter(name="query",type="str",description="query keyword",required=True) + Parameter( + name="query", type=str, description="query keyword", required=True + ) ] - def config(self,**kwargs) -> None: - api_key = kwargs.get("api_key",None) + def config(self, **kwargs) -> None: + api_key = kwargs.get("api_key") if not api_key: raise ValueError("API Key is required") self.client = TavilyClient(api_key=api_key) def run(self, **kwargs) -> SearchResult: - query = kwargs.get("query",None) + query = kwargs.get("query") if not query: raise ValueError("查询参数不能为空") - response = self.client.search(query=query,search_depth="advanced",include_answer="advanced") + response = self.client.search( + query=query, search_depth="advanced", include_answer="advanced" + ) return SearchResult.model_validate(response) diff --git a/src/mini_agent/tools/tool.py b/src/mini_agent/tools/tool.py index 94c3d2f..059008f 100644 --- a/src/mini_agent/tools/tool.py +++ b/src/mini_agent/tools/tool.py @@ -1,49 +1,51 @@ +from abc import ABC, abstractmethod + from pydantic import BaseModel -from abc import ABC,abstractmethod class Parameter(BaseModel): - name:str - type:str - description:str - required:bool =True + name: str + type: type + description: str + required: bool = True + class Configuration(BaseModel): - name:str - type:str - required:bool = True + name: str + type: str + required: bool = True + class Tool[T](ABC): - @property @abstractmethod - def name(self)->str: + def name(self) -> str: pass @property @abstractmethod - def description(self)->str: + def description(self) -> str: pass @property @abstractmethod - def catalog(self)->str: + def catalog(self) -> str: pass @property @abstractmethod - def parameters(self)->list[Parameter]: + def parameters(self) -> list[Parameter]: pass @property @abstractmethod - def configurations(self)->list[Configuration]: + def configurations(self) -> list[Configuration]: pass @abstractmethod - def config(self,**kwargs)->None: + def config(self, **kwargs) -> None: pass @abstractmethod - def run(self,**kwargs)->T: + def run(self, **kwargs) -> T: pass diff --git a/src/mini_agent/tools/tool_manager.py b/src/mini_agent/tools/tool_manager.py deleted file mode 100644 index ea2935b..0000000 --- a/src/mini_agent/tools/tool_manager.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Any - -from mini_agent.tools.tool import Tool - - -class ToolManager: - - _tools:dict[str,Tool] = {} - - def register[T](self,tool:Tool[T])->None: - if tool.name in self._tools: - raise ValueError(f"{tool.name} is already registered") - self._tools[tool.name] = tool - - def get_tools(self,keyword:str,catalog:str | None)->list[Tool]: - return list(self._tools.values()) - - def get_tools_for_openai(self,keyword:str,catalog:str|None)->list[dict[str,Any]]: - tools = self.get_tools(keyword,catalog) - result = [] - for tool in tools: - result.append(self.tool_to_openai(tool)) - return result - - def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: - tool = self._tools.get(name, None) - - if tool is None: - raise ValueError(f"Tool not found: {name}") - - return tool.run(**arguments) - - def tool_to_openai[T](self,tool:Tool[T])->dict[str,Any]: - type_mapping = { - "str": "string", - "string": "string", - "int": "integer", - "integer": "integer", - "float": "number", - "number": "number", - "bool": "boolean", - "boolean": "boolean", - "list": "array", - "dict": "object", - } - properties = {} - required = [] - for parameter in tool.parameters: - json_type = type_mapping.get(parameter.type) - - if json_type is None: - raise ValueError( - f"不支持的参数类型: {parameter.type}" - ) - - properties[parameter.name] = { - "type": json_type, - } - - if parameter.description: - properties[parameter.name]["description"] = ( - parameter.description - ) - - if parameter.required: - required.append(parameter.name) - - return { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description.strip(), - "parameters": { - "type": "object", - "properties": properties, - "required": required, - "additionalProperties": False, - }, - }, - } diff --git a/src/mini_agent/tools/tool_repository.py b/src/mini_agent/tools/tool_repository.py new file mode 100644 index 0000000..c98f02f --- /dev/null +++ b/src/mini_agent/tools/tool_repository.py @@ -0,0 +1,111 @@ +from typing import Any + +from openai.types.chat import ChatCompletionToolParam + +from mini_agent.tools.tool import Configuration, Parameter, Tool + + +class ToolRepository(Tool[list[Tool]]): + def __init__(self): + self._tools: dict[str, Tool] = {} + self.register(self) + + @property + def name(self) -> str: + return """tool repository""" + + @property + def description(self) -> str: + return """ + "根据用户需求搜索可用工具。 当需要外部数据、查询业务信息或执行特定操作时,首先使用该工具查找合适的业务工具。可以根据需要多次搜索不同的工具。 + """ + + @property + def catalog(self) -> str: + return "system" + + def config(self, **kwargs) -> None: + pass + + @property + def configurations(self) -> list[Configuration]: + return [] + + @property + def parameters(self) -> list[Parameter]: + return [ + Parameter(name="keyword", type=str, description="关键词", required=True), + Parameter(name="catalog", type=str, description="工具分类", required=False), + ] + + def run(self, **kwargs) -> list[Tool]: + keyword = kwargs.get("keyword") + catalog = kwargs.get("catalog") + if not keyword: + raise ValueError("keyword is required for query") + + return self.get_tools(keyword, catalog) + + def register[T](self, tool: Tool[T]) -> None: + if tool.name in self._tools: + raise ValueError(f"{tool.name} is already registered") + self._tools[tool.name] = tool + + def get_tools(self, keyword: str, catalog: str | None) -> list[Tool]: + return list(self._tools.values()) + + def get_tools_for_openai(self, keyword: str, catalog: str | None) -> list[dict[str, Any]]: + tools = self.get_tools(keyword, catalog) + result = [] + for tool in tools: + result.append(self.tool_to_openai(tool)) + return result + + def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: + tool = self._tools.get(name, None) + + if tool is None: + raise ValueError(f"Tool not found: {name}") + + return tool.run(**arguments) + + def tool_to_openai[T](self, tool: Tool[T]) -> ChatCompletionToolParam: + param_type_mapping = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", + } + properties = {} + required = [] + for parameter in tool.parameters: + json_type = param_type_mapping.get(parameter.type) + + if json_type is None: + raise ValueError(f"不支持的参数类型: {parameter.type}") + + properties[parameter.name] = { + "type": json_type, + } + + if parameter.description: + properties[parameter.name]["description"] = parameter.description + + if parameter.required: + required.append(parameter.name) + + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description.strip(), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + }, + } diff --git a/src/mini_agent/tools/tool_search.py b/src/mini_agent/tools/tool_search.py deleted file mode 100644 index dcfebd3..0000000 --- a/src/mini_agent/tools/tool_search.py +++ /dev/null @@ -1,43 +0,0 @@ -from mini_agent.tools.tool import Tool,Parameter,Configuration -from mini_agent.tools.tool_manager import ToolManager - -class ToolSearch(Tool[list[Tool]]): - - def __init__(self,tool_manager:ToolManager) -> None: - self.tool_manager = tool_manager - - @property - def name(self)->str: - return """search_tools""" - - @property - def description(self) -> str: - return """ - "根据用户需求搜索可用工具。 当需要外部数据、查询业务信息或执行特定操作时,首先使用该工具查找合适的业务工具。可以根据需要多次搜索不同的工具。 - """ - - @property - def catalog(self) -> str: - return "system" - - def config(self, **kwargs) -> None: - pass - - @property - def configurations(self) -> list[Configuration]: - return [] - - @property - def parameters(self) -> list[Parameter]: - return [ - Parameter(name="keyword",type="str",description="关键词",required=True), - Parameter(name="catalog",type="str",description="工具分类",required=False) - ] - - def run(self, **kwargs) -> list[Tool]: - keyword = kwargs.get("keyword", None) - catalog = kwargs.get("catalog", None) - if not keyword: - raise ValueError("keyword is required for query") - - return self.tool_manager.get_tools(keyword,catalog) diff --git a/uv.lock b/uv.lock index c613b7d..fc6efd6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 5 requires-python = ">=3.15" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -89,6 +98,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "fastapi" +version = "0.162.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -124,6 +159,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, ] +[[package]] +name = "httptools" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" }, + { url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" }, + { url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -213,18 +282,26 @@ name = "mini-agent" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "openai" }, { name = "pydantic" }, + { name = "pyodbc" }, { name = "python-dotenv" }, + { name = "sqlalchemy" }, { name = "tavily-python" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.162.0" }, { name = "openai", specifier = ">=3.42.0" }, { name = "pydantic", specifier = ">=2.14.2" }, + { name = "pyodbc", specifier = ">=5.3.0" }, { name = "python-dotenv", specifier = ">=1.2.4" }, + { name = "sqlalchemy", specifier = ">=2.1.6" }, { name = "tavily-python", specifier = ">=0.8.5" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" }, ] [[package]] @@ -244,6 +321,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/79/904afac12186d9ab9e3f731eb798e36dc0f5b991a557c4304b0a43e393c1/openai-3.42.0-py3-none-any.whl", hash = "sha256:efb9d089ecf66c668289f647717389df98b40b954cd86d4fe54eddce39aabb3f", size = 2397860, upload-time = "2026-10-31T00:27:24.493Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" }, +] + [[package]] name = "pydantic" version = "2.14.2" @@ -300,6 +389,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/17/eea7a1e7d98a79ef11b6d31748b70c58f8723f87d7ee42edbb6a101f6245/pydantic_core-2.50.2-cp315-cp315t-win_arm64.whl", hash = "sha256:91315aea7adb51821d4a3378ede953bfdd76e3bf71551de4a82d7638cef7dc02", size = 2166387, upload-time = "2026-10-29T16:34:24.433Z" }, ] +[[package]] +name = "pyodbc" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" } + [[package]] name = "python-dotenv" version = "1.2.4" @@ -309,6 +404,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } + [[package]] name = "regex" version = "2026.9.29" @@ -373,6 +474,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52", size = 10589700, upload-time = "2026-10-21T16:25:06.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/da/b04b7e7da0374d8e40a6d894fe9969a4d7477d4b5963c94736e85599d27d/sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d", size = 2471694, upload-time = "2026-10-21T18:46:27.122Z" }, + { url = "https://files.pythonhosted.org/packages/67/17/cc5203117626f71ab3a9ef2fee4cc5ec5c53a80d72b1dd791bc3d497afd2/sqlalchemy-2.1.6-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e12763cb2a400879c6dfe34995a032f707de635c8cad14d6ba777a1250be90b", size = 4599858, upload-time = "2026-10-21T18:55:38.095Z" }, + { url = "https://files.pythonhosted.org/packages/be/bd/a4d24b125b92bb156fe2aa3ce8894d19d7d015e246ce88baceb724776a26/sqlalchemy-2.1.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086", size = 4635165, upload-time = "2026-10-21T18:57:40.67Z" }, + { url = "https://files.pythonhosted.org/packages/21/12/34545d002189c49f05121ecc2b393bd2a5d2dbd9dc0c6e4510c95e3284e1/sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7", size = 4341217, upload-time = "2026-10-21T19:17:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/bf/491ed3e97d1625245c153c083064f36b1c3c1ecc1b1258a84a1f67f80f98/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f", size = 4528088, upload-time = "2026-10-21T18:55:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/10/7f/8750ec25961075140def7fa15f3d07d800a68bacfdbc90112387345b78ca/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622", size = 4340063, upload-time = "2026-10-21T19:17:07.687Z" }, + { url = "https://files.pythonhosted.org/packages/62/5a/a9c53bb9dad2732de73d835f691784a3153b4ed693fa385534d2bba39c25/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d", size = 4597401, upload-time = "2026-10-21T18:57:42.376Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/bc6b004cd8e5906efca1dbbb4e158aa2efe31a5623c32946ec9da73126ae/sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3", size = 2388678, upload-time = "2026-10-21T17:51:54.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d6/13087d9a1ded5bf8ccadc2f315c157d4796828829b2c2d6cf1031f1c00ba/sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff", size = 2439956, upload-time = "2026-10-21T17:51:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/6d/90/263b259d872cf4e11c7bcd1b278256096482db06fa23ddbc8c00c9918f3d/sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4", size = 2403996, upload-time = "2026-10-21T18:50:02.367Z" }, + { url = "https://files.pythonhosted.org/packages/04/60/b4821fe7a42405d12abf9ba48e13b5e056e428d6f0e48c8166f0c7df61fa/sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32", size = 2507298, upload-time = "2026-10-21T16:53:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/4feccf4511123d55e26269aa22c426ca2cdba88298bd65781bd3609e0e1b/sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25199a1b499da643de52ab73e972eb07e55e2a95bb99eaa491846098fa2214c", size = 4905036, upload-time = "2026-10-21T18:59:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fa/7329c40698034056c924210eaba019f46f2914783c3d52b29c47806399c9/sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823", size = 4854715, upload-time = "2026-10-21T19:17:36.904Z" }, + { url = "https://files.pythonhosted.org/packages/79/66/098e0981ba55bf61a4b85ae904714987640a7f489da57641c8b6f7d1cfc6/sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff", size = 4502934, upload-time = "2026-10-21T19:44:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/69/f7ef99af2f3b17e45c1189176decaa2be595848f67827dd7c665ec8e2166/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8", size = 4760666, upload-time = "2026-10-21T18:59:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d7/3dbbefd2342237fbce9b9169ac4f425d039e97b8a9f765467e4a0319c9aa/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b", size = 4502218, upload-time = "2026-10-21T19:44:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/a4/22/07acba0614b788f94c655a25d76188591921a459fe4f4925a3176b576328/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f", size = 4780103, upload-time = "2026-10-21T19:17:39.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/0d/ea88e6e0e069b6c1c65c7a32043ab2203386c80d0bd1a25cf1ba39007741/sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1", size = 2444581, upload-time = "2026-10-21T18:11:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/2182431a4955adfeab69a89e7e4e81eaabbfbf1846ff8634b0f9448fd6cd/sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19", size = 2510428, upload-time = "2026-10-21T18:11:59.23Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/e290384d311e6e2c495734673bdbd2e358dc6725e7c1525326eb8e34af8d/sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4", size = 2429115, upload-time = "2026-10-21T18:48:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe", size = 2063055, upload-time = "2026-10-21T18:42:10.234Z" }, +] + +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + [[package]] name = "tavily-python" version = "0.8.5" @@ -451,3 +596,120 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909 wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, +] + +[[package]] +name = "websockets" +version = "17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/79/590c5f0cc7d1661195aeefccb0e342b79f4c77d368d526d028821a390332/websockets-17.4.tar.gz", hash = "sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb", size = 191391, upload-time = "2026-10-30T15:10:35.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/be/4032fde05e62d1c52edd250fda475e910cac0cd180e377690bff24147b5e/websockets-17.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6", size = 218740, upload-time = "2026-10-30T15:09:13.833Z" }, + { url = "https://files.pythonhosted.org/packages/74/1c/b47f5786eb45599072411571651606fb22f6e825d522d627daad974ea636/websockets-17.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16", size = 216470, upload-time = "2026-10-30T15:09:15.394Z" }, + { url = "https://files.pythonhosted.org/packages/70/98/0021f55de633617b4a8390cca17e700c9ba5d99323103128412dc2c15aca/websockets-17.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01", size = 216695, upload-time = "2026-10-30T15:09:17.185Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/2c67389b47c4b9e9e9318d9b3e4bb52a2fef3749ff00b0b151a342c4d42d/websockets-17.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e", size = 226134, upload-time = "2026-10-30T15:09:19.156Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/ee3a679a10741a0cdd7aa8bdd6d03999069672826aa7a6495fdf589ed961/websockets-17.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04", size = 226569, upload-time = "2026-10-30T15:09:20.587Z" }, + { url = "https://files.pythonhosted.org/packages/c4/42/b293a5d07099549797138d09fda5bf878d302b4980e61678981c8dfdc98f/websockets-17.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0", size = 227726, upload-time = "2026-10-30T15:09:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/d048ff83c4d0fc386f6008529d12690d0dcb410f73570430f13fe4d543f0/websockets-17.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb", size = 229426, upload-time = "2026-10-30T15:09:23.444Z" }, + { url = "https://files.pythonhosted.org/packages/e6/21/1df51f94047dfb76c32fd7508036e4b55352d0f04fd0e3bee525a2122225/websockets-17.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339", size = 228211, upload-time = "2026-10-30T15:09:24.902Z" }, + { url = "https://files.pythonhosted.org/packages/cf/13/33d6a28471da4c30a98a2313c76f984be2056a703c5c532225dc28f5dba3/websockets-17.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892", size = 227106, upload-time = "2026-10-30T15:09:26.315Z" }, + { url = "https://files.pythonhosted.org/packages/4a/86/ff8441e56ca677e51f9c826868fccde2a82bafe0ffa337fd38823b64253a/websockets-17.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b", size = 224499, upload-time = "2026-10-30T15:09:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/da/91/470d7a619ce4aa837ff5b6a5836227c43bdfdc42ef837cce48f508992438/websockets-17.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119", size = 227213, upload-time = "2026-10-30T15:09:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/55ed1422d6c73b5b81996ca195f2a256a339b26c553f5686afc2a926bc2b/websockets-17.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188", size = 225876, upload-time = "2026-10-30T15:09:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/e9/59/f133d260e7a48d3e231c3a495eeb6114b880c1e96bfc781a949c1f73991c/websockets-17.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921", size = 226403, upload-time = "2026-10-30T15:09:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/bb3355453ffdc31d3a14ddc0b50e058f13a97d2ed019ba6c380e03456169/websockets-17.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978", size = 227558, upload-time = "2026-10-30T15:09:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/f3d55a83c2a4daa8bf62594b00a721dc8767518ccb833f932b0178eb6c19/websockets-17.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6", size = 225369, upload-time = "2026-10-30T15:09:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/3a320bfb718d9ae1eff15ba7790979c0e6fdec74e388430f426e6647d7f4/websockets-17.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7", size = 226268, upload-time = "2026-10-30T15:09:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/1f9cdeeaaa2d5e158b7a40f971e74b641b35f977674b46229a5bb14e270a/websockets-17.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e", size = 226530, upload-time = "2026-10-30T15:09:39.171Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/506406ef09e6e168796f0187bdac0767f38a560e6785c4ee26e583aaaf76/websockets-17.4-cp315-cp315-win32.whl", hash = "sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57", size = 218751, upload-time = "2026-10-30T15:09:40.748Z" }, + { url = "https://files.pythonhosted.org/packages/25/a6/818647a9a9b3ee8bf131e9ab4fa1f2db9c6539f08e7084dd57549e0bd49d/websockets-17.4-cp315-cp315-win_amd64.whl", hash = "sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b", size = 219045, upload-time = "2026-10-30T15:09:42.251Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/0338a9abec3df8556627af987352657ce1c8456f2060b54741570b174122/websockets-17.4-cp315-cp315-win_arm64.whl", hash = "sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913", size = 218957, upload-time = "2026-10-30T15:09:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/22e83d003cdbd22be5d35e28c260203e9fbb6c73b17a8e1509f4ad49e74d/websockets-17.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d", size = 219056, upload-time = "2026-10-30T15:09:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ca/071d38e7237f6ada4f577b74f7cbf5bc2ff7e1f9360ebd2837e74a22dfd4/websockets-17.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a", size = 216630, upload-time = "2026-10-30T15:09:46.951Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/22d4523f92d88b8f5ebfd3df7ab5e825146af100f7e2caeae7d359ae51ea/websockets-17.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7", size = 216841, upload-time = "2026-10-30T15:09:48.285Z" }, + { url = "https://files.pythonhosted.org/packages/87/29/bc94be4a8bc19ef518e9efecd90a992f2991b7cfc6eb8a7430c4e2dc097c/websockets-17.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf", size = 226992, upload-time = "2026-10-30T15:09:49.776Z" }, + { url = "https://files.pythonhosted.org/packages/fa/25/58f69b5fec064764ddf5b88045397433cdb028f5b17c82fbe047fd31fff7/websockets-17.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70", size = 227258, upload-time = "2026-10-30T15:09:51.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/bc932a54c5f1bd78461574aea539fc562fd2de494738a2c0675c4de860be/websockets-17.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0", size = 228589, upload-time = "2026-10-30T15:09:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/57ec83066d0bfa8a9b9f594bc61951073c4a86e34c562a5292604118dab6/websockets-17.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463", size = 230217, upload-time = "2026-10-30T15:09:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fc/0f2fe533b5b4dc22ac704b3357f5c99fa60a3837218b3c6c6ad63a60120e/websockets-17.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de", size = 229026, upload-time = "2026-10-30T15:09:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/ed28307835106a9a2dbd1a5efc597e8befa3d0a5f2b46a4c1e466b2f4759/websockets-17.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9", size = 227904, upload-time = "2026-10-30T15:09:57.221Z" }, + { url = "https://files.pythonhosted.org/packages/39/4d/24ad7deddc7b31c3e430acc1c00c7b384ae42f1f973e9cc01aa42d19628f/websockets-17.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c", size = 225253, upload-time = "2026-10-30T15:09:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/f6/9c92230ebfab42fee482b236c45c32ed34b24f7360080b400cce6cfcac11/websockets-17.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7", size = 228089, upload-time = "2026-10-30T15:10:00.308Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7a/f523830e562da9b1d7a610174752753720c5e47e84b59b5fc47721d416bd/websockets-17.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad", size = 226743, upload-time = "2026-10-30T15:10:01.744Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e6/d1a56495cb7b1a0a4688145131be38cfd0c1342bde8125e136a6e6ee494e/websockets-17.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c", size = 227237, upload-time = "2026-10-30T15:10:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/44/41/c4e53f7ad63838d7eba6e82c5d743827e91dafe5e48c88a58f7e90043945/websockets-17.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556", size = 228348, upload-time = "2026-10-30T15:10:05.149Z" }, + { url = "https://files.pythonhosted.org/packages/ca/01/f6f78c3edeb8ad2b3c35a8c4117ec2cf9346215afaed8155af1e7933818b/websockets-17.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222", size = 226099, upload-time = "2026-10-30T15:10:06.588Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/d8c11740231b63d1aba6bad6b1d4e72e8384b26c5448801da238ebe82c92/websockets-17.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5", size = 226956, upload-time = "2026-10-30T15:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/87/dbfd43ae01ea8fdb7478a1b3d58b32aca83bb9c4aa61ebe187d1e188ac8e/websockets-17.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb", size = 227253, upload-time = "2026-10-30T15:10:09.533Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8e/fbd968a75428063560ada02d564de8d6c49ca34ec29dfb78e5d900f4b0ef/websockets-17.4-cp315-cp315t-win32.whl", hash = "sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe", size = 218877, upload-time = "2026-10-30T15:10:10.925Z" }, + { url = "https://files.pythonhosted.org/packages/f9/53/8f37a3943517bba77fc86f7c4162c455018c5b6d760c5e9c9a11de9e37f7/websockets-17.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca", size = 219195, upload-time = "2026-10-30T15:10:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/cf33cba7248fcd80cfe5911ffe67aa2637f50c2bfaada123b1e9d2aeca90/websockets-17.4-cp315-cp315t-win_arm64.whl", hash = "sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf", size = 219117, upload-time = "2026-10-30T15:10:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/eb/27/9db924cb0dbf22eade78d98fbae77790f9bf6bc729e6ab29aef9d68e4156/websockets-17.4-py3-none-any.whl", hash = "sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9", size = 212900, upload-time = "2026-10-30T15:10:34.482Z" }, +] From c7274f6f00c39f318271cc4874225799abd68eda Mon Sep 17 00:00:00 2001 From: asypost Date: Fri, 6 Nov 2026 21:03:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=9E=E7=8E=B0Route?= =?UTF-8?q?=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mini_agent/agent/base_agent.py | 69 +++++++ src/mini_agent/agent/base_tool_agent.py | 58 ++++++ src/mini_agent/agent/context.py | 5 +- src/mini_agent/agent/llm_client.py | 48 +++++ src/mini_agent/agent/message.py | 92 ++++++--- src/mini_agent/agent/react_agent.py | 238 +++++++++--------------- src/mini_agent/agent/route_agent.py | 236 +++++++++++++++++++++++ src/mini_agent/api/websocket.py | 24 ++- src/mini_agent/main.py | 44 +++-- src/mini_agent/tools/tavily_search.py | 5 + src/mini_agent/tools/tool.py | 47 +++++ src/mini_agent/tools/tool_repository.py | 66 ++----- 12 files changed, 681 insertions(+), 251 deletions(-) create mode 100644 src/mini_agent/agent/base_agent.py create mode 100644 src/mini_agent/agent/base_tool_agent.py create mode 100644 src/mini_agent/agent/llm_client.py create mode 100644 src/mini_agent/agent/route_agent.py diff --git a/src/mini_agent/agent/base_agent.py b/src/mini_agent/agent/base_agent.py new file mode 100644 index 0000000..23a9371 --- /dev/null +++ b/src/mini_agent/agent/base_agent.py @@ -0,0 +1,69 @@ +from abc import abstractmethod +from collections.abc import Iterator +import json +from typing import Any + +from openai.types.chat import ChatCompletionMessage, ChatCompletionMessageToolCallUnion +from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam +from pydantic import BaseModel, TypeAdapter + +from mini_agent.agent.context import ConversationContext +from mini_agent.agent.message import Message + + +class BaseAgent: + @property + @abstractmethod + def id(self) -> str: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + @property + @abstractmethod + def description(self) -> str: + pass + + @property + @abstractmethod + def compatibility(self) -> list[str]: + pass + + @property + @abstractmethod + def system_prompt(self) -> str: + pass + + def serialize_json(self, data: Any) -> str: + if isinstance(data, BaseModel): + return data.model_dump_json(ensure_ascii=False) + return json.dumps(data, ensure_ascii=False, default=str) + + def _initialize_context(self, context: ConversationContext, question: str) -> None: + """ + 初始化会话context + """ + if len(context.messages) == 0: + context.messages.append({"role": "system", "content": self.system_prompt}) + context.messages.append({"role": "user", "content": question}) + + def _build_message_param(self, message: ChatCompletionMessage) -> ChatCompletionMessageParam: + """ + 将ChatCompletionMessage转换成ChatCompletionMessageParam + """ + message_param: ChatCompletionMessageParam = { + "role": message.role, + "content": message.content, + } + + if message.tool_calls: + adapter = TypeAdapter(list[ChatCompletionMessageToolCallUnion]) + message_param["tool_calls"] = adapter.dump_python(message.tool_calls, exclude_none=True) + return message_param + + @abstractmethod + def run(self, question: str, context: ConversationContext) -> Iterator[Message]: + pass diff --git a/src/mini_agent/agent/base_tool_agent.py b/src/mini_agent/agent/base_tool_agent.py new file mode 100644 index 0000000..f583857 --- /dev/null +++ b/src/mini_agent/agent/base_tool_agent.py @@ -0,0 +1,58 @@ +from abc import abstractmethod +import json +from typing import Any, override + +from openai.types.chat import ChatCompletionMessageToolCallUnion + +from mini_agent.agent.base_agent import BaseAgent +from mini_agent.agent.context import ConversationContext +from mini_agent.tools.tool_repository import ToolRepository + + +class BaseToolAgent(BaseAgent): + @property + @abstractmethod + def tool_repository(self) -> ToolRepository: + pass + + @override + def _initialize_context(self, context: ConversationContext, question: str) -> None: + super()._initialize_context(context, question) + if len(context.discovered_tools) == 0 and self.tool_repository: + context.add_tool(self.tool_repository) + + def _parse_tool_call( + self, tool_call: ChatCompletionMessageToolCallUnion + ) -> tuple[str, dict[str, Any]]: + """ + 解析Tool call,返回(tool_id, arguments) + """ + if tool_call.type != "function" and tool_call.type != "custom": + raise ValueError(f"不支持的工具调用类型{tool_call.type}") + if tool_call.type == "function": + tool_id = tool_call.function.name + raw_arguments = tool_call.function.arguments + else: + tool_id = tool_call.custom.name + raw_arguments = tool_call.custom.input + arguments = json.loads(raw_arguments) + if not isinstance(arguments, dict): + raise ValueError("工具参数必须是 JSON 对象") + return (tool_id, arguments) + + def _execute_tool_call( + self, tool_id: str, arguments: dict[str, Any], context: ConversationContext + ) -> Any: + """ + 执行tool call + """ + observation = self.tool_repository.call_tool(tool_id, arguments) + # 更新当前可用工具 + if tool_id == self.tool_repository.id: + available_tools = [] + for item in observation: + if item.name not in context.discovered_tool_names: + context.add_tool(item) + available_tools.append(item.to_openai()) + observation = available_tools + return observation diff --git a/src/mini_agent/agent/context.py b/src/mini_agent/agent/context.py index c3f3a53..51c9a2a 100644 --- a/src/mini_agent/agent/context.py +++ b/src/mini_agent/agent/context.py @@ -6,6 +6,7 @@ from mini_agent.tools.tool import Tool class ConversationContext(BaseModel): conversation_id: str = Field(description="会话ID") + run_id: str = Field(description="当前会话轮id") messages: list[ChatCompletionMessageParam] = Field(description="消息历史", default_factory=list) _discovered_tools: list[Tool] = PrivateAttr(default_factory=list) @@ -17,6 +18,6 @@ class ConversationContext(BaseModel): return self._discovered_tools def add_tool(self, tool: Tool): - if tool.name not in self.discovered_tool_names: - self.discovered_tool_names.append(tool.name) + if tool.id not in self.discovered_tool_names: + self.discovered_tool_names.append(tool.id) self.discovered_tools.append(tool) diff --git a/src/mini_agent/agent/llm_client.py b/src/mini_agent/agent/llm_client.py new file mode 100644 index 0000000..07ecb59 --- /dev/null +++ b/src/mini_agent/agent/llm_client.py @@ -0,0 +1,48 @@ +import os + +from openai import OpenAI +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessageParam, + ChatCompletionToolChoiceOptionParam, + ChatCompletionToolParam, +) + + +class LLMClient: + def __init__( + self, + model: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + timeout: int | None = None, + ) -> None: + self.model = model or os.getenv("LLM_MODEL_ID") + self.base_url = base_url or os.getenv("LLM_BASE_URL") + self.api_key = api_key or os.getenv("LLM_API_KEY") + self.timeout = timeout if timeout is not None else int(os.getenv("LLM_TIMEOUT", "60")) + + if not all([self.model, self.base_url, self.api_key]): + raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。") + + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url, + timeout=self.timeout, + ) + + def think( + self, + messages: list[ChatCompletionMessageParam], + tools: list[ChatCompletionToolParam], + tool_choice: ChatCompletionToolChoiceOptionParam = "auto", + **kwargs, + ) -> ChatCompletion: + assert self.model is not None + return self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + **kwargs + ) diff --git a/src/mini_agent/agent/message.py b/src/mini_agent/agent/message.py index 977cdec..b1a11f7 100644 --- a/src/mini_agent/agent/message.py +++ b/src/mini_agent/agent/message.py @@ -1,47 +1,91 @@ from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Any, Literal from uuid import uuid4 from pydantic import BaseModel, Field +class TextContent(BaseModel): + type: Literal["text"] = "text" + text: str + + +class ThinkContent(BaseModel): + type: Literal["think"] = "think" + text: str + + +class ImageContent(BaseModel): + type: Literal["image"] = "image" + url: str + mime_type: str | None = None + + +class JsonContent(BaseModel): + type: Literal["json"] = "json" + data: Any + + +class ToolCallContent(BaseModel): + type: Literal["tool_call"] = "tool_call" + id: str + name: str + tool_call_id:str + arguments: dict[str, Any] + + +class ToolResultContent(BaseModel): + type: Literal["tool_result"] = "tool_result" + tool_call_id: str + result: Any = None + error: str | None = None + + +class ErrorContent(BaseModel): + type: Literal["error"] = "error" + message: str + code: str | None = None + + +class AudioContent(BaseModel): + type: Literal["audio"] = "audio" + url: str + mime_type: str | None = None + + +MessageContent = ( + TextContent + | ThinkContent + | ImageContent + | JsonContent + | ToolCallContent + | ToolResultContent + | ErrorContent +) + + class MessageRole(StrEnum): USER = "user" ASSISTANT = "assistant" TOOL = "tool" -class MessageType(StrEnum): - TEXT = "text" - THINK = "think" - TOOL_CALL = "tool_call" - TOOL_RESULT = "tool_result" - - -class MessageStatus(StrEnum): +class RunStatus(StrEnum): RUNNING = "running" COMPLETED = "completed" FAILED = "failed" + CANCELLED = "cancelled" class Message(BaseModel): - id: str = Field(default_factory=lambda: str(uuid4())) - conversation_id: str - run_id: str | None = None + id: str = Field(description="消息ID", default_factory=lambda: str(uuid4())) + conversation_id: str = Field(description="会话ID") + run_id: str | None = Field(description="当前轮的ID", default=None) + run_status: RunStatus = Field(description="当前轮的状态") - role: MessageRole - type: MessageType + role: MessageRole = Field(description="当前角色") - content: Any = None - status: MessageStatus = MessageStatus.COMPLETED - - tool_call_id: str | None = None - - tool_name: str | None = None - - arguments: dict[str, Any] | None = None - - tool_call_result:Any |None = None + content: list[MessageContent] = Field(description="消息内容", default_factory=list) created_at: datetime = Field(default_factory=lambda: datetime.now()) diff --git a/src/mini_agent/agent/react_agent.py b/src/mini_agent/agent/react_agent.py index 4beb696..943696e 100644 --- a/src/mini_agent/agent/react_agent.py +++ b/src/mini_agent/agent/react_agent.py @@ -1,154 +1,93 @@ from collections.abc import Iterator -import json import os -from typing import Any +from typing import override from uuid import uuid7 -from openai import OpenAI -from openai.types.chat import ChatCompletionMessage, ChatCompletionMessageToolCallUnion -from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam -from pydantic import BaseModel, TypeAdapter - +from mini_agent.agent.base_tool_agent import BaseToolAgent from mini_agent.agent.context import ConversationContext -from mini_agent.agent.message import Message, MessageRole, MessageStatus, MessageType +from mini_agent.agent.llm_client import LLMClient +from mini_agent.agent.message import ( + ErrorContent, + Message, + MessageRole, + RunStatus, + TextContent, + ThinkContent, + ToolCallContent, + ToolResultContent, +) from mini_agent.tools.tavily_search import TavilySearch from mini_agent.tools.tool_repository import ToolRepository -class ReActAgent: - SYSTEM_PROMPT = """ - 你是一个能够调用外部工具的智能助手。 - - 工作规则: - - 1. 首先理解用户的问题,判断是否需要外部工具。 - 2. 如果需要工具,调用工具搜索接口搜索合适的工具。 - 3. 根据搜索结果选择合适的工具。 - 4. 工具执行后,根据返回结果判断下一步行动。 - 5. 当已经获得足够的信息,能够完整回答用户问题时,直接给出最终答案。 - - 注意事项: - - - 不允许编造工具执行结果。 - - 工具搜索没有结果时,可以尝试调整关键词重新搜索。 - - 如果工具无法完成任务,明确告知用户。 - - 不要为了调用工具而调用工具。 - - 只有在任务已经完成或明确无法继续时才结束回答。 - """ - +class ReActAgent(BaseToolAgent): def __init__( self, tool_repository: ToolRepository, - model: str | None = None, - api_key: str | None = None, - base_url: str | None = None, - timeout: int | None = None, + llm_client: LLMClient, max_steps: int = 10, ) -> None: - self.model = model or os.getenv("LLM_MODEL_ID") - self.base_url = base_url or os.getenv("LLM_BASE_URL") - self.api_key = api_key or os.getenv("LLM_API_KEY") - self.timeout = timeout if timeout is not None else int(os.getenv("LLM_TIMEOUT", "60")) + super().__init__() - if not all([self.model, self.base_url, self.api_key]): - raise ValueError("模型ID、API密钥和服务地址必须被提供或在.env文件中定义。") - - self.client = OpenAI( - api_key=self.api_key, - base_url=self.base_url, - timeout=self.timeout, - ) - - self.tool_repos = tool_repository + self.client = llm_client self.max_steps = max_steps web_search_tool = TavilySearch() web_search_tool.config(api_key=os.getenv("TAVILY_KEY", "")) - self.tool_repos.register(web_search_tool) + self._tool_repository = tool_repository + self.tool_repository.register(web_search_tool) - def serialize_json(self, data: Any) -> str: - if isinstance(data, BaseModel): - return data.model_dump_json() - return json.dumps(data, ensure_ascii=False, default=str) + @property + def id(self) -> str: + return "react_agent" - def _initialize_context(self, context: ConversationContext, question: str) -> None: - """ - 初始化会话context - """ - if len(context.messages) == 0: - context.messages.append({"role": "system", "content": self.SYSTEM_PROMPT}) - if len(context.discovered_tools) == 0: - context.add_tool(self.tool_repos) - context.messages.append({"role": "user", "content": question}) + @property + def name(self) -> str: + return "ReAct Agent" - def _build_message_param(self, message: ChatCompletionMessage) -> ChatCompletionMessageParam: - """ - 将ChatCompletionMessage转换成ChatCompletionMessageParam - """ - message_param: ChatCompletionMessageParam = { - "role": message.role, - "content": message.content, - } + @property + def description(self) -> str: + return "思考-回答-观察-再思考的模式来解决问题" - if message.tool_calls: - adapter = TypeAdapter(list[ChatCompletionMessageToolCallUnion]) - message_param["tool_calls"] = adapter.dump_python(message.tool_calls, exclude_none=True) - return message_param + @property + def compatibility(self) -> list[str]: + return ["通用问题回答", "资料网络搜索"] - def _parse_tool_call( - self, tool_call: ChatCompletionMessageToolCallUnion - ) -> tuple[str, dict[str, Any]]: - """ - 解析Tool call,返回(tool_name, arguments) - """ - if tool_call.type != "function" and tool_call.type != "custom": - raise ValueError(f"不支持的工具调用类型{tool_call.type}") - if tool_call.type == "function": - tool_name = tool_call.function.name - raw_arguments = tool_call.function.arguments - else: - tool_name = tool_call.custom.name - raw_arguments = tool_call.custom.input - arguments = json.loads(raw_arguments) - if not isinstance(arguments, dict): - raise ValueError("工具参数必须是 JSON 对象") - return (tool_name, arguments) + @property + def tool_repository(self) -> ToolRepository: + return self._tool_repository - def _execute_tool_call( - self, tool_name: str, arguments: dict[str, Any], context: ConversationContext - ) -> Any: - """ - 执行tool call - """ - observation = self.tool_repos.call_tool(tool_name, arguments) - # 更新当前可用工具 - if tool_name == self.tool_repos.name: - available_tools = [] - for item in observation: - if item.name not in context.discovered_tool_names: - context.add_tool(item) - available_tools.append(self.tool_repos.tool_to_openai(item)) - observation = available_tools - return observation + @property + def system_prompt(self) -> str: + return """ + 你是一个能够调用外部工具的智能助手。 + 工作规则: + + 1. 首先理解用户的问题,判断是否需要外部工具。 + 2. 如果需要工具,可以从工具库中搜索你所需要的工具。 + 3. 从工具搜索结果中选择合适的工具并调用执行。 + 4. 工具执行后,根据返回结果判断下一步行动。 + 5. 当已经获得足够的信息,能够完整回答用户问题时,直接给出最终答案。 + + 注意事项: + + - 不允许编造工具执行结果。 + - 工具搜索没有结果时,可以尝试调整关键词重新搜索。 + - 如果工具无法完成任务,明确告知用户。 + - 不要为了调用工具而调用工具。 + - 只有在任务已经完成或明确无法继续时才结束回答。 + """ + + @override def run(self, question: str, context: ConversationContext) -> Iterator[Message]: self._initialize_context(context, question) run_id = str(uuid7()) - yield Message( - conversation_id=context.conversation_id, - role=MessageRole.USER, - run_id=run_id, - status=MessageStatus.RUNNING, - type=MessageType.TEXT, - content=question, - ) for _ in range(self.max_steps): - tools = [self.tool_repos.tool_to_openai(tool) for tool in context.discovered_tools] + tools = [tool.to_openai() for tool in context.discovered_tools] - assert self.model is not None - response = self.client.chat.completions.create( - model=self.model, + response = self.client.think( messages=context.messages, tools=tools, tool_choice="auto", @@ -166,54 +105,58 @@ class ReActAgent: yield Message( conversation_id=context.conversation_id, run_id=run_id, + run_status=RunStatus.COMPLETED, role=MessageRole.ASSISTANT, - content=message.content, - status=MessageStatus.COMPLETED, - type=MessageType.TEXT, + content=[TextContent(text=message.content)], ) return elif reasoning_content := getattr(message, "reasoning_content", None): yield Message( conversation_id=context.conversation_id, run_id=run_id, + run_status=RunStatus.RUNNING, role=MessageRole.ASSISTANT, - type=MessageType.THINK, - content=reasoning_content, - status=MessageStatus.RUNNING, + content=[ThinkContent(text=reasoning_content)], ) else: yield Message( conversation_id=context.conversation_id, run_id=run_id, + run_status=RunStatus.FAILED, role=MessageRole.ASSISTANT, - type=MessageType.TEXT, - status=MessageStatus.FAILED, - content="模型没有返回有效内容", + content=[ErrorContent(message="模型没有返回有效内容")], ) return if message.tool_calls: for tool_call in message.tool_calls: - tool_name = None + tool_id = None arguments = {} - tool_call_status = MessageStatus.RUNNING + tool_call_error: str | None = None try: - tool_name, arguments = self._parse_tool_call(tool_call) + tool_id, arguments = self._parse_tool_call(tool_call) + tool = self.tool_repository.get_tool(id=tool_id) + if not tool: + raise KeyError(f"{tool_id}工具不存在") yield Message( conversation_id=context.conversation_id, run_id=run_id, role=MessageRole.ASSISTANT, - status=MessageStatus.RUNNING, - type=MessageType.TOOL_CALL, - content=f"call tool:{tool_name}({arguments})", - tool_name=tool_name, - arguments=arguments, + run_status=RunStatus.RUNNING, + content=[ + ToolCallContent( + tool_call_id=tool_call.id, + id=tool.id, + name=tool.name, + arguments=arguments, + ) + ], ) - observation = self._execute_tool_call(tool_name, arguments, context) + observation = self._execute_tool_call(tool_id, arguments, context) except Exception as e: - tool_call_status = MessageStatus.FAILED + tool_call_error = str(e) observation = {"error": str(e)} # 统一记录工具执行结果 @@ -228,20 +171,21 @@ class ReActAgent: yield Message( conversation_id=context.conversation_id, run_id=run_id, + run_status=RunStatus.RUNNING, role=MessageRole.TOOL, - type=MessageType.TOOL_RESULT, - status=tool_call_status, - content=f"call {tool_name or 'unknown'} result:{self.serialize_json(observation)}", - tool_name=tool_name, - arguments=arguments, - tool_call_result=observation, + content=[ + ToolResultContent( + tool_call_id=tool_call.id, + result="执行成功" if tool_call_error is None else None, + error=tool_call_error, + ) + ], ) yield Message( conversation_id=context.conversation_id, run_id=run_id, + run_status=RunStatus.FAILED, role=MessageRole.ASSISTANT, - status=MessageStatus.FAILED, - type=MessageType.TEXT, - content=f"已达到最大执行步数 ({self.max_steps}),任务未完成", + content=[ErrorContent(message=f"已达到最大执行步数 ({self.max_steps}),任务未完成")], ) diff --git a/src/mini_agent/agent/route_agent.py b/src/mini_agent/agent/route_agent.py new file mode 100644 index 0000000..419e31e --- /dev/null +++ b/src/mini_agent/agent/route_agent.py @@ -0,0 +1,236 @@ +from collections.abc import Iterator +from typing import Literal, override +from uuid import uuid7 + +from pydantic import BaseModel, Field + +from mini_agent.agent.base_agent import BaseAgent +from mini_agent.agent.context import ConversationContext +from mini_agent.agent.llm_client import LLMClient +from mini_agent.agent.message import ( + ErrorContent, + Message, + MessageRole, + RunStatus, + TextContent, + ThinkContent, +) + + +class RouteDecision(BaseModel): + action: Literal["route", "direct", "clarify", "reject"] = Field(description="路由动作") + agent_id: str | None = Field(description="Agent id", default=None) + reason: str | None = Field(description="路由理由", default=None) + message: str | None = Field(description="回复用户的消息", default=None) + task: str | None = Field(description="交给子Agent的任务描述") + + +class RouteAgent(BaseAgent): + def __init__(self, llm_client: LLMClient, max_steps: int = 10) -> None: + super().__init__() + self.sub_agents: dict[str, BaseAgent] = {} + self.llm_client = llm_client + self.max_steps = max_steps + + def register_agent(self, agent: BaseAgent): + if agent.id not in self.sub_agents: + self.sub_agents[agent.id] = agent + else: + raise ValueError(f"{agent.id}已经存在") + + @property + def system_prompt(self) -> str: + template = """ + # 角色 + + 你是一个 AI Agent 路由调度器(Route Agent),负责理解用户请求,并将其分配给最合适的专业 Agent。 + + 你不直接处理用户的业务问题,不执行工具,不编造业务数据。你的核心职责是识别用户意图、选择目标 Agent,并生成清晰的路由决策。 + + # 核心职责 + + 1. 理解用户当前请求的真实意图。 + 2. 结合当前会话上下文,理解用户的追问、省略表达和上下文引用。 + 3. 从系统提供的 Agent 列表中选择最合适的 Agent。 + 4. 判断用户请求是否需要补充信息。 + 5. 判断当前请求是否可以直接回答,还是必须交给专业 Agent。 + 6. 输出符合指定结构的路由决策。 + + # 路由规则 + + ## 1. Agent 选择 + + - 必须从系统提供的可用 Agent 列表中选择目标 Agent。 + - 优先选择职责与用户请求最匹配的 Agent。 + - 不得虚构 Agent,不得调用未注册的 Agent。 + - 如果一个请求涉及多个领域,优先选择能够完成主要任务的 Agent。 + - 如果需要多个 Agent 协作,只有在系统明确支持多 Agent 调度时才能提出多个目标。 + + ## 2. 上下文理解 + + - 用户当前消息优先级最高,但不能脱离历史对话理解。 + - 如果用户使用“继续”“刚才那个”“再查一下”等表达,应结合历史消息确定实际任务。 + - 如果用户是在纠正上一轮结果,应将请求交给原任务对应的 Agent。 + - 如果用户明确提出新的任务,应重新进行路由,不要机械沿用上一轮 Agent。 + - 不要因为历史上使用过某个 Agent,就将无关的新问题继续分配给它。 + + ## 3. 信息不足 + + - 如果缺少执行任务所必需的信息,选择需要补充信息的处理方式。 + - 只询问真正影响任务执行的必要信息。 + - 不要为了补充非必要信息而阻断任务。 + - 如果可以使用合理默认值,应优先继续执行。 + + ## 4. 直接回答 + + 以下情况可以直接回答,不需要调用专业 Agent: + + - 简单的常识性问题。 + - 问候、感谢、告别等日常交流。 + - 对当前 Agent 工作状态的简单询问。 + - 不涉及外部数据查询的简单解释。 + - 用户只是要求修改、解释或讨论当前对话中已经提供的内容。 + + 直接回答时,不得伪装成调用了专业 Agent。 + + ## 5. 无法路由 + + 出现以下情况时,选择无法路由: + + - 用户意图无法理解。 + - 系统没有合适的 Agent。 + - 用户请求超出所有可用 Agent 的职责范围。 + - 需要执行的操作不被系统支持。 + + 应简要说明原因,不要虚构处理能力。 + + # 水利业务路由参考 + + 系统提供以下 Agent,参考这些Agent的职责做出选择: + + {} + + 以上只是路由参考,实际选择必须以系统提供的 Agent 定义为准。 + + # 输出要求 + + 必须输出符合系统指定 JSON Schema 的结构化结果。 + + 路由决策至少包含以下语义: + + - action:决策类型,可选 route、direct、clarify、reject。 + - agent_id:目标 Agent 标识。非 route 决策时为空。 + - reason:路由原因。 + - message:直接回复用户的内容,或者需要向用户询问的内容。 + - task:交给目标 Agent 的任务描述,尽可能保留用户原始需求和必要上下文。 + + 不要输出 JSON 以外的解释文字。 + + # 重要约束 + + 1. 你只负责路由,不负责执行专业任务。 + 2. 不得调用业务工具。 + 3. 不得虚构系统中不存在的 Agent。 + 4. 不得擅自修改用户需求。 + 5. 不得丢失用户请求中的关键参数。 + 6. 不要将工具调用需求误判为普通聊天。 + 7. 对于明确的业务查询,优先路由给专业 Agent。 + 8. 输出必须符合指定 JSON Schema,不允许输出额外字段。 + """ + agent_descriptions = [ + f"-{agent.id}: {','.join(agent.compatibility)}" for agent in self.sub_agents.values() + ] + return template.format("\n".join(agent_descriptions)) + + @override + def run(self, question: str, context: ConversationContext) -> Iterator[Message]: + self._initialize_context(context=context, question=question) + run_id = context.run_id + for _ in range(self.max_steps): + response = self.llm_client.think( + messages=context.messages, + tools = [], + tool_choice="auto", + response_format={ + "type": "json_schema", + "json_schema": { + "name": "agent_decision", + "strict": True, + "schema": RouteDecision.model_json_schema(), + }, + }, + ) + + message = response.choices[0].message + + # 保存模型返回消息 + message_param = self._build_message_param(message) + context.messages.append(message_param) + + if message.content: + decision = RouteDecision.model_validate_json(message.content) + if decision.action == "clarify" or decision.action == "direct": + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + run_status=RunStatus.COMPLETED, + role=MessageRole.ASSISTANT, + content=[TextContent(text=decision.message or "")], + ) + if decision.action == "direct": + return + elif decision.action == "route": + agent = self.sub_agents.get(decision.agent_id or "") + if not agent: + context.messages.append( + {"role": "user", "content": f"{decision.agent_id} Agent不存在"} + ) + else: + yield Message( + conversation_id=context.conversation_id, + run_id=context.run_id, + run_status=RunStatus.RUNNING, + role = MessageRole.ASSISTANT, + content= [ThinkContent(text=decision.reason or "正在路由请求")] + ) + agent_context = ConversationContext(conversation_id=context.conversation_id,run_id=str(uuid7())) + for agent_message in agent.run( + context=agent_context, question=decision.task or question + ): + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + run_status=agent_message.run_status, + content=agent_message.content, + role=agent_message.role, + ) + if ( + agent_message.run_status == RunStatus.COMPLETED + or agent_message.run_status == RunStatus.FAILED + ): + return + + elif reasoning_content := getattr(message, "reasoning_content", None): + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + run_status=RunStatus.RUNNING, + role=MessageRole.ASSISTANT, + content=[ThinkContent(text=reasoning_content)], + ) + else: + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + run_status=RunStatus.FAILED, + role=MessageRole.ASSISTANT, + content=[ErrorContent(message="模型没有返回有效内容")], + ) + return + yield Message( + conversation_id=context.conversation_id, + run_id=run_id, + run_status=RunStatus.FAILED, + role=MessageRole.ASSISTANT, + content=[ErrorContent(message=f"已达到最大执行步数 ({self.max_steps}),任务未完成")], + ) diff --git a/src/mini_agent/api/websocket.py b/src/mini_agent/api/websocket.py index 04c1fe8..897b3c8 100644 --- a/src/mini_agent/api/websocket.py +++ b/src/mini_agent/api/websocket.py @@ -3,7 +3,7 @@ from uuid import uuid7 from fastapi import APIRouter, WebSocket, WebSocketDisconnect from mini_agent.agent.context import ConversationContext -from mini_agent.agent.message import Message, MessageRole, MessageStatus, MessageType +from mini_agent.agent.message import Message, MessageRole, RunStatus, TextContent router = APIRouter() @@ -12,28 +12,36 @@ router = APIRouter() async def websocket_endpoint(websocket: WebSocket): await websocket.accept() agent = websocket.app.state.agent - context = ConversationContext(conversation_id=str(uuid7())) + context = ConversationContext(conversation_id=str(uuid7()),run_id="") try: while True: request = await websocket.receive_json() question = request.get("question") + context.run_id = str(uuid7()) + + await websocket.send_json(Message( + conversation_id=context.conversation_id, + role=MessageRole.USER, + run_id=context.run_id, + run_status=RunStatus.RUNNING, + content=[TextContent(text=question)], + ).model_dump(mode="json")) if not question: await websocket.send_json( Message( conversation_id=context.conversation_id, role=MessageRole.ASSISTANT, - run_id="", - type=MessageType.TEXT, - status=MessageStatus.FAILED, - content="请输入问题", + run_id=context.run_id, + run_status=RunStatus.FAILED, + content=[TextContent(text="请输入问题")], ).model_dump(mode="json") ) continue - for status in agent.run(question, context): - await websocket.send_json(status.model_dump(mode="json")) + for message in agent.run(question, context): + await websocket.send_json(message.model_dump(mode="json")) except WebSocketDisconnect: print("客户端断开连接") diff --git a/src/mini_agent/main.py b/src/mini_agent/main.py index f348f5d..16ddc72 100644 --- a/src/mini_agent/main.py +++ b/src/mini_agent/main.py @@ -1,12 +1,15 @@ from contextlib import asynccontextmanager from pathlib import Path +from uuid import uuid7 from dotenv import load_dotenv from fastapi import FastAPI from mini_agent.agent.context import ConversationContext -from mini_agent.agent.message import MessageStatus, MessageType +from mini_agent.agent.llm_client import LLMClient +from mini_agent.agent.message import TextContent, ToolCallContent, ToolResultContent from mini_agent.agent.react_agent import ReActAgent +from mini_agent.agent.route_agent import RouteAgent from mini_agent.api.websocket import router from mini_agent.tools.tool_repository import ToolRepository @@ -17,9 +20,10 @@ load_dotenv(PROJECT_ROOT / ".env") @asynccontextmanager async def lifespan(app: FastAPI): - app.state.agent = ReActAgent( - tool_repository=ToolRepository(), - ) + react_agent = ReActAgent(tool_repository=ToolRepository(), llm_client=LLMClient(),max_steps=50) + route_agent = RouteAgent(llm_client=LLMClient()) + route_agent.register_agent(react_agent) + app.state.agent = route_agent yield @@ -28,6 +32,7 @@ app = FastAPI(title="Mini Agent", lifespan=lifespan) app.include_router(router) + @app.get("/health") async def health(): return { @@ -37,24 +42,25 @@ async def health(): if __name__ == "__main__": - agent = ReActAgent( - tool_repository=ToolRepository(), - ) - context = ConversationContext(conversation_id="1") + agent = ReActAgent(tool_repository=ToolRepository(), llm_client=LLMClient()) + context = ConversationContext(conversation_id="1",run_id="") while True: question = input("提问:") + context.run_id = str(uuid7()) try: for message in agent.run(question=question, context=context): - status_type = message.type - match status_type: - case MessageType.TOOL_CALL: - print(f"🔧调用{message.tool_name} 参数 {message.arguments}") - case MessageType.TOOL_RESULT: - print(f"🔧调用{message.tool_name}结束") - case MessageType.TEXT: - if message.status == MessageStatus.FAILED: - print(f"❌{message.content}") - else: - print(f"ℹ{message.content}") + for content in message.content: + match content: + case TextContent(text=text): + print(f"💬{text}") + case ToolCallContent(name=name, arguments=arguments): + print(f"🔧调用工具{name},参数:{arguments}") + case ToolResultContent(error=error): + if error: + print(f"🔧调用工具失败:{error}") + else: + print("🔧调用工具成功") + case _: + pass except Exception as e: print(f"❌{e}") diff --git a/src/mini_agent/tools/tavily_search.py b/src/mini_agent/tools/tavily_search.py index aca9f8a..b508b59 100644 --- a/src/mini_agent/tools/tavily_search.py +++ b/src/mini_agent/tools/tavily_search.py @@ -21,6 +21,11 @@ class SearchResult(BaseModel): class TavilySearch(Tool[SearchResult]): + + @property + def id(self) -> str: + return "Tavily Search" + @property def name(self) -> str: return "Tavily网络搜索工具" diff --git a/src/mini_agent/tools/tool.py b/src/mini_agent/tools/tool.py index 059008f..1672afa 100644 --- a/src/mini_agent/tools/tool.py +++ b/src/mini_agent/tools/tool.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod +from openai.types.chat import ChatCompletionToolParam from pydantic import BaseModel @@ -17,6 +18,11 @@ class Configuration(BaseModel): class Tool[T](ABC): + @property + @abstractmethod + def id(self) -> str: + pass + @property @abstractmethod def name(self) -> str: @@ -49,3 +55,44 @@ class Tool[T](ABC): @abstractmethod def run(self, **kwargs) -> T: pass + + def to_openai(self) -> ChatCompletionToolParam: + param_type_mapping = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", + } + properties = {} + required = [] + for parameter in self.parameters: + json_type = param_type_mapping.get(parameter.type) + + if json_type is None: + raise ValueError(f"不支持的参数类型: {parameter.type}") + + properties[parameter.name] = { + "type": json_type, + } + + if parameter.description: + properties[parameter.name]["description"] = parameter.description + + if parameter.required: + required.append(parameter.name) + + return { + "type": "function", + "function": { + "name": self.id, + "description": self.description.strip(), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + }, + } diff --git a/src/mini_agent/tools/tool_repository.py b/src/mini_agent/tools/tool_repository.py index c98f02f..e61b6ea 100644 --- a/src/mini_agent/tools/tool_repository.py +++ b/src/mini_agent/tools/tool_repository.py @@ -1,7 +1,5 @@ from typing import Any -from openai.types.chat import ChatCompletionToolParam - from mini_agent.tools.tool import Configuration, Parameter, Tool @@ -10,9 +8,13 @@ class ToolRepository(Tool[list[Tool]]): self._tools: dict[str, Tool] = {} self.register(self) + @property + def id(self) -> str: + return "tool repository" + @property def name(self) -> str: - return """tool repository""" + return "工具库" @property def description(self) -> str: @@ -47,65 +49,27 @@ class ToolRepository(Tool[list[Tool]]): return self.get_tools(keyword, catalog) def register[T](self, tool: Tool[T]) -> None: - if tool.name in self._tools: - raise ValueError(f"{tool.name} is already registered") - self._tools[tool.name] = tool + if tool.id in self._tools: + raise ValueError(f"{tool.id} is already registered") + self._tools[tool.id] = tool def get_tools(self, keyword: str, catalog: str | None) -> list[Tool]: return list(self._tools.values()) + def get_tool(self, id: str) -> Tool | None: + return self._tools.get(id) + def get_tools_for_openai(self, keyword: str, catalog: str | None) -> list[dict[str, Any]]: tools = self.get_tools(keyword, catalog) result = [] for tool in tools: - result.append(self.tool_to_openai(tool)) + result.append(tool.to_openai()) return result - def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: - tool = self._tools.get(name, None) + def call_tool(self, id: str, arguments: dict[str, Any]) -> Any: + tool = self._tools.get(id, None) if tool is None: - raise ValueError(f"Tool not found: {name}") + raise ValueError(f"Tool not found: {id}") return tool.run(**arguments) - - def tool_to_openai[T](self, tool: Tool[T]) -> ChatCompletionToolParam: - param_type_mapping = { - str: "string", - int: "integer", - float: "number", - bool: "boolean", - list: "array", - dict: "object", - } - properties = {} - required = [] - for parameter in tool.parameters: - json_type = param_type_mapping.get(parameter.type) - - if json_type is None: - raise ValueError(f"不支持的参数类型: {parameter.type}") - - properties[parameter.name] = { - "type": json_type, - } - - if parameter.description: - properties[parameter.name]["description"] = parameter.description - - if parameter.required: - required.append(parameter.name) - - return { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description.strip(), - "parameters": { - "type": "object", - "properties": properties, - "required": required, - "additionalProperties": False, - }, - }, - }