diff --git a/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md b/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md new file mode 100644 index 0000000..637af5e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md @@ -0,0 +1,56 @@ +# RIGHTS · 权利与授权声明 + +## 1. 提交人 + +- 提交团队:**YAI**(单人团队) +- 提交人 / 版权人:**Gi-Tuu**(https://github.com/Gi-Tuu) +- 项目仓库:https://github.com/Gi-Tuu/yai-agent-core +- 提交人声明:本项目全部代码、文档、示例均由本人原创编写,对代码、服务、依赖与数据拥有提交所需的全部权利;不存在未披露的第三方代码抄袭、虚假部署或身份操纵。 + +## 2. 项目许可证 + +本项目以 **MIT License** 开源,许可证全文见 `source/LICENSE`: + +> Copyright (c) 2026 Gi-Tuu + +任何人(含 X-Agent 评审与后续 MCP 标准化流程)可在 MIT 条款下使用、复制、修改、合并、发布、再许可与分发。 + +## 3. 第三方依赖许可证 + +内核本体(`src/yai_core/` 的核心部分)**零第三方运行时依赖**,仅使用 Python 标准库。 +下列依赖全部为可选 extras(按需安装、模块内懒加载),均为 MIT / BSD / Apache 类宽松许可证,完整版本锁定见 `source/uv.lock`,各依赖的许可证原文随其分发包提供: + +| 依赖(extras) | 用途 | 许可证 | +|---|---|---| +| openai(llm) | OpenAI 兼容模型客户端(线上接 DeepSeek) | Apache-2.0 | +| fastapi(server) | HTTP API 框架 | MIT | +| uvicorn[standard](server) | ASGI 服务器 | BSD-3-Clause | +| pydantic(server) | 请求模型与数据校验 | MIT | +| starlette(fastapi 传递依赖) | ASGI 工具集 | BSD-3-Clause | +| mcp(mcp) | 官方 Model Context Protocol Python SDK v2 | MIT | +| anyio / httpx / httpcore(传递依赖) | 异步与 HTTP 运行时 | MIT / BSD-3-Clause | +| pydantic-core / typing-extensions / sniffio / h11 / idna 等 | 运行时支撑库 | MIT / BSD / PSF 类宽松许可 | + +开发期工具(pytest、ruff、python-docx 等)仅用于测试与文档构建,**不进入运行时镜像与服务**。 +如评审需要逐包许可证清单,可在隔离环境执行 `uv tree` / 查看 `uv.lock` 完整复现。 + +## 4. 数据与外部服务 + +- 线上服务不采集、不存储、不转发任何用户个人数据;内置示例数据(3 条演示笔记、演示天气文案)均为虚构。 +- 运行时唯一的外部出站请求是发往模型 API(线上为 DeepSeek,https://api.deepseek.com)的任务推理请求,遵循该服务方的公开服务条款;提交人对发送内容负责,不包含任何第三方隐私数据。 +- 不使用任何未授权的付费资源、私有数据集或抓取数据。 + +## 5. 提交与存档授权 + +提交人授权 X-Agent 官方及评审流程: + +1. 在隔离评审环境中拉取、构建、运行本提交的 `source/` 与在线服务,进行硬门槛校验、源码评审、安全与数据审查; +2. 在通过评审后,将与评审基线 commit 一致的完整源码复制到官方存档 ref、生成不可变的验收 release; +3. 对入选项目按公开规则进行 MCP 标准化适配(适配器与提交包由 X-Agent 侧拥有,底层能力的真实性、运行与维护责任由提交人承担)。 + +提交人理解:通过评审不构成 OKX 收录、上架、流量或收益的承诺;撤回未合并 PR 即视为退赛;已验收/获奖的源码将保留在官方存档中。 + +## 6. 商标与署名 + +- "YAI Agent Core" 与 "AMBRACE(拥爱)"为提交人自有项目名称; +- DeepSeek、OKX、X-Agent、Render、GitHub 等名称归各自权利人所有,本项目仅作技术兼容性描述,不暗示任何背书或关联关系。 diff --git a/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md b/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md new file mode 100644 index 0000000..df69135 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md @@ -0,0 +1,111 @@ +# YAI Agent Core · X-Agent MCP Hackathon 提交说明 + +- **项目名**:YAI Agent Core(yai-agent-core) +- **提交团队**:YAI(单人开发者 Gi-Tuu,https://github.com/Gi-Tuu) +- **一句话定位**:进程内嵌入式、自适应的 Agent 内核——已有软件只需声明自己的普通业务函数(或挂载 MCP Server),Core 自动发现能力、自适应选择执行策略并完成任务,**宿主不写一行 Agent Loop / Planner / 工具选择代码**。 +- **在线 API**:https://yai-agent-core.onrender.com +- **源码仓库**:https://github.com/Gi-Tuu/yai-agent-core(MIT) +- **评审基线 commit**:以 `submission.json` 的 `reviewCommit`(40 位 SHA)为准,与 `/health`、`/.well-known/xagent-verification.json` 三处一致。 + +--- + +## 1. 解决什么真实问题 + +市面上的 Agent 形态分两类:平台型(Dify/Coze,要把东西搬进别人的云)和框架型(LangGraph/OpenAI Agents SDK,开发者仍要自己定义 Agent、工具、编排)。 +大量已有软件(笔记应用、数据后台、陪伴 App)想要 Agent 能力,但不想重写、不想上云、也不想在业务代码里堆 Agent 胶水代码。 + +YAI Agent Core 类比"Agent 世界的 SQLite":以库的形式运行在宿主进程内,范式是 **host-declares-capability / core-adapts**: + +1. 宿主只提供普通 Python 函数(type hints + docstring),Core 内省生成工具规格; +2. 外部 MCP Server 的工具经 MCP Client 同构接入(v0.2 已落地); + Core 同时内置 OpenAPI 发现(可选):任意 OpenAPI 3 REST API 提供描述即可被自动注册为工具, + 与 MCP 工具在同一 Tool Bus 上同构调度(read_only 只读模式可用于公网演示); +3. Adaptive Router 把任务路由到 `direct / react / plan / clarify` 四种策略: + v0.2 起先由模型做一次轻量分类(输出 `{strategy, reason, tier}` JSON), + 超时/异常/非法输出自动回退确定性规则,**两条路径都返回带来源的决策记录**; +4. Agent Loop 完成 ReAct 工具循环 / 计划拆解执行(规划轮使用路由建议的模型档位); +5. 每一次策略选择(含 `source=llm|rules` 与理由)、工具调用、权限确认都作为事件流出, + **过程可审计,不是黑盒**。 + +它不是又一个需要开发者拼装的 Agent 框架,而是可以"装进软件里"的内核;项目最终将回流嵌入开源 AI Companion 项目 AMBRACE。 + +## 2. 在线能力(评审可直接调用) + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/health` | GET | 健康检查,返回 `status` 与本次部署的 40 位 commit | +| `/.well-known/xagent-verification.json` | GET | 部署证明:`schemaVersion=1` + `slug` + `commit` | +| `/v1/tools` | GET | 当前宿主注册的全部工具(含 `source: native/mcp/openapi` 来源标注;线上实例同时挂载本地笔记工具与公共 DeepWiki MCP Server 的 3 个远程工具) | +| `/v1/agent/run` | POST | 入参 `{"task": "..."}`,返回策略、最终结果与**全过程事件流**;任务涉及外部仓库问答时会真实发起 MCP 工具调用 | + +可复现的 curl 命令与期望输出见 `verification/README.md`。 + +> 免费实例 15 分钟无流量会休眠,首次请求冷启动约 30–90 秒,请耐心等待或先请求一次 `/health` 唤醒。 + +## 3. 工程与架构 + +``` +src/yai_core/ +├── types.py # ToolSpec / AgentEvent / Strategy 等核心数据结构(零依赖) +├── spi/ # Model / Channel / Memory / Policy 四个可替换契约 +├── discovery/ # 宿主函数内省 → ToolSpec(能力自发现) +├── tools/ # ToolRegistry(统一花名册)+ ToolExecutor(权限→执行→事件) +├── kernel/ # AdaptiveRouter(策略路由)+ Context + AgentLoop(主循环) +├── llm/ # OpenAI 兼容模型后端(DeepSeek 等,可选依赖、懒加载) +├── memory/ policy/ channels/ # 默认实现:内存记忆 + SQLite 持久化(opt-in)/ 白名单权限 / CLI·收集通道 +├── integrations/ +│ ├── mcp/ # MCP Client 桥接(可选 [mcp] 依赖、懒加载) +│ └── openapi/ # OpenAPI 3 发现 → 工具(可选 [openapi] 依赖、懒加载) +└── batteries/fastapi_server/ # 在线 API Battery(可选 [server] 依赖,含限流中间件;历史保留由宿主按 env 注入) +``` + +关键工程原则: + +- **内核本体零第三方硬依赖**:`pyproject.toml` 的 `dependencies` 为空;openai / fastapi / mcp / httpx 全部归入可选 extras(llm / server / mcp / openapi)并在模块内懒加载,有 AST 测试防止顶层误引入。 +- **工具同构**:本地函数与外部 MCP 工具在 ToolRegistry 中都是 ToolSpec,Router/Loop/Executor 对工具位置零感知。 +- **错误回灌而非崩溃**:工具(含 MCP 工具)异常被捕获为失败结果回灌模型,事件流照常完整。 +- **可复现**:`uv.lock` 锁定全部依赖;Dockerfile 多阶段构建、`docker compose` 一键起;Render Blueprint(`render.yaml`)即点即部署。 + +## 4. 测试与可观测 + +- 离线测试 **125 项**全部通过(截至 v0.3,以 `pytest -q` 实跑为准;不需要网络与 API Key):用 ScriptedModel 假模型、内存态 MCP Server 假外部服务、`httpx.MockTransport` 假 REST 端点,覆盖路由、ReAct/Plan 循环、权限、MCP/OpenAPI 桥接、记忆保留策略、限流与 API 端点。 +- GitHub Actions CI 矩阵:Ubuntu × Python 3.11/3.12/3.13 + Windows × 3.13。 +- 每次运行返回完整事件序列(strategy_selected / plan_created / tool_call / tool_result / done…),可直接作为评审的"能力证据"。 +- 长期运行的记忆治理:历史支持条数上限与 TTL(opt-in,按对话轮对齐裁剪,不拆散工具调用对),SQLite 存储在写入与启动时自动清理过期历史,裁剪规则为纯函数并有对等测试。 + +## 5. MCP 产品化准备(对应 15 分评分项) + +- 工具边界清晰:每个工具有 name / description / JSON Schema 入参,MCP 工具 schema 经白名单清洗后与本地工具同构; +- 已实现 MCP Client(官方 Python SDK v2),支持 Streamable HTTP、stdio 子进程、内存直连三种传输; +- **线上 API 自身即 MCP 消费方**:部署期通过 `MCP_SERVER_URL` 环境变量挂载公共免鉴权 MCP Server(DeepWiki),评审可直接 POST 任务让线上服务真实调用远程 MCP 工具,挂载失败不影响本地工具与验证端点(优雅降级); +- 错误语义明确:MCP `is_error` 统一翻译为失败结果并产生 `tool_result(ok=false)` 事件; +- 权限、副作用边界在 Tool Bus 一层统一收口;超时(规划中)同样收口于此;已实现评审期限流(每 IP 每分钟 30 次、429 + Retry-After,GET 验证端点不受限); +- 入选后可直接配合 X-Agent 做工具边界与 I/O schema 标准化,本项目自身不绑定任何特定 MCP Server。 + +## 6. 安全与数据处理(评审须知) + +- 线上服务不提供账号体系、不采集个人信息:仅使用演示宿主自带的 3 条虚构笔记;会话历史按部署配置可落盘(`YAI_DB_PATH`,Render 免费层为临时盘、实例重建即清空),不用于任何训练或分析用途。 +- 出站请求仅发往配置的 LLM 端点(线上为 DeepSeek 官方 API);代码内无任何遥测/统计 SDK。 +- API Key 仅存在于部署平台 Secret 与本地 `.env`(gitignore),不入库、不入镜像层(`.dockerignore` 排除)。 +- 评审期端点开放调用(满足"可在线调用"要求),已加按 IP 的零依赖限流(每 IP 每分钟 30 次,超限返回 429 与 Retry-After;health/verification/tools 三个验证端点不受限);不提供登录墙。 +- 依赖与授权清单见 `RIGHTS.md`。 + +## 7. 本地复现(隔离环境,5–10 分钟) + +```bash +git clone https://github.com/Gi-Tuu/yai-agent-core && cd yai-agent-core +uv venv && uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +uv run pytest # 125 passed,离线 +uv run ruff check src tests examples scripts +uv run python scripts/smoke_test.py # 同一内核自适应三个不同宿主(离线) +docker compose up --build # 容器化(容器内 8000,宿主 127.0.0.1:8001) +``` + +接真实模型:复制 `.env.example` 为 `.env` 填入 OpenAI 兼容 Key(DeepSeek 等),运行 +`uv run python examples/host_d_mcp/run.py` 可看到本地工具与 MCP 工具在同一注册表里协同。 + +## 8. 已知限制(诚实声明) + +- 免费层部署会休眠、冷启动约 30–90 秒(波动较大,建议先请求 `/health` 预热);正式评审期将迁移常驻 VPS(手册见仓库 `docs/competitions/deployment.md`)。 +- 路由为"单次轻量 LLM 分类 + 确定性规则兜底",不做多层反思/多智能体编排;持久化记忆为 SQLite(opt-in),免费层临时盘随实例重建清空、且不支持多进程共享,长期留存需挂盘或迁移常驻 VPS;限流按 IP 滑动窗口,不防御伪造 XFF,当前无账号体系与鉴权(列入后续版本计划)。 +- 不做 MCP Server、Multi-Agent、自进化写工具、向量记忆、内置 UI、coding agent(明确的 v0.1 红线,避免过度设计)。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.dockerignore b/submissions/mcp-hackathon/yai-agent-core/source/.dockerignore new file mode 100644 index 0000000..90748c2 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.dockerignore @@ -0,0 +1,29 @@ +# 本地环境与密钥(绝不进镜像) +.env +.venv/ +venv/ + +# 本地 SQLite 数据(容器内如需持久化走挂载卷,不进镜像) +data/ +*.db +*.db-wal +*.db-shm + +# 版本控制与缓存 +.git/ +.github/ +.pytest_cache/ +.ruff_cache/ +**/__pycache__/ +*.pyc + +# 文档与生成产物(镜像只跑服务,不需要讲义) +docs/ +*.docx +*.pdf + +# 编辑器/系统 +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.env.example b/submissions/mcp-hackathon/yai-agent-core/source/.env.example new file mode 100644 index 0000000..e305935 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.env.example @@ -0,0 +1,46 @@ +# 复制为 .env 后填写;.env 已被 .gitignore 排除,不会进仓库 +# +# DeepSeek 接入步骤: +# 1. 打开 https://platform.deepseek.com/ 注册并登录 +# 2. 左侧「API keys」→ 创建并复制 sk- 开头的 Key +# 3. 填到下面 OPENAI_API_KEY= 后面(等号两边不要加空格、不要加引号) +# 通义千问 / OpenAI / 本地 vLLM 等任何 OpenAI 兼容端点同理,改 BASE_URL 与模型名即可。 +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.deepseek.com +LLM_MODEL=deepseek-chat +# 复杂/规划任务使用的强模型(可选,缺省与 LLM_MODEL 相同) +LLM_STRONG_MODEL=deepseek-chat + +# X-Agent 部署验证(batteries/fastapi_server 读取) +YAI_PROJECT_SLUG=yai-agent-core + +# 持久化记忆(可选;不设置则用进程内内存,重启即失) +# YAI_DB_PATH=data/yai.db # 容器内路径;Render 免费层磁盘为临时存储,重启/redeploy 会丢 + +# 历史保留策略(可选;不设置则永不裁剪,行为与旧版一致) +# YAI_HISTORY_MAX_MESSAGES=200 # 保留最近 N 条消息(按轮对齐、整轮删除,至少留最后一轮) +# YAI_HISTORY_TTL_SECONDS=604800 # 历史存活秒数(7 天);过期历史在写入与启动时清理,全部过期则清空 + +# 评审期限流(可选;默认开启,防公网误刷与失控循环) +# YAI_RATE_LIMIT_ENABLED=1 # 0/false 关闭(本地开发可关) +# YAI_RATE_LIMIT_PER_MINUTE=30 # 每 IP 每窗口允许的 POST /v1/agent/run 次数 +# YAI_RATE_LIMIT_WINDOW_SECONDS=60 + +# 外部 MCP Server(可选;不设置时 serve_example 只提供本地 native 工具, +# host_d_mcp 示例则回落到自带的本地 stdio 演示 Server) +# Streamable HTTP 形态(线上默认接公共免鉴权的 DeepWiki): +# MCP_SERVER_URL=https://mcp.deepwiki.com/mcp +# stdio 子进程形态(二选一): +# MCP_SERVER_COMMAND=python examples/host_d_mcp/demo_mcp_server.py + +# OpenAPI 发现(可选;不设置则不挂载任何 REST API) +# OPENAPI_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json # 在线描述(JSON/YAML) +# OPENAPI_SPEC_PATH= # 本地描述文件路径,与 URL 二选一 +# OPENAPI_AUTH_TOKEN= # bearer / apiKey 共用的令牌 +# OPENAPI_BASE_URL= # 覆盖 spec servers[0].url +# OPENAPI_READ_ONLY=1 # 1/true/yes 时只注册 GET/HEAD +# YAI_GIT_COMMIT 保持注释状态: +# - 本地直接跑 uvicorn 时,留空会自动读取当前 git HEAD; +# - Docker 部署时由构建参数 --build-arg YAI_GIT_COMMIT=<40位commit> 固化; +# 不要在 .env 里写 YAI_GIT_COMMIT=(空值会在运行时覆盖镜像内固化的 commit)。 +# YAI_GIT_COMMIT= diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.gitattributes b/submissions/mcp-hackathon/yai-agent-core/source/.gitattributes new file mode 100644 index 0000000..0258f15 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.gitattributes @@ -0,0 +1,11 @@ +# 默认让 Git 以 LF 归一化文本文件,工作区按平台检出 +* text=auto eol=lf + +# 二进制产物不做换行符与文本转换 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.pdf binary +*.docx binary +*.zip binary diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/ci.yml b/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/ci.yml new file mode 100644 index 0000000..0d675f9 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: lint & test (${{ matrix.os }} / py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + env: + # 用矩阵解释器覆盖仓库里的 .python-version(3.13),保证 uv sync/run 不擅自换版本 + UV_PYTHON: ${{ matrix.python-version }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.11", "3.12", "3.13"] + include: + - os: windows-latest + python-version: "3.13" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install project (dev + llm + server + mcp + openapi extras) + run: uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi + + - name: Ruff lint + run: uv run ruff check src tests examples scripts + + - name: Pytest + run: uv run pytest diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/keepwarm.yml b/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/keepwarm.yml new file mode 100644 index 0000000..de8d165 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.github/workflows/keepwarm.yml @@ -0,0 +1,20 @@ +# 评审期保活:X-Agent 自动门槛对 GET /health 的超时是 10 秒,而 Render 免费层 +# 15 分钟无流量会休眠(冷启动 30–90 秒)。评审窗口(2026-09-20 ~ 10-04)内每 10 分钟 +# 轻量唤醒一次;只打 GET /health(返回体 <200B,不触达模型、不花额度、不受限流影响)。 +# 评审结束后(10-04 之后)请手动停用本工作流(GitHub Actions 页 disable 即可)。 +name: keepwarm + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +jobs: + ping-health: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Wake review instance via GET /health + run: | + curl -sS --fail --max-time 90 https://yai-agent-core.onrender.com/health diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.gitignore b/submissions/mcp-hackathon/yai-agent-core/source/.gitignore new file mode 100644 index 0000000..9f94ab7 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ + +# Env / secrets +.env +*.local + +# 本地 SQLite 记忆库与 WAL 旁车文件(含用户对话,绝不入库) +data/ +*.db +*.db-wal +*.db-shm + +# 本地 AI 协作者约定,只留本机、不推远端 +AGENTS.md + +# Test / tool caches +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Editor / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# 由 scripts/build_walkthrough_docx.py 生成的讲义产物(需要时本地构建) +docs/exports/ + +# 杭州参赛材料截图(含个人信息,永不入库;目录由用户录材料时本地创建) +docs/competitions/hangzhou/evidence/ diff --git a/submissions/mcp-hackathon/yai-agent-core/source/.python-version b/submissions/mcp-hackathon/yai-agent-core/source/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/CONTRIBUTING.md b/submissions/mcp-hackathon/yai-agent-core/source/CONTRIBUTING.md new file mode 100644 index 0000000..9b4b16c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# 参与贡献 YAI Agent Core + +感谢你对 YAI Agent Core 的兴趣。本项目是一个**进程内嵌入式、自适应的 Agent 内核**:宿主软件只声明普通业务函数,Core 自动把函数变成工具、自适应选择执行策略并把全过程以事件流"喊"出来。在动手之前,请先读 [README.md](README.md) 的定位章节——我们不做又一个需要开发者自己拼装的 Agent 框架。 + +本文档同时是维护者本人的标准操作手册:环境怎么装、代码往哪放、提交前必须过哪些检查。 + +## 1. 开发环境 + +要求 Python 3.11+(开发与 CI 以 3.13 为准),包管理使用 [uv](https://docs.astral.sh/uv/)。 + +```bash +# 1. 创建并激活虚拟环境 +uv venv +.venv\Scripts\activate # Windows PowerShell;Linux/macOS 用 source .venv/bin/activate + +# 2. 安装:可编辑模式 + 开发/真实模型/在线API 可选依赖(接入 MCP/OpenAPI 再加对应 extra) +uv pip install -e ".[dev,llm,server]" +uv pip install -e ".[mcp]" # 仅在开发/运行 MCP Client 集成时需要 +uv pip install -e ".[openapi]" # 仅在开发/运行 OpenAPI 发现时需要(httpx + pyyaml) +# 一次装齐(全量 125 项测试):uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi + +# 3. 验证:离线测试与冒烟,全程不需要 API Key(激活 venv 后可直接用 pytest;未激活用 uv run pytest) +uv run pytest # 125 passed,离线(extras 组合与 CI 一致时无 skip) +uv run python scripts/smoke_test.py +``` + +需要跑通真实模型(DeepSeek 等 OpenAI 兼容端点)时,复制 `.env.example` 为 `.env` 并填入 Key: + +```bash +cp .env.example .env # Windows: copy .env.example .env +``` + +`.env` 已在 `.gitignore` 中,**永远不要把 API Key 提交进仓库**。 + +## 2. 架构红线(改动前必读) + +1. **内核本体零第三方硬依赖**。`pyproject.toml` 的 `dependencies` 必须保持为空;openai、fastapi、mcp 等只能出现在可选依赖(`llm` / `server` / `mcp`)里,并在对应模块内**懒加载**(`integrations/mcp/` 顶层禁止 import mcp,有 AST 测试守这条线)。 +2. **一切外部能力走 SPI 契约**(`src/yai_core/spi/`):模型、通道、记忆、权限四个插槽必须可替换,Core 只依赖 `Protocol`,不依赖具体实现。 +3. **事件流只有一条出口**:工具执行等过程事件由执行方收集、统一由 `AgentLoop` yield、`AgentCore.astream` 是唯一对外 emit 点,不允许出现第二条事件路径。 +4. **每个自适应决策必须可观测**:新增任何"Core 自己做决定"的分支,都要通过 `AgentEvent` 发出对应事件,不允许静默决策。 +5. **v0.1 不做**:MCP Server、Multi-Agent、自进化写工具、向量记忆、内置 UI、coding agent。相关讨论先进 Issue。 + +## 3. 代码规范 + +- Linter / import 排序:[Ruff](https://docs.astral.sh/ruff/),规则集见 `pyproject.toml`(`E,F,I,UP,B`),行宽 100。 +- 提交前必须本地通过: + + ```bash + ruff check src tests examples scripts + pytest + ``` + +- 类型注解为硬性要求:公开 API 必须带参数与返回值注解;数据结构优先用 `@dataclass`,枚举用 `StrEnum`。 +- 每个模块开头写模块 docstring,说明它在全景图中的位置;面向学习者,复杂逻辑块配行内注释。 +- **改了源码就要同步改逐行讲解**:`docs/walkthrough/` 与代码逐块对应,行为变化时同 PR 更新对应篇目。 + +## 4. 测试约定 + +- 测试**不允许依赖网络、API Key 或外部服务**。真实协议用假模块/假端点验证,参考 `tests/test_openai_compat.py`(注入假 `openai` 模块)、`tests/test_loop.py`(ScriptedModel)与 `tests/test_mcp_bridge.py`(内存 MCPServer 实例直连,不起子进程、不触网;文件首行 `pytest.importorskip` 保证最小环境可跳过)。 +- 新功能必须带测试:新策略、新工具来源、新 SPI 实现至少各一条端到端用例。 +- 修 Bug 先写一个能复现该 Bug 的失败测试,再修代码让它通过。 + +## 5. 常见开发任务怎么做 + +| 想做的事 | 动哪里 | 参考 | +|---|---|---| +| 让一个新软件被 Core 适配 | 新建 `examples/host_x/`,只写普通函数 + run 脚本 | `examples/host_a_notes/` | +| 支持一家新模型厂商 | 实现 `ModelProvider` 契约(OpenAI 兼容优先复用现有 provider) | `src/yai_core/llm/`、`spi/model.py` | +| 增加一种工具来源(MCP/OpenAPI) | 产出统一 `ToolSpec` 注册进 `ToolRegistry`,执行侧不改;MCP 范式见 `integrations/mcp/` | `integrations/mcp/client.py`、`discovery/introspect.py` | +| 增加一种执行策略 | 先在 `AdaptiveRouter` 加确定性规则并补测试,LLM 分类是 v0.2 的事 | `kernel/router.py` | +| 换记忆/权限/输入输出 | 实现对应 SPI,通过 `AgentCore(..., memory=/policy=/channel=)` 注入 | `src/yai_core/spi/` | + +## 6. 提交与 PR 流程 + +- 分支命名:`feat/xxx`、`fix/xxx`、`docs/xxx`、`test/xxx`、`chore/xxx`。 +- Commit message 使用 Conventional Commits:`feat: host_a 接入 DeepSeek 真实工具调用`、`fix: provider 直接消费 Context 产出的线格式消息`、`docs: 补 06 loop 逐行讲解`。 +- 一个 PR 只解决一件事;保持小步提交,不要把重构和功能混在一起。 +- PR 描述需说明:改了什么、为什么、怎么验证的(贴命令与结果)、是否改动了公开 API 或 walkthrough。 +- 合并前 checklist:`ruff check` 通过、`pytest` 全绿、新行为有测试、文档同步、无 Key/隐私泄露。 + +## 7. Issue 与讨论 + +- Bug 报告请附:系统与 Python 版本、复现命令、期望与实际结果、完整报错堆栈;模型相关问题注明厂商、模型名、是否离线测试可通过。 +- 功能建议先描述**宿主场景与痛点**(哪个软件、原本要写什么 Agent 代码),再谈方案;与"嵌入式自适应内核"定位不符的需求可能会被建议放到 Battery 或独立仓库。 + +## 8. 许可证 + +本项目采用 MIT 许可证,提交 PR 即表示你同意你的贡献以 MIT 许可发布。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/Dockerfile b/submissions/mcp-hackathon/yai-agent-core/source/Dockerfile new file mode 100644 index 0000000..7345487 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/Dockerfile @@ -0,0 +1,46 @@ +# YAI Agent Core —— 在线 API 示例镜像(X-Agent 部署形态) +# 内核零硬依赖;镜像装 server + llm + mcp + openapi 四组可选依赖。 +# 构建:docker build -t yai-agent-core . +# 运行:docker run --rm -p 8000:8000 --env-file .env yai-agent-core + +FROM python:3.13-slim + +# 复用官方 uv 二进制 +COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ + +WORKDIR /app + +# 直接装进系统环境,省去激活 venv +ENV UV_PROJECT_ENVIRONMENT=/usr/local \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 + +# 构建时固化 pinned commit(X-Agent 硬门槛:部署版本与提交哈希一致) +# docker build --build-arg YAI_GIT_COMMIT=$(git rev-parse HEAD) -t yai-agent-core . +ARG YAI_GIT_COMMIT=dev +ENV YAI_GIT_COMMIT=${YAI_GIT_COMMIT} + +# PyPI 源可在构建时覆盖(网络受限环境传国内镜像,默认官方源保持中立) +# 例:--build-arg INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +ARG INDEX_URL=https://pypi.org/simple +ENV UV_INDEX_URL=${INDEX_URL} + +# 先拷依赖清单以利用层缓存;README 是 pyproject 的 readme 字段所需 +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src +# 在线示例依赖 examples(宿主能力)与 scripts(启动模块) +COPY examples ./examples +COPY scripts ./scripts + +RUN uv pip install --system -e ".[server,llm,mcp,openapi]" + +# 默认 8000;托管平台(如 Render)通过 PORT 环境变量指定端口,CMD/HEALTHCHECK 均兼容 +EXPOSE 8000 + +# 容器级健康检查,直接打 X-Agent 要求的 /health 端点(shell 形式,运行时展开 $PORT) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import os,urllib.request,sys; p=os.environ.get('PORT','8000'); sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{p}/health', timeout=4).status == 200 else 1)" + +# shell 形式让 ${PORT:-8000} 在启动时展开:自管环境 8000,Render 等平台用注入的 PORT +CMD uvicorn scripts.serve_example:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/submissions/mcp-hackathon/yai-agent-core/source/LICENSE b/submissions/mcp-hackathon/yai-agent-core/source/LICENSE new file mode 100644 index 0000000..a822b4b --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gi-Tuu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/submissions/mcp-hackathon/yai-agent-core/source/README.md b/submissions/mcp-hackathon/yai-agent-core/source/README.md new file mode 100644 index 0000000..9dd0be6 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/README.md @@ -0,0 +1,131 @@ +# YAI Agent Core + +> 进程内嵌入式、自适应的 Agent 内核(Embeddable Self-Adaptive Agent Kernel)。 +> 宿主软件只声明"我有什么能力",Core 自动发现能力、自适应选择策略并完成任务——**宿主不写一行 Agent Loop / Planner / 工具选择代码**。 + +## 定位:不是又一个 Agent 框架 + +| 形态 | 数据库类比 | Agent 世界 | 你要做什么 | +|---|---|---|---| +| 平台/SaaS | 云数据库控制台 | Dify、Coze | 注册账号在平台里搭 | +| 框架/SDK | ORM | LangGraph、CrewAI、OpenAI/Claude Agents SDK | 自己定义 Agent、工具、编排 | +| **嵌入式内核(本项目)** | **SQLite** | YAI Agent Core | **把现有软件接进来,能力自动长出来** | + +## 30 秒快速开始 + +```bash +uv venv +uv pip install -e ".[dev,llm,server]" +uv run python scripts/smoke_test.py # 离线冒烟:同一 Core 自适应三个不同宿主 +uv run pytest # 单元 + 端到端测试(不需要 API Key) +# 全量 125 项测试:建议一次装齐 extras(与 CI 一致) +# uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +``` + +**第一次读代码**:[`docs/reading-guide.md`](docs/reading-guide.md) 是学习路线;[`docs/walkthrough/00-index.md`](docs/walkthrough/00-index.md) 是每个源码文件的逐行讲解。 + +宿主接入只有三步: + +```python +from yai_core import AgentCore +import my_app_capabilities as cap # 宿主自己的普通业务函数 + +core = AgentCore.auto(cap, model) # ① 内省宿主能力,自动注册工具 +result = await core.run("搜索本周记录并整理成报告") # ② 自适应:direct/react/plan/clarify +print(result.final_text) # ③ 可交付结果 + 全程事件可观测 +``` + +## 自适应机制(当前能力) + +1. **能力自发现**:Python 函数 type hints + docstring 自动生成 JSON Schema 工具规格;外部 MCP Server 的工具经 MCP Client 同构接入注册表(v0.2 已落地,`[mcp]` 可选依赖) +2. **接入任意 REST API(OpenAPI 发现,可选)**:给一个 OpenAPI 3 描述(URL/文件/dict),自动把 operations 注册为工具($ref 内联、path/query/body 入参合并、bearer/apiKey 鉴权、只读模式),`[openapi]` extra 懒加载 +3. **策略自适应**:Adaptive Router 将任务路由到 `direct / react / plan / clarify`;规则实现零成本可测,v0.2 已加 LLM 分类器(输出 `{strategy, reason, tier}`,异常/超时/非法输出自动回退规则,决策来源随事件流可审计) +4. **模型自适应**:标准任务/规划任务可路由到不同模型(tier: standard/strong),失败可回退;OpenAI 兼容(DeepSeek、通义千问等) +5. **宿主自适应(SPI)**:Model / Channel / Memory / Policy 四个契约宿主可替换,Core 提供零配置默认实现 +6. **过程可观测**:每次策略选择、工具调用、权限确认都通过 Observer 事件流对外发出 + +## 目录结构 + +``` +src/yai_core/ +├── core.py # AgentCore 门面(auto / run / astream) +├── types.py # ToolSpec / ChatMessage / AgentEvent / Strategy +├── spi/ # 宿主可替换契约:model / channel / memory / policy +├── discovery/ # 能力自发现(函数内省;v0.2 OpenAPI/MCP) +├── tools/ # ToolRegistry + ToolExecutor(Tool Bus) +├── kernel/ # AdaptiveRouter + AgentLoop + Context +├── llm/ # OpenAI 兼容模型后端(可选依赖) +├── memory/ policy/ channels/ # 默认实现(内存记忆 + SQLite 持久化 opt-in / 白名单权限 / CLI·收集通道) +├── integrations/ +│ ├── mcp/ # MCP Client 桥接(可选 [mcp] 依赖,懒加载,v0.2) +│ └── openapi/ # OpenAPI 3 发现 → 工具(可选 [openapi] 依赖,懒加载,v0.2) +└── batteries/ + └── fastapi_server/ # 在线 API + /health + X-Agent 验证端点 +examples/ +├── host_a_notes/ # 宿主 A:笔记应用(只有业务函数,零 Agent 代码) +├── host_b_data/ # 宿主 B:销售数据应用(同一 Core 零修改适配) +├── host_c_companion/ # 宿主 C:AI 陪伴应用(AMBRACE 回流形态预演) +└── host_d_mcp/ # 宿主 D:接入外部 MCP Server 工具(自带 stdio 演示 Server) +tests/ # 离线 ScriptedModel 端到端测试 +docs/ # 架构设计、代码学习导览、三个比赛的提交清单 +``` + +## 接入外部 MCP 工具(v0.2) + +```bash +uv pip install -e ".[mcp]" # 或 uv sync --extra mcp +uv run python examples/host_d_mcp/run.py # 自带本地 stdio 演示 Server,离线可跑 + +# 改接任意公共 MCP Server(HTTP 形态),无需改代码: +$env:MCP_SERVER_URL="https://mcp.deepwiki.com/mcp" # PowerShell +uv run python examples/host_d_mcp/run.py "用 MCP 工具问一下 modelcontextprotocol/python-sdk:Client 怎么初始化?" +``` + +在线 API 同样靠环境变量挂载外部 MCP(`scripts/serve_example.py` 的 lifespan +启动挂载、关闭断开、失败降级为仅本地工具);线上实例默认挂公共免鉴权的 DeepWiki, +评审可直接 POST 任务让服务真实调用远程 MCP 工具。 + +```python +from yai_core.integrations.mcp import McpServerConfig, attach_mcp_tools + +# Streamable HTTP:McpServerConfig(alias="x", url="https://host/mcp") +# stdio 子进程: McpServerConfig(alias="x", command="uv", args=["run","server.py"]) +bridge = await attach_mcp_tools(core.registry, McpServerConfig(alias="demo", url=url)) +# 远端工具已作为 ToolSpec(source="mcp") 注册,Router/Loop/Executor 零感知 +await bridge.aclose() +``` + +## 在线 API(X-Agent 部署要求) + +```bash +uvicorn 启动示例见 docs/architecture.md +GET /health # {"status":"ok","commit":"..."} +GET /.well-known/xagent-verification.json # {"schemaVersion":1,"slug":...,"commit":...} +GET /v1/tools # 当前宿主自动发现的工具清单 +POST /v1/agent/run # {"task": "..."} -> 事件流 + 最终结果 +``` + +部署相关环境变量(完整清单见 `.env.example`): + +```bash +# 持久化记忆(可选):设置后落 SQLite,重启不丢 +YAI_DB_PATH=data/yai.db +# 历史保留策略(可选):条数上限 / TTL 秒数,按轮对齐裁剪 +YAI_HISTORY_MAX_MESSAGES=200 +YAI_HISTORY_TTL_SECONDS=604800 +# 评审期限流(默认开启):每 IP 每窗口 30 次 POST;0 关闭 +YAI_RATE_LIMIT_ENABLED=1 +YAI_RATE_LIMIT_PER_MINUTE=30 +YAI_RATE_LIMIT_WINDOW_SECONDS=60 +``` + +## 版本路线 + +- **v0.1(已完成)**:函数内省、规则路由、Agent Loop、SPI 默认实现、FastAPI Battery、三宿主 demo、容器化与 PaaS 部署 +- **v0.2(已完成)**:MCP Client、LLM 路由器(规则兜底)、SQLite 持久化记忆(opt-in,`YAI_DB_PATH`)、OpenAPI 发现(opt-in,`OPENAPI_SPEC_URL/PATH`) +- **v0.3(部分完成)**:历史保留策略(条数裁剪/TTL,opt-in,轮边界对齐,已落地)、限流 Battery(已落地);检查点与失败恢复、Flutter Channel(后续) +- v1.0:作为 AMBRACE 的 Agent 内核回流嵌入 + +## 许可证 + +MIT diff --git a/submissions/mcp-hackathon/yai-agent-core/source/deploy/Caddyfile b/submissions/mcp-hackathon/yai-agent-core/source/deploy/Caddyfile new file mode 100644 index 0000000..af5e2e9 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/deploy/Caddyfile @@ -0,0 +1,13 @@ +# Caddy 反代配置:自动向 Let's Encrypt 申请并续期 HTTPS 证书 +# 用法见 docs/competitions/deployment.md +# 把下面的域名换成你自己的域名 / DuckDNS 免费子域名; +# Caddy 与 app 在同一个 compose 网络里,直接用服务名:容器端口访问。 + +:80 { + # 占位:没有域名、只做 HTTP 连通性演练时用,正式部署请改成下面的域名形式 + reverse_proxy yai-agent-core:8000 +} + +# yai-agent-core.duckdns.org { +# reverse_proxy yai-agent-core:8000 +# } diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.deploy.yml b/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.deploy.yml new file mode 100644 index 0000000..5d02d05 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.deploy.yml @@ -0,0 +1,23 @@ +# 正式部署叠加文件:在基础 compose 之上增加 Caddy 反代(自动 HTTPS) +# 启动: +# YAI_GIT_COMMIT=$(git rev-parse HEAD) \ +# docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d --build +# 前置:deploy/Caddyfile 里的域名已解析到本机公网 IP,安全组/防火墙放行 80、443 +services: + caddy: + image: caddy:2 + container_name: yai-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - yai-agent-core + +volumes: + caddy-data: + caddy-config: diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.yml b/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.yml new file mode 100644 index 0000000..acae6d6 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docker-compose.yml @@ -0,0 +1,20 @@ +# 本地/服务器一键起服务:docker compose up --build +# 环境变量从 .env 读取(OPENAI_API_KEY / OPENAI_BASE_URL / LLM_MODEL / YAI_PROJECT_SLUG) +services: + yai-agent-core: + build: + context: . + args: + YAI_GIT_COMMIT: "${YAI_GIT_COMMIT:-dev}" + INDEX_URL: "${INDEX_URL:-https://pypi.org/simple}" + image: yai-agent-core:latest + ports: + # 只绑回环:公网由 Caddy(docker-compose.deploy.yml)反代,不直接暴露容器端口 + # 本地直连调试仍可用 http://127.0.0.1:8001 + - "127.0.0.1:8001:8000" + env_file: + - .env + environment: + # 留空时容器内自动读不到 git,会回退 dev;生产部署显式传 40 位 commit + YAI_GIT_COMMIT: "${YAI_GIT_COMMIT:-}" + restart: unless-stopped diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/architecture.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/architecture.md new file mode 100644 index 0000000..cf1863d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/architecture.md @@ -0,0 +1,128 @@ +# YAI Agent Core 架构设计 + +## 1. 设计原则 + +1. **Library-first(进程内库)**:`pip install` 后运行在宿主进程内,不独立起服务、不要求云端。 +2. **Host-declares-capability**:宿主只声明能力(普通函数/OpenAPI/MCP),Core 负责一切 Agent 逻辑。 +3. **内核最小**:判断标准——"是不是任何宿主都需要?"否则进 batteries/ 或 adapter。 +4. **零硬依赖**:内核只用标准库;openai / fastapi 为可选依赖、懒加载。 +5. **自适应必须可解释**:每个决策产出 Observer 事件,禁止黑盒。 +6. **模型/通道/存储无关**:通过 SPI 契约解耦,Core 给零配置默认实现。 + +## 2. 分层 + +``` +宿主应用(AMBRACE / host_a / host_b) 只声明能力,零 Agent 代码 + │ 内省 / 注册 +能力自发现 Auto-Discovery(函数 / OpenAPI[v0.3] / MCP Client[v0.2 已落地]) + ▼ +YAI Kernel + ├─ Adaptive Router 任务分类 → direct/react/plan/clarify + 模型 tier + ├─ Agent Loop 按策略执行(工具循环、计划拆解、澄清重入) + ├─ Context 消息组装与 token 预算 + └─ Tool Bus 权限 → 执行(sync/async)→ 结构化结果 + ▲ +SPI 契约环(可替换 + 默认实现) + ModelProvider(OpenAI 兼容) / Channel(CLI·FastAPI·Flutter) + MemoryStore(内存→SQLite→向量) / PermissionPolicy(auto/ask/deny) + ▲ +Integrations(可选,懒加载):MCP Client(integrations/mcp/,[mcp] extra) +Batteries(可选):FastAPI Server、SQLite Memory +``` + +### 2.1 MCP Client 集成(v0.2 已落地) + +``` +MCP Server(HTTP / stdio 子进程 / 内存实例) + │ list_tools / call_tool(MCP Python SDK v2,可选依赖、懒加载) + ▼ +McpToolBridge(integrations/mcp/client.py) + ├─ sanitize_schema 清洗 SDK 生成 schema 里的私有键 + ├─ _to_spec 每个远端工具 → ToolSpec(source="mcp"),handler 为异步闭包 + └─ _flatten_call_result content 文本 / structured_content / is_error → 返回值或异常 + ▼ +ToolRegistry(与 Native 工具同构,Router/Loop/Executor 零感知) +``` +- 内核本体不 import mcp(AST 测试守红线);`uv sync --extra mcp` 才启用。 +- 连接三态:`McpServerConfig.url`(Streamable HTTP)/ `command+args`(stdio 子进程)/ + `server`(内存实例,离线测试用);`prefix` 解决多 Server 工具重名。 +- 生命周期:`async with McpToolBridge(...)` 或 `connect()/aclose()` 手动管理; + stdio 形态必须 aclose,否则子进程成为孤儿。 + +### 2.2 持久化记忆(SQLite,v0.2 已落地) + +`memory/sqlite_store.py` 提供 `SqliteStore`:MemoryStore 契约的 SQLite 实现(标准库、 +零新依赖)。history/kv 两张表,`scope` 列做会话隔离,`PRAGMA user_version` 做迁移钩子, +WAL + 单锁覆盖单进程并发;`history()` 保持契约的同步签名。装配走 `YAI_DB_PATH` +(`SqliteStore.from_env()`),未设置时回退 InMemoryStore,线上默认不开启(免费 PaaS 临时盘)。 +Loop/Core/宿主示例零改动。边界(历史裁剪、多进程写、临时盘持久性)见 walkthrough 第 11 章 H 节。 +历史保留策略(v0.3,opt-in):`memory/retention.py` 提供纯函数 count_cut/ttl_cut, +两种 Store 以构造参数 max_messages / ttl_seconds / clock 对等支持;裁剪边界按"轮" +对齐(一条 user 到下一条 user 之前为一轮),保证 assistant(tool_calls)/tool 配对 +不被拆散;SQLite 构造时自动 prune 一次覆盖重启场景。MemoryStore 契约与 schema +版本(仍为 v1)不变;KV 的 TTL 需 schema v2 迁移,留待 v0.4。`Context._compact` +同步改为轮边界丢弃(kernel 内同源私有实现)。 + +### 2.3 OpenAPI 发现(v0.2 已落地) + +`integrations/openapi/` 把任意 OpenAPI 3 描述自动翻译成 ToolSpec(source="openapi")(alias 仅用于日志与 notes): +spec.py 负责载入/版本闸/内部 $ref 内联(循环与外部引用保留并记 notes),discovery.py +纯函数地把 operations 规划成工具(operationId/合成命名、path/query/body 入参合并、 +include/exclude、read_only 只留 GET/HEAD、max_operations=40 截断),client.py 用懒加载的 +httpx 执行(bearer/apiKey 鉴权令牌走环境变量、调用期校验、204/非2xx/JSON/文本截断归一)。 +httpx/pyyaml 在 [openapi] extra,内核本体零硬依赖;测试全部走 httpx.MockTransport 离线。 +与 MCP 桥同构,在 serve_example 的 app_lifespan 中顺序挂载、共用关闭列表。 + +## 3. SPI 契约 + +| 契约 | 方法 | 默认实现 | 宿主何时替换 | +|---|---|---|---| +| ModelProvider | `achat(messages, tools, tier)` | OpenAICompatProvider(DeepSeek 等) | 接私有模型/本地模型 | +| Channel | `emit / ask / confirm` | CollectChannel、CliChannel | Web SSE、Flutter UI | +| MemoryStore | `history / put / get / clear` | InMemoryStore | SQLite、向量记忆 | +| PermissionPolicy | `check(tool, args) → allow/deny/ask` | AllowlistPolicy | 高危工具人工确认 | + +## 4. Adaptive Router 状态机 + +``` +任务文本 + 可用工具集 + ├─ 无工具 → direct(模型直接答) + ├─ 模糊短句 → clarify(Channel.ask 后重入) + ├─ 含行动词 + 多步骤信号 → plan(先拆解,再 react) + └─ 含行动词 → react(工具循环) +``` + +v0.2(已落地):LLM 一次性分类输出 `{strategy, reason, tier}`,异常/超时/非法输出回退规则路由; +两条路径统一返回 RouteDecision,`strategy_selected` 事件带 `source=llm|rules` 可审计。 + +## 5. 启动在线 API(batteries) + +```python +import os, uvicorn +from yai_core import AgentCore, OpenAICompatProvider +from yai_core.batteries.fastapi_server import create_app +import my_app_capabilities as cap + +core = AgentCore.auto(cap, OpenAICompatProvider()) +app = create_app(core) +# 环境变量:YAI_GIT_COMMIT=<40位 commit>、YAI_PROJECT_SLUG= +# 容器内监听 8000;宿主机端口由部署侧映射(本项目约定 8001:8000) +# uvicorn module:app --host 0.0.0.0 --port 8000 +``` + +v0.3 评审期限流(默认开启):`rate_limit.py` 提供每 IP 滑动窗口(`enabled=True`、 +`per_minute=30`、`window_seconds=60`,可用 `YAI_RATE_LIMIT_*` 调整或关闭)。中间件只拦 +`POST /v1/agent/run`,超限返回 429 + `Retry-After`(与响应体同源),被限请求不触达 +模型;三个 GET 验证端点永不限流(评审硬门槛)。客户端身份取 XFF 最左非空、回退对端 +地址;状态只在内存,重启清零;全局限流兜底不做(单实例)。 + +## 6. 与生态的差异化(诚实对比,用于比赛材料) + +- Claude/OpenAI Agents SDK、LangGraph:developer-builds-agent,工具/编排需显式定义,且绑定自家生态; +- agentic-kernel(TS):嵌入式运行时理念相近,但仅 TypeScript 且无能力自发现/策略自适应; +- MCP:工具互操作**协议**,不是运行时;YAI 是 MCP 的消费者(v0.2 Client)而非竞争者; +- YAI:host-declares-capability、Python 进程内、模型/通道/存储全无关、自适应过程可观测。 + +## 7. 明确不做(v0.1 红线) + +MCP Server、Multi-Agent 编排、自进化写工具、向量长期记忆、内置 UI、coding agent 场景、队列/中间件、K8s。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/deployment.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/deployment.md new file mode 100644 index 0000000..263cbb9 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/deployment.md @@ -0,0 +1,147 @@ +# 公网部署手册(X-Agent 硬门槛落地指南) + +> 最后核实:2026-09-10。免费政策变化快,动手前点文末来源链接复核。 +> 目标:让 `https://<你的域名>/health` 等四个端点公网可访问,且返回的 commit 与提交 PR 里的 pinned commit 完全一致。 + +## 0. 要满足的硬门槛(对应 X-Agent 7 项硬门槛中的部署部分) + +| 门槛 | 本项目落地方式 | +|---|---| +| 已部署、公网可调用的 API | Docker Compose 常驻 VPS,Caddy 反代 443 | +| `GET /health` 返回 status + 40 位 commit | Battery 已实现;commit 构建时由 `--build-arg YAI_GIT_COMMIT` 固化 | +| `/.well-known/xagent-verification.json` 同源同 commit | Battery 已实现,与 /health 读同一个环境变量 | +| pinned commit | 部署哪个 commit,PR 的 `source/` 就放哪个 commit 的源码 | +| 可复现说明 | 本手册 + README 的两条 compose 命令 | + +## 1. 免费方案选型(2026-09 官方页面核实) + +| 方案 | 成本 | 是否常驻 | HTTPS 与域名 | 结论 | +|---|---|---|---|---| +| **Oracle Cloud Always Free** | 永久免费:2 台 AMD micro(1/8 OCPU、1GB)+ ARM Ampere A1(总量最多 4 OCPU/24GB,可开 1-2 台)、200GB 块存储、约 10TB/月出站流量 | 常驻不休眠 | 自备域名(免费 DuckDNS 即可),Caddy 自动签证书 | **正式部署首选** | +| Google Cloud e2-micro | 永久免费 1 台(限定美国 3 个区,1GB) | 常驻 | 同上 | 备选,注册同样要卡 | +| Render Free Web Service | 免费 | **15 分钟无流量休眠,冷启动约 30–90 秒(波动大)**;750 实例小时/月 | 自带 `*.onrender.com` 证书 | **今天做演练用**,评审期有被休眠打到的风险 | +| Fly.io | 无免费层,shared-cpu-1x 256MB 约 $3.14/月起 | 常驻 | 自带 `*.fly.dev` 证书 | 不想折腾 Oracle 时的便宜替代 | +| Koyeb | 免费实例已取消(官网只剩付费计划) | — | — | 排除 | +| Hugging Face Spaces | Docker/Gradio Space 创建已需付费计划;免费硬件会休眠 | 休眠 | 自带 | 排除 | +| 阿里云/腾讯云学生机 | 约 ¥10/月级 | 常驻 | **大陆机房域名走 443 需 ICP 备案(赶不上 09-19/10-04)**;海外地域免备案但学生价通常限大陆地域 | 不推荐用于本次比赛 | + +**建议路线**:今天先用 **Render 免费层 30 分钟拿到公网 HTTPS 地址**跑通全流程;正式提交前迁到 **Oracle Always Free + Caddy** 常驻。我们的服务只是转发到 DeepSeek,本机不跑模型,AMD 1GB 小机器都够用。 + +## 2. 路线 A:Render 免费层快速演练(零成本、约 30 分钟) + +仓库根目录已放好 `render.yaml`(Render Blueprint),平台名、端口、健康检查、非敏感变量都已声明, +只需在网页上连接仓库并粘贴一次 Key: + +1. 打开 https://render.com → 用 GitHub 账号登录并授权访问 `Gi-Tuu/yai-agent-core`。 +2. 控制台 **New → Blueprint**,选择 `yai-agent-core` 仓库,Render 会读取 `render.yaml` + 自动生成一个 Free 的 Docker Web Service(区域 Singapore,健康检查路径 `/health`)。 +3. Blueprint 向导会提示填写标记为 `sync: false` 的 Secret:`OPENAI_API_KEY` = DeepSeek Key,粘贴即可。 + 其余变量(`OPENAI_BASE_URL`、`LLM_MODEL`、`YAI_PROJECT_SLUG` 等)已在 yaml 里写好。 +4. 点 **Apply / Deploy**,云端开始构建(海外环境用官方 PyPI,不需要 `INDEX_URL`)。 +5. 两个自动化约定(已在代码里处理,无需手动操作): + - **端口**:Render 注入 `PORT`(默认 10000),Dockerfile 的 CMD 是 + `--port ${PORT:-8000}`,本地/compose 仍是 8000,上了 Render 自动跟随; + - **commit**:Render 自动注入 `RENDER_GIT_COMMIT`(本次部署的完整 SHA), + `serve_example.py` 的解析链 `YAI_GIT_COMMIT → RENDER_GIT_COMMIT → git HEAD → dev` + 会自动采用它,/health 与验证端点返回的 commit 天然等于部署的 commit。 + 注意解析时只接受完整 40 位小写哈希——镜像里烤的默认值 `dev` 不是合法哈希会被跳过 + (首部署实测:若不跳过,非空的 `dev` 会遮蔽平台注入的真实 SHA)。 +6. 部署完成后得到 `https://yai-agent-core.onrender.com`(名字以实际为准),按第 4 节验证四个端点。 +7. 风险:15 分钟无流量会休眠,评审机第一次访问可能撞上约 30–90 秒冷启动页(不是我们的 JSON)。 + **正式评审前必须换到常驻 VPS。** + +> 若不用 Blueprint、手动 New → Web Service:Language 选 Docker、Dockerfile Path 留空(根目录)、 +> Plan 选 Free,然后在 Environment 里补齐上面同样的变量即可,效果相同。 + +## 3. 路线 B:Oracle Always Free + Docker + Caddy(正式部署) + +### 3.1 注册与开机器 +1. 在 https://www.oracle.com/cloud/free/ 注册(需一张信用卡/借记卡做验证,不扣款;一人一号,信息真实)。 +2. Home Region **一旦选定不可更改**,建议 Tokyo / Seoul / Singapore(离大陆近、PyPI 与 DeepSeek 都通)。 +3. Create Instance: + - Image:Ubuntu 24.04 + - Shape:优先 `VM.Standard.A1.Flex`(ARM,给 1 OCPU/6GB 即可,抢不到容量就用 `VM.Standard.E2.1.Micro`,AMD 1GB 也能跑) + - 保存 SSH 私钥(页面可自动生成并下载) +4. VCN 安全列表(Security List)放行入站:tcp 80、tcp 443;tcp 22 建议只放行你自己的出口 IP。 + +### 3.2 装 Docker(SSH 上去后) +```bash +sudo apt update +sudo apt install -y docker.io docker-compose-v2 git +sudo usermod -aG docker ubuntu # 重新登录后免 sudo +``` + +### 3.3 拉代码、配环境、起服务 +```bash +git clone https://github.com/Gi-Tuu/yai-agent-core.git +cd yai-agent-core +cp .env.example .env +nano .env # 填 OPENAI_API_KEY、YAI_PROJECT_SLUG;YAI_GIT_COMMIT 保持注释 +nano deploy/Caddyfile # 把域名换成你的(见 3.4) + +git rev-parse HEAD # 记下这 40 位 commit,X-Agent 提交 PR 时用同一个 +YAI_GIT_COMMIT=$(git rev-parse HEAD) \ + docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d --build +``` +说明: +- 基础 compose 只把容器端口绑在 `127.0.0.1:8001`(本机调试用),公网只能经 Caddy 进入; +- deploy 叠加文件启动 Caddy,占用 80/443,首次请求时自动向 Let's Encrypt 申请证书并自动续期; +- ARM 机器无需改 Dockerfile,`python:3.13-slim` 有 arm64 镜像; +- 海外 VPS 构建用官方 PyPI 即可;若网络抽风,构建时加 `INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple`。 + +### 3.4 免费域名(DuckDNS,不用买域名) +1. 打开 https://www.duckdns.org/ ,用 GitHub/Google 登录; +2. 新建子域名(如 `yai-agent-core`),把 A 记录填成 VPS 公网 IP; +3. 把 `deploy/Caddyfile` 改成: + ``` + yai-agent-core.duckdns.org { + reverse_proxy yai-agent-core:8000 + } + ``` +4. `docker compose -f docker-compose.yml -f docker-compose.deploy.yml restart caddy`, + 首次 HTTPS 访问时 Caddy 自动签好证书(`docker compose logs caddy` 可看到证书申请记录)。 + +### 3.5 更新版本(比赛迭代时反复用) +```bash +git pull +YAI_GIT_COMMIT=$(git rev-parse HEAD) \ + docker compose -f docker-compose.yml -f docker-compose.deploy.yml up -d --build +``` +重新构建后 /health 的 commit 自动变成新版本——**每次重新部署,X-Agent PR 里的 pinned commit 也要同步更新**。 + +## 4. 部署后验证清单(在自己电脑上跑) + +```bash +BASE=https://<你的域名> +curl $BASE/health # 期望 {"status":"ok","commit":"<40位>"} +curl $BASE/.well-known/xagent-verification.json # 期望 schemaVersion/slug/commit,commit 与上面一致 +curl $BASE/v1/tools # 期望工具清单 +curl -X POST $BASE/v1/agent/run -H "Content-Type: application/json" \ + -d '{"task":"列出我的全部笔记"}' # 期望 strategy/events/final_text +docker compose ps # caddy 与 app 均 healthy +``` +四项全过 = X-Agent 部署类硬门槛达标。 + +- [x] `YAI_DB_PATH`:Render Blueprint 已默认开启(`data/yai.db` → 容器内 `/app/data`,启动时自动建目录);本地/容器不设置该变量时仍是进程内内存。 + **Render 免费层是临时盘,redeploy 后会丢库**:重建后历史清空属预期行为,对外演示口径需如实说明;VPS 形态请把该路径放在挂载卷上。 + +## 5. 安全与运维清单 + +- `.env` 只存在于服务器,不进 git、不进镜像(`.dockerignore` 已排除); +- 安全组只开 22/80/443,容器端口不直接对公网开放(已绑回环); +- Caddy 证书数据在 `caddy-data` 卷里,重启不丢、自动续期; +- 模型调用全部出站到 DeepSeek 云端,VPS 不跑模型,CPU/内存压力很小; +- 若海外 VPS 访问 `api.deepseek.com` 异常,先 `curl https://api.deepseek.com` 排查,必要时换 Oracle 区域(待首次部署实测)。 + +## 6. 参考来源(官方) + +- Oracle Cloud Free Tier:https://www.oracle.com/cloud/free/ +- Render Free 限制(休眠/750 小时):https://render.com/docs/free +- Render Docker 部署(PORT/构建参数):https://render.com/docs/docker +- Render 注入的环境变量(RENDER_GIT_COMMIT):https://render.com/docs/environment-variables +- Render Blueprint(render.yaml):https://render.com/docs/blueprint-spec +- Fly.io 定价(无免费层):https://fly.io/docs/about/pricing/ +- Koyeb 定价(免费实例已取消):https://www.koyeb.com/pricing +- Hugging Face Spaces(Docker Space 需付费):https://huggingface.co/docs/hub/spaces-overview +- Caddy 文档(自动 HTTPS):https://caddyserver.com/docs/ +- DuckDNS 免费域名:https://www.duckdns.org/ diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou.md new file mode 100644 index 0000000..27749da --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou.md @@ -0,0 +1,24 @@ +# AI杭州·码动未来 2026 — AI+超级智能体赛道(高校组)提交清单 + +- 官网:https://aichallenge.msup.com.cn/ | 赛题:/tracks +- 时间:初赛材料与组队报名 **09.19 同步关闭**(具体时刻以报名系统为准);评审 09.20–09.26;100 强 09.30;决赛路演 10.31 + +## 初赛评分(5×20) + +| 维度 | YAI 对应 | +|---|---| +| 技术可行性 20 | 离线测试 + 在线 API 双验证 | +| 市场可行性 20 | "任何软件零改造获得数字员工"的通用性故事 | +| 综合创新性 20 | host-declares-capability 范式反转 + 自适应路由 | +| AI 大模型结合 20 | 方案文档写清分类/路由/工具循环全流程 | +| 赛道维度 20 | 任务理解与自主规划=Router;工具调用=Discovery+ToolBus;可验收交付=Channel 输出;端到端闭环=双宿主 demo | + +## 材料(详见 docs/competitions/hangzhou/提交清单.md,含 5 个待确认问题) + +- 创意/方案文档(含大模型环节细节) +- 可运行原型(Core + 一个办公场景宿主) +- 演示视频 ≤5 分钟(import 前 vs 后;双宿主自适应) +- 代码 + 说明文档 +- 三份落地文档:docs/competitions/hangzhou/{方案文档,演示脚本,提交清单}.md +- 决赛(若入围):高校组 5 分钟路演 + 2 分钟答辩;**每组别前 3 名晋级,初赛成绩清零**;决赛维度为 技术可行性/市场可行性/综合创新性/项目完成度/团队潜力 +- 备注:模型加分与魔搭算力支持**待确认**(已在提交清单 §5 跟踪,确认前不作为既成事实) diff --git "a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\212\245\345\220\215\346\226\207\346\241\210.md" "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\212\245\345\220\215\346\226\207\346\241\210.md" new file mode 100644 index 0000000..18be41b --- /dev/null +++ "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\212\245\345\220\215\346\226\207\346\241\210.md" @@ -0,0 +1,81 @@ +# AI杭州初赛 · 报名系统填写文案包(复制即用) + +> v1.0,2026-09-14,豆包整理。所有内容只写仓库/线上可复现的事实;**真实姓名、学校、证件、联系方式只填报名系统,不写进任何公开材料**。 +> 使用方法:登录报名系统后,按实际字段长度选版本粘贴;系统字段与本文件不一致时,以系统为准并截图留痕到 `evidence/`。 +> 公开署名口径统一:队名 **YAI**(单人队),GitHub: **Gi-Tuu**。 + +## 1. 作品名称 + +- 主用:**YAI 数字员工——让现有软件零改造获得自适应 Agent 能力** +- 若系统限 20 字内:**YAI 数字员工:可内嵌的自适应 Agent 内核** +- 内核/仓库名(技术字段用):`yai-agent-core`(YAI Agent Core) + +## 2. 一句话简介(三版,按字段限额选) + +- 30 字版:YAI 是可内嵌进现有软件的自适应 Agent 内核,宿主声明能力,内核自动完成理解、规划与工具调用。 +- 50 字版:YAI Agent Core 是进程内、零硬依赖的智能体内核:软件只需声明普通函数,内核即自适应完成意图路由、规划、工具调用(含 MCP)与结果交付,import 即得数字员工。 +- 80 字版:不同于又一个独立 Agent 应用,YAI 是可内嵌进现有软件的 Agent 内核(Library-first)。宿主只声明"我有什么能力"(普通 Python 函数 + 一行描述),内核在进程内自适应完成意图理解、策略路由、任务规划、本地/远程工具调用(含 MCP/OpenAPI)、记忆与可审计的结果交付,已在线部署并通过 125 项离线测试。 + +## 3. 作品介绍 / 项目概述 + +### 3.1 短版(约 300 字,适合"作品简介"类字段) + +YAI 数字员工解决一个被多数 Agent 产品忽略的问题:软件厂商想让自己的现有软件拥有 Agent 能力,仍然要自己接模型、写工具总线、写 Agent 循环、写记忆、做适配,门槛高且重复造轮子。YAI 反转范式——宿主软件只负责"声明能力"(普通业务函数加类型注解与 docstring),YAI 内核在进程内自适应完成其余全部:LLM 路由在 direct/react/plan/clarify 四种策略间选择(模型不可用时规则兜底,全程事件可审计),ReAct 循环经 JSON Schema 校验与权限白名单后调用本地函数、MCP Server 远程工具或 OpenAPI 服务工具,结果回灌后由模型收尾交付;记忆层支持 SQLite 持久化与条数/TTL 保留策略。内核本体零第三方运行时硬依赖,模型经 OpenAI 兼容协议可替换(当前演示用 DeepSeek)。作品已 Docker 部署上线、具备免登录在线 API,仓库含 4 个不同行业形态的宿主示例证明"同一内核换宿主即换岗位",并配 125 项离线测试与 CI 四版本矩阵。 + +### 3.2 长版(约 800 字,适合"项目详细介绍/创意描述"字段;含大模型环节,满足评分硬要求) + +**痛点与创意。** 当下 Agent 领域的多数成果是独立应用或编排框架,软件厂商想让"自己已有的软件"获得 Agent 能力,仍要自行集成模型、工具总线、执行循环、记忆与权限体系。YAI 数字员工(内核名 YAI Agent Core)给出另一条路径:做一颗可内嵌的自适应内核,宿主只声明能力,内核适配宿主,而非宿主改造自己去适配框架。接入成本为一行 `AgentCore.auto(capabilities, model)`,capabilities 就是普通 Python 函数。 + +**大模型环节全链路(4 个调用点,全程可审计)。** ① 路由分类:每个任务先经一次轻量 LLM 分类,输出策略(direct 直接回答 / react 工具循环 / plan 先规划后执行 / clarify 反问澄清)、模型档位与中文理由;模型异常或输出非法时自动回退确定性规则路由,决策来源随事件流出。② 计划生成:仅 plan 策略触发,模型把任务拆为 2–5 个有序步骤并发 `plan_created` 事件。③ ReAct 推理与工具选择:模型在上下文中选择工具并产出参数,内核做 JSON Schema 校验、allowlist 权限检查后执行;工具来源三类同构——宿主本地函数、MCP Server 远程工具(官方 SDK,支持 Streamable HTTP/stdio/进程内三传输,在线实例已真实接入 DeepWiki)、OpenAPI spec 自动桥接工具。④ 收尾汇总:模型基于完整工具观察链产出可交付的最终结果。所有自适应决策经唯一事件出口 `astream` 以类型化事件流式发出,无静默决策。 + +**工程与落地。** 内核本体零第三方运行时硬依赖(模型客户端、FastAPI、MCP、HTTP 全部为可选 extra);四个 SPI 契约(model/channel/memory/policy)支持逐项替换;SQLite 记忆带 scope 隔离、schema 版本钩子与条数/TTL 保留策略;在线服务提供 /health(实时返回 commit)、/v1/tools、POST /v1/agent/run,并实现每 IP 滑动窗口限流。仓库含 4 个宿主示例(办公笔记、销售数据、AI 陪伴、本地 MCP 演示),同一内核不改一行即切换岗位;125 项测试全部离线(假模型/MockTransport/注入时钟,不依赖网络与密钥),GitHub Actions 在 Ubuntu 3.11/3.12/3.13 与 Windows 3.13 四矩阵跑 ruff + pytest。 + +**演进路线。** 第一阶段作为独立开源内核(本作品);第二阶段回流内嵌进开源 AI 陪伴项目 AMBRACE,验证真实 To C 软件内嵌路径;第三阶段经 MCP 标准化接入更广生态(已同步投稿 X-Agent MCP Hackathon)。多智能体、向量记忆、工具自生成等在公开路线图中,初赛版本不做宣称。 + +## 4. 创新点(分条,适合"项目亮点/创新点"字段,4 条) + +1. **范式创新:host-declares-capability / core-adapts。** 现有框架止步于提供积木,YAI 让已有软件以零 Agent 代码获得能力,定位是"可内嵌内核"而非又一个 Agent 应用;宿主接入只需一行 auto。 +2. **自适应路由可审计、可降级。** LLM 一次性输出策略/档位/理由,每个决策落类型化事件流;模型不可用时规则路由兜底,保证离线、断网、超额场景下的确定性退化。 +3. **三源工具同构 + 双协议桥接。** 本地函数、MCP Server、OpenAPI 服务在同一注册表/同一权限链/同一事件流中无差别运行,远程 MCP 工具已在线真实跑通。 +4. **内核零硬依赖与 SPI 四契约。** 模型、通道、记忆、权限全部面向契约编程,模型厂商可替换(DeepSeek/通义/OpenAI 仅改环境变量),可进程内嵌、可服务化部署。 + +## 5. 技术方案 / 架构说明(若系统有独立技术字段) + +- 分层:宿主声明 capabilities → discovery/introspect 内省为统一 ToolSpec → AdaptiveRouter(LLM 路由 + 规则兜底)→ AgentLoop(direct/react/plan/clarify)→ ToolExecutor(Schema 校验 → allowlist 权限 → 执行/回灌)→ 通道交付(CLI / collect / FastAPI)。 +- 大模型:OpenAI 兼容抽象层,当前演示使用 DeepSeek deepseek-chat,切换厂商仅改环境变量;路由用 standard 档、规划用 strong 档,成本与时延可控。 +- 记忆:内存态 + SQLite 持久化(opt-in),条数上限与 TTL 按对话轮边界裁剪,不拆散工具调用对。 +- 质量:125 项离线测试、ruff、CI 四矩阵、Dockerfile + render.yaml 一键部署、限流与保活工作流。 +- 诚实边界(如系统要求"不足与展望"字段直接用):未做多智能体协作、向量检索/RAG、自进化写工具与内置 UI;在线实例为免费 PaaS(15 分钟休眠、冷启动约 30–90 秒且波动大、临时盘重建清空);限流不防御伪造 X-Forwarded-For;无账号鉴权(评审免登录调用,鉴权在路线图)。 + +## 6. 团队介绍(单人队,两版,均不含真名) + +- 短版(约 80 字):YAI 为单人参赛队,队员即作者本人,GitHub ID:Gi-Tuu,独立完成内核设计、开发、测试、部署与全部参赛材料。具备 Python 后端与开源工程实践,正在同步建设 AI 陪伴开源项目 AMBRACE,YAI 内核计划回流内嵌。 +- 长版(约 200 字,若系统有"团队优势/团队潜力"字段):YAI 是单人战队,覆盖产品定义、架构设计、Python 工程化、CI/CD 与云部署全链路,独立完成本作品从 0 到在线运行的全部工作:内核采用零硬依赖的库形态设计,配套 125 项离线测试、四矩阵 CI、Docker/PaaS 部署与 15 篇源码逐行讲义(面向学习者的开源文档)。作者同时是开源 AI 陪伴项目 AMBRACE(github.com/Gi-Tuu/AMBRACE)的维护者,YAI 的架构需求来自该真实项目的内嵌场景,赛后将作为 Agent 能力底座回流,形成"比赛验证—开源沉淀—产品内嵌"的持续迭代闭环。 + +## 7. 链接与地址类字段 + +- 源码仓库:https://github.com/Gi-Tuu/yai-agent-core +- 在线原型(Base URL):https://yai-agent-core.onrender.com +- 在线验证端点:https://yai-agent-core.onrender.com/health (实时返回冻结 commit) +- 工具清单端点:https://yai-agent-core.onrender.com/v1/tools +- 能力调用端点:POST https://yai-agent-core.onrender.com/v1/agent/run ,请求体 `{"task":"..."}`(免登录、免密钥) +- 关联开源项目:https://github.com/Gi-Tuu/AMBRACE (AI 陪伴宿主,回流目标) +- 冻结 commit:0a2634f3b13d690398c77d9119c3beecd4881929 +- 演示数据声明:全部为虚构笔记/虚构陪伴档案,无真实个人数据。 + +## 8. 关键词 / 标签(按系统限额挑 3–6 个) + +智能体内核、Agent、工具调用、MCP、自适应路由、任务规划、数字员工、开源、Library-first、大模型应用、ReAct、记忆 + +## 9. 模型与算力相关字段(待核实口径,见提交清单 §5 第 4 问) + +- 标准答复:作品使用 DeepSeek deepseek-chat(OpenAI 兼容接口),模型层为厂商可替换抽象,不绑定单一模型;未使用专项算力券。 +- 背景线索(**需在报名系统/选手群核实是否适用本届后再提**):msup 活动页(event.msup.com.cn/hangzhou,时间线与本届官网不一致,疑似往届页)有"使用推荐平台通义千问、DeepSeek 有额外加分""算力由魔搭社区免费提供"的表述。若本届适用,我们已在用 DeepSeek,属于自然命中,无需改造;是否需要在报名时勾选/声明以系统为准。 + +## 10. 填写纪律清单(提交前逐项打勾) + +- [ ] 公开字段(作品介绍、团队介绍、视频、文档)无真名/学校/联系方式 +- [ ] 每个字段填完先截图再点下一页(存 evidence/,该目录不入库) +- [ ] 链接类字段填完用无痕窗口复验可打开 +- [ ] 截止时刻、单人资格、格式限额、模型加分四个问题在系统里找到答案并回填《提交清单.md》§5 +- [ ] 若系统要求 PDF/Word 版方案文档,按《提交清单.md》§4 导出(Mermaid 先在 GitHub 渲染截图嵌入) diff --git "a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\217\220\344\272\244\346\270\205\345\215\225.md" "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\217\220\344\272\244\346\270\205\345\215\225.md" new file mode 100644 index 0000000..d2a57c7 --- /dev/null +++ "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\217\220\344\272\244\346\270\205\345\215\225.md" @@ -0,0 +1,65 @@ +# AI杭州初赛提交清单(用户执行手册) + +> v0.3,2026-09-14:三个演示动作全部实测通过(在线笔记统计、本地 host_c 陪伴 plan 全链路、在线 MCP 提问);新增《报名文案.md》复制即用文案包;host_c 段改本地运行(在线服务只挂 host_a)。报名系统字段必须由用户登录后核实,豆包不代填、不代注册。 +> 关键日期:初赛材料 + 组队报名 **09-19 同步关闭**(当天只做应急,主力 09-18 前完成);评审 09.20–09.26;100 强 09-30;决赛路演 10-31(高校组 5 分钟路演 + 2 分钟答辩,每组别前 3 名晋级)。 + +## 1. 账号与报名(只能用户本人做) + +- [ ] 登录官网 https://aichallenge.msup.com.cn/ ,完成注册/实名认证(如系统要求) +- [ ] 选择赛道:**AI+超级智能体**;组别:**高校组**(若系统无组别选项则按实际页面填写并截图记录) +- [ ] 创建队伍:队名 **YAI**,单人;填写本人真实信息(姓名/学校/联系方式,以系统字段为准)——**真名只进报名系统**;公开仓库与对外材料统一只署队名 YAI 与 GitHub: Gi-Tuu(官网赛题页无材料署名/匿名要求,2026-09-13 核实) +- [ ] 记录报名系统显示的**截止具体时刻**(官网首页只写日期 09.19,未写时刻),回填到本文件 §5 +- [ ] 确认系统是否允许**单人参赛**(高校组);若不允许,立即在选手群反馈并调整 +- [ ] 确认模型相关要求:是否指定/建议通义、DeepSeek 等模型,是否有加分或算力券(魔搭),回填 §5 + +## 2. 初赛材料四件套(系统上传字段以页面为准) + +| # | 材料 | 本地产物 | 状态 | +|---|---|---|---| +| 1 | 创意/方案文档(含大模型环节流程细节) | `docs/competitions/hangzhou/方案文档.md`(v0.3;待 Codex 终审与 zcode 入库;若系统只收 PDF/Word,导出步骤见 §4) | 已定稿待入库 | +| 1b | 报名字段文案(名称/简介/介绍/创新点/团队) | `docs/competitions/hangzhou/报名文案.md`(v1.0,复制即用,多字数版本) | 已就绪 | +| 2 | 可运行原型 | 在线 API https://yai-agent-core.onrender.com (冻结 0a2634f)+ GitHub https://github.com/Gi-Tuu/yai-agent-core | 已就绪,09-14 复测两端点正常 | +| 3 | 演示视频 ≤5 分钟 | 按《演示脚本.md》v0.3 于 09-18 录制,一次录三版;三个演示动作 09-14 均实测通过 | 脚本定稿待录 | +| 4 | 代码 + 说明文档 | README、`docs/walkthrough/` 00–14 逐行讲义、`docs/architecture.md`、CONTRIBUTING | 已就绪(随仓库公开) | + +## 3. 上传与截图留痕(每步截图存本地 `docs/competitions/hangzhou/evidence/`,该目录不入库) + +> 忽略纪律(zcode 入库时一并落实):在 `.gitignore` 增加一行 `docs/competitions/hangzhou/evidence/`;提交前执行 `git status` 确认该目录未出现在暂存区。截图含报名系统个人信息,禁止推到公开仓库。 + +- [ ] 报名/组队成功页截图 +- [ ] 作品信息填写页截图(每个字段填完先截图再提交) +- [ ] 方案文档上传成功截图 +- [ ] 视频上传成功截图(记录系统是否转码、是否有封面) +- [ ] 仓库链接/在线地址填写截图 +- [ ] 最终提交确认页截图(含提交时间、作品编号,如有) +- [ ] 提交后用另一浏览器/无痕窗口复验:链接可打开、视频可播放 + +## 4. 方案文档导出(如系统要求 PDF/Word) + +- 仓库已有讲义构建链:`.\.venv\Scripts\python.exe scripts\build_walkthrough_docx.py`(docx 产物在 gitignore 的 `docs/exports/`); +- PDF:先启动 LibreOffice headless 监听 2002,再用 LibreOffice 自带 python 跑 `scripts/lo_update_and_export.py `(venv python 无 uno 模块); +- 方案文档是单文件 md,导出前先把 Mermaid 图在 GitHub 渲染后截图嵌入,或保留文字版全链路描述(§2.2 表格本身已完整描述流程,图失效不影响合规)。 + +## 5. 待确认问题(用户在系统/选手群核实后回填,禁止编造) + +1. [ ] 09-19 截止的**具体时刻**(系统页/公告原文):__________ +2. [ ] 高校组**单人是否允许**(系统提示/群内组委会答复):__________ +3. [ ] 初赛材料**确切上传字段与格式**(md/pdf/docx/视频大小上限/是否要封面图):__________ +4. [ ] 模型使用是否有指定、加分或算力支持:__________(线索:msup 活动页 event.msup.com.cn/hangzhou 称"使用通义千问、DeepSeek 有额外加分、算力由魔搭社区提供",但该页时间线与本届官网不一致、疑似往届页,须在系统/选手群核实本届口径;我们已在用 DeepSeek,若适用属自然命中) +5. [ ] 决赛"团队潜力"维度单人如何呈现(路演 FAQ 若有说明):__________ + +## 6. 时间安排(与三赛总时间线对齐) + +| 日期 | 动作 | 责任 | +|---|---|---| +| 09-13 ~ 09-14 | 方案文档/演示脚本 v0.2→v0.3(Codex 审核意见已改完;09-14 豆包实测三个演示动作并出报名文案包) | 豆包 ✔ 待终审 | +| 09-15 ~ 09-16 | Codex 终审(随 X 批交接单 §B);zcode 落实文档入库与 hangzhou.md 勘误 | Codex/zcode | +| 09-17 | 文档定稿(如需 PDF 当天导出);用户完成 §1 账号报名、回填 §5 | 用户/豆包 | +| 09-18 | 录屏(一次三版,按 v0.3 脚本,host_c 段本地跑);系统先上传占坑,再补传定稿 | 用户 | +| 09-19 | 截止日只做应急(补传/替换/联系组委会) | 用户 | + +## 7. 与 X-Agent 冻结的冲突防护(重要) + +- 杭州材料入库只动 `docs/competitions/`,**不触发 Render 部署**(Auto-Deploy 已关,线上锁死 0a2634f); +- 09-19 前若 X-Agent PR 需要更新,优先处理 X-Agent(要重建快照 + 手动 redeploy + 改 reviewCommit),杭州文档提交与其错开; +- 录屏用的线上地址、commit 必须与 X-Agent PR 的 reviewCommit 一致(当前均为 0a2634f)。 diff --git "a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\226\271\346\241\210\346\226\207\346\241\243.md" "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\226\271\346\241\210\346\226\207\346\241\243.md" new file mode 100644 index 0000000..f981d9b --- /dev/null +++ "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\226\271\346\241\210\346\226\207\346\241\243.md" @@ -0,0 +1,189 @@ +# YAI 数字员工——让现有软件零改造获得自适应 Agent 能力 + +> 2026 AI杭州·码动未来 AI模型智能体创新大赛|AI+超级智能体赛道|高校组 +> 初赛创意/方案文档(v0.3,2026-09-14:v0.2 已按 Codex `40-review-hangzhou-entry.md` 完成 P1×3 + P2×5 修订;v0.3 将 §7 评审复现命令改为动作型措辞,规则/LLM 两条路由路径下均稳定命中工具调用) +> 写作纪律:本文只写仓库里可复现的事实(命令、数字、commit、线上响应),不写无法现场演示的能力。 + +## 0. 作品基本信息 + +| 项 | 内容 | +|---|---| +| 作品名 | YAI 数字员工(内核名 YAI Agent Core,发行包 `yai-agent-core`) | +| 赛道 | AI+超级智能体 | +| 组队 | 单人队,队名 **YAI**(GitHub: [Gi-Tuu](https://github.com/Gi-Tuu));参赛者真实姓名/学校等身份信息仅填写在报名系统,公开材料与代码仓不署真名 | +| 源码仓库 | https://github.com/Gi-Tuu/yai-agent-core | +| 在线原型 | https://yai-agent-core.onrender.com (免费 PaaS,Docker 部署) | +| 冻结 commit | `0a2634f3b13d690398c77d9119c3beecd4881929`(线上 /health 实时返回该值) | +| 模型 | DeepSeek `deepseek-chat`(OpenAI 兼容接口;模型可替换,内核不绑定厂商) | +| 主演示场景 | `examples/host_a_notes` 笔记/数据整理(办公提效) | +| 泛化佐证场景 | `examples/host_c_companion` 陪伴助手(同一内核换宿主即换岗位) | + +## 1. 痛点与创意(一句话) + +市面上的 Agent 产品大多是"又一个独立 Agent 应用/编排框架":软件厂商想让自己的软件拥有 Agent 能力,仍然要自己接模型、写工具总线、写循环、写记忆、做适配。**YAI 反转了这个范式:宿主软件只负责"声明自己有什么能力"(普通业务函数 + 一行描述),YAI 内核在进程内自适应地完成意图理解、策略选择、规划、工具调用与结果交付**——宿主不写任何 Agent 代码,import 即得数字员工。 + +```python +# 宿主侧全部接入成本(examples/host_a_notes/run.py 同构): +core = AgentCore.auto(capabilities, model) # capabilities 就是普通 Python 函数的列表 +result = await core.run("帮我统计一下笔记数量") # 或 async for event in core.astream(...) 拿事件流 +``` + +内核本体(`src/yai_core/`)**零第三方运行时硬依赖**(`pyproject.toml` 的 `dependencies = []`);模型客户端、FastAPI 服务、MCP/OpenAPI 集成为可选 extra,按需安装。 + +## 2. 大模型环节全链路(评分硬要求:必须写清使用大模型的流程细节) + +### 2.1 全链路图(Mermaid,GitHub 直接渲染) + +```mermaid +flowchart TD + U[用户任务文本] --> D[能力自发现 introspect
宿主声明的函数 → 统一 ToolSpec] + D --> R{① LLM 路由器
一次性分类} + R -->|direct| A1[直接回答
模型自由生成] + R -->|react| A2[③ ReAct 循环] + R -->|plan| P[② Plan 模式:模型先拆步骤计划] + R -->|clarify| C[反问澄清] + R -.模型不可用/格式异常.-> RF[规则兜底路由
带工具即 react,可审计] + P --> A2 + A2 -->|模型输出 tool_calls| T[工具执行器 ToolExecutor
schema 校验 → allowlist 权限 → 执行] + T -->|native| TN[宿主本地函数] + T -->|mcp| TM[MCP Server 远程工具
如 DeepWiki,Streamable HTTP] + T -->|openapi| TO[OpenAPI 服务工具
spec 自动发现] + TN --> O[工具结果 tool_result 回灌上下文] + TM --> O + TO --> O + O -->|未完成| A2 + O -->|已完成| F[④ 模型收尾汇总 → 结构化结果 final_text] + A1 --> F + C --> F + F --> CH[通道交付:CLI / collect / FastAPI POST /v1/agent/run] + M[(SQLite / 内存记忆
条数+TTL 保留策略)] -.读写.-> A2 +``` + +### 2.2 每一次模型调用的职责(共 4 个可能的调用点) + +| # | 调用点 | 输入 | 模型产出 | 失败处理 | 对应代码 | +|---|---|---|---|---|---| +| ① | **路由分类**(每个任务恰好 1 次) | 用户任务 + 全部工具的 name/description | `strategy`(direct/react/plan/clarify)、`tier`、`reason` | 模型异常或 JSON 不合规时回退**规则路由**(有工具→react,无工具→direct),事件里 `source=rules` 可审计 | `kernel/router.py` | +| ② | **计划生成**(仅 plan 策略,1 次) | 任务 + 工具清单 | 有序步骤计划,发 `plan_created` 事件 | 计划为空则降级进 ReAct | `kernel/loop.py` | +| ③ | **ReAct 推理/工具选择**(0 到 N 轮) | 历史消息 + 工具结果 | 自然语言推理或 `tool_calls`(工具名+参数) | 参数经 JSON Schema 校验,非法即报错事件,不执行 | `kernel/loop.py`、`tools/executor.py`、`tools/schema.py` | +| ④ | **收尾汇总**(1 次) | 完整工具观察链 | 面向用户的最终答复 `final_text` | —— | `kernel/loop.py` | + +### 2.3 决策全程可审计:事件流实证 + +内核的每个自适应决策都通过**唯一事件出口** `AgentCore.astream` 以 `AgentEvent(type, data)` 流式发出,无静默决策。真实事件类型(`src/yai_core/types.py`): + +```json +[ + {"type": "strategy_selected", "data": {"strategy": "react", "source": "llm", "reason": "任务需要查询笔记", "tier": "standard"}}, + {"type": "tool_call", "data": {"tool": "count_notes", "arguments": {}}}, + {"type": "tool_result", "data": {"tool": "count_notes", "ok": true, "preview": "3"}}, + {"type": "model_message","data": {"text": "你一共有 3 条笔记。"}}, + {"type": "done", "data": {"strategy": "react", "final_text": "你一共有 3 条笔记。"}} +] +``` + +字段口径(与源码逐行一致):`tool_call` 为 `{tool, arguments}`;`tool_result` 为 `{tool, ok, preview|error}`,`preview` 是结果 JSON 文本的前 200 字符;`model_message` 为 `{text}`;`done` 为 `{strategy, final_text}`。 + +线上实测(2026-09-13,真实 DeepSeek):原生工具任务"统计笔记数"十秒量级返回"3 条笔记",事件链完整;MCP 任务(问 DeepWiki 上 python-sdk 的 Client 初始化)二十秒量级完成一次真实远程 MCP 工具调用并给出正确中文结论(时延含网络与免费实例冷启动波动,仅作参考)。 + +## 3. 五维评分逐项举证(各 20 分) + +### 3.1 技术可行性(20)——已运行,不是设想 + +- **在线可验收**:`GET https://yai-agent-core.onrender.com/health` 实时返回 `{"status":"ok","commit":"0a2634f…"}`;`GET /v1/tools` 列出当前工具;`POST /v1/agent/run`(请求体 `{"task": "..."}`)为真实能力端点。 +- **测试**:**125 项离线测试全绿**(假模型 / httpx MockTransport / 注入时钟,不依赖网络与 API Key),覆盖路由、循环、上下文压缩、记忆、保留策略、MCP 桥、OpenAPI 桥、限流、API。 +- **CI**:GitHub Actions 四矩阵(Ubuntu Python 3.11/3.12/3.13 + Windows 3.13)跑 ruff + pytest,最近一次 run 成功。 +- **可复现**:`uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi` → `uv run pytest`(125 项全绿,命令与 CI 一致)→ `uv run python scripts/smoke_test.py`(一个内核冒烟跑通三个宿主);`docker compose up --build` 容器化(容器内 8000);PaaS 部署件为 `render.yaml` + `Dockerfile`。 +- **生产化细节**:POST 端点每 IP 滑动窗口限流(默认 30 次/分钟,超限返回 429 + Retry-After,GET 验证端点永久放行,不触达模型不花额度);历史保留策略(默认 200 条 / 7 天 TTL,按对话轮边界裁剪,不会拆散工具调用对)。 + +### 3.2 市场可行性(20)——泛用性与落地路径 + +- **泛用性实证**:仓库内置 4 个宿主示例——办公笔记(host_a,3 个本地工具)、数据应用(host_b)、AI 陪伴(host_c,5 个工具:档案/回忆/记忆/心情/关怀建议)、本地 MCP 演示(host_d)。**同一份内核,不改一行内核代码,换 capabilities 即换岗位**。 +- **接入成本低**:宿主工具就是普通 Python 函数,类型注解 + docstring 自动转 JSON Schema(与 MCP 工具同构),无需学习新框架概念。 +- **落地路线明确**:第一阶段作为独立开源内核(本作品);第二阶段回流内嵌进开源 AI 陪伴项目 AMBRACE(https://github.com/Gi-Tuu/AMBRACE),验证真实 To C 软件内嵌路径;第三阶段经 MCP 标准化接入更大生态(X-Agent MCP Hackathon 已同步投稿,PR #56)。 +- **商业模式友好**:Library-first、进程内运行,不强制托管、不抽成、不锁定模型厂商,软件厂商保留自己的数据与 UI。 + +### 3.3 综合创新性(20) + +1. **范式创新——host-declares-capability / core-adapts**:现有框架(LangChain/CrewAI/AutoGen 等)止步于"提供积木",仍需开发者自己组装 Agent;YAI 让**已有软件零 Agent 代码**获得能力,定位是"可内嵌的内核"而非"又一个 Agent 应用"。 +2. **自适应路由可审计**:LLM 一次性输出策略决策(strategy/tier/reason),每个决策落事件流;模型不可用时有规则兜底,保证确定性退化。 +3. **双协议工具发现**:同一工具注册表面对三类来源——native 函数、MCP Server(官方 python-sdk,支持 Streamable HTTP / stdio / 进程内直连三种传输)、OpenAPI spec(最小规范解析 → 工具自动桥接,测试用 MockTransport 离线验证)。 +4. **内核零硬依赖 + SPI 四契约**(model/channel/memory/policy):模型、通道、记忆、权限全部面向契约编程,宿主可逐项替换。 + +### 3.4 AI 大模型结合(20)——见 §2 全链路 + +补充事实:① 模型层为 OpenAI 兼容抽象(`llm/openai_compat.py`),DeepSeek/通义/OpenAI 仅改环境变量;② 上下文管理按轮边界压缩(`kernel/context.py`),避免长对话超窗且不切断工具调用对;③ 鼓励项"长短期记忆"已落地:SQLite 持久化(带 scope 隔离与 schema 版本钩子)+ 条数/TTL 保留;④ 鼓励项"工具调用"为核心能力,MCP 远程工具已在线真实跑通;⑤ 鼓励项"工作流编排"对应 plan 策略的显式计划事件。多智能体、多模态在路线图中(见 §6),初赛版本不宣称。 + +### 3.5 赛道维度(20)——任务理解/规划/工具/交付/闭环 + +| 赛道评分点 | 作品对应 | +|---|---| +| 模糊意图解析与任务拆解 | LLM 路由器 + plan 模式;意图不清走 clarify 反问 | +| 自主规划 | `plan_created` 事件给出有序步骤,再逐步执行 | +| 工具调用与环境交互(区别于问答机器人的关键) | Discovery 自发现 → ToolRegistry → Schema 校验 → Allowlist 权限 → Executor;native/MCP/OpenAPI 三源 | +| 结果交付与可验收性 | 结构化 RunResult(strategy/events/final_text);FastAPI 上 POST 即得 JSON 事件序列;CLI/collect 双通道 | +| 场景价值与端到端闭环 | host_a"整理/统计笔记"全流程录屏演示;线上 curl 可现场复验 | +| 长短期记忆(鼓励项) | SQLite 持久化 + 200 条/7 天保留策略 | +| 检索增强 RAG(鼓励项) | OpenAPI/MCP 工具即外部知识入口(DeepWiki 远程查询实证);向量检索在路线图,不冒称已具备 | + +## 4. 演示场景设计(与演示视频脚本一致,详见《演示脚本.md》) + +- **主场景(host_a 办公提效)**:3 条虚构笔记 + `list_notes(tag)` / `search_notes(keyword)` / `count_notes()` 三个工具,演示"统计笔记数 → 按关键词检索 → 汇总"端到端闭环,画面带事件流。 +- **换岗位(host_c)**:同一内核切到陪伴宿主,工具集与策略随之变化,证明泛化。 +- **能力纵深**:MCP 远程工具(DeepWiki)一次真实调用;SQLite 记忆持久化说明。 +- 全部演示数据为虚构,无真实个人数据。 + +## 5. 技术架构(简版) + +```text +宿主软件(host_a / host_c / 任意 Python 软件 / AMBRACE) + │ 声明 capabilities(普通函数)+ 选择 model/channel/memory/policy + ▼ +YAI Agent Core(进程内库,零第三方硬依赖) + ├── discovery/introspect 能力自发现(函数 → ToolSpec) + ├── kernel/router ① LLM 路由(规则兜底,可审计) + ├── kernel/context 轮边界压缩 + ├── kernel/loop ②③④ 计划/ReAct/收尾,事件流唯一出口 + ├── tools/ registry / executor / schema 校验 + ├── policy/allowlist 工具权限收口 + ├── memory/ inmemory + sqlite_store + retention(条数/TTL) + ├── spi/ model / channel / memory / policy 四契约 + ├── integrations/mcp MCP Client(HTTP/stdio/内存) + ├── integrations/openapi OpenAPI spec 解析与工具桥接 + └── batteries/fastapi_server /health /验证端点 /v1/tools /v1/agent/run + 限流中间件 +``` + +## 6. 诚实边界(可现场说明,不回避) + +- v0.1 明确**未做**:MCP Server、多智能体协作、自进化写工具、向量记忆/RAG、内置 UI、coding agent;这些在公开路线图中,初赛材料不宣称。 +- 在线实例为 Render 免费层:15 分钟无流量休眠(冷启动约 30–90 秒、波动较大,已配每 10 分钟保活工作流);本地 SQLite 随临时盘重建即清空(演示数据本为虚构,不影响评审)。 +- 限流用于防误刷,不防御伪造 X-Forwarded-For(代码注释与提交材料均如实声明)。 +- 无账号体系/鉴权墙:评审无需登录即可调用,反而降低验收门槛;鉴权在路线图。 + +## 7. 关键复现命令(评审现场可用) + +```bash +# 线上(无需任何安装) +curl https://yai-agent-core.onrender.com/health +curl https://yai-agent-core.onrender.com/v1/tools +curl -X POST https://yai-agent-core.onrender.com/v1/agent/run \ + -H "Content-Type: application/json" -d '{"task":"帮我统计一下笔记总数"}' + +# 离线复现(Python 3.11+,uv) +git clone https://github.com/Gi-Tuu/yai-agent-core && cd yai-agent-core +git checkout 0a2634f3b13d690398c77d9119c3beecd4881929 +uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +uv run pytest # 125 项离线测试(命令与 CI 一致) +uv run ruff check src tests examples scripts +uv run python scripts/smoke_test.py # 三宿主自适应冒烟 +``` + +## 8. 附:评分→证据速查 + +| 维度 | 最强证据 | +|---|---| +| 技术可行性 | 线上 API + 125 测试 + CI 四矩阵 + Docker/Render 部署 | +| 市场可行性 | 4 宿主泛化实证 + 三行接入 + AMBRACE 回流路线 | +| 综合创新性 | 范式反转 + 可审计路由 + MCP/OpenAPI 双协议 + 内核零依赖 | +| AI 大模型结合 | §2 全链路图 + 4 个模型调用点职责 + 事件流实证 | +| 赛道维度 | 路由/规划/工具/交付/记忆逐项有模块与演示对应 | diff --git "a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\274\224\347\244\272\350\204\232\346\234\254.md" "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\274\224\347\244\272\350\204\232\346\234\254.md" new file mode 100644 index 0000000..f5d4353 --- /dev/null +++ "b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/hangzhou/\346\274\224\347\244\272\350\204\232\346\234\254.md" @@ -0,0 +1,55 @@ +# YAI 数字员工 · 初赛演示视频脚本(≤5 分钟) + +> v0.3,2026-09-14,按豆包实测修订:host_c 陪伴段改为**本地终端运行**(在线服务只挂载 host_a,不能在线 POST 陪伴任务,2026-09-14 实测确认);补 POST #1 路由兜底重发预案;三个演示动作均已实测(host_a 在线、host_c 本地默认任务、MCP 在线提问)。 +> 硬约束:总时长 ≤ 5:00;在线 POST 限流 30 次/分钟/IP,本脚本在线 POST **至多 2 次**(host_c 段为本地运行,不计限流),安全;演示数据全部虚构。 + +## 0. 录制前置(开录前 10 分钟做完) + +1. 浏览器先访问一次 https://yai-agent-core.onrender.com/health 唤醒免费实例(冷启动约 30–90 秒,波动大),确认返回 `"commit":"0a2634f…"`; +2. 终端开好两个标签页:① 在线 curl(PowerShell 7 / pwsh);② 本地运行(pwsh,已 `cd 'D:\YAI Agent Core'`,用于 host_c 段);VS Code 单独开窗(已 checkout `0a2634f`); +3. 本地备好 `examples/host_a_notes/capabilities.py`、`examples/host_c_companion/capabilities.py`、`src/yai_core/kernel/router.py` 三个文件标签; +4. 关闭无关通知;录屏分辨率 1080p;浏览器缩放到 125%,终端字号 16+; +5. 录屏中不出现 `.env`、API Key、任何真实个人信息。 + +## 1. 分镜时间轴 + +| 时间 | 画面操作 | 旁白要点(口播稿) | 素材/命令 | +|---|---|---|---| +| 0:00–0:30 | 标题卡 + 痛点画面(一个普通软件的函数列表) | "现有软件想拥有 Agent,要自己接模型、写工具总线、写循环和记忆。YAI 数字员工换了个方向:软件只声明自己会什么,内核在进程里自适应地完成剩下全部。" | 标题卡:作品名 + 队名 YAI | +| 0:30–1:00 | VS Code 打开 host_a `capabilities.py`:三个普通函数;再打开 `run.py` 展示 `AgentCore.auto(...)` | "宿主侧没有任何 Agent 代码:三个普通业务函数,类型注解和 docstring 就是全部声明;接入内核只有一行 auto。" | 代码特写:`list_notes/search_notes/count_notes`、`AgentCore.auto` | +| 1:00–1:30 | 终端 curl `/health`、`/v1/tools`(GET,不计限流) | "这是已经在线部署的原型,健康端点实时返回被评审的 commit;工具端点列出宿主声明后内核自动发现的全部工具,包括本地工具和远程 MCP 工具。" | `curl .../health`;`curl .../v1/tools` | +| 1:30–3:00 | **在线 POST 第 1 次**:任务"我一共有多少条笔记?";展示返回的事件流 JSON(strategy_selected→tool_call→tool_result→done)与最终答复(2026-09-14 实测:在线 LLM 路由对该问法判 react,正确调 count_notes 返回 3 条) | "一次真实任务:内核先让模型做策略判断——这是工具型任务,走 ReAct;模型选择 count_notes,内核校验参数、过权限白名单后执行,结果回灌,模型收尾:3 条笔记。每一步决策都在事件流里,可审计、可回放。" | `curl -X POST .../v1/agent/run -d '{"task":"我一共有多少条笔记?"}'`;**兜底预案**:若返回 `strategy` 不是 react(LLM 分类偶发波动),原样重发一次动作型措辞 `{"task":"帮我统计一下笔记总数"}`(规则与 LLM 两条路径都稳定命中 react),重发不占额外准备时间 | +| 3:00–3:40 | VS Code 切到 host_c `capabilities.py`(5 个陪伴工具);切到终端标签②**本地运行 host_c**(不是在线 POST——在线服务只挂载 host_a):跑默认任务,展示 plan 策略下 3 个工具依次调用与最终关怀建议(2026-09-14 实测通过:plan → get_companion_profile → recall_memories → suggest_care_action → 完整汇报) | "同一个内核,换成陪伴宿主,只换 capabilities,工具集、路由策略随之改变——数字员工换了岗位,内核一行没动。这里还能看到 plan 模式:模型先拆出三步计划,再逐步调用工具。" | 命令:`.\.venv\Scripts\python.exe examples/host_c_companion/run.py`(默认任务即可,无需引号传参);本地走 .env 的 DeepSeek,不计在线限流 | +| 3:40–4:20 | **在线 POST 第 2 次**:MCP 任务"DeepWiki 上 modelcontextprotocol python-sdk 的 Client 怎么初始化?"(2026-09-14 实测通过:read_wiki_structure → ask_question 两次远程 MCP 调用,二十秒量级,返回带代码的中文结论);展示 `tool_call`/`tool_result` 事件,并切到 `/v1/tools` 指给观众看该工具的 `source` 字段为 `mcp`,最后落到中文结论 | "工具不止本地函数:内核内置 MCP 客户端,这是对 DeepWiki 远程 MCP Server 的一次真实调用,返回的结论来自远程知识库而不是模型背诵。" | 同端点 POST + `curl .../v1/tools`(GET);注意"工具来源"看 `/v1/tools` 的 `source` 字段,事件流里没有该字段 | +| 4:20–4:45 | 切到仓库网页:Actions CI 绿勾、测试计数;SQLite 记忆文件/保留策略配置一笔带过 | "工程侧:125 项离线测试不依赖网络和密钥,CI 四个操作系统/版本矩阵全绿;记忆默认 SQLite 持久化,并带条数与 7 天 TTL 的保留策略。" | GitHub Actions 页、`tests/` 目录、`memory/retention.py` | +| 4:45–5:00 | 收尾卡:在线地址 + 仓库地址 + 一句话 | "YAI 数字员工:让已有软件零改造获得自适应 Agent 能力。谢谢。" | 收尾卡两行 URL | + +备用素材(剪辑取舍,不进主时间轴):OpenAPI 工具发现(petstore 最小 spec 离线 demo)、三宿主冒烟脚本 `uv run python scripts/smoke_test.py` 终端录屏。 + +## 2. POST 预算核对(限流 30 次/分钟/IP) + +- 主时间轴**在线** POST:1:30 段 0 次(GET);1:30–3:00 第 1 次(笔记统计);3:00–3:40 为**本地运行**(host_c,不占在线额度);3:40–4:20 第 2 次(MCP);合计在线 **2 次**,远低于阈值。 +- host_c 段为本地进程(`examples/host_c_companion/run.py`),消耗的是本地 .env 的模型额度,与在线限流无关;若时间紧,该段可整段删(属泛化佐证,非主链路)。 +- 若录制失误重拍:在线 POST 每分钟不超过 30 次即可;被限流会收到 429 + `Retry-After`,等 60 秒再录。 +- GET(/health、/v1/tools、部署证明)不受限流影响,可随意刷新。 + +## 3. 冷启动与保活预案 + +- 每段录制开始前 1 分钟先 curl 一次 /health 预热; +- 仓库已配 keepwarm 工作流(每 10 分钟自动 GET /health),评审/录制窗口实例通常是热的; +- 若 POST 超 60 秒无响应:实例正在冷启动,等 1 分钟重试,不要连点(连点会浪费限流额度)。 + +## 4. 口播纪律(哪些话不能说) + +- 不说"支持任意工具/任意软件"(无法当场演示);说"任何能提供普通 Python 函数的宿主,三行接入"。 +- 不说多智能体、RAG、向量记忆、自进化(v0.1 未做);如被问到,答"在公开路线图中,初赛版本聚焦单内核自适应"。 +- 不贬低任何具体框架名字;只讲定位差异("积木 vs 内嵌内核")。 +- 数字只说可复现的:125 项测试、四矩阵、4 个示例宿主、3 个本地工具、5 个陪伴工具、冻结 commit 0a2634f。 + +## 5. 录制后剪辑三版分工 + +| 版本 | 素材取舍 | 时长 | +|---|---|---| +| 杭州主版(本脚本) | 全段,标题/收尾用杭州赛事口径 | ≤5:00 | +| X-Agent 版 | 0:30–1:00 代码段压缩,保留 curl/health/POST/MCP 段,收尾用英文地址卡 | 2–3 分钟 | +| 上海版 | 加重 CI/测试/讲义/架构段,弱化"数字员工"叙事,改"开源智能体内核"叙事 | 3–4 分钟 | diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/shanghai.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/shanghai.md new file mode 100644 index 0000000..5d4c706 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/shanghai.md @@ -0,0 +1,24 @@ +# 2026 上海开源软件应用创新大赛 提交清单 + +- 官网:https://www.oschina.net/os2026/ +- 时间:报名+提交至 10.11 24:00(邮件 oscc@oschina.cn);入围公布 10.16;决赛 10.24 张江科学会堂 + +## 选赛道 + +首选 **智算云赛道(智能体框架/运行时方向)**;备选开源 AI 工具赛道(AI 开发工具)。 + +## 评审维度对应 + +| 维度 | 权重 | YAI 动作 | +|---|---|---| +| 技术创新 | 30% | 能力自发现+策略自适应;README 放与 5 类现有方案的诚实对比表 | +| 场景落地 | 30% | 双宿主 demo + 在线 API + 录屏,可部署可运行可测试 | +| 开源治理 | 20% | MIT LICENSE、README/CONTRIBUTING、GitHub Actions(CI)、pytest、Issue/PR 模板、规范 commit | +| 长期发展 | 20% | AMBRACE 回流路线图、版本路线、社区文档 | + +## 提交材料 + +1. 代码仓库链接(公开、完整可运行) +2. 作品介绍 PDF(背景/技术架构/应用场景/创新点) +3. 演示视频链接(核心功能与使用流程) +4. 个人/团队均可,建议 3–5 人;每项目限一个赛道 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/xagent.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/xagent.md new file mode 100644 index 0000000..783c5bf --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/competitions/xagent.md @@ -0,0 +1,49 @@ +# X-Agent AI MCP Hackathon 2026 提交清单(开放创新挑战赛) + +- 官网:https://xagt.ai/hackathon?lang=zh +- 提交仓库:https://github.com/xagentAI/xagt-plugin(Fork 后提 PR) +- 关键时间:报名+开发 09.02–09.19;技术审核评审 09.20–10.01;结果 10.02–10.04 + +## 硬门槛(7 项最低提交要求) + +1. 评审窗口内可调用的已部署 API +2. GET /health 返回 `{"status":"ok","commit":"<40位被审 commit>"}` +3. 完整源码放入 PR 的 `source/`(外链仓库不算提交) +4. 与部署一致的公开 GitHub pinned commit +5. 可复现的 setup/test/deploy/一次真实调用说明 +6. `/.well-known/xagent-verification.json` → `{"schemaVersion":1,"slug":...,"commit":...}` +7. 安全/数据/依赖/权利声明(RIGHTS.md) + +## 提交目录 + +``` +submissions/mcp-hackathon/-/ +├── SUBMISSION.md +├── submission.json +├── RIGHTS.md +├── source/ +└── verification/README.md +``` + +## YAI 的打法 + +- 卖点:嵌入式自适应 Agent 内核(官方赛道明列 Agent 基础设施) +- 杀手证据:同一 Core 挂载两个宿主能力集,API 完成两类不同任务 +- 不需要自建 MCP Server(官方验证后协助标准化) +- 禁止:链上安全/审计类项目 + +## 提交物骨架(已落地,2026-09-10 按官方 validate-submission.mjs 核对) + +- 元数据目录:`submissions/mcp-hackathon/yai-agent-core/`(SUBMISSION.md / RIGHTS.md / + verification/README.md / submission.template.json;`source/` 不入库,构建时生成) +- 构建 + 离线预检:`python scripts/build_xagent_submission.py` + (git archive HEAD 导出 source/、注入 reviewCommit、镜像官方结构/密钥/体积校验) +- 线上一致性预检:`python scripts/build_xagent_submission.py --online` + (校验 GitHub commit 可公开访问、/health 与验证端点的 slug/commit 三处一致) +- slug 统一定名 `yai-agent-core`(render.yaml、.env.example、验证端点、提交目录四处一致) +- 冻结提交时:Render 关 autoDeploy → 手动部署 pinned commit → 跑 --online 预检 → + fork xagt-plugin → 复制 dist 快照目录 → 提 PR(只动一个提交目录) +- 评审期保活(2026-09-20 ~ 10-04):`.github/workflows/keepwarm.yml` 每 10 分钟 GET /health, + 规避 Render 免费层冷启动(30–90s)撞校验器 10s GET 超时;评审结束后手动停用。 +- 校验器口径(2026-09-13 复核官方 scripts/validate-submission.mjs):10s 超时与 64KiB 上限 + 只卡 health / 部署证明两个 GET;POST 不进自动门槛;包体 5MiB/文件、20MiB/包、2000 文件上限。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/reading-guide.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/reading-guide.md new file mode 100644 index 0000000..a94f552 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/reading-guide.md @@ -0,0 +1,101 @@ +# YAI Agent Core 代码学习导览(边学边造专用) + +> 目标:不是"看懂每一行",而是按顺序读完后,能向别人讲清楚 Core 怎么转起来,并能动手改。 +> 建议节奏:每天 1~2 站,配合运行与小练习;卡住超过 30 分钟就先跳过,在文件里留 `# Q:` 注释。 + +> **配套逐行讲解**:`docs/walkthrough/` 里有每个源码文件逐块、逐行的解释(00 总览 → 09 在线 API),本篇负责路线,walkthrough 负责抠细节,两篇对照着看。 + +## 0. 先跑起来(10 分钟) + +```powershell +cd "D:\YAI Agent Core" +.\.venv\Scripts\python.exe scripts\smoke_test.py +``` + +观察三个宿主输出的差异:**同一份 Core 代码没动**,只是换了 `capabilities`,自动发现的工具、选择的策略就不同。这就是整个项目的核心主张,先建立感性认识。 + +## 1. 推荐阅读顺序(7 站) + +### 站 1:`src/yai_core/types.py` —— 项目的"词汇表" +- 解决什么:全项目传递的数据长什么样(工具、消息、事件、策略)。 +- Python 知识点:`dataclass`(数据容器)、`StrEnum`(字符串枚举)、`Literal`、类型注解、`field(default_factory=...)`。 +- 读完标志:能说出 `ToolSpec / ChatMessage / ModelResponse / AgentEvent` 各自装什么。 +- 小练习:给 `EventType` 加一个 `RETRY = "retry"`,想想哪个环节会发出它。 + +### 站 2:`src/yai_core/discovery/introspect.py` —— "能力自发现"的魔法来源 +- 解决什么:宿主的普通函数如何自动变成带 JSON Schema 的工具。 +- Python 知识点:`inspect.signature`、类型注解如何被程序读取、`get_origin/get_args`(Optional 原理)、字典构造。 +- 关键理解:这里没有魔法,只是把函数签名"读"出来翻译成 LLM 认识的格式。 +- 小练习:在 `examples/host_a_notes/capabilities.py` 加一个带默认参数的新函数,重跑 smoke,看它是否自动出现。 + +### 站 3:`src/yai_core/tools/` —— 注册表与执行总线 +- `registry.py`:Python 知识点——类、字典封装、自定义异常(`KeyError/ValueError` 的使用场景)。 +- `executor.py`:重点看三件事——权限决策、`inspect.isawaitable` 如何让同步/异步函数都能跑、为什么用 try/except 把工具错误"回灌"给模型而不是崩溃。 +- 小练习:故意把工具函数改成 `raise ValueError("boom")`,观察 Agent 是否还能正常收尾。 + +### 站 4:`src/yai_core/kernel/router.py` —— 自适应决策的第一版 +- 解决什么:凭什么选 direct/react/plan/clarify。 +- Python 知识点:枚举比较、`any()/sum()` 生成器、纯函数(同样输入必同样输出,所以最好测)。 +- 对照测试读:`tests/test_router.py` 每个用例就是一条决策规则的"说明书"。 +- 小练习:加一个你自己的中文触发词,让"帮我看看"也能触发 react;同步加一条测试。 + +### 站 5:`src/yai_core/kernel/loop.py` —— Core 的心脏(最重要,慢读) +- 解决什么:策略选定后,模型与工具如何来回循环直到产出结果。 +- Python 知识点(正好是你的学习清单): + - `async def / await`:为什么 IO 密集的 LLM 调用必须异步; + - **异步生成器** `async ... yield`、`async for`:事件为什么能"边跑边往外吐"; + - 控制流:plan 先拆步骤、react 循环、max_iters 兜底、clarify 递归重入。 +- 调试建议:在 `_react_cycle` 每轮循环开头打印 `len(ctx.messages)`,看消息历史如何增长。 +- 读完标志:能默画出"用户任务 → 模型 → 工具调用 → 工具结果 → 模型 → 最终答案"的闭环。 + +### 站 6:`src/yai_core/spi/` 与默认实现 —— 可替换的四个插槽 +- 解决什么:为什么 Core 不绑定模型厂商、不绑定 UI、不绑定数据库。 +- Python 知识点:`Protocol`(结构化子类型:"只要长这样就能插进来",不需要继承)、依赖注入(构造函数传入)。 +- 对照读:`llm/openai_compat.py`(真实插槽)、`channels/collect.py`(测试插槽)、`tests/test_loop.py` 里的 `ScriptedModel`(假插槽)。 +- 小练习:写一个 `EchoModel` 实现 `achat`(直接返回固定话),接进 Core 跑一次。 + +**加一站(v0.2)**:`src/yai_core/memory/sqlite_store.py` —— 同一个记忆插槽的第二个实现。 +对照 InMemoryStore 读:表结构、scope 隔离、user_version 迁移钩子、为什么同步 SQL 直接跑在 +async 方法里(见 walkthrough 第 11 章)。读完应能回答:为什么 Loop 一行没改就获得了持久记忆。 + +**再加一站(v0.2)**:`src/yai_core/integrations/openapi/` —— 对照第 10 章 MCP 桥读: +spec.py 的 $ref 内联与循环保护、discovery.py 的入参合并、client.py 的鉴权与响应归一。 +读完应能回答:为什么一份 OpenAPI 描述就能让 Core 不写一行适配代码获得一批工具(见 walkthrough 第 12 章)。 + +### 站 7:`src/yai_core/core.py` 与 `batteries/fastapi_server/app.py` +- `core.py` 是门面:宿主只需要认识 `AgentCore.auto()` 和 `run()`,内部全被藏起来(门面模式)。 +- Battery 展示"内核"与"外围"的边界:注意 fastapi/pydantic 是**懒加载**的,内核本体不依赖它们。 +- 小练习:用 `scripts/serve_example.py` 的写法,把 host_c_companion 也变成一个 HTTP 服务。 + +## 2. 术语对照表(第一次读会反复遇到) + +| 术语 | 在本项目里的意思 | +|---|---| +| 宿主 Host | 嵌入 Core 的现有软件(host_a/b/c,未来是 AMBRACE) | +| SPI | 宿主可替换、Core 给默认实现的接口契约(Model/Channel/Memory/Policy) | +| Adaptive Router | 决定任务走哪种执行策略的组件 | +| ReAct | 推理-调用工具-观察结果-再推理的循环 | +| Tool Bus | 统一执行工具的通道(权限、异常、结果格式化都在这) | +| Observer 事件流 | 运行过程对外发出的可观测事件,自适应决策不许黑盒 | +| Battery | 可选外围能力(HTTP 服务等),不属于内核 | + +## 3. 动手实验环境 + +- 推荐用 VS Code 打开 `D:\YAI Agent Core`,在 `loop.py` 行号左侧点一下设断点,F5 调试 `scripts/smoke_test.py`。 +- 常用命令: + ```powershell + .\.venv\Scripts\python.exe -m pytest # 全部测试 + .\.venv\Scripts\python.exe -m pytest -k router # 只跑路由测试 + .\.venv\Scripts\python.exe -m ruff check src tests examples scripts + ``` + +## 4. "学懂了"自检清单 + +- [ ] 能不看代码画出 Core 的分层图并说出每层文件 +- [ ] 能解释宿主为什么"零 Agent 代码" +- [ ] 能说出一次 react 循环中 messages 列表里依次出现了哪几种 role +- [ ] 能新增一个宿主函数并让 Agent 调用到它 +- [ ] 能写一个假 ModelProvider 离线测试一条新执行路径 +- [ ] 能指出哪部分代码是"内核绝不能依赖第三方"的边界 + +全部打勾后,再进入 Day 1-3(真实 DeepSeek 接入),那时你会清楚知道每一行在干什么。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/00-index.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/00-index.md new file mode 100644 index 0000000..0d054ec --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/00-index.md @@ -0,0 +1,98 @@ +# 逐行讲解 00 · 导读:一次请求穿过整个 Core + +> 这套文档对 `src/yai_core/` 每个文件做逐块、逐行解释,面向"边学边造"的你。 +> 读法:先读本篇建立全局地图,再按编号逐篇对照源码看。每篇末尾有自检问题,答得上来再往下走。 + +## 0. 先记住一句话 + +**宿主只提供普通函数;Core 把函数变成工具,自己决定怎么用工具完成任务,并把过程一步步喊出来。** + +## 1. 一张全景图(数字就是执行顺序) + +``` +你的任务 "先查看状态,然后检索约定记忆,给出关怀建议" + │ + │ ① AgentCore.auto(宿主模块, 模型) 【core.py】 + │ └─ discover(宿主) 【discovery/introspect.py】 + │ 读函数签名/docstring → ToolSpec 列表 → 注册进 ToolRegistry【tools/registry.py】 + │ └─ (可选)attach_mcp_tools 接入外部 MCP 工具,同样注册进 ToolRegistry + │ 【integrations/mcp/client.py】 + │ + │ ② await core.run(任务) 【core.py】 + │ └─ AgentLoop.astream(任务) 【kernel/loop.py】 + │ + │ ③ Router.classify(任务, 注册表) → 策略 【kernel/router.py】 + │ 没有工具→direct / 行动词→react / 多步+行动→plan / 太模糊→clarify + │ + │ ④ Context 组装消息列表 【kernel/context.py】 + │ + │ ⑤(plan 时)先让强模型拆步骤,再补一条"按计划执行"的 user 指令 + │ + │ ⑥ ReAct 循环 【kernel/loop.py】 + │ 模型.achat(消息, 工具清单) 【llm/openai_compat.py / 任何 SPI 模型】 + │ ├─ 模型不调工具 → 最终答案,结束 + │ └─ 模型要调工具 → ToolExecutor.execute 【tools/executor.py】 + │ ├─ PermissionPolicy.check 权限 【policy/allowlist.py】 + │ ├─ 真正执行宿主的普通 Python 函数 + │ └─ 结果包成 tool 消息塞回 Context,继续循环 + │ + │ ⑦ 全过程的每个动作变成 AgentEvent,经 Channel 往外发 【channels/】 + │ ⑧ 历史写进 Memory(InMemoryStore / SqliteStore)【memory/】 + └ 返回 RunResult(策略 + 事件列表 + 最终文本) +``` + +## 2. 文件分工速查 + +| 文件 | 角色 | 类比 | +|---|---|---| +| `types.py` | 全项目通用的数据结构 | 普通话词典 | +| `spi/*.py` | 四个"插槽"的接口约定 | 插座标准 | +| `discovery/introspect.py` | 把函数读成工具规格 | 自动翻译官 | +| `tools/registry.py` | 工具花名册 | 电话簿 | +| `tools/executor.py` | 真正调用工具的地方 | 总机 | +| `kernel/router.py` | 决定用哪种打法 | 作战参谋 | +| `kernel/context.py` | 管理发给模型的消息 | 剪贴板 | +| `kernel/loop.py` | 主循环(心脏) | 发动机 | +| `memory/inmemory.py` | 默认记忆 | 短期便签本 | +| `memory/sqlite_store.py` | 持久化记忆(opt-in) | 带锁的笔记本 | +| `memory/retention.py` | 历史裁剪纯函数(条数/TTL,轮边界对齐) | 会碎纸的碎纸机 | +| `policy/allowlist.py` | 默认权限 | 门禁 | +| `channels/*.py` | 默认输入输出 | 显示屏/话筒 | +| `llm/openai_compat.py` | 真实模型适配 | 翻译给 DeepSeek 听 | +| `core.py` | 对外唯一门面 | 前台 | +| `integrations/mcp/client.py` | 接入外部 MCP Server 的工具 | 外接设备转接头 | +| `integrations/openapi/` | OpenAPI 发现(opt-in) | REST API 的自动翻译官 | +| `batteries/fastapi_server/app.py` | 包成 HTTP 服务 | 对外营业窗口 | +| `batteries/fastapi_server/ratelimit.py` | 评审期限流(每 IP 滑动窗口) | 门口的取号机 | + +## 3. 贯穿全项目的 5 个 Python 语法(先混个脸熟,后面逐个细讲) + +1. `from __future__ import annotations`:让类型注解延迟成字符串,写新式注解不报错。 +2. `async def / await / async for / yield`:异步与异步生成器,loop.py 的核心。 +3. `@dataclass`:几行代码写出"只装数据的类"。 +4. `Protocol`:不继承也能算"符合接口"(鸭子类型的书面版)。 +5. `X | None`、`list[dict]`:新式类型注解。 + +## 4. 分篇目录 + +- 01 · `types.py`:数据词汇表 +- 02 · `spi/`:四个可替换插槽 +- 03 · `discovery/introspect.py`:能力自发现 +- 04 · `tools/`:注册表 + 执行总线 +- 05 · `kernel/router.py` + `context.py`:决策与上下文 +- 06 · `kernel/loop.py`:主循环逐行(最重要) +- 07 · 默认实现:memory / policy / channels +- 08 · `llm/openai_compat.py` + `core.py`:真实模型与门面 +- 09 · `batteries/fastapi_server` + `Dockerfile`:变成在线 API 并容器化部署 +- 10 · `integrations/mcp/client.py`:MCP Client,把外部工具接进注册表 +- 11 · `memory/sqlite_store.py`:SQLite 持久化记忆(scope、迁移钩子、WAL) +- 12 · `integrations/openapi/`:OpenAPI 发现(REST API 零适配变工具,$ref/鉴权/只读闸) +- 13 · `memory/retention.py` + 两个 Store:历史保留策略(条数裁剪/TTL,轮边界对齐,opt-in) +- 14 · `batteries/fastapi_server/ratelimit.py`:限流 Battery(滑动窗口、429 + Retry-After、GET 放行) + +## 5. 自检(读完本篇应能回答) + +1. 宿主写的函数,是在哪一步变成工具的? +2. 策略是在哪决定的?四种策略分别什么意思? +3. 工具结果通过什么回到模型那里? +4. 为什么说 Channel 是"显示屏"而 Core 本身不打印东西? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/01-types.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/01-types.md new file mode 100644 index 0000000..6d571b9 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/01-types.md @@ -0,0 +1,158 @@ +# 逐行讲解 01 · `types.py`:全项目的数据词汇表 + +> 目标:看懂项目里传递的每一种"数据包"长什么样。 +> 知识点:模块 docstring、`from __future__`、类型别名、StrEnum、dataclass、field。 + +## 块 1 · 文件头与导入(L1-L11) + +```python +"""YAI Agent Core 的核心数据类型。 + +刻意只依赖标准库:内核本体零第三方依赖,第三方能力全部通过 SPI 注入。 +""" +``` +- 三引号字符串放在文件最开头叫**模块 docstring**,调用 `help(模块)` 或 IDE 悬停时能看到。它也是给你自己看的设计说明。 + +```python +from __future__ import annotations +``` +- "未来导入"。Python 3.11 其实已经支持新注解,但加上它后,所有类型注解都不在运行时立刻求值(当成字符串处理),可以放心写 `list[ToolSpec]`、自引用等而不报错。**项目里每个文件第一行都是它,照抄即可。** + +```python +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Literal +``` +- `Callable`:"这是一个可以被调用的东西(函数)"的类型。注意它从 `collections.abc` 导入(现代写法),Ruff 会自动帮你从旧的 `typing` 挪过来。 +- `dataclass, field`:数据类装饰器和它的辅助函数,下面细讲。 +- `StrEnum`:字符串枚举(Python 3.11+),成员本身就是字符串。 +- `Any`:任意类型(放弃检查,只在确实无法确定时用);`Literal[...]`:取值只能是列出的字面量之一。 + +## 块 2 · 角色类型别名(L13) + +```python +Role = Literal["system", "user", "assistant", "tool"] +``` +- 定义**类型别名**:以后写 `role: Role` 等价于 `role: Literal[...]`,IDE 会提示只能填这 4 种字符串,拼错立刻警告。 +- 这 4 个角色正是大模型对话协议里的角色:系统设定 / 用户 / 助手(模型)/ 工具回传。 + +## 块 3 · 两个枚举(L16-L36) + +```python +class Strategy(StrEnum): + DIRECT = "direct" + REACT = "react" + PLAN = "plan" + CLARIFY = "clarify" +``` +- 枚举 = "有限的几个候选值"。`Strategy.DIRECT` 既是枚举成员,`str(Strategy.DIRECT)` 又是 `"direct"`,这就是 StrEnum 比 `(str, Enum)` 更干净的地方。 +- 为什么不直接到处写字符串 `"react"`?因为字符串拼错不报错,而 `Strategy.REAC` 拼错会直接报 `AttributeError`,且 IDE 能自动补全。 + +`EventType` 同理,列出运行全过程可能出现的 9 种事件。**这就是"可观测"的词汇表**:以后每做一个动作,都从这里挑一个事件类型发出去,不会出现叫法不一。 + +## 块 4 · ToolSpec:工具的统一身份证(L39-L58) + +```python +@dataclass +class ToolSpec: + name: str + description: str + input_schema: dict[str, Any] + handler: Callable[..., Any] + source: Literal["native", "openapi", "mcp"] = "native" +``` +- `@dataclass` 会自动生成 `__init__`,所以你能直接 `ToolSpec(name="x", description=..., ...)`,不用手写构造函数。 +- 字段逐个看: + - `name`:工具名(模型靠它点名调用); + - `description`:给模型看的"这个工具干嘛的"; + - `input_schema`:参数的 JSON Schema(什么参数、什么类型、哪些必填); + - `handler`:真正干活的 Python 函数;`Callable[..., Any]` 表示"任意参数、返回任意值的可调用对象"; + - `source`:工具来源,默认 `"native"`(宿主自己的函数),未来 OpenAPI/MCP 来的工具也用**同一个类**装——这就是"同构"。 +- 注意写法规则:**没有默认值的字段必须排在有默认值的前面**(和函数参数一个道理)。 + +```python +def llm_schema(self) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.input_schema, + }, + } +``` +- 普通方法:把内部表示翻译成 OpenAI 兼容接口要求的工具形状。以后 DeepSeek 收到的就是这个字典。**"内部一种结构,对外按需翻译"是贯穿项目的手法。** + +## 块 5 · ChatMessage:一条对话消息(L61-L78) + +```python +@dataclass +class ChatMessage: + role: Role + content: str = "" + tool_calls: list[dict[str, Any]] | None = None + tool_call_id: str | None = None + name: str | None = None +``` +- `X | None`:要么是 X,要么是 None(等价旧写法 `Optional[X]`)。 +- 一条消息在不同角色下用不同字段: + - user/assistant 普通对话:主要用 `role + content`; + - assistant 决定调工具时:填 `tool_calls`(可能一次调多个); + - 工具回传结果时:`role="tool"` + `tool_call_id`(对应哪次调用)+ `name`(哪个工具)。 + +```python +def to_llm_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"role": self.role, "content": self.content} + if self.tool_calls: + d["tool_calls"] = self.tool_calls + ... + return d +``` +- 先建一个最小字典,**有值才加键**:因为模型接口不喜欢收到 `tool_calls: None` 这种空字段。 +- `d: dict[str, Any] = {...}` 里的注解是写给人和 IDE 看的,运行不影响。 + +## 块 6 · 其余四个数据类(L81-L107) + +```python +@dataclass +class ToolCallRequest: + id: str + name: str + arguments: dict[str, Any] = field(default_factory=dict) +``` +- 模型说"我要调工具"时的结构化请求。 +- **重点:`field(default_factory=dict)`**。可变默认值(列表/字典)不能直接写 `={}`,否则所有实例会共享同一个字典!`default_factory=dict` 的意思是"每次新建时调用 `dict()` 生成一个新的"。下面 `ModelResponse.tool_calls`、`AgentEvent.data` 同理,这是 Python 经典坑,记住就好。 + +```python +@dataclass +class ModelResponse: + content: str = "" + tool_calls: list[ToolCallRequest] = field(default_factory=list) + raw: Any = None +``` +- 模型回复的统一形状:要么有正文 `content`,要么有工具调用,也可能都有。`raw` 保存厂商原始返回,调试用。 + +```python +@dataclass +class AgentEvent: + type: EventType + data: dict[str, Any] = field(default_factory=dict) +``` +- Observer 事件:`type` 说明发生了什么,`data` 装细节。Loop 里 `yield AgentEvent(...)` 就是在"对外广播"。 + +```python +@dataclass +class RunResult: + strategy: Strategy + events: list[AgentEvent] + final_text: str +``` +- `core.run()` 最终交给宿主的结果包:用了什么策略、全过程事件、最终文本。 + +## 自检 + +1. 为什么 `arguments` 要用 `field(default_factory=dict)` 而不能直接 `={}`? +2. 一条 `role="tool"` 的消息为什么必须带 `tool_call_id`? +3. `ToolSpec.source` 留了哪三种来源?这和"不重复造轮子"有什么关系? +4. 把 `EventType` 里删掉一个、在 loop.py 里引用它会发生什么?(动手试) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/02-spi.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/02-spi.md new file mode 100644 index 0000000..09fcd5c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/02-spi.md @@ -0,0 +1,107 @@ +# 逐行讲解 02 · `spi/`:四个可替换插槽 + +> 目标:理解 Core 为什么不绑定模型厂商、不绑定 UI、不绑定数据库。 +> 知识点:Protocol(结构化子类型 / 鸭子类型)、runtime_checkable、`...` 函数体、接口与实现分离。 + +## 0. 什么是 SPI + +SPI = Service Provider Interface(服务提供者接口)。意思是:**Core 只规定"你得长这样",不规定"你是谁家的"。** +谁满足这套方法签名,谁就能插进 Core 当模型/通道/记忆/权限。Core 自己另外提供了"默认款"(见第 07 篇)。 + +四个文件结构几乎一样,学会一个就会四个。 + +## 1. `spi/model.py`:模型插槽 + +```python +from typing import Any, Protocol, runtime_checkable +from yai_core.types import ChatMessage, ModelResponse + +@runtime_checkable +class ModelProvider(Protocol): + async def achat( + self, + messages: list[ChatMessage], + tools: list[dict[str, Any]] | None = None, + *, + tier: str = "standard", + ) -> ModelResponse: + ... +``` + +逐行: +- `class Xxx(Protocol)`:声明一个**协议**。它不写实现,只写"符合本协议的对象必须有哪些方法、什么参数、返回什么"。 +- `@runtime_checkable`:允许用 `isinstance(obj, ModelProvider)` 运行时检查(没它只能做类型提示)。 +- `async def achat(...)`:模型调用是网络 IO,所以是异步方法。 +- `messages`:对话历史;`tools`:本轮给模型的工具清单(None=不让用工具)。 +- `*`:**仅限关键字参数分隔符**,它后面的 `tier` 调用时必须写名字 `tier="strong"`,防止位置传参搞错。`tier` 是模型路由的钩子:standard 走便宜模型,strong 走强模型(规划时用)。 +- 函数体只有 `...`(Ellipsis):协议方法不需要实现,`...` 就是"占位、到此为止"。 + +**关键理解**:DeepSeek 适配(`OpenAICompatProvider`)、测试里的 `ScriptedModel`、示例里的 `OfflineScriptedModel` 都没有继承 `ModelProvider`,但因为它们都"恰好有一个签名一致的 `achat` 方法",就都能被 Core 当模型用。这叫**结构化子类型**(俗称鸭子类型:走起来像鸭子就是鸭子)。 + +## 2. `spi/channel.py`:输入输出插槽 + +```python +@runtime_checkable +class Channel(Protocol): + async def emit(self, event: AgentEvent) -> None: ... + async def ask(self, question: str) -> str: ... + async def confirm(self, tool_name: str, arguments: dict[str, Any]) -> bool: ... +``` +- `emit`:Core 把运行事件往外发(打印、推给网页、推给 Flutter 都靠各自实现)。 +- `ask`:策略为 clarify 时反问用户,拿回一句回答。 +- `confirm`:权限策略为 ASK 时,问宿主"这个工具允许执行吗",返回布尔。 +- 返回 `None` 表示这个方法没有有意义的返回值(只做动作)。 + +**为什么 Core 不直接 print?** 因为 Core 是内核,它不知道自己将来跑在终端、网页还是 Flutter 里。把"怎么显示/怎么问人"抽成 Channel,内核就和 UI 彻底解耦。 + +## 3. `spi/memory.py`:记忆插槽 + +```python +@runtime_checkable +class MemoryStore(Protocol): + async def append_history(self, message: ChatMessage) -> None: ... + def history(self) -> list[ChatMessage]: ... + async def put(self, key: str, value: str) -> None: ... + async def get(self, key: str) -> str | None: ... + async def clear(self) -> None: ... +``` +- v0.1 只要求两件事:**会话历史**(append/history)和**简单 KV**(put/get)。 +- 注意 `history()` 不是 async:它只是读内存列表,没有 IO,没必要异步。**同步/异步的选择标准:有没有等待 IO(网络/磁盘)。** +- 未来 `SqliteStore`、向量记忆只要实现这几个方法就能替换,Loop 一行不用改。 + +## 4. `spi/policy.py`:权限插槽 + +```python +class PermissionDecision(StrEnum): + ALLOW = "allow" + DENY = "deny" + ASK = "ask" + +@runtime_checkable +class PermissionPolicy(Protocol): + async def check(self, tool_name: str, arguments: dict[str, Any]) -> PermissionDecision: ... +``` +- 每次工具执行前问一句权限:直接放行 / 拒绝 / 先问人。 +- 三态而不是两态,是因为陪伴/数据类软件里"删除、发送、付款"这类动作必须有人确认,这是 AMBRACE 未来必备的安全闸。 + +## 5. `spi/__init__.py`:统一出口 + +```python +from yai_core.spi.channel import Channel +from yai_core.spi.memory import MemoryStore +from yai_core.spi.model import ModelProvider +from yai_core.spi.policy import PermissionDecision, PermissionPolicy +``` +- 把四个协议集中到 `yai_core.spi` 包名下,外部一行导入:`from yai_core.spi import ModelProvider, Channel`。 +- `__init__.py` 的作用:把一个文件夹标记为 Python 包,并决定"对外暴露什么"。 + +## 设计思想小结(比赛材料也用得上) + +> Core 面向协议编程,不面向具体实现编程。新增一种模型/UI/数据库 = 新写一个满足协议的小类,内核零修改——这正是"可扩展、可内嵌"的根。 + +## 自检 + +1. `ScriptedModel` 没有继承 `ModelProvider`,为什么能传给 AgentCore 当模型? +2. `*, tier` 里的星号起什么作用? +3. 为什么 `history()` 是同步方法而 `append_history` 是异步? +4. 如果要接微信小程序当 UI,你需要实现哪个 SPI?实现哪几个方法? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/03-discovery.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/03-discovery.md new file mode 100644 index 0000000..def770e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/03-discovery.md @@ -0,0 +1,163 @@ +# 逐行讲解 03 · `discovery/introspect.py`:能力自发现 + +> 这是 YAI"宿主零 Agent 代码"的魔法来源。其实没有魔法——只是用标准库 `inspect` 把函数签名读出来。 +> 知识点:inspect.signature、类型注解的读取、Optional 原理、字典构建、模块/方法判断。 + +## 块 1 · 导入与类型映射表(L7-L22) + +```python +import inspect +import types +from typing import Any, get_args, get_origin +from yai_core.types import ToolSpec + +_PY_TO_JSON: dict[type, str] = { + str: "string", int: "integer", float: "number", + bool: "boolean", list: "array", dict: "object", +} +``` +- `inspect`:Python 反射标准库,可以在运行时读取"函数叫什么、有哪些参数、注解是什么、docstring 是什么"。 +- `get_origin / get_args`:拆解复杂注解的工具,比如把 `Optional[str]`(即 `str | None`)拆成 `(str, NoneType)`。 +- `_PY_TO_JSON`:Python 类型 → JSON Schema 类型名的对照表。**字典的键可以是类型本身**(类也是对象)。 +- 下划线前缀 `_PY_TO_JSON`:约定"模块内部使用",`from xxx import *` 不会导出它。 + +## 块 2 · 把单个注解翻译成 JSON 类型(L25-L36) + +```python +def _annotation_to_json_type(annotation: Any) -> tuple[str, bool]: + if annotation is inspect.Parameter.empty: + return "string", False +``` +- 返回值标注 `tuple[str, bool]`:返回一个二元组(JSON 类型、是否可空)。 +- `inspect.Parameter.empty` 是个**哨兵对象**:当参数根本没写类型注解时,`param.annotation` 就是它。这里保守回退成 `"string"`。 + +```python + origin = get_origin(annotation) + if origin in (types.UnionType, getattr(__import__("typing"), "Union", object)): + args = [a for a in get_args(annotation) if a is not type(None)] + nullable = len(args) != len(get_args(annotation)) + if args: + return _PY_TO_JSON.get(args[0], "string"), nullable + return _PY_TO_JSON.get(annotation, "string"), False +``` +逐行: +- `get_origin(annotation)`:取出"外壳"。`str | None` 的外壳是 union 类型;普通 `str` 的外壳是 None。 +- 判断外壳是不是联合类型:`types.UnionType` 对应新式 `X | None`;`typing.Union` 对应老式 `Optional[X]`。两种写法都兼容。 + - 这里 `getattr(__import__("typing"), "Union", object)` 是"动态导入 typing 再取 Union"的防御写法,等价于先 `from typing import Union` 再用 `Union`(你自己写代码时用普通 import 更清晰)。 +- 列表推导式 `[a for a in get_args(...) if a is not type(None)]`:把 `NoneType` 过滤掉,剩下真实类型。 +- `nullable`:过滤前后数量不一样,说明里面原本有 None → 可空。 +- `dict.get(k, 默认)`:找不到映射就回退 `"string"`,绝不因为陌生类型崩溃。 + +## 块 3 · build_spec:一个函数 → 一张工具身份证(L39-L74) + +```python +def build_spec(fn: Any, *, name: str | None = None, source: str = "native") -> ToolSpec: + if not callable(fn): + raise TypeError(f"build_spec 需要可调用对象,得到 {type(fn)!r}") +``` +- `*,` 后面的参数必须用关键字传:`build_spec(f, name="x")`。 +- `callable(fn)`:判断是不是函数/可调用对象;不是就**快速失败**并给出清晰错误。 +- f-string 里 `!r` 表示用 `repr()` 显示(带引号/类型细节),调试信息更明确。 + +```python + sig = inspect.signature(fn) + properties: dict[str, Any] = {} + required: list[str] = [] +``` +- `inspect.signature(fn)` 拿到函数签名对象;后两个变量分别装"参数 schema"和"必填参数名"。 + +```python + for param_name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + json_type, nullable = _annotation_to_json_type(param.annotation) + prop: dict[str, Any] = {"type": json_type} + if param.default is inspect.Parameter.empty: + required.append(param_name) + else: + prop["default"] = param.default + if nullable: + prop["type"] = [json_type, "null"] + properties[param_name] = prop +``` +逐行: +- `sig.parameters` 是有序映射,键是参数名,值是参数对象。 +- `VAR_POSITIONAL`(`*args`)和 `VAR_KEYWORD`(`**kwargs`)无法表达成固定 JSON 参数,`continue` 跳过。 +- 每个参数生成一个 `{"type": ...}` 描述。 +- **没有默认值 = 必填**(加入 required);有默认值就把默认值写进 schema,模型可以不传。 +- 可空时 JSON Schema 写法是 `"type": ["string", "null"]`。 +- 最后挂进 `properties[参数名]`。 + +```python + doc = inspect.getdoc(fn) or "" + description = doc.split("\n\n", 1)[0].strip() or name or fn.__name__ +``` +- `inspect.getdoc(fn)`:拿到函数 docstring(还会自动去掉缩进)。 +- `split("\n\n", 1)[0]`:只取第一段(空行之前)作为简介,避免把长篇说明塞给模型。 +- `A or B or C` 链式兜底:没 docstring 就用传入 name,再不行用函数自身的 `__name__`。 + +```python + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return ToolSpec( + name=name or fn.__name__, description=description, + input_schema=schema, handler=fn, source=source, + ) +``` +- 拼成标准 JSON Schema:`{"type":"object","properties":{...},"required":[...]}`。 +- required 为空就不写这个键(干净)。 +- 产出 ToolSpec:函数本体存进 `handler`,以后执行就是调它。 + +## 块 4 · discover:自动扫出一批函数(L77-L100) + +```python +def discover(host: Any) -> list[ToolSpec]: + specs: list[ToolSpec] = [] + if inspect.ismodule(host): + module_name = host.__name__ + members = inspect.getmembers(host, inspect.isfunction) + for member_name, fn in members: + if member_name.startswith("_"): + continue + if getattr(fn, "__module__", None) != module_name: + continue + specs.append(build_spec(fn)) + return specs +``` +针对**模块**(我们的 `capabilities.py` 就是模块): +- `inspect.getmembers(host, inspect.isfunction)`:列出模块里所有函数,返回 `(名字, 函数)` 列表。 +- 下划线开头的跳过(私有约定)。 +- **关键防线**:`fn.__module__ != module_name` 时跳过——因为模块顶部 `import xxx` 进来的函数也会被 getmembers 列出来!只保留"本模块自己定义的",避免把 `date`、第三方函数误注册成工具。 + +```python + for member_name, fn in inspect.getmembers(host, inspect.ismethod): + if member_name.startswith("_"): + continue + specs.append(build_spec(fn)) + return specs +``` +针对**对象实例**(未来宿主用类组织能力时):`inspect.ismethod` 只挑绑定方法,同样跳过私有方法。 + +## 串起来看 + +`examples/host_a_notes/capabilities.py` 里写: +```python +def search_notes(keyword: str) -> list[dict]: ... +``` +`discover(capabilities)` 会自动产出等价于: +```json +{"name": "search_notes", + "description": "按关键词搜索笔记标题与正文。", + "parameters": {"type": "object", + "properties": {"keyword": {"type": "string"}}, + "required": ["keyword"]}} +``` +宿主完全没写 Agent 代码,工具就"长出来了"。 + +## 自检 + +1. 为什么必须判断 `fn.__module__`?删掉这行会发生什么(可以在 host_a 顶部加个 import 试试)? +2. 参数 `limit: int = 5` 会被翻译成什么 schema?必填吗? +3. `Optional[str]` 经过 `_annotation_to_json_type` 返回什么? +4. 给 host_c 新增一个带两个参数的函数,跑 smoke 观察自动发现结果。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/04-tools.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/04-tools.md new file mode 100644 index 0000000..77ded0d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/04-tools.md @@ -0,0 +1,146 @@ +# 逐行讲解 04 · `tools/`:注册表与执行总线 + +> 两个文件分工:`registry.py` 管"有哪些工具",`executor.py` 管"怎么安全地调用一次工具"。 +> 知识点:类的封装、字典即存储、自定义异常、列表推导、async/sync 统一、错误回灌。 + +## A. `tools/registry.py` + +### A1. 初始化(L8-L12) +```python +class ToolRegistry: + def __init__(self) -> None: + self._tools: dict[str, ToolSpec] = {} +``` +- 内部就是一个字典:`工具名 -> ToolSpec`。**数据结构选对,代码就简单**:查找天然 O(1)。 +- `_tools` 下划线开头 = "外部别直接碰,走我的方法"(封装约定)。 + +### A2. 注册(L14-L21) +```python +def register(self, spec: ToolSpec) -> None: + if spec.name in self._tools: + raise ValueError(f"工具 {spec.name!r} 已注册,名称必须唯一") + self._tools[spec.name] = spec + +def register_many(self, specs: Iterable[ToolSpec]) -> None: + for spec in specs: + self.register(spec) +``` +- 重名立刻报错(快速失败),否则后注册的会悄悄覆盖前者,极难排查。 +- `register_many` 只是循环调用单个注册,**复用而不是重写**。 +- `Iterable[ToolSpec]`:任何可迭代对象(列表/生成器)都行,比写死 `list` 更宽松。 + +### A3. 查询(L23-L35) +```python +def get(self, name: str) -> ToolSpec: + if name not in self._tools: + raise KeyError(f"未知工具 {name!r},当前可用:{list(self._tools)}") + return self._tools[name] + +def has(self, name: str) -> bool: + return name in self._tools + +def all(self) -> list[ToolSpec]: + return list(self._tools.values()) + +def llm_schemas(self) -> list[dict]: + return [spec.llm_schema() for spec in self._tools.values()] +``` +- `get` 找不到时把"当前有哪些工具"一并报出来,方便模型/开发者纠错。 +- `all()` 返回 `list(...)` 而不是直接给内部字典视图,防止外部改坏内部状态(防御性拷贝)。 +- `llm_schemas` 用列表推导式把每个 ToolSpec 翻译成模型要的格式——一行等价一个 for 循环。 + +### A4. 描述与长度(L37-L44) +```python +def describe(self) -> str: + if not self._tools: + return "(当前宿主没有提供任何工具)" + return "\n".join(f"- {s.name}: {s.description}" for s in self._tools.values()) + +def __len__(self) -> int: + return len(self._tools) +``` +- `describe()` 生成系统提示词里的"能力清单"文本。 +- `__len__` 是**魔术方法**:实现后就能写 `len(registry)`,router 里 `len(registry) == 0` 就是这么来的。 + +## B. `tools/executor.py` + +### B1. 构造(L19-L27) +```python +def __init__(self, registry, policy, channel) -> None: + self.registry = registry + self.policy = policy + self.channel = channel +``` +- 执行器需要三样东西:从注册表找工具、问权限策略做决策、通过 channel 向人确认。这就是典型的**依赖注入**——需要什么就传什么,不自己 new,方便替换和测试。 + +### B2. execute 的骨架与返回约定(L29-L35) +```python +async def execute(self, name, arguments) -> tuple[list[AgentEvent], bool, str]: + """返回 (过程事件列表, 是否成功, 文本结果),永不向 Agent Loop 抛异常。""" + events: list[AgentEvent] = [] + if not self.registry.has(name): + return events, False, f"工具 {name!r} 不存在" +``` +- 三元组返回:过程中产生的**事件**、成功与否、给模型看的**文本结果**。 +- 铁律"永不抛异常":工具是宿主写的,可能出错;但工具出错绝不能让整个 Agent 崩掉,而是把错误文本回灌给模型,让它自己换办法(在 06 篇会看到这条错误文本如何进入下一轮消息)。 + +### B3. 权限闸门(L37-L46) +```python +decision = await self.policy.check(name, arguments) +if decision == PermissionDecision.ASK: + events.append(AgentEvent(EventType.PERMISSION_ASKED, {...})) + approved = await self.channel.confirm(name, arguments) + if not approved: + return events, False, f"用户/宿主拒绝执行工具 {name}" +if decision == PermissionDecision.DENY: + return events, False, f"权限策略拒绝执行工具 {name}" +``` +- `await policy.check(...)`:权限判断本身也允许是异步的(比如要查数据库/问远程)。 +- ASK:先发一个"正在请求权限"的事件(UI 可据此弹确认框),再通过 channel 等人确认。 +- DENY / 拒绝都走同一条"返回失败文本"的路。 + +### B4. 真正执行(L48-L65) +```python +events.append(AgentEvent(EventType.TOOL_CALL, {"tool": name, "arguments": arguments})) +spec = self.registry.get(name) +try: + result = spec.handler(**arguments) + if inspect.isawaitable(result): + result = await result + text = json.dumps(result, ensure_ascii=False, default=str) +except Exception as exc: + events.append(AgentEvent(EventType.TOOL_RESULT, {"tool": name, "ok": False, "error": str(exc)})) + return events, False, f"工具 {name} 执行出错: {type(exc).__name__}: {exc}" +``` +逐行: +- 先发 TOOL_CALL 事件(UI 上显示"正在调用 xxx")。 +- `spec.handler(**arguments)`:`**arguments` 把字典展开成关键字参数。模型给 `{"keyword":"周报"}`,等价于调用 `search_notes(keyword="周报")`。 +- **同步/异步统一**:宿主函数可能是普通函数也可能是 `async def`。先调用,若返回的是可等待对象(协程),就再 `await` 一下。这样两种宿主函数都支持。 +- `json.dumps(..., ensure_ascii=False)`:把任意 Python 对象(dict/list/数字)转成 JSON 文本;`ensure_ascii=False` 让中文直接显示而不是 `\uXXXX`;`default=str` 遇到不认识的类型就调 `str()` 兜底,不崩溃。 +- `except Exception as exc` 兜住所有业务异常,`type(exc).__name__` 取异常类名,组成错误文本返回。`# noqa: BLE001` 是告诉 Ruff"我知道这里捕获很宽,是故意的"。 + +```python +events.append(AgentEvent(EventType.TOOL_RESULT, {"tool": name, "ok": True, "preview": text[:200]})) +return events, True, text +``` +- 成功:发 TOOL_RESULT 事件,事件里只放前 200 字预览(避免 UI 被巨量数据刷屏),但**完整 text 返回给模型**。 + +## 一次工具调用的完整时序 + +``` +Loop 说:执行 search_notes({"keyword":"周报"}) + → Executor: 有这个工具吗? + → Policy: allow / deny / ask? + → 发 TOOL_CALL 事件 + → handler(**arguments),必要时 await + → json.dumps 成文本 + → 发 TOOL_RESULT 事件 + → 把 (events, ok, text) 交回 Loop +``` + +## 自检 + +1. 为什么工具出错不能 raise 到 Loop?错误最后以什么形式回到模型? +2. `**arguments` 展开的是哪一步产生的字典? +3. Executor 怎么同时支持同步函数和 async 函数? +4. 为什么 TOOL_RESULT 事件里只放 200 字预览,而返回值给全文? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/05-router-context.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/05-router-context.md new file mode 100644 index 0000000..fbd4406 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/05-router-context.md @@ -0,0 +1,227 @@ +# 逐行讲解 05 · `kernel/router.py` 与 `kernel/context.py` + +> Router 决定"用什么打法",Context 管理"发给模型的消息本"。都很短,但都是决策核心。 + +## A. `kernel/router.py`:自适应路由 + +### A1. 信号词表(router.py 顶部) +```python +_PLAN_HINTS = ("然后", "接着", "之后", "再把", "分步", "步骤", "先", "并且", + "同时", "对比", "整理成", "汇总成", "最终", "一共", "分别") +_ACTION_HINTS = ("查", "找", "搜", "列出", "统计", "计算", "导出", "获取", + "读取", "记录", "新增", "整理", "分析", "筛选", "生成", + # 显式的工具/MCP 调用意图(v0.2 接外部 MCP Server 后补齐) + "问一下", "查询", "调用", "工具", "mcp") +_CLARIFY_HINTS = ("随便", "你看着办", "什么都行", "帮我弄一下") +``` +- 三个元组就是三张"关键词表"。这是 v0.1 的确定性规则实现:**零成本、离线可跑、每条规则都能写单元测试**(见 `tests/test_router.py`)。 +- 元组而不是列表:这些表不该被运行时修改,元组语义更准确。 +- 最后五个词是一次真实回归补的:任务写"用 MCP 工具**问一下**……"时,旧词表没有任何动作信号,被路由成 direct,结果模型想调工具却拿不到工具清单,只能把工具调用写成文本。规则路由是 LLM 路由的兜底,**兜底也必须覆盖最直白的工具意图表达**。 + +### A2. 类与构造(L27-L29) +```python +class AdaptiveRouter: + def __init__(self, *, min_plan_steps: int = 2) -> None: + self.min_plan_steps = min_plan_steps +``` +- 预留参数 `min_plan_steps`(v0.2 LLM 分类器会用到)。v0.1 先占住扩展位,这叫"为变化留门,但不提前实现"。 + +### A3. classify 决策函数(L31-L47)——按顺序读,顺序就是优先级 +```python +def classify(self, task: str, registry: ToolRegistry) -> Strategy: + text = task.strip() + if not text: + return Strategy.CLARIFY +``` +- `strip()` 去掉首尾空白;空任务无法处理 → 反问。 + +```python + if any(h in text for h in _CLARIFY_HINTS) and len(text) < 12: + return Strategy.CLARIFY +``` +- `any(生成器)`:只要有一个信号词命中就为 True。 +- **为什么还要 `len(text) < 12`?** 防止误杀:一句很长、很具体但恰好含"随便"二字的任务不该被当成模糊请求。规则系统要靠这种"组合条件"降低误判。 + +```python + if len(registry) == 0: + return Strategy.DIRECT +``` +- 宿主一个工具都没有,再复杂也没法调工具,只能让模型直接回答。注意它在行动判断**之前**——能力边界优先于用户意图。 + +```python + # 拉丁字母提示词(如 mcp)大小写不敏感;中文提示词按原文匹配。 + lowered = text.lower() + wants_action = any( + (h in lowered) if h.isascii() else (h in text) for h in _ACTION_HINTS + ) + multi_step = sum(1 for h in _PLAN_HINTS if h in text) >= 1 + if multi_step and wants_action: + return Strategy.PLAN + if wants_action: + return Strategy.REACT + return Strategy.DIRECT +``` +- `wants_action`:像需要动手的任务。 +- `h.isascii()` 判断提示词是不是纯拉丁字母(如 `"mcp"`):是就对**小写化后的任务**匹配,让 "MCP"、"mcp"、"Mcp" 都能命中;中文词不受影响(中文没有大小写,`lower()` 也不会改它,但保持语义清晰)。 +- 生成器表达式里的三元表达式 `(h in lowered) if h.isascii() else (h in text)`:逐个提示词选择匹配文本,`any(...)` 有一个命中即 True。 +- `multi_step`:`sum(1 for h in ... if 命中)` 是"数命中了几个多步信号"的生成器写法;命中 ≥1 就算多步。 +- 决策优先级:**多步且要动手 → PLAN(先规划再执行);只动手 → REACT;其余 → DIRECT。** +- 最后一行注释解释了一个反直觉点:DIRECT 时模型并非被禁止用工具,只是首轮不塞工具清单(在 06 篇看 `use_tools`)。 + +> v0.2 已加 LLM 分类器(见 C 节),**规则实现保留作兜底**:模型分类失败/超时时回退到这里。 +> 这种"智能方案 + 确定性兜底"是生产级 Agent 的常见结构。 + +## B. `kernel/context.py`:消息本与 token 预算 + +### B1. 初始化(L9-L11) +```python +def __init__(self, system_prompt: str, *, max_chars: int = 24000) -> None: + self.max_chars = max_chars + self.messages: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt)] +``` +- 一次运行对应一个 Context。创建时**第一条永远是 system 消息**(角色设定 + 工具清单)。 +- v0.1 用字符数粗略估 token(中文约 1 字 ≈ 1~2 token),所以预算叫 `max_chars`;v2 再换精确的 tokenizer。 + +### B2. 追加并自动压缩(L13-L25) +```python +def add(self, message: ChatMessage) -> None: + self.messages.append(message) + self._compact() +``` +- 每次追加后立刻检查是否超预算,调用方不用操心。 + +```python +def _compact(self) -> None: + total = sum(len(m.content) for m in m for m in self.messages) # 示意 +``` +实际代码: +```python + total = sum(len(m.content) for m in self.messages) + i = 1 + while total > self.max_chars and i < len(self.messages) - 1: + total -= len(self.messages[i].content) + i += 1 + if i > 1: + self.messages = [self.messages[0], *self.messages[i:]] +``` +逐行: +- 先算所有消息内容总字符数(生成器求和)。 +- `i = 1`:**从下标 1 开始**,因为下标 0 是 system 消息,永远保留;最后一条也保留(`i < len-1`),因为它通常是当前问题。 +- while 循环:只要还超预算,就"虚拟跳过"第 i 条并累加 i。 +- `[self.messages[0], *self.messages[i:]]`:`*` 解包——保留 system,拼接从 i 开始的剩余消息,中间旧消息被丢弃。 +- 这是最简单的"滑动窗口"记忆压缩;v2 会换成摘要式压缩。 + +### B3. 输出给模型(L27-L28) +```python +def llm_messages(self) -> list[dict]: + return [m.to_llm_dict() for m in self.messages] +``` +- 把内部 ChatMessage 列表统一转成模型接口要的字典列表。再次体现"内部对象、边界翻译"。 + +## C. LLM 路由器(v0.2):模型分类 + 规则兜底 + +规则路由零成本、可测试,但覆盖不了千变万化的自然表达("用 MCP 工具问一下"就是补了 +又补的例子)。v0.2 增加一次**轻量模型分类**:让模型直接输出结构化路由 JSON, +解析失败/超时/非法输出时**自动回退 A 节的规则**。规则实现一行没删,它是兜底。 + +### C1. 决策记录 RouteDecision(dataclass) +```python +@dataclass +class RouteDecision: + strategy: Strategy # 四种策略之一 + source: str # "llm"(模型分类)或 "rules"(规则,含兜底) + reason: str # 人话理由,随事件流给用户/评委看 + tier: str = "standard" # 建议模型档位:standard 便宜快 / strong 给规划等重活 +``` +- 规则路径以前只返回一个 `Strategy` 枚举,现在统一返回 `RouteDecision`—— + **每个决策都带来源、理由、档位**,对应红线"每个自适应决策必须发事件、禁止静默决策"。 +- `classify()`(同步,返回枚举)保留给外部兜底调用;内部新增 `_rules()` 返回完整记录。 + +### C2. 规则路径改造 `_rules()` +原来 `classify` 里的每个 `return Strategy.X` 都换成 +`return RouteDecision(Strategy.X, "rules", "理由", 档位)`,判定顺序与条件完全不变; +`classify()` 变成一行:`return self._rules(task, registry).strategy`。 +注意 plan 分支带 `"strong"`:多步规划是重活,建议用强模型(见 06 篇 `_make_plan`)。 + +### C3. 异步入口 `aclassify()`——短路、尝试、兜底 +```python +async def aclassify(self, task, registry) -> RouteDecision: + text = task.strip() + if not text or self.model is None or len(registry) == 0: + return self._rules(task, registry) # 三种情况不浪费模型调用 + try: + raw = await asyncio.wait_for( + self._llm_classify(text, registry), timeout=self.classify_timeout) + return self._parse(raw, registry) + except Exception as exc: # 网络/超时/JSON 非法/策略越界 + fallback = self._rules(task, registry) + fallback.reason = f"LLM 分类失败({type(exc).__name__}),回退规则:{fallback.reason}" + return fallback +``` +- **三个短路条件**:空任务(必澄清)、没配模型(纯规则部署)、宿主无工具(必 direct)—— + 能力边界判断永远在本地,不花一次模型调用,也不会被模型带偏。 +- `asyncio.wait_for` 给分类加硬超时(默认 15s):路由是主流程的第一道关,不能卡死。 +- `except Exception` 兜底一切:**分类是增强而不是依赖**。失败理由里写清异常类型, + 事件流里能直接看到"这次为什么走了规则"。 + +### C4. 分类调用 `_llm_classify()` +```python +prompt = ("你是嵌入式 Agent 的任务路由器。……只输出一个 JSON 对象……\n" + '{"strategy": "direct|react|plan|clarify", "tier": "standard|strong",' + ' "reason": "不超过30字的中文理由"}\n' + "策略判定标准:……\n宿主可用工具:\n{逐行 name: description}\n任务:{task}") +resp = await self.model.achat(messages, tools=None, tier="standard") +``` +- `tools=None`:分类轮**不给工具清单的 function-calling 形式**,而是把工具的 + name/description 以文本列进提示词——分类只需要"看得懂有什么能力",不调工具。 +- 固定 `tier="standard"`:路由本身用最便宜快的模型/档位,成本一次调用、几十 token。 +- 走的还是 SPI 的 `ModelProvider.achat`:DeepSeek、离线假模型、未来的本地模型一视同仁。 + +### C5. 解析与校验 `_parse()` +```python +fenced = re.search(r"\{.*\}", text, flags=re.DOTALL) # 容忍 ```json 围栏与前后啰嗦 +data = json.loads(fenced.group(0)) +if data["strategy"] not in {s.value for s in Strategy}: + raise ValueError(...) # 非法策略 -> 触发兜底 +if len(registry) == 0 and strategy in (REACT, PLAN): + raise ValueError(...) # 模型让调不存在的工具 -> 兜底 +tier = data.get("tier", "standard") +if tier not in ("standard", "strong"): + tier = "standard" # 档位非法:降级而不是报错 +``` +- 用"截取第一个 `{` 到最后一个 `}`"容忍模型套代码块或说客套话(实测常见)。 +- **校验而不是信任模型输出**:策略值必须是四枚举之一;无工具却选工具策略直接判非法; + 只有档位字段采取"非法即归一化"的宽容策略(它不影响安全,只影响成本)。 + +### C6. 接线:AgentCore 开关 + Loop 事件 +- `AgentCore(..., llm_router=False)`:默认关闭(零额外调用、离线测试完全确定); + 开启时构造 `AdaptiveRouter(model=model)`,把同一个模型后端注入路由器。 +- `AgentLoop.astream` 改为 `decision = await self.router.aclassify(...)`, + `STRATEGY_SELECTED` 事件数据从 `{"strategy"}` 扩成 + `{"strategy","source","reason","tier"}`(strategy 键保留,老断言不破)。 +- `_make_plan(..., tier=decision.tier)`:规划轮的模型档位由路由决策建议, + 规则路由的 plan 默认 strong,LLM 路由可按需给 standard。 + +### C7. 实测(DeepSeek) +- "你好,简单介绍一下你自己" → `direct / llm / "闲聊自我介绍,无需调用工具"`; +- "搜索笔记里关于比赛的内容并总结" → `react / llm / "搜索笔记即可,单步工具调用"`; +- 分类输出乱码/超时时自动回退规则,事件 reason 以"LLM 分类失败(…),回退规则:"开头。 + +## 两个文件如何协作 + +Loop 开始时: +1. `await router.aclassify(任务, 注册表)` → 得到 RouteDecision(LLM 或规则兜底); +2. `Context(系统提示词)` → 建消息本; +3. 之后每轮模型回复、工具结果都 `ctx.add(...)`,超预算自动压缩; +4. 每次调模型前 `ctx.llm_messages()` 取最新快照。 + +## 自检 + +1. classify 的四个 if 顺序能不能调换?为什么"没有工具"必须排在前面? +2. "随便帮我写个周报框架,要包含本周进展和下周计划"会被路由成什么?为什么? +3. `_compact` 为什么从下标 1 开始、且保留最后一条? +4. 给 `_ACTION_HINTS` 加一个你常用的动词,并在 `tests/test_router.py` 加对应测试。 +5. LLM 分类失败有哪几种情况?代码分别在哪里兜底?为什么兜底理由要写进事件? +6. 分类轮为什么 `tools=None`、tier 固定 standard? +7. 模型返回 `{"strategy":"react"}` 但宿主一个工具都没有,怎么走?为什么? +8. `llm_router` 为什么默认关闭?哪些部署形态会希望它关着? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/06-loop.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/06-loop.md new file mode 100644 index 0000000..ee9cfff --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/06-loop.md @@ -0,0 +1,213 @@ +# 逐行讲解 06 · `kernel/loop.py`:Core 的心脏(最重要,慢读) + +> 建议配合断点读:在 `astream`、`_react_cycle` 里设断点,跑 `scripts/smoke_test.py` 看每一步。 +> 知识点:异步生成器、`yield` 事件流、async for、递归重入、ReAct 循环、消息角色闭环。 + +## 块 1 · 系统提示词模板(L27-L33) +```python +_SYSTEM_TEMPLATE = """你是运行在宿主软件内部的 AI 助手。\ +你只能通过"工具"操作宿主的能力,不要编造工具不存在的数据。\ +当任务完成时,直接给出可交付的最终结果。 + +宿主当前提供的能力: +{tools} +""" +``` +- 这是发给模型的"人设与规矩"。`{tools}` 占位符运行时被 `registry.describe()` 替换。 +- 行尾反斜杠 `\` 表示字符串里不换行(三句人设连成一段),空行才真正分段。 + +## 块 2 · 构造(L36-L54) +```python +def __init__(self, model, registry, executor, channel, memory, + router: AdaptiveRouter | None = None, *, max_iters: int = 6) -> None: + ... + self.router = router or AdaptiveRouter() + self.max_iters = max_iters +``` +- 五大依赖全部注入。`router or AdaptiveRouter()`:调用方没给就用默认款(`None or x` 结果是 x,这是常见的默认值惯用法)。 +- `max_iters=6`:工具循环最多 6 轮,**防止模型反复调工具停不下来**(失控保护)。 + +## 块 3 · astream:总调度(L56-L92) + +```python +async def astream(self, task: str) -> AsyncIterator[AgentEvent]: +``` +- 关键语法:`async def` + 函数体内有 `yield` = **异步生成器**。调用它得到一个"异步事件流",外部用 `async for event in core.astream(...)` 逐个取事件。好处:任务还在跑,UI 就能实时显示"正在调工具…",而不是干等 30 秒拿一个最终结果。 + +```python + decision = await self.router.aclassify(task, self.registry) + strategy = decision.strategy + yield AgentEvent(EventType.STRATEGY_SELECTED, { + "strategy": strategy.value, "source": decision.source, + "reason": decision.reason, "tier": decision.tier}) +``` +- 第一步决策策略,立刻"喊出来"(发事件)。`aclassify` 是 v0.2 的异步入口: + 配置了 LLM 路由就先模型分类、失败回退规则,返回带 `source/reason/tier` 的 RouteDecision + (见 05 篇 C 节);事件里保留 `strategy` 键,老的消费方不受影响。 + +```python + if strategy == Strategy.CLARIFY: + yield AgentEvent(EventType.CLARIFY_REQUESTED, {"question": task}) + answer = await self.channel.ask(task) + async for ev in self.astream(answer): + yield ev + return +``` +- 澄清分支:发事件 → 通过 channel 问用户拿到回答 → **用回答重新调用自己**(递归重入,重新走一遍路由)→ 把内部流的事件继续往外 yield(`async for ... yield` 叫"代理转发")→ `return` 结束本次。 + +```python + ctx = Context(_SYSTEM_TEMPLATE.format(tools=self.registry.describe())) + for msg in self.memory.history(): + ctx.add(msg) + ctx.add(ChatMessage(role="user", content=task)) +``` +- 建消息本:system(含工具清单)→ 历史记忆 → 当前用户任务。 +- `str.format(tools=...)` 填充模板。 + +```python + if strategy == Strategy.PLAN: + async for ev in self._make_plan(ctx, task): + yield ev +``` +- plan 策略:先规划(额外一轮强模型调用),事件同样转发。 + +```python + # 计划只是"助手说过的话",必须再推一把,模型才会进入工具执行; + # 否则真实模型会把计划本身当成最终答复(离线脚本模型曾掩盖此问题)。 + ctx.add( + ChatMessage( + role="user", + content="请按上面的计划逐步调用工具执行,拿到全部结果后给出最终汇报。", + ) + ) +``` +- **这是接真实模型后才暴露、离线测试没抓到的坑**(Day 1-3 修复)。 +- 拆完计划后,消息本最后两条是 `user:拆步骤指令 → assistant:计划文本`,对话停在"助手刚说完计划"。真实模型会认为轮次该结束了,于是把计划原文当最终答复,**一个工具都不调**。 +- 修复办法:补一条 user 消息明确下令"按计划执行"。这模拟了多轮对话里用户的追问,模型才会带着计划进入下面的 ReAct 循环。 +- 教训:**离线脚本模型(ScriptedModel)只会按剧本走,测不出"模型愿不愿意继续"这类问题**;关键路径必须用真实模型回归一次。 + +```python + final_text = "" + async for ev in self._react_cycle(ctx, use_tools=strategy != Strategy.DIRECT): + yield ev + if ev.type == EventType.MODEL_MESSAGE: + final_text = ev.data.get("text", final_text) +``` +- 进入主循环。`use_tools=strategy != DIRECT`:只有 react/plan 才把工具清单给模型;direct 首轮不给。 +- 一边转发事件,一边顺手记下最终文本(MODEL_MESSAGE 事件里)。`dict.get(k, 默认)` 找不到键时保留旧值。 + +```python + await self.memory.append_history(ChatMessage(role="user", content=task)) + await self.memory.append_history(ChatMessage(role="assistant", content=final_text)) + yield AgentEvent(EventType.DONE, {"strategy": strategy.value, "final_text": final_text}) +``` +- 收尾:把这一轮问答写进记忆,发 DONE 事件(携带最终结果)。 + +## 块 4 · _make_plan:先拆步骤(L94-L106) +```python +prompt = ("把下面的任务拆成 2-5 个可执行步骤,每行一个步骤,用 1. 2. 3. 编号," + "只输出步骤本身:\n" + task) +ctx.add(ChatMessage(role="user", content=prompt)) +resp = await self.model.achat(ctx.llm_messages(), tools=None, tier="strong") +``` +- 拼一个"只做规划"的提示词,加进消息本。 +- `tools=None`:规划轮不许调工具,只输出文字。 +- `tier="strong"`:模型路由钩子,规划这种难活交给强模型。 + +```python +steps = [s.strip() for s in re.findall(r"\d+[.、)]\s*(.+)", resp.content)] +if not steps: + steps = [task] +plan_text = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(steps)) +ctx.add(ChatMessage(role="assistant", content=plan_text)) +yield AgentEvent(EventType.PLAN_CREATED, {"steps": steps}) +``` +- 正则 `\d+[.、)]\s*(.+)` 匹配 "1. / 1、/ 1)" 开头的步骤行,`(.+)` 捕获步骤正文。 +- 模型没按格式输出时兜底:把整个任务当成唯一步骤,**绝不让空计划卡住流程**。 +- 把计划作为 assistant 消息放回消息本——这样后续工具循环"看得到自己刚定的计划"。 +- 发 PLAN_CREATED 事件,UI 可展示计划。 + +## 块 5 · _react_cycle:ReAct 工具循环(L108-L164) + +### 5.1 循环开头 +```python +tools_schema = self.registry.llm_schemas() if use_tools else None +for _ in range(self.max_iters): +``` +- 准备工具清单(direct 时为 None)。 +- `for _ in range(n)`:`_` 表示"这个循环变量我用不到";最多 n 轮。 + +### 5.2 调模型,网络错误不崩 +```python +try: + resp = await self.model.achat(ctx.llm_messages(), tools=tools_schema) +except Exception as exc: + yield AgentEvent(EventType.ERROR, {"error": f"{type(exc).__name__}: {exc}"}) + return +``` +- 把当前消息本快照 + 工具清单发给模型。模型挂了(网络/Key/限流)就发 ERROR 事件并优雅结束,而不是抛栈崩溃。 + +### 5.3 模型不调工具 = 任务收尾 +```python +if not resp.tool_calls: + text = resp.content.strip() + ctx.add(ChatMessage(role="assistant", content=text)) + yield AgentEvent(EventType.MODEL_MESSAGE, {"text": text}) + return +``` +- 空列表/None 都算"没有工具调用"(`not []` 为 True)。 +- 模型给的自然语言就是最终答案:入消息本、发事件、`return` 结束循环。**循环的唯一正常出口在这里。** + +### 5.4 模型要调工具:先把"调用请求"记账 +```python +ctx.add(ChatMessage( + role="assistant", content=resp.content, + tool_calls=[{"id": tc.id, "type": "function", + "function": {"name": tc.name, + "arguments": json.dumps(tc.arguments, ensure_ascii=False)}} + for tc in resp.tool_calls], +)) +``` +- OpenAI 协议要求:assistant 发起工具调用时,要先以 assistant 角色把调用记录原样放回消息历史,否则下一轮会报"tool_call 找不到对应 assistant 消息"。 +- 注意 `arguments` 要序列化成 **JSON 字符串**(协议要求字符串,不是对象)。 + +### 5.5 逐个执行并把结果回填 +```python +for call in resp.tool_calls: + tool_events, ok, result_text = await self.executor.execute(call.name, call.arguments) + for tool_event in tool_events: + yield tool_event + ctx.add(ChatMessage(role="tool", content=result_text, + tool_call_id=call.id, name=call.name)) +``` +- 模型可能一轮要多个工具,逐个执行。 +- executor 返回的事件逐个转发(TOOL_CALL/TOOL_RESULT/PERMISSION_ASKED)。 +- **关键闭环**:每个工具结果以 `role="tool"` 消息放回消息本,并用 `tool_call_id` 对应到是哪次调用。下一轮模型就能"看到"工具返回了什么,再决定继续调还是收尾。 + +### 5.6 迭代上限兜底(L156-L164) +```python +# for 循环正常跑完(6 轮都在调工具、没收尾)才会走到这里 +ctx.add(ChatMessage(role="user", + content="已达到工具调用上限,请基于已有结果直接给出最终答案。")) +resp = await self.model.achat(ctx.llm_messages(), tools=None) +yield AgentEvent(EventType.MODEL_MESSAGE, {"text": resp.content.strip()}) +``` +- 如果 6 轮都没结束,强制再问模型一次且**不给工具**(想调也没得调),逼它基于已有观察交答案。这保证 run 一定会终止。 + +## 一张时序表(react 两轮的消息本变化) + +| 时刻 | 消息本内容(角色:内容) | +|---|---| +| 开始 | system:人设+工具清单 → user:任务 | +| 第1轮模型返回工具调用 | + assistant:我要调 search_notes | +| 执行后 | + tool:工具返回的 JSON | +| 第2轮模型给最终答案 | + assistant:最终自然语言 → MODEL_MESSAGE,结束 | + +## 自检(这篇必须全懂再往下) + +1. 异步生成器和普通 async 函数的区别?为什么 Agent 过程适合"边跑边 yield"? +2. clarify 分支是怎么重新进入路由的? +3. 为什么 assistant 的 tool_calls 和 tool 结果都必须写回消息本?缺了 tool_call_id 会怎样? +4. 循环有哪三个出口?(正常收尾 / 错误 / 上限兜底) +5. direct 策略下 `tools_schema` 是什么?模型此时还能调工具吗? +6. 为什么 plan 拆完步骤后必须再补一条 user 消息?离线脚本模型为什么没暴露这个问题? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/07-defaults.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/07-defaults.md new file mode 100644 index 0000000..30991f9 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/07-defaults.md @@ -0,0 +1,131 @@ +# 逐行讲解 07 · 默认实现:memory / policy / channels + +> SPI 只定契约,这四个文件是"开箱即用的默认款"。都很短,合在一起讲。 +> 知识点:默认实现与接口解耦、防御性拷贝、asyncio.to_thread、字典映射替代长 if。 + +## A. `memory/inmemory.py`:内存记忆 + +```python +class InMemoryStore: + def __init__(self) -> None: + self._history: list[ChatMessage] = [] + self._kv: dict[str, str] = {} +``` +- 两个私有容器:历史消息列表、键值小仓库。全在内存里,进程退出即清空——v0.1 够用,v2 换 SQLite 实现同一套方法即可持久化。 + +```python +async def append_history(self, message: ChatMessage) -> None: + self._history.append(message) + +def history(self) -> list[ChatMessage]: + return list(self._history) +``` +- 追加是 async(为了和 SPI 契约一致,将来换成数据库时本来就要 await)。 +- `history()` 返回 `list(...)` 浅拷贝:外部遍历/改这份列表不会动到内部存储。 + +```python +async def put(self, key, value) -> None: + self._kv[key] = value + +async def get(self, key) -> str | None: + return self._kv.get(key) + +async def clear(self) -> None: + self._history.clear() + self._kv.clear() +``` +- KV 读写;`dict.get` 找不到返回 None;clear 同时清空两块。 +- **对照 SPI 看**:方法签名和 `spi/memory.py` 一一对应,但不需要继承——这就是第 02 篇说的结构化子类型。 + +## B. `policy/allowlist.py`:白名单权限 + +```python +def __init__(self, allowed: Iterable[str] = (), *, mode: str = "auto") -> None: + self.allowed = set(allowed) + self.mode = mode +``` +- `allowed` 白名单;转成 `set` 让"是否在白名单"的判断 O(1)。 +- 默认参数用不可变的空元组 `()` 而不是空列表/集合(可变默认参数坑,和 dataclass 那条同理)。 +- 三种模式:`auto`(白名单放行,其余询问)、`allow_all`(全自动,演示/测试用)、`deny_all`(默认拒绝,最保守)。 + +```python +async def check(self, tool_name: str, arguments: dict) -> PermissionDecision: + if tool_name in self.allowed: + return PermissionDecision.ALLOW + if self.mode == "allow_all": + return PermissionDecision.ALLOW + if self.mode == "deny_all": + return PermissionDecision.DENY + return PermissionDecision.ASK +``` +- 决策顺序:白名单优先(任何模式下都放行)→ 再看模式 → auto 兜底返回 ASK。 +- 在 `core.py` 里,`auto_approve_tools=True`(默认)会选 `allow_all`,让你本地跑 demo 不被确认打断;做真实产品时关掉它走 ASK。 + +## C. `channels/collect.py`:收集通道(测试/API 用) + +```python +class CollectChannel: + def __init__(self, *, auto_confirm: bool = True, default_answer: str = "") -> None: + self.events: list[AgentEvent] = [] + self.auto_confirm = auto_confirm + self.default_answer = default_answer + + async def emit(self, event: AgentEvent) -> None: + self.events.append(event) + + async def ask(self, question: str) -> str: + return self.default_answer + + async def confirm(self, tool_name, arguments) -> bool: + return self.auto_confirm +``` +- 三个方法正好实现 SPI Channel:emit 把事件存进列表(事后可断言/可转 JSON)、ask 给预设回答、confirm 给预设是否批准。 +- **它为什么重要**:测试和 HTTP API 没有"真人"可问,必须有一个不交互、只记录的通道。`tests/test_loop.py` 断言事件序列、FastAPI Battery 返回事件数组,靠的都是它。 + +## D. `channels/cli.py`:终端通道(人看的) + +```python +_LABELS = { + EventType.STRATEGY_SELECTED: "策略", + EventType.PLAN_CREATED: "计划", + EventType.MODEL_MESSAGE: "回复", + EventType.TOOL_CALL: "调用工具", + ... +} +``` +- 用字典把事件类型映射成中文标签。**用字典查表代替一长串 if/elif**,是这段代码想让你学的手法。 + +```python +async def emit(self, event: AgentEvent) -> None: + label = _LABELS.get(event.type, event.type.value) + if event.type == EventType.TOOL_CALL: + print(f" [{label}] {event.data['tool']}({event.data['arguments']})") + elif event.type == EventType.STRATEGY_SELECTED: + print(f" [{label}] {event.data['strategy']}") + elif event.type == EventType.MODEL_MESSAGE: + print(f" [{label}] {event.data.get('text', '')}") + else: + print(f" [{label}] {event.data}") +``` +- 不同事件 data 结构不同,所以分三种定制打印,其余直接打印字典。 +- `data['tool']`(确定有键,缺了是 bug 该报错)vs `data.get('text','')`(可空,给默认)——**两种取值方式的选择体现你对数据是否必填的判断**。 + +```python +async def ask(self, question: str) -> str: + return await asyncio.to_thread(input, f" [澄清] {question} > ") +``` +- **重点难点**:`input()` 是阻塞函数,会卡住整个事件循环!`asyncio.to_thread(input, 提示)` 把它丢到另一个线程执行,await 其结果,既保留同步输入体验又不阻塞异步循环。confirm 同理。 +- `answer.strip().lower() in ("y","yes","是")`:去空白、转小写再判断,兼容 `Y / yes / 是`。 + +## 四个默认款的共同设计套路 + +1. 都实现 SPI 协议但不继承它; +2. 构造零参数或带安全默认值,做到"装上就能跑"; +3. 需要真人/外部系统的地方留好替换点(channel.ask/confirm)。 + +## 自检 + +1. 为什么 `history()` 要返回拷贝?直接 `return self._history` 有什么风险? +2. 默认参数为什么写 `allowed=()` 而不是 `allowed=[]` 或 `set()`? +3. 为什么不能在 async 函数里直接调 `input()`?`asyncio.to_thread` 解决了什么? +4. 试着写一个只打印 TOOL_CALL 事件、忽略其他事件的 QuietChannel,接进 AgentCore 跑一次。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/08-model-and-core.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/08-model-and-core.md new file mode 100644 index 0000000..0af6cb8 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/08-model-and-core.md @@ -0,0 +1,164 @@ +# 逐行讲解 08 · `llm/openai_compat.py` 与 `core.py` + +> 前者把"任意 OpenAI 兼容模型"插进 ModelProvider 插槽;后者是宿主认识的唯一门面。 + +## A. `llm/openai_compat.py`:真实模型适配 + +### A1. 懒加载第三方库(L13-L33) +```python +def __init__(self, *, api_key=None, base_url=None, model=None, strong_model=None): + try: + from openai import AsyncOpenAI + except ImportError as exc: + raise ImportError( + "使用 OpenAICompatProvider 需要安装可选依赖:uv pip install -e '.[llm]'" + ) from exc +``` +- **import 写在方法内部 = 懒加载**:只有真正实例化模型时才要求装 openai。这样内核在"没装 openai、只用离线假模型"时也能 import、能测试——零硬依赖原则的落地。 +- `raise ... from exc`:保留原始错误链(caused-by),调试时能看到根因。 +- `AsyncOpenAI`:openai 库的异步客户端,和我们全异步的 Loop 配套。 + +```python +self.client = AsyncOpenAI( + api_key=api_key or os.getenv("OPENAI_API_KEY", "missing"), + base_url=base_url or os.getenv("OPENAI_BASE_URL"), +) +self.model = model or os.getenv("LLM_MODEL", "deepseek-chat") +self.strong_model = strong_model or os.getenv("LLM_STRONG_MODEL") or self.model +``` +- 参数优先,其次读环境变量(`.env` 里配置),再给默认值。`A or B or C` 链式兜底再次出现。 +- **为什么换个 base_url 就能接 DeepSeek/通义**:它们都提供 OpenAI 兼容接口,请求格式一样,只是地址和模型名不同。这就是"模型无关"。 + +### A2. achat:内部消息 → 厂商请求(L35-L49) +```python +async def achat(self, messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, *, tier="standard"): + # messages 由 Context.llm_messages() 产出,已经是 OpenAI 线格式 dict + kwargs: dict[str, Any] = { + "model": self.strong_model if tier == "strong" else self.model, + "messages": messages, + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = "auto" +``` +- 根据 tier 选模型:规划用强模型,平时用普通模型(省钱/快)。 +- **消息在这里只透传、不转换**:`Context.llm_messages()` 已经把内部 `ChatMessage` 逐个 `to_llm_dict()` 转成了 OpenAI 线格式 dict(见第 05 篇),Provider 直接转发即可。这里最初多写了一层 `to_llm_dict()`,对 dict 再调一次方法,真接 DeepSeek 必崩(Day 1-3 修复,离线假模块没暴露)——**格式转换只做一次,且在边界上做**。 +- 形参类型因此是 `list[dict[str, Any]]`:Provider 插槽面向"厂商线格式",不认识内核的 ChatMessage 类。 +- 只有确实有工具时才传 tools,并设 `tool_choice="auto"`(让模型自行决定是否调用)。 + +```python +completion = await self.client.chat.completions.create(**kwargs) +msg = completion.choices[0].message +``` +- 真正的网络调用,await。返回结构里第 0 个 choice 的 message 就是模型回复。 + +### A3. 解析工具调用,抹平厂商形状(L54-L62) +```python +tool_calls: list[ToolCallRequest] = [] +for tc in getattr(msg, "tool_calls", None) or []: + try: + args = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCallRequest(id=tc.id, name=tc.function.name, arguments=args)) +return ModelResponse(content=msg.content or "", tool_calls=tool_calls, raw=completion) +``` +- `getattr(msg, "tool_calls", None)`:属性可能不存在,安全取值;`... or []`:存在但为 None 时也变空列表。 +- 模型给的参数是 **JSON 字符串**,`json.loads` 解析成字典;模型偶尔会吐出非法 JSON,解析失败就用空字典兜底,不崩。 +- 最终统一返回我们自己的 `ModelResponse`——**厂商 SDK 的数据结构到此为止,不许泄漏进内核**,以后换厂商只改这一个文件。 + +## B. `core.py`:门面(Facade) + +### B1. 构造 = 把所有零件组装起来(L22-L47) +```python +def __init__(self, model, *, channel=None, memory=None, policy=None, + router=None, auto_approve_tools=True, llm_router=False): + self.model = model + self.registry = ToolRegistry() + self.channel = channel or CollectChannel() + self.memory = memory or InMemoryStore() + self.policy = policy or AllowlistPolicy( + mode="allow_all" if auto_approve_tools else "auto") + if router is not None: + self.router = router # 显式传入优先 + else: + self.router = AdaptiveRouter(model=model if llm_router else None) + self.executor = ToolExecutor(self.registry, self.policy, self.channel) + self._loop = AgentLoop(model=self.model, registry=self.registry, + executor=self.executor, channel=self.channel, + memory=self.memory, router=self.router) +``` +- 组装顺序:先建无依赖的注册表/默认组件 → 再建执行器(依赖注册表、策略、通道)→ 最后建 Loop(依赖全部)。**依赖顺序就是创建顺序。** +- 每个 SPI 都是"传了用你的,没传用默认款"。宿主可以只给一个 model,其他全自动。 +- `llm_router`(v0.2):开启才把 model 注入路由器做模型分类(失败回退规则,见 05 篇 C 节); + 默认关闭,离线测试与极简部署零额外模型调用。 +- `_loop` 下划线:外部不该直接操作发动机,走门面方法。 + +### B2. auto:一行接入(L51-L56) +```python +@classmethod +def auto(cls, host: object, model: ModelProvider, **kwargs: object) -> AgentCore: + core = cls(model, **kwargs) + core.register_tools(discover(host)) + return core +``` +- `@classmethod` + 第一个参数 `cls`:这是"工厂方法",`AgentCore.auto(...)` 内部先构造自己、再自动发现宿主能力并注册、返回装好的 core。 +- `**kwargs` 把 channel/policy 等自定义参数透传给构造函数,保持 auto 也能高度定制。 +- **这就是 YAI 的招牌三行**:`core = AgentCore.auto(宿主, 模型)` → 能力自动长出来。 + +### B3. 工具查看(L58-L65) +```python +def register_tools(self, specs): self.registry.register_many(specs) + +def list_tools(self): + return [{"name": s.name, "description": s.description, "source": s.source} + for s in self.registry.all()] +``` +- 手动补注册的入口(除 auto 外,也允许手工加工具)。 +- `list_tools` 返回精简字典(不含 handler 这种不能序列化的东西),供 `/v1/tools` API 直接吐 JSON。 + +### B4. 两种运行方式(L69-L91) +```python +async def astream(self, task: str) -> AsyncIterator[AgentEvent]: + async for event in self._loop.astream(task): + await self.channel.emit(event) + yield event +``` +- 流式:转发 Loop 事件的**唯一出口**,并在这里统一 `channel.emit`。注意第 04 篇重构后,Executor 不再自己发事件,所有事件都从这条路径经过——**全项目事件流只有一条路径**,便于排查。 + +```python +async def run(self, task: str) -> RunResult: + events, final_text, strategy = [], "", None + async for event in self.astream(task): + events.append(event) + data = event.data + if "strategy" in data: + from yai_core.types import Strategy + strategy = Strategy(data["strategy"]) + if event.type.value == "done": + final_text = data.get("final_text", "") + return RunResult(strategy=strategy or self.router.classify(task, self.registry), + events=events, final_text=final_text) +``` +- `run` 是"懒人版":内部消费 astream,把事件攒起来,最后打包成 RunResult。 +- 从事件数据里反推策略;兜底再 classify 一次(防御)。 +- 函数内 import Strategy 只是为了避免顶部循环导入的写法,你也可以放到顶部(这里没有真正的环,后续可简化)。 + +## 两条接入路径对比 + +```python +# 路径 1:要实时过程(CLI/Web SSE/Flutter 流式 UI) +async for ev in core.astream(task): # 边跑边渲染 + +# 路径 2:只要最终结果(脚本/HTTP 普通响应) +result = await core.run(task) # result.final_text / .events +``` + +## 自检 + +1. openai 为什么在 `__init__` 里才 import?顶层 import 会破坏什么? +2. DeepSeek、通义千问为什么能用同一个 Provider?要改哪几个配置? +3. ChatMessage → 厂商 dict 的转换发生在哪一层?为什么 Provider 里不能再转一次? +4. `AgentCore.auto` 内部按什么顺序组装零件? +5. `run` 和 `astream` 分别适合什么场景?为什么说事件流只有一条路径? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/09-battery.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/09-battery.md new file mode 100644 index 0000000..0bb3234 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/09-battery.md @@ -0,0 +1,154 @@ +# 逐行讲解 09 · `batteries/fastapi_server/app.py`:把 Core 变成在线 API + +> Battery = 可选外围。内核不依赖 FastAPI;只有要部署成 HTTP 服务时才用它。 +> 知识点:可选依赖的导入守卫、Pydantic 请求模型、闭包捕获、路由装饰器、JSON 安全序列化。 +> 配套启动示例:`scripts/serve_example.py`。 + +## 块 1 · 依赖守卫(L15-L21) +```python +try: + from fastapi import FastAPI + from pydantic import BaseModel +except ImportError as exc: + raise ImportError( + "FastAPI Battery 需要可选依赖:uv pip install -e '.[server]'" + ) from exc +``` +- 模块被 import 时就尝试引入 fastapi/pydantic;没装就给出**可操作的安装提示**,而不是让用户面对一堆难懂的 traceback。 +- 注意:这个文件属于 Battery,允许依赖第三方;`yai_core` 内核文件(types/kernel/spi...)依然零硬依赖。边界要清楚。 + +## 块 2 · 请求体模型(L24-L25) +```python +class RunRequest(BaseModel): + task: str +``` +- Pydantic 模型:声明 HTTP 请求体长什么样。客户端 POST `{"task": "..."}`,FastAPI 自动校验:缺字段/类型错会直接返回 422,并把 JSON 转成 `req.task`。 +- **踩过的坑(真实记录)**:这个类最初写在 `create_app` 函数内部,FastAPI 无法把闭包里的模型识别成请求体,报 422 query 参数缺失。提到模块级后正常——所以框架的"魔法"也有边界,遇到怪错先怀疑作用域。 + +## 块 3 · create_app:工厂函数 +```python +def create_app(core: Any, lifespan: Any = None) -> FastAPI: + app = FastAPI(title="YAI Agent Core API", version="0.1.0", lifespan=lifespan) + commit = os.getenv("YAI_GIT_COMMIT", "dev") + slug = os.getenv("YAI_PROJECT_SLUG", "yai-agent-core") +``` +- **应用工厂**:传入一个已组装好的 core,返回一个 HTTP app。这样同一份代码可以挂不同宿主的 core。 +- `core: Any`:Battery 不反向依赖内核具体类型,避免循环依赖,也方便测试时传假 core。 +- commit/slug 从环境变量读,部署 X-Agent 时注入真实 pinned commit;本地缺省 "dev"。 +- 可选 `lifespan` 形参(v0.2 加):把 FastAPI 的生命周期钩子(启动挂载外部 MCP、 + 关闭断开连接)留给宿主脚本注入,电池本身不认识 MCP——依赖方向仍然是"宿主 → 电池 → 内核"。 + 具体用法见第 10 章块 10。 + +## 块 4 · 四个路由(L33-L54) + +### 4.1 健康检查(X-Agent 硬门槛) +```python +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "commit": commit} +``` +- `@app.get(...)`:路由装饰器,把 URL 和函数绑定。 +- 返回 dict,FastAPI 自动转 JSON。X-Agent 审核机就靠这个端点确认服务在线且 commit 与提交一致。 + +### 4.2 验证文件(X-Agent 硬门槛) +```python +@app.get("/.well-known/xagent-verification.json") +async def verification() -> dict: + return {"schemaVersion": 1, "slug": slug, "commit": commit} +``` +- 固定路径、固定字段(官方契约)。`/.well-known/` 是业界放站点元信息的惯例目录。 + +### 4.3 工具清单 +```python +@app.get("/v1/tools") +async def list_tools() -> list[dict]: + return core.list_tools() +``` +- 直接复用门面方法,把"自动发现了哪些能力"暴露出去,评委 curl 一下就能看到宿主能力集。 + +### 4.4 执行任务 +```python +@app.post("/v1/agent/run") +async def run_agent(req: RunRequest) -> dict: + result = await core.run(req.task) + return { + "strategy": result.strategy.value, + "final_text": result.final_text, + "events": [ + {"type": e.type.value, "data": _jsonable(e.data)} for e in result.events + ], + } +``` +- 请求体由 Pydantic 自动解析成 `req`。 +- `await core.run(...)`:异步框架配异步内核,一个进程就能并发处理多个请求。 +- 响应三部分:用了什么策略、最终结果、**全过程事件**(这是 YAI 的差异化:别的黑盒 API 只给结果,我们给可审计过程)。 +- 列表推导式逐个事件转成可 JSON 化的字典。 + +## 块 5 · JSON 安全兜底(L59-L63) +```python +def _jsonable(data: dict) -> dict: + out: dict[str, Any] = {} + for k, v in data.items(): + out[k] = v if isinstance(v, str | int | float | bool | list | dict | None) else str(v) +``` +- 事件 data 里可能混进枚举、异常对象等不能直接 json.dumps 的东西。 +- `isinstance(v, 白名单类型)`:是 JSON 原生类型就原样保留,否则 `str(v)` 转字符串,保证响应永远不会因为序列化失败而 500。 +- 这是"边界层做防御"的典型:内部可以灵活,对外输出必须规整。 + +## 块 6 · 怎么启动(对照 `scripts/serve_example.py`) +```python +core = AgentCore.auto(capabilities, _model) # 组装内核(有 Key 用真模型,否则离线模型) +app = create_app(core) # 包成 HTTP 应用 +``` +启动脚本还做了两件部署相关的小事: +- `load_dotenv()`:自动读项目根 `.env`(Key、base_url、slug 都在里面),不用手动 set 环境变量; +- `YAI_GIT_COMMIT` 留空时自动执行 `git rev-parse HEAD` 取 40 位 commit——本地直跑永远和当前代码同源。 + +本地直跑(**宿主机统一用 8001**;8000 是容器内端口,且本机 8000 可能被别的程序占用): +```powershell +uv run uvicorn scripts.serve_example:app --host 127.0.0.1 --port 8001 +``` +然后: +```powershell +curl http://localhost:8001/health +curl -X POST http://localhost:8001/v1/agent/run -H "Content-Type: application/json" -d '{"task":"列出全部笔记"}' +``` + +## 块 7 · 容器化部署(对照 `Dockerfile` 与 `docker-compose.yml`) +端口约定:**容器内永远监听 8000**(Dockerfile 的 EXPOSE、HEALTHCHECK 都打 8000),宿主机映射 `8001:8000`。这样镜像可以原样部署到任何机器,宿主机端口冲突只改映射、不改镜像。 + +Dockerfile 的几个教学点: +```dockerfile +COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ # 从官方 uv 镜像里直接拷二进制 +ARG YAI_GIT_COMMIT=dev +ENV YAI_GIT_COMMIT=${YAI_GIT_COMMIT} # 构建时把 pinned commit 烤进镜像 +ARG INDEX_URL=https://pypi.org/simple +ENV UV_INDEX_URL=${INDEX_URL} # 网络受限时构建参数换国内镜像源 +HEALTHCHECK ... CMD python -c "urllib 请求 /health ..." # 容器级健康检查,打同一个端点 +``` +- **commit 在构建时固化**:镜像里没有 `.git`(被 `.dockerignore` 排除),运行时取不到哈希,所以用 `--build-arg YAI_GIT_COMMIT=$(git rev-parse HEAD)` 在构建期注入。注意 `.env` 里不要留 `YAI_GIT_COMMIT=` 空行——空值会在运行时覆盖镜像里烤好的值。 +- `.dockerignore` 把 `.env`、`.venv`、`.git`、`docs/` 全部挡在镜像外:密钥不进镜像、镜像只装跑服务需要的东西。 +- 一键起:`docker compose up --build`(compose 经 `env_file: .env` 注入密钥)。 +- 踩坑记录:容器内访问官方 PyPI 会超时,构建时加 `--build-arg INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple`;宿主机端口被占时换宿主端口(如 8002:8000),容器内 8000 不动。 +- **PaaS 平台的端口约定**:Render 这类平台不让你自己决定监听端口,它会注入 `PORT` 环境变量(Render 默认 10000),只把流量转发到容器的这个端口。所以 CMD 写成 shell 形式 `--port ${PORT:-8000}`:自管环境没有 `PORT` 就听 8000,上了平台自动听平台指定的端口。同理,平台还会注入"本次部署的 commit"(Render 是 `RENDER_GIT_COMMIT`),启动脚本按 `YAI_GIT_COMMIT → RENDER_GIT_COMMIT → git HEAD → dev` 的顺序解析,且只认完整 40 位哈希(镜像里烤的默认值 `dev` 不是合法哈希,会被自动跳过,平台注入的真实 SHA 才会生效——这是 Render 首部署时实测出来的坑),验证端点在任何环境都能报出真实版本。这就是"一次构建、处处可部署"的写法:**环境差异全部走环境变量,代码不写死。** + +## 一次 HTTP 请求的完整链路 +``` +客户端 POST /v1/agent/run + → Pydantic 校验请求体 + → core.run(task) + → Router 选策略 → Loop 跑 ReAct → Executor 调宿主函数 → 事件流 + → 结果经 _jsonable 规整 + → JSON 响应回客户端 +``` + +## 自检 + +1. 为什么 Pydantic 模型必须放模块级而不能放在 create_app 里? +2. `/health` 里的 commit 为什么要从环境变量读,而不是写死? +3. 为什么响应里要带 events?这对比赛评审意味着什么? +4. `_jsonable` 防的是什么问题?删掉它、在事件 data 里放一个枚举会怎样? +5. 把 host_c_companion 也包成一个 app(改 serve_example 即可),用 curl 跑通。 +6. 为什么 commit 要在构建镜像时用 ARG/ENV 固化,而不是容器启动后再读 git? +7. 容器内端口为什么固定 8000、宿主机却映射 8001?端口冲突时该改哪一边? +8. 部署到 Render 时为什么不需要改代码就能监听平台端口?`${PORT:-8000}` 这种写法叫什么、解决了什么问题? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/10-mcp-client.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/10-mcp-client.md new file mode 100644 index 0000000..b30eec4 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/10-mcp-client.md @@ -0,0 +1,333 @@ +# 逐行讲解 10 · `integrations/mcp/client.py`:把外部 MCP Server 的工具接进 Core + +> MCP(Model Context Protocol)是"工具界的 USB-C":任何 MCP Server 暴露的工具, +> 任何 MCP Client 都能用同一套协议调用,不用为每个服务写专用对接代码。 +> 本章实现的是 **MCP Client**(我们去用别人的工具);v0.1 红线明确不做 MCP Server。 +> 知识点:可选依赖懒加载、协议适配层、闭包按值捕获、JSON Schema 清洗、 +> 三种传输(内存 / stdio / HTTP)、协议返回值归一。 +> 配套示例:`examples/host_d_mcp/`(自带一个本地 stdio 演示 Server)。 + +## 块 0 · 先搞清楚 MCP 在架构里的位置 + +``` +AgentCore + └── ToolRegistry ← 工具唯一注册表,里面的每张"身份证"都是 ToolSpec + ├── Native Tools ← 宿主自己的函数(discovery 自动发现) + ├── MCP Tools ← 本章:外部 MCP Server 的工具,被包成 ToolSpec + └── (未来)OpenAPI Tools +``` +关键思想只有一句:**MCP 工具在注册表里和本地函数长得一模一样**。Router、Loop、 +Executor、权限策略、事件流全部零改动——它们根本不知道工具在本机还是在远端。 +适配层只负责两件事:`list_tools` 把远端工具"翻译"成 ToolSpec;调用时把 +`call_tool` 的协议返回值"翻译"回普通返回值/异常。 + +MCP Python SDK(`pip install mcp`)2026 年已进入 **v2 稳定线**(v2.x),高层 API: + +```python +from mcp import Client, StdioServerParameters +from mcp.server import MCPServer # 写 Server 用,本章测试/示例会用到 + +async with Client(target) as client: # target 三种形态,见块 3 + tools = (await client.list_tools()).tools # 列工具 + result = await client.call_tool("add", {"a": 1, "b": 2}) # 调工具 +``` + +## 块 1 · 模块定位与零依赖红线(L1-L19) +```python +from dataclasses import dataclass, field +from typing import Any +from yai_core.types import ToolSpec +``` +- 本文件**顶层只 import 标准库和内核自己的 types**,没有 `import mcp`。 +- 内核红线:`pyproject.toml` 的 `dependencies` 永远为空;mcp 放在可选依赖组 + `mcp = ["mcp>=2.0,<3"]` 里,用户 `uv sync --extra mcp` 才有。 +- 这样做的好处:只想要本地工具的宿主(比如未来内嵌进 AMBRACE 的移动端) + 不会被 mcp 那一长串传递依赖(anyio/httpx/pydantic…)拖累体积。 +- 测试里有一条 AST 静态检查(`test_no_toplevel_mcp_import_keeps_kernel_dependency_free`), + 用语法树保证谁都不能手滑在顶层 import mcp——**红线用测试守住,不靠自觉**。 + +## 块 2 · JSON Schema 白名单清洗(L22-L73) +```python +_SCHEMA_KEYS = frozenset({"type", "properties", "required", "items", "enum", ...}) + +def sanitize_schema(schema: Any) -> Any: + if isinstance(schema, dict): + cleaned: dict[str, Any] = {} + for key, value in schema.items(): + if key in ("properties", "$defs") and isinstance(value, dict): + cleaned[key] = {name: sanitize_schema(sub) for name, sub in value.items()} + elif key in _SCHEMA_KEYS: + cleaned[key] = sanitize_schema(value) + return cleaned or dict(_EMPTY_SCHEMA) + if isinstance(schema, list): + return [sanitize_schema(item) for item in schema] + return schema +``` +- MCP SDK 会从 Python 类型注解自动生成工具的入参 schema,但里面夹带实现私有键, + 实测形如 `{'title': 'echoArguments', 'properties': {'text': {'title': 'Text', ...}}}`。 + 个别 OpenAI 兼容接口对多余键敏感,喂给模型前递归只保留 JSON Schema 标准关键字。 +- **真实踩坑(测试先抓到的)**:第一版写成"所有键都过白名单",结果连 + `properties` 里的**属性名**(如 `text`)也被删了——白名单只该作用于 schema + 关键字层,`properties`/`$defs` 的键是用户定义的名字,必须原样保留、只清洗值。 +- `cleaned or 空schema`:清洗后若为空(Server 没给 schema),给一个最小合法的 + `{"type":"object","properties":{}}`,别让下游拿到空 dict。 +- `frozenset`:不可变常量集合,查找 O(1) 且不会被任何代码改坏。 + +## 块 3 · 连接配置 McpServerConfig(L76-L93) +```python +@dataclass +class McpServerConfig: + alias: str + url: str | None = None # ① Streamable HTTP,部署形态 + command: str | None = None # ② stdio 子进程命令 + args: list[str] = field(default_factory=list) + env: dict[str, str] | None = None + prefix: str = "" # 工具名前缀,多 Server 重名时去重 + server: Any = None # ③ MCPServer 实例,内存直连(测试用) +``` +三种 target 对应 MCP 的三种接法: +1. **url**:`https://host/mcp`,跨网络调远端服务(X-Agent 生态里的 MCP 服务多是这种); +2. **command+args**:SDK 帮你拉起一个本地子进程,通过 stdin/stdout 通信(stdio)。 + 好处是工具运行在独立进程,崩了不拖垮宿主;`env` 可以给它单独传密钥; +3. **server**:直接传 MCPServer 实例做内存直连,不起进程、不触网——**测试专用**, + 我们的离线测试全靠它,CI 里不需要任何网络。 +- `prefix`:两个 Server 都有 `search` 时,注册表会因重名报错;加 `"shop__"` 这类 + 前缀即可区分。注意前缀只改**本地注册名**,远端调用仍用原名(块 6)。 +- `field(default_factory=list)`:dataclass 里可变默认值不能写 `= []`(会被所有实例 + 共享,经典 Python 坑),必须用工厂函数。 + +## 块 4 · 协议返回值归一 _flatten_call_result(L95-L112) +```python +content = getattr(result, "content", []) or [] +if getattr(result, "is_error", False): + texts = [b.text for b in content if getattr(b, "text", None)] + raise RuntimeError("; ".join(texts) or "MCP 工具返回错误") +texts = [b.text for b in content if getattr(b, "text", None)] +if texts: + return "\n".join(texts) +structured = getattr(result, "structured_content", None) +if structured is not None: + return structured +return "" +``` +MCP `call_tool` 返回的 `CallToolResult` 有三种信息,实测(SDK v2.2): +- `content`:内容块列表,文本块是 `TextContent`,取 `.text`。这是 MCP **面向 LLM 的 + 规范输出**,所以优先用它; +- `structured_content`:结构化结果。注意两个实测细节:普通 `return "字符串"` 会被 + 包成 `{'result': '...'}`,而返回 Pydantic 模型则直接展开成 dict; +- `is_error`:**蛇形命名**(不是协议 JSON 里的 isError),工具抛 ToolError 时 + SDK 不向客户端抛异常,而是返回 `is_error=True` + 错误文本块。 +- 归一策略:错误 → 抛 `RuntimeError`,交给 ToolExecutor 既有的"异常回灌模型"路径 + (和本地工具报错走同一条路);正常 → 文本优先、结构化兜底、都没有给空串。 +- 全用 `getattr(..., 默认值)` 而不是直接取属性:对 SDK 小版本差异保持韧性。 + +## 块 5 · connect:懒加载、建连、列工具(L141-L154) +```python +async def connect(self) -> list[ToolSpec]: + try: + from mcp import Client, StdioServerParameters + except ImportError as exc: + raise ImportError( + "MCP 集成需要可选依赖:uv sync --extra mcp(或 pip install 'yai-agent-core[mcp]')" + ) from exc + self._client = Client(self._build_target(StdioServerParameters)) + await self._client.__aenter__() + listed = await self._client.list_tools() + self._specs = [self._to_spec(tool) for tool in listed.tools] + return self._specs +``` +- **懒加载**:`from mcp import ...` 写在方法体内。没装 mcp 的环境导入本模块不报错, + 只有真正 `connect()` 时才提示安装命令——`raise ... from exc` 保留原始报错链。 +- `Client(target)` 是异步上下文管理器,我们要把连接存起来跨多次调用使用, + 所以不写 `async with`,而是手动 `__aenter__()`,配对 `aclose()` 里的 `__aexit__()`。 +- `list_tools()` 返回分页结果(`.tools` + `.next_cursor`);v0.2 工具数量少,先取 + 首页,未来接大 Server 再补游标翻页(SDK 官方教程里有分页范式)。 +- 列表推导式把每个远端工具交给 `_to_spec` 翻译成 ToolSpec。 + +## 块 6 · _to_spec:一个远端工具 → 一张 ToolSpec(L168-L185) +```python +def _to_spec(self, tool: Any) -> ToolSpec: + remote_name = tool.name + local_name = f"{self.config.prefix}{remote_name}" + schema = sanitize_schema(getattr(tool, "input_schema", None)) or dict(_EMPTY_SCHEMA) + + async def handler(**kwargs: Any) -> Any: + result = await self._client.call_tool(remote_name, kwargs) + return _flatten_call_result(result) + + return ToolSpec(name=local_name, description=..., input_schema=schema, + handler=handler, source="mcp") +``` +- **闭包按值捕获**:`handler` 是异步闭包,`remote_name` 是 `_to_spec` 的局部变量, + 每次调用生成一个新闭包、各自记住自己的工具名。若图省事在循环体里用可变的 + 循环变量,所有闭包最后会指向同一个名字——这是闭包经典坑,这里天然避开。 +- 本地名可能带前缀,远端只认原名,所以闭包内部坚持用 `remote_name`。 +- `handler(**kwargs)`:Executor 调用规范就是"关键字参数展开",和本地函数完全一致; + 返回值交回 Executor 统一 `json.dumps`(所以字符串结果会被 JSON 编码成带引号形式, + 这是既有契约,测试里用 `json.loads` 断言)。 +- `source="mcp"`:ToolSpec 早预留好的字段,`core.list_tools()` 会如实标注来源, + 评委一眼能看出哪些是外部生态工具。 + +## 块 7 · 生命周期与便捷函数(L134-L139、L187-L203) +```python +async def __aenter__(self) -> McpToolBridge: + await self.connect() + return self + +async def aclose(self) -> None: + if self._client is not None: + await self._client.__aexit__(None, None, None) + self._client = None + +async def attach_mcp_tools(registry, config) -> McpToolBridge: + bridge = McpToolBridge(config) + specs = await bridge.connect() + registry.register_many(specs) + return bridge +``` +- 两种用法:`async with McpToolBridge(cfg) as b:` 自动关连接;或手动 + `connect()/aclose()`(host_d 用后者,因为要在整个任务期间保持连接、任务结束才关)。 +- `aclose()` 幂等:关完置 `None`,重复调用不报错。 +- stdio 形态下 `aclose()` 会终止子进程——**不关掉就会在后台留孤儿进程**, + host_d 用 `try/finally` 保证任何退出路径都关。 +- `attach_mcp_tools` 是给宿主的一行流:建连 + 翻译 + 注册,返回 bridge 供稍后关闭。 + +## 块 8 · 演示 Server(`examples/host_d_mcp/demo_mcp_server.py`) +```python +from mcp.server import MCPServer +mcp = MCPServer("YAI-DemoTools") + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two integers and return the sum.""" + return a + b + +if __name__ == "__main__": + mcp.run() # 默认 stdio 传输:stdin/stdout 即 MCP 通道 +``` +- 写 MCP Server 简单到"类型注解 + docstring 即 schema",不用手写 JSON Schema。 +- `mcp.run()` 默认走 stdio;也可以 `run("streamable-http", port=...)` 起 HTTP。 +- 这个文件同时是教学样本:未来你想把 AMBRACE 的能力开放给别的 MCP 宿主, + 照这个样子包一层即可(那是 v1 之后的事,v0.1 红线不做 Server)。 + +## 块 9 · 宿主接线(`examples/host_d_mcp/run.py`) +```python +def build_config(): + url = os.getenv("MCP_SERVER_URL") # ① 环境变量给 URL → HTTP + if url: return McpServerConfig(alias="remote", url=url) + command_line = os.getenv("MCP_SERVER_COMMAND") # ② 给命令 → stdio + if command_line: + parts = shlex.split(command_line, posix=(os.name != "nt")) + return McpServerConfig(alias="stdio", command=parts[0], args=parts[1:]) + return McpServerConfig(alias="demo-stdio", # ③ 缺省拉起自带演示 Server + command=sys.executable, args=[str(demo_server)]) + +bridge = await attach_mcp_tools(core.registry, build_config()) +try: + await stream(core, task, backend) +finally: + await bridge.aclose() +``` +- 同一份宿主代码,靠环境变量在 HTTP / stdio / 本地演示三种部署形态间切换, + 这就是"传输无关"的适配层价值。 +- `shlex.split` 把命令字符串切成 argv;Windows 下 `posix=False` 保留反斜杠路径。 +- host_d 还注册了一个本地函数 `host_label`:跑起来你会在工具列表里同时看到 + `native` 和 `mcp` 两种来源——统一注册表的直接证据。 +- 运行:`.venv/Scripts/python.exe examples/host_d_mcp/run.py`(需 `--extra mcp`)。 + +## 块 10 · 部署期接线:config_from_env 与 FastAPI lifespan(线上也能调 MCP) + +host_d 里"读环境变量 → 构造 config"的逻辑被收敛成可复用函数 `config_from_env`: + +```python +def config_from_env(*, alias="remote", + url_env="MCP_SERVER_URL", + command_env="MCP_SERVER_COMMAND", env=None): + environ = os.environ if env is None else env + if url := environ.get(url_env): + return McpServerConfig(alias=alias, url=url) # HTTP 形态 + if command_line := environ.get(command_env): + parts = shlex.split(command_line, posix=(os.name != "nt")) + if parts: + return McpServerConfig(alias=alias, command=parts[0], args=parts[1:]) + return None # 没配置 → None +``` +- `env=None` 时默认读 `os.environ`,测试时可传入假字典——纯函数、离线可测。 +- 返回 `None` 而不是抛异常:**"不接外部 MCP"是合法部署形态**,调用方据此决定跳过。 + +在线服务 `scripts/serve_example.py` 用 FastAPI 的 lifespan 在进程启停时挂载/断开: + +```python +@contextlib.asynccontextmanager +async def app_lifespan(_app): + bridges = [] + try: + from yai_core.integrations.mcp import attach_mcp_tools, config_from_env + if (cfg := config_from_env(alias="remote")) is not None: + try: + bridges.append(await attach_mcp_tools(core.registry, cfg)) + except Exception as exc: # 外部 Server 挂了是运行时常态 + print(f"[警告] 外部 MCP 挂载失败,仅提供本地工具:{exc}") + yield # ← 服务在这期间对外提供请求 + finally: + for bridge in bridges: + with contextlib.suppress(Exception): # 关闭阶段任何异常都不能拖住停机 + await bridge.aclose() + +app = create_app(core, lifespan=app_lifespan) +``` +> v0.2 持久化记忆落地后,app_lifespan 的 finally 段同时负责关闭 SqliteStore(探测式调用 close()),见第 11 章 G 节。 + +逐行要点: +- **优雅降级**:MCP 挂载失败只打印警告,native 工具、`/health`、验证端点照常服务。 + 外部依赖永远不能成为内核启动的单点——这是嵌入式内核的自我要求。 +- `contextlib.suppress(Exception)`:停机清理阶段"尽力而为",连接早断了也不该抛栈。 +- `create_app(core, lifespan=...)`:battery 的 `create_app` 增加了可选 lifespan 形参, + 电池本身不认识 MCP,接线仍由宿主脚本决定(依赖方向不反转)。 +- 镜像侧:Dockerfile 的依赖安装改为 `.[server,llm,mcp]`;Render 侧 `render.yaml` + 增加普通环境变量 `MCP_SERVER_URL=https://mcp.deepwiki.com/mcp`(公共免鉴权 DeepWiki)。 + **接哪个 MCP Server 是部署期配置,代码一行不用改。** + +### 一次真实端到端(DeepSeek × DeepWiki MCP) + +设置 `MCP_SERVER_URL=https://mcp.deepwiki.com/mcp` 后跑 host_d,任务 +"用 MCP 工具问一下 GitHub 仓库 modelcontextprotocol/python-sdk:Client 初始化最小代码?": +1. 第一次跑路由误判成 `direct`(不传工具清单),模型把工具调用写成了文本标签—— + 规则词表缺"问一下/工具/mcp"这类**显式工具意图**; +2. 给 `_ACTION_HINTS` 补词、拉丁词改大小写不敏感匹配,并在 `tests/test_router.py` + 加 3 条回归测试(见 05 篇 A1); +3. 重跑:路由 `react` → DeepSeek 发出标准 function call → HTTP 调远程 + `ask_question` → 真实 wiki 内容回灌 → 模型产出中文结论与代码。 +这条链路(本服务 → LLM 规划 → Streamable HTTP MCP → 结果回灌 → 模型总结) +就是 X-Agent "MCP 产品化"评分项最硬的证据,且可被评审用 curl 复现。 + +## 测试策略(`tests/test_mcp_bridge.py` + `tests/test_battery_api.py`) +- 文件首行 `pytest.importorskip("mcp")`:最小安装(无 mcp extra)的环境整文件跳过, + 内核老测试照常全绿;CI 矩阵统一加装 `--extra mcp` 后这些用例才真正执行。 +- 用块 3 的第 ③ 种形态(内存 MCPServer 实例)测:文本工具、Pydantic 结构化工具、 + ToolError 错误工具各一个,不起子进程、不触网、毫秒级。 +- 端到端用例把 MCP 工具塞进真的 `ToolExecutor`(配 allow_all 策略 + CollectChannel), + 验证成功路径的事件序列和失败路径的 `ok=False` 回灌——证明对 Loop 完全透明。 +- `test_config_from_env`:空环境返回 None、URL/命令两种形态的解析,纯函数离线覆盖。 +- `tests/test_battery_api.py::test_lifespan_attaches_mcp_tools`:用 TestClient 跑 + lifespan,内存 MCP Server 的工具必须出现在 `/v1/tools` 且 `source=="mcp"`, + 这是线上部署形态(块 10)的离线回放。 +- stdio 子进程路径、公共 HTTP MCP(DeepWiki)端到端在开发期用临时探针/示例实测, + 不进自动化测试,避免给 CI 引入网络与平台差异;实测步骤沉淀在本章块 10。 + +## 自检 + +1. 为什么 `import mcp` 必须写在 connect() 方法体内而不是模块顶层?哪条测试在守这条红线? +2. sanitize_schema 第一版误删了工具属性名(如 text),根因是什么?为什么 properties + 的键不能过白名单? +3. MCP 工具执行失败时,SDK v2 是抛异常还是返回 is_error?我们为什么要把它翻译成 + RuntimeError 再抛一次? +4. `handler` 闭包为什么用 `_to_spec` 的局部变量 remote_name,而不是直接用注册名? + 如果两个 Server 都有 search 工具,prefix 怎么配、远端收到的是哪个名字? +5. MCP 的三种连接 target 分别是什么?为什么测试选内存直连? +6. 为什么 host_d 要用 try/finally 调 aclose?不关掉 stdio 形态会留下什么? +7. 让 host_d 改接一个 HTTP 形态的 MCP Server,需要改代码吗?要改什么? +8. 本章新增的代码,哪些属于"内核零依赖"范围,哪些属于可选集成?边界由什么机制保证? +9. 线上服务启动时外部 MCP Server 不可达,为什么不能让服务启动失败?代码怎么实现降级? +10. "用 MCP 工具问一下……"第一次被路由成 direct 导致什么现象?为什么规则兜底也必须 + 覆盖显式工具意图? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/11-sqlite-memory.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/11-sqlite-memory.md new file mode 100644 index 0000000..7530d34 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/11-sqlite-memory.md @@ -0,0 +1,180 @@ +# 逐行讲解 11 · SQLite 持久化记忆:把"便签本"换成"笔记本" + +> 对应文件:`src/yai_core/memory/sqlite_store.py`、`src/yai_core/spi/memory.py`。 +> 读前先读第 07 章(InMemoryStore)。本章只新增一个契约实现,**不改契约、不改 Loop**。 + +## A. 先回顾:记忆契约长什么样 + +`spi/memory.py` 五个方法(注意第 2 个是**同步**方法,其余是 async): + +```python +async def append_history(self, message: ChatMessage) -> None # 追加一条历史 +def history(self) -> list[ChatMessage] # 取全部历史(同步) +async def put(self, key: str, value: str) -> None # KV 写 +async def get(self, key: str) -> str | None # KV 读 +async def clear(self) -> None # 清空 +``` + +Loop 只在两个地方碰记忆(`kernel/loop.py`): + +1. 任务开始:`for msg in self.memory.history(): ctx.add(msg)` —— 把历史回放进上下文; +2. 任务结束:写 user、assistant 两条历史。 + +**关键认知:Loop 只认识契约,不认识具体实现。** 所以只要新类实现了这五个方法, +Loop、Core、三个 host 示例一行都不用改——这就是 SPI(插槽)的意义,也是本章最好的教学点。 + +## B. 为什么放 memory/ 而不是 integrations/ + +MCP 客户端放在 `integrations/`,因为它依赖第三方 `mcp` 包;SQLite 用的 `sqlite3` +是 Python 自带标准库,零依赖、零 extra,和 InMemoryStore 是同一层东西,所以放在 `memory/`。 +判断口诀:**要装第三方包才能用的能力进 integrations,标准库能力留在本体包。** + +## C. 表结构:两张表 + 一个版本号 + +```sql +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键,顺序以它为准 + scope TEXT NOT NULL DEFAULT 'default', -- 会话/宿主隔离标签 + role TEXT NOT NULL, -- user/assistant/system/tool + content TEXT NOT NULL DEFAULT '', + tool_calls TEXT, -- ChatMessage.tool_calls 的 JSON 文本,无则 NULL + tool_call_id TEXT, + name TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); +CREATE INDEX IF NOT EXISTS idx_history_scope_id ON history(scope, id); -- 按会话取历史的常用路径 + +CREATE TABLE IF NOT EXISTS kv ( + scope TEXT NOT NULL DEFAULT 'default', + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (scope, key) -- 复合主键:同会话内 key 唯一 +); + +PRAGMA user_version = 1; -- schema 版本,迁移钩子 +``` + +逐块解释: + +- **为什么顺序用 `id` 不用 `created_at`**:CURRENT_TIMESTAMP 精度到秒,同一轮任务 + 连续写两条消息可能同一秒,时间戳分不出先后;自增 id 严格递增。 +- **为什么 history 要建 `(scope, id)` 索引**:查询永远是"某 scope 的全部历史,按 id 排序", + 索引前缀就是 scope,一次索引扫描拿完。 +- **KV 为什么是复合主键 `(scope, key)`**:不同会话允许有同名 key(比如都叫 "profile"), + 单 key 主键会互相覆盖。 +- **为什么 tool_calls 用 TEXT 存 JSON**:SQLite 是动态类型但没有原生 list/dict; + ChatMessage 里只有 tool_calls 是结构化字段,`json.dumps/json.loads` 往返最简单。 +- **`IF NOT EXISTS`**:重复打开同一个库不报错,幂等初始化靠它。 + +### C1. 迁移钩子 user_version + +SQLite 内置一个整数 `PRAGMA user_version`,专门留给应用存 schema 版本: + +```python +version = int(conn.execute("PRAGMA user_version").fetchone()[0]) +if version == 0: # 全新数据库:建表并写版本号 + conn.executescript(_SCHEMA_SQL); conn.commit() +elif version > _SCHEMA_VERSION: # 未来版本:旧代码打开新库,直接拒绝,避免写坏 + raise RuntimeError(...) +# version == 当前版本:什么都不做 +``` + +以后表结构要改(v0.3 加列、加表),就在这里加 `if version == 1: ...迁移到 2` 的分支。 +**注意:PRAGMA user_version 不能用 ? 参数化,版本号必须是 SQL 字面量。** + +## D. 连接策略:线程、锁与 WAL + +```python +self._lock = threading.Lock() +self._conn = sqlite3.connect(db_path, check_same_thread=False) +self._conn.row_factory = sqlite3.Row +if not self._is_memory: + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA synchronous=NORMAL") +``` + +- `check_same_thread=False`:sqlite3 默认禁止"在 A 线程建连、在 B 线程使用", + 而 FastAPI/uvicorn 会把同步函数丢到线程池,不同请求可能落在不同线程。 + 关掉这个保护后,**互斥要自己负责**——所以每个方法体都在 `with self._lock:` 里。 +- `row_factory = sqlite3.Row`:查出来的行能用 `row["role"]` 按列名取值,比下标好读。 +- WAL(Write-Ahead Logging):文件形态下读写不互相阻塞、崩溃更稳;内存库不需要。 + 副作用是产生 `-wal`/`-shm` 两个旁车文件,所以 `.gitignore`/`.dockerignore` 要忽略它们。 +- `synchronous=NORMAL`:WAL 模式下安全且更快。 + +### D1. 为什么 async 方法里直接跑同步 SQL(不引 aiosqlite) + +契约方法是 `async def`,但 sqlite3 是同步驱动。v0.2 的每次操作都是单条小事务 +(一行 INSERT / 主键点查),毫秒级返回,不会真的卡住事件循环;引入 aiosqlite 要多一个 +依赖、多一套连接生命周期,收益为零。**边界要记牢**:批量导入、长事务、高并发写场景 +才需要异步驱动或连接池,那时再换不迟——这是有意识的取舍,不是偷懒。 + +### D2. 为什么 history() 是同步的也要加锁 + +契约里 `history()` 不是 async(Loop 启动时直接 `for ... in history()`)。 +但它同样访问连接,而别的 async 方法可能正在别的线程里写——同步方法也要进同一把锁。 + +## E. 字段往返:最容易错的三个边界 + +```python +tool_calls = None if message.tool_calls is None else json.dumps(...) +``` + +| 情形 | 存进去 | 读回来 | +|---|---|---| +| `tool_calls=None`(普通消息) | NULL | None | +| `tool_calls=[{...}]`(工具调用消息) | JSON 文本 | list[dict] | +| `tool_calls=[]`(空列表) | `"[]"` | **[],不是 None** | + +第三行是最容易写错的:`None` 和 `[]` 在 Python 里都是假值,图省事写 +`json.dumps(x) if x else None` 就会把空列表吞成 NULL,与 InMemoryStore(原样保留 []) +行为不一致,参数化对等测试会立刻红。`tool_call_id/name` 同理:None 存 NULL, +**空字符串按空字符串存**,不折叠。 + +## F. UPSERT 与 scope 语义 + +KV 覆盖写用 SQLite 的 UPSERT(需要 SQLite ≥ 3.24,Python 3.11+ 自带版本远高于此): + +```sql +INSERT INTO kv(scope, key, value) VALUES (?, ?, ?) +ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP +``` + +`excluded` 是"这次试图插入但撞了主键的值"的固定别名。 + +- `clear()` 只删**当前 scope** 的两张表行;一个文件可以装多个会话,互不干扰。 +- 需要多会话的宿主:构造多个 `SqliteStore(path, scope="user-a")` 实例,契约方法签名不变。 + +## G. 装配:from_env 与 lifespan 关闭 + +```python +@classmethod +def from_env(cls, env=None): + raw = (environ.get("YAI_DB_PATH") or "").strip() + return None if not raw else cls(raw) +``` + +- 返回 None 而不是抛异常:"不持久化"是合法部署形态,`SqliteStore.from_env() or InMemoryStore()` + 一行完成回退,不配置环境变量时行为与改动前**完全一致**。 +- `env` 参数允许测试注入假字典,绝不碰真实 `os.environ`。 +- `close()` 不是契约方法(Protocol 里没有),所以 serve_example 用 + `getattr(_store, "close", None)` 探测式调用,InMemoryStore 没有这个方法就跳过。 + +## H. 诚实的边界(写进比赛材料也不心虚) + +1. **Render 免费层磁盘是临时的**:redeploy/休眠回收后文件丢失,要"永久记忆"得挂持久卷或 VPS; +2. **历史裁剪已落地、摘要归档未做**:Context 层有 24000 字符压缩兜底;持久层的条数上限/TTL + 已在 v0.3 落地(`memory/retention.py`,由宿主按 env 注入,见第 13 章),摘要归档仍列入后续规划; +3. **单 worker + 单文件**:多进程同时写一个文件可能 `database is locked`,v0.2 用 WAL+锁覆盖单进程场景; +4. **DB 里是用户对话**:不进 git、不进镜像、不进提交快照、日志不打印内容(启动只印路径)。 + +## 自检 + +1. 为什么 SqliteStore 放在 `memory/` 而 MCP 客户端放在 `integrations/`? +2. `history()` 为什么是同步方法?为什么同步方法也要持锁? +3. `tool_calls=[]` 落库再读出应该是什么?写错会怎样? +4. `PRAGMA user_version` 的作用是什么?打开一个版本号更高的库会怎样? +5. 为什么顺序按 `id` 而不是 `created_at`? +6. `from_env()` 为什么返回 None 而不是给默认路径? +7. 为什么 v0.2 不引入 aiosqlite?什么场景下才需要? +8. 为什么 Loop / Core / 三个 host 示例不需要任何改动就能用上 SQLite 记忆? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/12-openapi-discovery.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/12-openapi-discovery.md new file mode 100644 index 0000000..b59e231 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/12-openapi-discovery.md @@ -0,0 +1,140 @@ +# 逐行讲解 12 · OpenAPI 发现:让任意 REST API 零适配变成工具 + +> 对应目录:`src/yai_core/integrations/openapi/`(spec.py / discovery.py / client.py)。 +> 读前先读第 10 章(MCP 桥)——本章与它同构,只是"外部能力"从 MCP Server 换成了 REST API。 + +## A. OpenAPI 是什么,为什么它能做"发现" + +OpenAPI(前身 Swagger)是 REST API 的**机器可读说明书**:一个 JSON/YAML 文件里写清楚了 +有哪些路径(paths)、每个路径支持哪些 HTTP 方法(operations)、要什么参数、请求体长什么样、 +怎么鉴权。主流后端框架(FastAPI、Spring、NestJS)都能自动导出它。 + +MCP 是"专门为 AI 工具设计的协议";OpenAPI 是"互联网上已经存在几十年的 REST 世界的说明书"。 +YAI 两者都接:MCP 面向新一代工具生态,OpenAPI 面向存量的海量 REST API。**发现的含义**: +宿主只要给一个 spec 地址,Core 自动读出工具清单并注册,不用为每个 API 手写一个函数。 + +## B. 三个文件的分工(先记住这张图) + +```text +spec.py 载入(dict/文件/URL)→ 版本校验 → 内部 $ref 内联展开 → (spec, notes) +discovery.py 纯函数:spec + config → list[OperationPlan](名字/入参/方法/路径/鉴权) +client.py 桥:connect() 串起前两步并生成 ToolSpec;handler 闭包负责真实 HTTP 调用 +``` + +和 MCP 桥一样:顶层不 import httpx/pyyaml(内核零硬依赖),全部在函数里懒加载, +缺依赖时报 `uv sync --extra openapi`。 + +## C. spec.py:载入、校验、$ref 展开 + +### C1. 三选一载入与版本闸 + +`load_spec` 按 `config.spec / config.path / config.url` 三选一:文件读字节(先查 +`max_spec_bytes` 体积闸),URL 用 httpx 拉取(测试时注入 MockTransport);`.yaml/.yml` +或 content-type 含 yaml 才 import pyyaml,其余按 JSON 解析。然后两道版本闸: + +- 没有 `openapi` 字段但有 `swagger: 2.x` → 明确报错让用户先转 OpenAPI 3; +- 版本号不是 3 开头 → 报错(v0.2 不支持 3 之外的世界)。 + +### C2. $ref 是什么,为什么要展开 + +OpenAPI 复用结构靠 JSON Reference:`{"$ref": "#/components/schemas/Pet"}` 指向文档内 +另一个节点。模型看不懂 $ref,所以发现前要**内联展开**。`resolve_refs` 是一次带"展开栈"的 +递归遍历: + +- 内部引用(`#/` 开头):沿 JSON Pointer 找到目标,深拷贝后递归展开; +- **循环引用**(Category.children.items 指回 Category):如果当前 ref 已在展开栈里, + 说明绕成环了,停止展开、把 `{"$ref": ...}` 原样留下并记一条 note——否则无限递归; +- **外部引用**(指向另一个 URL/文件):v0.2 不跨文档抓取,原样保留 + note; +- 3.1 允许 $ref 带兄弟键,内联后兄弟键覆盖同名属性。 + +notes 是这个模块的一贯设计:**遇到不完美的输入不静默、不崩溃,降级处理并留痕**, +启动日志打印给宿主看。 + +## D. discovery.py:operation → 工具 + +### D1. 工具命名(两条规则 + 去重) + +1. 有合法 `operationId`:驼峰转蛇形(listPets → list_pets)、非法字符变下划线、截 64; +2. 没有:用方法+路径合成(`GET /pets/{petId}` → `get_pets_pet_id`:去花括号、连字符转下划线、 + 驼峰转蛇形、全小写)。 +同一 spec 内撞名追加 `__2/__3` 并记 note;**跨来源(native/mcp/openapi)撞名不在这处理**—— +ToolRegistry.register 本来就直接报错,让宿主的 prefix 去解决。 + +### D2. 入参 schema 的合并(最需要细心的一段) + +工具的入参是一个扁平 JSON Schema(object),来源有三处: + +- **path 参数**(`in: path`):OpenAPI 规定恒必填,强制进 required;调用时填进 URL 模板; +- **query 参数**(`in: query`):按声明决定是否必填;header/cookie 参数 v0.2 直接忽略 + (不暴露给模型,避免模型伪造内部头); +- **requestBody(application/json)**:schema 是 object 就**拍平合并**(Pet 的 id/name/category + 变成工具的顶层入参,required 并集去重保序);是数组等非 object 类型就包成一个 `body` 入参。 + +每个子 schema 都过一遍 `sanitize_schema`(第 10 章已见过,本批次它归位到 `tools/schema.py`, +MCP 桥和 OpenAPI 桥共用)。 + +### D3. 过滤与截断 + +- `include_paths / exclude_paths`:按路径**前缀**白/黑名单(先白后黑); +- `read_only=True`:只留 GET/HEAD——在线演示服务的安全闸,写操作不暴露到公网; +- `max_operations=40`:超出截断并记 note,防止一个巨型 spec 注册几百个工具撑爆系统提示词。 + +工具描述统一成 `[METHOD /path] summary description`,截 1000 字符——模型一眼知道这工具打哪。 + +## E. client.py:注册与执行 + +### E1. connect 做四件事 + +载入+展开(spec.py)→ 发现计划(discovery.py)→ 解析 base_url(override > servers[0], +相对地址按 spec 的 URL 补全)→ 建 `httpx.AsyncClient` 并把每个 plan 闭包成 ToolSpec。 +同时做**鉴权审计**:oauth2/basic/mutualTLS 暂不支持,注册期只记 note(不阻断)。 + +### E2. 一次调用的完整路径(handler 闭包) + +```text +模型给出参数 kwargs + → _auth_for:按 security 挑方案(bearer→Authorization 头;apiKey→头/query), + 令牌只从环境变量读,缺失在【调用期】抛错(注册期不报错,允许先发现后配钥) + → _build_path:path 参数 quote(safe="") 填模板(参数里的 / 也编码,防路径穿越) + → _build_query:query 参数组装;bool → true/false;list 交给 httpx 展开成重复键 + → _build_body:拍平型按 body_fields 重组 dict;包裹型取 body + → client.request(method, url, params, json, headers) + → 响应归一:204→"";非 2xx→RuntimeError(走工具错误事件通道); + JSON content-type→.json();文本超 max_result_chars(16000)→截断加 …[truncated] +``` + +为什么令牌缺失是**调用期**而不是注册期报错:发现是"看菜单",调用才是"点菜"; +宿主可能先把工具清单展示出来,稍后才配置密钥。 + +### E3. 为什么用 httpx 而不是 requests + +- 全栈异步:handler 是 async 闭包,跑在 Loop 的事件循环里,不能用同步 requests 阻塞循环; +- `MockTransport`:测试可以不触网地断言 method/url/headers/body,这是选型的决定性理由; +- 同步拉 spec 用短生命周期的 `httpx.Client`(只在启动时跑一次),执行期统一 AsyncClient。 + +## F. 安全边界(写进比赛材料) + +1. 在线演示端点 `read_only=True` 硬编码,公网只暴露 GET/HEAD; +2. 令牌只从环境变量读,不进 spec、不进日志、不进事件; +3. path 参数全编码;非 2xx 不把响应体直接喂模型(抛错并截断 500 字符); +4. 结果文本 16000 字符截断(对照 Context 24000 上限,给模型的工具结果不能反客为主); +5. 不支持的鉴权方案显式记 note,绝不"假装支持"发匿名请求到需要鉴权的写接口 + (需要鉴权却没令牌 = 调用期 RuntimeError)。 + +## G. 测试怎么做到全程离线 + +所有 HTTP 都不真实发出:`httpx.MockTransport(handler)` 接收 request、返回预制 response, +还能在 handler 里**断言请求本身**(方法、路径、query 重复键、Authorization 头、JSON body)。 +spec 用仓库内 `tests/fixtures/petstore.min.json`(含 $ref、递归 schema、bearer/apiKey、404)。 +这就是"测试不触网"纪律在集成层的标准打法,和 MCP 桥用内存假 server 是同一个思想。 + +## 自检 + +1. 为什么 OpenAPI 发现能做到"零适配接入"?它依赖 spec 里的哪些信息? +2. 循环 $ref 和外部 $ref 分别怎么处理?为什么不能一律递归展开? +3. object 型 requestBody 和数组型 requestBody 生成的入参 schema 有什么不同? +4. path 参数为什么强制必填?为什么 quote 要 `safe=""`? +5. 令牌缺失为什么在调用期而不是注册期报错? +6. read_only 过滤为什么是在线演示服务的硬要求? +7. 为什么选 httpx?MockTransport 在测试里替代了什么? +8. sanitize_schema 为什么从 mcp/client.py 归位到 tools/schema.py? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/13-history-retention.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/13-history-retention.md new file mode 100644 index 0000000..41c5277 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/13-history-retention.md @@ -0,0 +1,157 @@ +# 逐行讲解 13 · 历史保留策略:会"忘事"的记忆才跑得久 + +> 对应代码:`memory/retention.py`(新增)、`memory/inmemory.py`、`memory/sqlite_store.py`、 +> `kernel/context.py`(_compact 修复)。v0.3,opt-in:不配置环境变量时行为与旧版完全一致。 + +## A. 为什么需要裁剪:记忆只会涨,不会自己落 + +SQLite 记忆落地后出现一个新问题:`history` 表只增不减。Loop 每跑一轮就追加两条 +(user 任务 + assistant 最终答复,见 `kernel/loop.py` 末尾),下一轮启动时 +`for msg in self.memory.history()` 全量回放给模型。跑得越久: + +1. 每次请求的 token 越多,越慢越贵(评审期每一分钱都是 DeepSeek 额度); +2. 超长上下文更容易触发模型的"注意力稀释",答非所问; +3. 几个月前的陈旧对话永远占着上下文。 + +v0.3 给出两个 opt-in 开关:**条数上限**(`YAI_HISTORY_MAX_MESSAGES`)与 +**存活时间 TTL**(`YAI_HISTORY_TTL_SECONDS`)。默认都不开——"忘事"是策略, +不是内核该擅自做的决定。 + +## B. 最大的坑:不能按"第几条"硬删 + +OpenAI 兼容的对话接口要求消息序列合法,其中一条铁律: + +- `assistant` 消息带 `tool_calls`("我要调工具")之后,**必须**紧跟对应 `tool` 消息(工具结果)。 + +假设历史是这样(一轮任务里模型调了工具): + +``` +user "查一下订单" +assistant tool_calls=[get_orders] ← 工具调用的发起 +tool "订单结果..." ← 必须跟在发起后面 +user "谢谢" +assistant "不客气" +``` + +如果按"只留最近 3 条"硬删,会从第 2、3 条之间切开:`tool` 留下了、它配对的 +`assistant(tool_calls)` 没了——这叫**孤儿 tool 消息**,发给 DeepSeek 直接 400。 + +所以裁剪边界必须落在**一轮的开头**。 + +## C. "轮"的定义(全章统一口径) + +一条 `role == "user"` 的消息,到下一条 user 消息之前,算**一轮**: + +``` +user ┐ 第 1 轮 +assistant ┘ +user ┐ 第 2 轮(含工具调用) +assistant(tool_calls) +tool ┘ +user ┐ 第 3 轮 +assistant ┘ +``` + +历史开头若有非 user 消息(理论上的边界情况),归入"第 0 轮",与相邻轮同生共死。 +裁剪只在轮与轮之间下刀,任何一轮内部都不可切。 + +## D. retention.py:把"下刀位置"算成纯函数 + +两个 Store(内存/SQLite)的删除逻辑必须**逐字节一致**,所以边界计算抽成纯函数, +放在 `memory/retention.py`,存储实现只负责"取角色序列"和"按边界删"。 + +### D1. 条数裁剪 count_cut:边界向后对齐 + +```python +candidate = len(roles) - max_messages # 朴素切点 +k = _next_round_start(roles, candidate) # 从切点向后找第一个轮首 +``` + +6 条消息、上限 2 条:朴素切点 = 4,而 roles[4] 正好是 user → k=4,删 [0,4), +留最后一轮。若朴素切点落在一轮中间(比如上限 3,切点=4 之前的 3 是 assistant), +就**往后**退到这一轮结束后的下一个轮首——宁可少留,不可切轮。找不到后续轮首 +(只有一轮、或单轮自身就超限)→ 返回 None,**宁可超限也不裁**。 + +### D2. TTL 裁剪 ttl_cut:边界向前对齐 + +TTL 先数"从头开始连续过期"的条数 n(`expired[i]` 由存储层按 cutoff 算好, +严格 `<`,恰好等于 cutoff 的保留),再从 n 往**前**找最近的轮首: + +- 过期前缀恰好结束在轮边界(n 本身就是轮首)→ 删整轮; +- 过期前缀伸进了一轮中间 → 退回到这一轮开头,整轮保留(不能切一半); +- **全部过期**(n == len)→ 返回 len,整库清空。空历史是合法序列,TTL 的语义 + 是"过期就该删",这一条高于条数裁剪"至少留最后一轮"的约束。 + +### D3. 两个边界取 max + +条数裁剪和 TTL 可能同时命中,各自得到一个删除上界。两个上界都落在轮首, +较大的那个仍然是轮首,所以直接 `max(k_count, k_ttl)` 一次删完,工具对依旧完整。 + +## E. InMemoryStore:并行时间戳列表 + +ChatMessage 没有时间字段(也不该为存储策略改 `types.py` 的核心数据结构), +于是用一个与 `_history` **严格同长同序**的 `_timestamps` 列表记录每条消息的时间: + +```python +async def append_history(self, message): + self._history.append(message) + self._timestamps.append(self._clock()) # 注入时钟,测试可拨快 + self.prune() # 未配置策略时立即返回 0 +``` + +`prune()` 是非契约方法:`spi/memory.py` 的 Protocol 五个方法一个没加, +它和 `SqliteStore.close()` 同属"宿主探测式调用"的先例,内核契约保持稳定。 + +## F. SqliteStore:取行 → 算边界 → 按 id 删 + +`_prune_locked()`(调用方已持锁)三步走: + +1. `SELECT id, role, created_at ... ORDER BY id ASC` 取本 scope 全部历史; +2. 复用 retention 的两个纯函数算出删除上界 k; +3. `DELETE ... WHERE id < rows[k]["id"]`(k == 行数时整 scope 清空)。 + +注意 TTL **不用**裸 `DELETE ... WHERE created_at < ?`——那会绕过轮对齐、 +切散工具对。会话历史量级很小,统一走 Python 算边界更安全。 + +两个细节: + +- **created_at 改由 Python 侧注入时钟写入**(列默认 CURRENT_TIMESTAMP 保留给 + 外部插入)。否则测试在毫秒内跑完,DB 时钟根本不前进,假时钟管不到落库时间, + 两种 Store 就没法做对等测试。 +- **构造时自动 prune 一次**:服务重启后库里可能全是过期数据,打开连接、建完 + schema 就清一遍,覆盖"重启场景"。 + +## G. Context._compact:同一类 bug 的另一处 + +`kernel/context.py` 的 `_compact()` 是**单次请求内**的字符预算压缩(与跨请求的 +记忆裁剪是两层),旧实现按下标逐个丢消息,同样可能切散工具对。本批一并修复: +候补边界向后对齐到轮首,之后没有新一轮就宁可超预算也不裁。kernel 不能反向 +依赖 memory 包,所以那里保留一份同源私有实现——两处规则改动时必须同步。 + +## H. env 解析与装配:只解析一次 + +`retention_from_env()` 是唯一入口:空白=未设置(静默);0/负数/非整数=忽略并 +`warnings.warn`(不崩、不静默)。`serve_example.py` 启动时解析一次,显式传给 +`SqliteStore.from_env(retention=...)`,回落 InMemoryStore 时用同一组参数, +避免非法值 warning 两次。 + +## I. 诚实的边界 + +- KV(键值记忆)没有 TTL:给 kv 表加过期时间要 `ALTER TABLE`(schema v1→v2 + 迁移),是第一次实战迁移,留到 v0.4 单独做。 +- 裁剪不发 AgentEvent:它是存储维护,不是自适应决策,Loop 不感知; + 可观测性由 `prune()` 返回值与启动日志一行承担。 +- 条数上限是"上限"不是"目标值":轮对齐后实际保留可能少于配置值。 +- 多进程/多实例共享同一个 SQLite 文件的场景仍不在支持范围(v0.2 已声明)。 +- **两种实现的时间精度不同**:SQLite 侧 `created_at` 落库是**秒级**文本 + (`strftime("%Y-%m-%d %H:%M:%S")`),内存实现保留**微秒**。TTL 以秒/天为量级时两者等价, + 但当"消息时间戳与 cutoff 相差不足 1 秒"时,两种实现可能给出不同结果——不要假设它们逐比特一致。 + +## 自检 + +1. 为什么删除边界必须落在轮首?切散了会发生什么? +2. 条数裁剪的边界向哪个方向对齐?TTL 呢?为什么方向相反? +3. TTL 判定全部过期时为什么允许整库清空,而条数裁剪永远保留最后一轮? +4. `prune()` 为什么不写进 MemoryStore 契约? +5. SQLite 的 created_at 为什么改由 Python 侧写入? +6. Context._compact 和 Store 的 prune 是同一层东西吗?分别在什么时候触发? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/14-rate-limit-battery.md b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/14-rate-limit-battery.md new file mode 100644 index 0000000..91490f4 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/docs/walkthrough/14-rate-limit-battery.md @@ -0,0 +1,106 @@ +# 逐行讲解 14 · 限流 Battery:评审期保护公网 POST 端点 + +> 对应代码:`batteries/fastapi_server/ratelimit.py`(新增)、`app.py`(中间件挂载)。 +> v0.3,纯标准库(collections/threading/math),零新依赖;默认开启是对外承诺值。 + +## A. 为什么限流、限哪里 + +线上 `/v1/agent/run` 是真金白银的端点:每次 POST 都会调一次 DeepSeek。评审期最怕 +两类事故——失控脚本死循环重试把额度烧光、爬虫误扫打爆免费实例。限流 Battery 的 +答案很克制: + +- **只拦 `POST /v1/agent/run`**,白名单写死在 `_LIMITED_PATHS`; +- **三个 GET 端点(/health、/.well-known/xagent-verification.json、/v1/tools)永不限**: + 它们是 X-Agent 评审的硬门槛验证路径,任何"顺手全局限一下"都可能让评审官看到 429—— + 这是本批的第一设计红线; +- 中间件 + 路径白名单(而不是逐路由 `Depends`):以后新增 POST 路由时, + "忘记挂限流"比"忘记加 Depends"更不容易发生——白名单是默认在场的一道闸。 + +默认值 `enabled=True / per_minute=30 / window_seconds=60` 是对外承诺: +每 IP 每分钟 30 次 POST,正常评审用不完,失控脚本立刻被拦。 + +## B. 滑动窗口日志:最好解释的算法 + +```python +hits: dict[str, deque[float]] # 每 key 一个时间戳队列 + +def check(key): + now = time_func() + while hits[key] and hits[key][0] <= now - window: # 1. 弹出窗口外时间戳 + hits[key].popleft() + if len(hits[key]) >= per_minute: # 2. 剩余长度已达上限 → 拒 + retry_after = max(1, ceil(oldest + window - now)) + return False, retry_after + hits[key].append(now) # 3. 记录本次并放行 + return True, 0 +``` + +对比固定窗口("每分钟清零计数器"):固定窗口在边界处有 2× 突发(59 秒 30 次 + +61 秒 30 次 = 2 秒 60 次);滑动窗口日志语义就是字面的"任意 60 秒内最多 30 次", +没有边界突发,也最好向评审解释。代价是 O(窗口内请求数) 内存——单实例演示流量下 +可以忽略。 + +`Retry-After = max(1, ceil(oldest + window - now))`:队首(最老)请求过期的那一刻 +就是配额腾出空位的那一刻,向上取整保证至少等 1 秒。**响应头与响应体里的 +`retry_after` 来自同一次计算**——不一致会让客户端重试策略无所适从。 + +## C. client_ip:信任边界要写清楚 + +```python +X-Forwarded-For 最左非空段 → 缺失回退 request.client.host → 再缺省 "unknown" +``` + +Render 边缘代理会为每个请求附加 XFF,所以取 XFF 最左即真实客户端 IP; +不依赖 uvicorn `--proxy-headers`,部署形态少一个变量。 + +**威胁模型如实写**:这套识别防的是误刷/爬虫/失控循环;**不防**蓄意伪造 XFF 的 +分布式攻击——攻击者可以随便声明 `X-Forwarded-For: 8.8.8.8` 换桶。那要靠鉴权或 +人机验证,超出 v0.3 范围。文档里不装强,评审官反而信得过。 + +## D. 并发与生命周期:两处"防御性多虑" + +- `threading.Lock` 包住 `check()`:目标路由是 async,deque 操作中间没有 await, + 单事件循环下理论原子;但 TestClient 会跨线程、将来同步路由也可能跨线程, + 锁成本近乎为零,买了保险。 +- 状态只在内存:进程重启清零,不落盘、不引 Redis。演示服务重启后限流从零开始, + 这在评审语境下是可接受的边界(攻击者拿到的也只是重新数 30 次)。 + +## E. 装配:create_app 只加一个关键字参数 + +```python +def create_app(core, lifespan=None, *, rate_limit: RateLimitConfig | None = None): + config = rate_limit if rate_limit is not None else RateLimitConfig.from_env() + if config.enabled: + ...注册中间件... +``` + +- `None` → `from_env()`:既有调用(`create_app(core)`、`create_app(core, lifespan=...)`) + 零改动,行为即"默认开启"; +- `enabled=False` 时**根本不注册中间件**:关闭态零开销、零行为变化, + 本地开发设 `YAI_RATE_LIMIT_ENABLED=0` 即回到改造前; +- 被限请求在中间件层直接返回 429,**不进 `core.run`**——一分模型钱都不花 + (测试用计数模型断言:N+1 次请求只调 N 次模型)。 + +## F. 测试:假时钟贯穿,禁止 sleep + +窗口是 60 秒,真实等待不可能出现在测试里。全部用 `time_func` 注入假时钟: +`now = [0.0]`,`clock.advance(seconds=61)` 一行"等"过一分钟。能注入的前提是 +`check()` 只通过 `time_func()` 取时间——**永远不直接调 time.monotonic**, +这是可测性的代价与回报。边界语义钉死在用例里:恰好等于窗口的时间戳按过期清除 +(`<=`),`retry_after` 恒 ≥ 1。 + +## G. 诚实的边界 + +1. 不防伪造 XFF 的分布式攻击(见 C 节); +2. 重启清零,无跨实例共享计数——多实例部署要做全局限流,先解决状态共享; +3. 全局限流兜底(整实例总配额)暂不做,模块 docstring 留了 TODO 位; +4. `X-Forwarded-For` 链上只有最左段被信任,右段可能是任意代理追加的,不做校验。 + +## 自检 + +1. 为什么三个 GET 端点永远不限流?加"软限"会怎样? +2. 滑动窗口日志与固定窗口计数差在哪?本批为什么选前者? +3. `Retry-After` 怎么算的?为什么和响应体必须同源? +4. 伪造 XFF 能绕过本限流吗?那属于哪一层的问题? +5. 为什么 `enabled=False` 时不注册中间件,而不是注册了直接放行? +6. 被限请求为什么必须在中间件层返回,而不是放进去让模型拒绝? diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/demo_model.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/demo_model.py new file mode 100644 index 0000000..fbb823c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/demo_model.py @@ -0,0 +1,29 @@ +"""示例共用:无 API Key 时使用的脚本化离线模型(演示"零配置可跑")。""" + +from yai_core import ModelResponse, ToolCallRequest + + +class OfflineScriptedModel: + """行为:规划轮先给计划;第一次拿到工具清单时调用首个工具;之后收尾。""" + + def __init__(self) -> None: + self.calls = 0 + self._tool_used = False + + async def achat(self, messages, tools=None, *, tier="standard"): + self.calls += 1 + # 第一次拿到工具时调用第一个工具(react 首轮 / plan 的执行首轮都适用) + if tools and not self._tool_used: + self._tool_used = True + first_tool = tools[0]["function"]["name"] + return ModelResponse( + content="", + tool_calls=[ToolCallRequest(id="demo-1", name=first_tool, arguments={})], + ) + # 规划轮(无工具):输出步骤计划 + if self.calls == 1: + return ModelResponse(content="1. 筛选目标数据\n2. 调用宿主能力执行\n3. 整理最终结果") + # 收尾:基于工具观察回答 + last_tool_msg = next((m for m in reversed(messages) if m["role"] == "tool"), None) + observed = last_tool_msg["content"] if last_tool_msg else "(无工具结果)" + return ModelResponse(content=f"离线演示完成,宿主能力返回:{observed[:120]}") diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/capabilities.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/capabilities.py new file mode 100644 index 0000000..c330ea1 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/capabilities.py @@ -0,0 +1,30 @@ +"""宿主 A:一个极简笔记应用的业务能力层。 + +注意:这里没有任何 Agent 代码——只有普通业务函数。 +YAI Agent Core 通过内省自动把它们变成可被 Agent 调用的工具。 +""" + +from __future__ import annotations + +_NOTES: list[dict] = [ + {"id": 1, "title": "Core 骨架", "content": "完成 SPI 四契约与 Agent Loop", "tag": "工作"}, + {"id": 2, "title": "比赛清单", "content": "X-Agent 0919 截止,需要部署健康端点", "tag": "工作"}, + {"id": 3, "title": "购物", "content": "买咖啡豆和牛奶", "tag": "生活"}, +] + + +def list_notes(tag: str = "") -> list[dict]: + """列出全部笔记,可按标签筛选。""" + if tag: + return [n for n in _NOTES if n["tag"] == tag] + return _NOTES + + +def search_notes(keyword: str) -> list[dict]: + """按关键词搜索笔记标题与正文。""" + return [n for n in _NOTES if keyword in n["title"] or keyword in n["content"]] + + +def count_notes() -> int: + """统计笔记总数。""" + return len(_NOTES) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/run.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/run.py new file mode 100644 index 0000000..ecd2878 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_a_notes/run.py @@ -0,0 +1,34 @@ +"""宿主 A 演示:同一个 Core 自动适配笔记应用。 + +运行:.venv/Scripts/python.exe examples/host_a_notes/run.py [可选任务文本] +有 OPENAI_API_KEY(.env)时走真实模型,否则走离线脚本模型。 +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # examples/ + +from runner_common import bootstrap, build_model, load_dotenv, stream # noqa: E402 + +bootstrap() + +from host_a_notes import capabilities # noqa: E402 +from yai_core import AgentCore # noqa: E402 + +DEFAULT_TASK = "列出我的全部笔记" + + +async def main() -> None: + load_dotenv() + model, backend = build_model() + core = AgentCore.auto(capabilities, model) + task = " ".join(sys.argv[1:]).strip() or DEFAULT_TASK + await stream(core, task, backend) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/capabilities.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/capabilities.py new file mode 100644 index 0000000..f07d0c0 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/capabilities.py @@ -0,0 +1,30 @@ +"""宿主 B:一个极简销售数据应用的业务能力层(与宿主 A 完全不同的领域)。""" + +from __future__ import annotations + +_RECORDS = [ + {"order": "A001", "category": "硬件", "amount": 1200, "region": "华东"}, + {"order": "A002", "category": "软件", "amount": 800, "region": "华南"}, + {"order": "A003", "category": "硬件", "amount": 1500, "region": "华东"}, + {"order": "A004", "category": "服务", "amount": 600, "region": "华北"}, +] + + +def list_records() -> list[dict]: + """列出全部销售记录。""" + return _RECORDS + + +def filter_by_category(category: str = "") -> list[dict]: + """按品类筛选销售记录,品类为空时返回全部。""" + if category: + return [r for r in _RECORDS if r["category"] == category] + return _RECORDS + + +def sum_amount(category: str = "") -> int: + """统计销售额,可按品类筛选后汇总。""" + rows = _RECORDS + if category: + rows = [r for r in _RECORDS if r["category"] == category] + return sum(r["amount"] for r in rows) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/run.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/run.py new file mode 100644 index 0000000..b0b070e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_b_data/run.py @@ -0,0 +1,33 @@ +"""宿主 B 演示:同一份 Core、零修改,自动适配销售数据应用。 + +运行:.venv/Scripts/python.exe examples/host_b_data/run.py [可选任务文本] +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # examples/ + +from runner_common import bootstrap, build_model, load_dotenv, stream # noqa: E402 + +bootstrap() + +from host_b_data import capabilities # noqa: E402 +from yai_core import AgentCore # noqa: E402 + +DEFAULT_TASK = "先筛选硬件品类,然后统计销售额并整理成报告" + + +async def main() -> None: + load_dotenv() + model, backend = build_model() + core = AgentCore.auto(capabilities, model) + task = " ".join(sys.argv[1:]).strip() or DEFAULT_TASK + await stream(core, task, backend) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/capabilities.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/capabilities.py new file mode 100644 index 0000000..f786e71 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/capabilities.py @@ -0,0 +1,63 @@ +"""宿主 C:极简 AI 陪伴应用能力层 —— AMBRACE(拥爱)的缩影。 + +这是 YAI 未来回流 AMBRACE 后的形态预演: +陪伴业务(状态、记忆、心情、主动关怀)照常写成普通函数, +Agent 能力(该回忆什么、何时主动关心、怎么串起来)由 Core 自适应提供。 +""" + +from __future__ import annotations + +from datetime import date + +_PROFILE = {"name": "小拥", "mood": "平静", "energy": 62, "bond_days": 128} + +_MOOD_LOG = [ + {"day": "09-06", "mood": "焦虑", "note": "担心三个比赛来不及"}, + {"day": "09-07", "mood": "开心", "note": "YAI Core 骨架跑通了"}, +] + +_MEMORIES = [ + {"id": 1, "kind": "偏好", "content": "深夜写代码时喜欢听 lofi,不喜欢被催进度"}, + {"id": 2, "kind": "约定", "content": "每周五晚上一起做一周复盘"}, + {"id": 3, "kind": "经历", "content": "一起从零做 AMBRACE,报名了三个比赛"}, +] + + +def get_companion_profile() -> dict: + """获取陪伴对象当前的状态档案。""" + return dict(_PROFILE) + + +def recall_memories(keyword: str = "") -> list[dict]: + """回忆与关键词相关的长期记忆,关键词为空时返回全部。""" + if not keyword: + return list(_MEMORIES) + return [m for m in _MEMORIES if keyword in m["kind"] or keyword in m["content"]] + + +def save_memory(kind: str = "日常", content: str = "") -> dict: + """把一条新的长期记忆写入陪伴记忆库。""" + item = {"id": len(_MEMORIES) + 1, "kind": kind, "content": content} + _MEMORIES.append(item) + return {"saved": True, "memory": item, "total": len(_MEMORIES)} + + +def log_mood(mood: str = "平静", note: str = "") -> dict: + """记录此刻心情,并返回陪伴状态摘要。""" + entry = {"day": date.today().strftime("%m-%d"), "mood": mood, "note": note} + _MOOD_LOG.append(entry) + return {"logged": entry, "profile": dict(_PROFILE), "recent_moods": _MOOD_LOG[-3:]} + + +def suggest_care_action() -> dict: + """根据陪伴状态给出一条主动关怀建议(AMBRACE 主动行为的最小雏形)。""" + if _PROFILE["energy"] < 70: + action = "提醒休息,陪他聊五分钟而不是推进任务" + reason = "能量值偏低,优先安抚情绪" + elif _PROFILE["bond_days"] > 100: + action = "翻出一条共同记忆,主动开启回忆话题" + reason = "羁绊天数较长,用共同经历增强连接" + else: + action = "轻打个招呼,问问今天过得怎样" + reason = "状态平稳,常规陪伴即可" + return {"action": action, "reason": reason, "based_on": dict(_PROFILE)} diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/run.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/run.py new file mode 100644 index 0000000..cfb611a --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_c_companion/run.py @@ -0,0 +1,33 @@ +"""宿主 C 演示:同一份 Core 零修改,嵌入一个 AI 陪伴应用(AMBRACE 预演)。 + +运行:.venv/Scripts/python.exe examples/host_c_companion/run.py [可选任务文本] +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # examples/ + +from runner_common import bootstrap, build_model, load_dotenv, stream # noqa: E402 + +bootstrap() + +from host_c_companion import capabilities # noqa: E402 +from yai_core import AgentCore # noqa: E402 + +DEFAULT_TASK = "先查看小拥的状态,然后检索和约定有关的记忆,给出主动关怀建议" + + +async def main() -> None: + load_dotenv() + model, backend = build_model() + core = AgentCore.auto(capabilities, model) + task = " ".join(sys.argv[1:]).strip() or DEFAULT_TASK + await stream(core, task, backend) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/demo_mcp_server.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/demo_mcp_server.py new file mode 100644 index 0000000..b472adf --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/demo_mcp_server.py @@ -0,0 +1,29 @@ +"""host_d 配套的本地 stdio MCP Server(演示用,零网络、零账号)。 + +被 run.py 以子进程方式拉起;也可以手动运行观察: + .venv/Scripts/python.exe examples/host_d_mcp/demo_mcp_server.py +(stdio 服务通过标准输入输出通信,直接运行会安静等待客户端,属正常现象。) +""" + +from __future__ import annotations + +from mcp.server import MCPServer + +mcp = MCPServer("YAI-DemoTools") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two integers and return the sum.""" + return a + b + + +@mcp.tool() +def weather(city: str) -> str: + """Get a demo weather report for a city (mock data, not real).""" + return f"{city} 今天晴,26℃,东北风 2 级(演示数据,非真实天气)。" + + +if __name__ == "__main__": + # MCPServer.run() 默认 stdio 传输:把进程的 stdin/stdout 变成 MCP 通道。 + mcp.run() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/run.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/run.py new file mode 100644 index 0000000..868226c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/host_d_mcp/run.py @@ -0,0 +1,75 @@ +"""宿主 D 演示:Core 通过 MCP Client 接入外部 MCP Server 的工具。 + +默认以 stdio 子进程方式拉起同目录 demo_mcp_server.py(离线可跑); +也可以用环境变量改接任意 MCP Server: + MCP_SERVER_URL=http://127.0.0.1:8000/mcp # Streamable HTTP + MCP_SERVER_COMMAND="uv run server.py" # stdio 命令(空格分词) + +运行:.venv/Scripts/python.exe examples/host_d_mcp/run.py [可选任务文本] +""" + +from __future__ import annotations + +import asyncio +import os +import shlex +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # examples/ + +from runner_common import bootstrap, build_model, load_dotenv, stream # noqa: E402 + +bootstrap() + +from yai_core import AgentCore, build_spec # noqa: E402 + +DEFAULT_TASK = "用 MCP 工具算一下 17 加 25,再查一下湛江的天气,最后用一句话汇总" + + +def host_label() -> str: + """host_d 自己的本地工具:演示 Native 与 MCP 工具在同一注册表共存。""" + return "host_d(本地宿主)在线" + + +def build_config(): + """按环境变量构造 McpServerConfig;缺省回落到自带的本地 stdio 演示 Server。""" + from yai_core.integrations.mcp import McpServerConfig + + url = os.getenv("MCP_SERVER_URL") + if url: + return McpServerConfig(alias="remote", url=url) + command_line = os.getenv("MCP_SERVER_COMMAND") + if command_line: + parts = shlex.split(command_line, posix=(os.name != "nt")) + return McpServerConfig(alias="stdio", command=parts[0], args=parts[1:]) + demo_server = Path(__file__).with_name("demo_mcp_server.py") + return McpServerConfig( + alias="demo-stdio", + command=sys.executable, + args=[str(demo_server)], + ) + + +async def main() -> None: + load_dotenv() + model, backend = build_model() + core = AgentCore(model) + core.register_tools([build_spec(host_label)]) + + try: + from yai_core.integrations.mcp import attach_mcp_tools + except ImportError as exc: + print(f"[缺少依赖] {exc}") + return + + bridge = await attach_mcp_tools(core.registry, build_config()) + try: + task = " ".join(sys.argv[1:]).strip() or DEFAULT_TASK + await stream(core, task, backend) + finally: + await bridge.aclose() # 断开 stdio 子进程 / HTTP 连接,不留后台进程 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/examples/runner_common.py b/submissions/mcp-hackathon/yai-agent-core/source/examples/runner_common.py new file mode 100644 index 0000000..128938b --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/examples/runner_common.py @@ -0,0 +1,80 @@ +"""三个示例宿主共用的运行脚手架:路径引导、.env 加载、模型选择、事件流打印。 + +各宿主的 run.py 只负责声明自己的 capabilities 与默认任务,不重复这些样板。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def bootstrap() -> None: + """把 src/ 与 examples/ 放进 sys.path,使脚本可直接运行、无需安装。""" + sys.path.insert(0, str(ROOT / "src")) + sys.path.insert(0, str(ROOT / "examples")) + + +def load_dotenv() -> None: + """极简 .env 加载器:只认 KEY=VALUE,不覆盖已存在的环境变量,内核保持零依赖。""" + env_path = ROOT / ".env" + if not env_path.exists(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def build_model(): + """有 Key 走 OpenAI 兼容真实模型(DeepSeek 等),否则回退离线脚本模型。""" + if os.getenv("OPENAI_API_KEY"): + from yai_core import OpenAICompatProvider + + model = OpenAICompatProvider() + label = f"真实模型 {os.getenv('OPENAI_BASE_URL', '(SDK默认)')} :: {model.model}" + return model, label + from demo_model import OfflineScriptedModel + + return OfflineScriptedModel(), "离线脚本模型(未检测到 OPENAI_API_KEY)" + + +async def stream(core, task: str, backend: str) -> None: + """跑任务并把 Observer 事件流按人类可读方式打印出来。""" + from yai_core import EventType + + print("=" * 60) + print("后端:", backend) + print("自动发现的工具:", [t["name"] for t in core.list_tools()]) + print("任务:", task) + print("-" * 60) + + async for event in core.astream(task): + if event.type == EventType.STRATEGY_SELECTED: + print(f"[路由] 选定策略:{event.data['strategy']}") + elif event.type == EventType.PLAN_CREATED: + print("[计划] 拆出步骤:") + for i, step in enumerate(event.data["steps"], 1): + print(f" {i}. {step}") + elif event.type == EventType.TOOL_CALL: + print(f"[工具] 调用 {event.data['tool']}({event.data['arguments']})") + elif event.type == EventType.TOOL_RESULT: + preview = event.data.get("preview") or event.data.get("error", "") + print(f"[观察] 返回:{str(preview)[:120]}") + elif event.type == EventType.PERMISSION_ASKED: + print(f"[权限] 请求确认:{event.data['tool']}({event.data['arguments']})") + elif event.type == EventType.CLARIFY_REQUESTED: + print(f"[反问] {event.data['question']}") + elif event.type == EventType.MODEL_MESSAGE: + print(f"[模型] {event.data['text']}") + elif event.type == EventType.ERROR: + print(f"[错误] {event.data['error']}") + elif event.type == EventType.DONE: + print("-" * 60) + print(f"完成(策略 {event.data['strategy']})") + print("最终结果:", event.data["final_text"]) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/pyproject.toml b/submissions/mcp-hackathon/yai-agent-core/source/pyproject.toml new file mode 100644 index 0000000..254d545 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "yai-agent-core" +version = "0.1.0" +description = "YAI Agent Core — an embeddable, self-adaptive agent kernel for host applications" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [{ name = "Gi-Tuu" }] +keywords = ["agent", "llm", "tool-calling", "mcp", "runtime", "embedded", "kernel"] +dependencies = [] + +[project.optional-dependencies] +# 真实模型接入(OpenAI 兼容:DeepSeek / 通义千问 / OpenAI 等) +llm = ["openai>=1.40"] +# FastAPI Battery:在线 API、健康端点、X-Agent 部署验证端点 +server = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2.7", +] +# MCP Client 集成:接入外部 MCP Server 的工具(v2 稳定线,内核本体不依赖) +mcp = ["mcp>=2.0,<3"] +# OpenAPI 发现:把任意 OpenAPI 3 REST API 自动注册为工具(httpx 调用、pyyaml 解析 YAML 描述) +openapi = ["httpx>=0.27", "pyyaml>=6.0"] +dev = [ + "pytest>=8.0", + "ruff>=0.6", + "httpx>=0.27", +] +# 构建 docs/walkthrough 讲义 Word(PDF 导出走 LibreOffice UNO,无需 pip 包) +docs = ["python-docx>=1.1"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/yai_core"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests", "examples"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/render.yaml b/submissions/mcp-hackathon/yai-agent-core/source/render.yaml new file mode 100644 index 0000000..f0a806d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/render.yaml @@ -0,0 +1,41 @@ +# Render Blueprint(https://render.com/docs/blueprint-spec) +# 用法:Render 控制台 New → Blueprint → 连接 GitHub 仓库 yai-agent-core, +# Render 会按本文件自动创建免费 Docker Web Service;首次部署时按提示填写 OPENAI_API_KEY。 +# 说明: +# - PORT 由 Render 自动注入(默认 10000),Dockerfile 的 CMD 已兼容; +# - RENDER_GIT_COMMIT 由 Render 自动注入为本次部署的 commit,/health 直接读取,无需手填; +# - 免费实例 15 分钟无流量会休眠,仅用于演练,正式评审请用常驻 VPS(见 +# docs/competitions/deployment.md)。 +services: + - type: web + name: yai-agent-core + runtime: docker + plan: free + region: singapore + dockerfilePath: ./Dockerfile + healthCheckPath: /health + autoDeploy: true + envVars: + - key: OPENAI_API_KEY + sync: false # Secret:首次部署时在控制台粘贴,不入库 + - key: OPENAI_BASE_URL + value: https://api.deepseek.com + - key: LLM_MODEL + value: deepseek-chat + - key: LLM_STRONG_MODEL + value: deepseek-chat + - key: YAI_PROJECT_SLUG + value: yai-agent-core # X-Agent 提交 slug:必须与提交目录名、验证 JSON 一致 + - key: MCP_SERVER_URL + # 公共免鉴权 MCP Server(DeepWiki):让线上 API 也能真实调用外部 MCP 工具 + value: https://mcp.deepwiki.com/mcp + - key: YAI_DB_PATH + # 持久化记忆(v0.2):容器内相对路径 -> /app/data/yai.db,进程启动时自动建目录。 + # 注意:Render 免费层磁盘是临时盘,redeploy 后会丢库,演示口径见 docs/competitions/deployment.md + value: data/yai.db + - key: YAI_HISTORY_MAX_MESSAGES + # 历史保留策略(v0.3):保留最近 N 条消息,按对话轮对齐裁剪(不拆散工具调用对) + value: "200" + - key: YAI_HISTORY_TTL_SECONDS + # 超过 7 天的历史在写入与启动时清理;两者都是 opt-in,不设置则不裁剪 + value: "604800" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_walkthrough_docx.py b/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_walkthrough_docx.py new file mode 100644 index 0000000..908a063 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_walkthrough_docx.py @@ -0,0 +1,410 @@ +"""把 docs/walkthrough/ 下按 NN- 编号的分册合并排版成一份 Word 讲义(可再转 PDF)。 + +运行:.venv/Scripts/python.exe scripts/build_walkthrough_docx.py +设计:内容全部来自已有 markdown,脚本只负责解析与排版,不新增正文事实。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from docx import Document +from docx.enum.section import WD_SECTION +from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor + +ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = ROOT / "docs" / "walkthrough" +OUT_DIR = ROOT / "docs" / "exports" +OUT_DOCX = OUT_DIR / "YAI-Agent-Core-源码逐行讲解.docx" + +# 自动收录所有 NN- 开头的分册并按编号排序,新增章节无需改本脚本 +CHAPTERS = sorted(p.name for p in SRC_DIR.glob("[0-9][0-9]-*.md")) + + +# ---------- 样式工具 ---------- + +def set_east_asian(style, ascii_font: str, ea_font: str, size: Pt, + bold: bool | None = None, color: str = "1A1B1C") -> None: + style.font.name = ascii_font + style.font.size = size + style.font.color.rgb = RGBColor.from_string(color) + if bold is not None: + style.font.bold = bold + rpr = style.element.get_or_add_rPr() + rfonts = rpr.find(qn("w:rFonts")) + if rfonts is None: + rfonts = OxmlElement("w:rFonts") + rpr.append(rfonts) + rfonts.set(qn("w:ascii"), ascii_font) + rfonts.set(qn("w:hAnsi"), ascii_font) + rfonts.set(qn("w:eastAsia"), ea_font) + + +def shade_paragraph(par, fill: str) -> None: + ppr = par._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill) + ppr.append(shd) + + +def shade_run(run, fill: str) -> None: + rpr = run._r.get_or_add_rPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill) + rpr.append(shd) + + +def preserve_spaces(run) -> None: + t = run._r.find(qn("w:t")) + if t is not None: + t.set(qn("xml:space"), "preserve") + + +_INLINE = re.compile(r"(\*\*.+?\*\*|`.+?`|\[[^\]]+\]\([^)]+\))") + + +def add_inline(par, text: str, *, base_size: Pt | None = None) -> None: + """解析 **粗体**、`行内代码`、[链接](url)。""" + for token in _INLINE.split(text): + if not token: + continue + if token.startswith("**") and token.endswith("**"): + run = par.add_run(token[2:-2]) + run.bold = True + elif token.startswith("`") and token.endswith("`"): + run = par.add_run(token[1:-1]) + run.font.name = "Consolas" + run.font.size = Pt(10.5) + rpr = run._r.get_or_add_rPr() + rfonts = OxmlElement("w:rFonts") + rfonts.set(qn("w:ascii"), "Consolas") + rfonts.set(qn("w:hAnsi"), "Consolas") + rfonts.set(qn("w:eastAsia"), "宋体") + rpr.append(rfonts) + shade_run(run, "F2F2F2") + elif token.startswith("[") and "](" in token: + label, url = token[1:].split("](", 1) + run = par.add_run(f"{label}({url[:-1]})") + run.font.color.rgb = RGBColor.from_string("1F4E79") + else: + run = par.add_run(token) + if base_size is not None: + run.font.size = base_size + preserve_spaces(run) + + +# ---------- Markdown 分块解析 ---------- + +def _is_block_start(line: str) -> bool: + return ( + line.startswith("```") + or bool(re.match(r"^#{1,4} ", line)) + or line.startswith("|") + or line.startswith(">") + or bool(re.match(r"^[-*] ", line)) + or bool(re.match(r"^\d+\.\s", line)) + or line.strip() in ("---", "") + ) + + +def parse_md(text: str) -> list[tuple[str, object]]: + blocks: list[tuple[str, object]] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + buf: list[str] = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith("```"): + buf.append(lines[i]) + i += 1 + i += 1 + blocks.append(("code", "\n".join(buf))) + continue + m = re.match(r"^(#{1,4}) ", line) + if m: + blocks.append((f"h{len(m.group(1))}", line[len(m.group(0)):].strip())) + i += 1 + continue + if line.startswith("|"): + rows = [] + while i < len(lines) and lines[i].startswith("|"): + rows.append(lines[i]) + i += 1 + blocks.append(("table", rows)) + continue + if line.startswith(">"): + buf = [] + while i < len(lines) and lines[i].startswith(">"): + buf.append(lines[i].lstrip(">").strip()) + i += 1 + blocks.append(("quote", " ".join(buf))) + continue + if re.match(r"^[-*] ", line): + items = [] + while i < len(lines) and re.match(r"^[-*] ", lines[i]): + items.append(lines[i][2:]) + i += 1 + blocks.append(("ul", items)) + continue + if re.match(r"^\d+\.\s", line): + items = [] + while i < len(lines) and re.match(r"^\d+\.\s", lines[i]): + items.append(re.sub(r"^\d+\.\s", "", lines[i])) + i += 1 + blocks.append(("ol", items)) + continue + if line.strip() in ("---", ""): + i += 1 + continue + buf = [line] + i += 1 + while i < len(lines) and not _is_block_start(lines[i]): + buf.append(lines[i]) + i += 1 + blocks.append(("p", " ".join(buf))) + return blocks + + +def parse_table(rows: list[str]) -> tuple[list[str], list[list[str]]]: + matrix = [[c.strip() for c in r.strip().strip("|").split("|")] for r in rows] + header = matrix[0] + body = [r for r in matrix[2:]] # 第二行是 --- 分隔行 + return header, body + + +# ---------- docx 构建 ---------- + +def build() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + doc = Document() + + # 页面:A4 + 2.5cm + sec = doc.sections[0] + sec.page_width, sec.page_height = Cm(21.0), Cm(29.7) + sec.top_margin = sec.bottom_margin = Cm(2.5) + sec.left_margin = sec.right_margin = Cm(2.5) + + # 样式 + normal = doc.styles["Normal"] + set_east_asian(normal, "Arial", "宋体", Pt(12)) + normal.paragraph_format.line_spacing = 1.5 + ppr = normal.element.get_or_add_pPr() + ind = OxmlElement("w:ind") + ind.set(qn("w:firstLineChars"), "200") + ppr.append(ind) + + for name, size in (("Heading 1", 16), ("Heading 2", 14), ("Heading 3", 12)): + st = doc.styles[name] + set_east_asian(st, "Arial", "黑体", Pt(size), bold=True, color="000000") + title_style = doc.styles["Title"] + set_east_asian(title_style, "Arial", "黑体", Pt(22), bold=True) + # 去掉默认 Title 样式的蓝色下划线边框 + title_ppr = title_style.element.get_or_add_pPr() + title_bdr = title_ppr.find(qn("w:pBdr")) + if title_bdr is not None: + title_ppr.remove(title_bdr) + + # 打开时自动更新域(让 Word/LibreOffice 生成目录页码) + update_fields = OxmlElement("w:updateFields") + update_fields.set(qn("w:val"), "true") + doc.settings.element.append(update_fields) + + # 取 List Number 样式背后的抽象编号,供每个有序列表独立从 1 开始 + numbering_el = doc.part.numbering_part.element + ln_numpr = doc.styles["List Number"].element.find(qn("w:pPr")).find(qn("w:numPr")) + base_num_id = int(ln_numpr.find(qn("w:numId")).get(qn("w:val"))) + base_num = next( + n for n in numbering_el.findall(qn("w:num")) + if int(n.get(qn("w:numId"))) == base_num_id + ) + abstract_num_id = int(base_num.find(qn("w:abstractNumId")).get(qn("w:val"))) + + def new_restart_num_id() -> int: + existing = [int(n.get(qn("w:numId"))) for n in numbering_el.findall(qn("w:num"))] + num_id = max(existing) + 1 + num = OxmlElement("w:num") + num.set(qn("w:numId"), str(num_id)) + aref = OxmlElement("w:abstractNumId") + aref.set(qn("w:val"), str(abstract_num_id)) + num.append(aref) + override = OxmlElement("w:lvlOverride") + override.set(qn("w:ilvl"), "0") + start = OxmlElement("w:startOverride") + start.set(qn("w:val"), "1") + override.append(start) + num.append(override) + numbering_el.append(num) + return num_id + + code_style = doc.styles.add_style("CodeBlock", WD_STYLE_TYPE.PARAGRAPH) + code_style.base_style = doc.styles["Normal"] + set_east_asian(code_style, "Consolas", "宋体", Pt(10)) + code_style.paragraph_format.line_spacing = 1.15 + code_style.paragraph_format.left_indent = Cm(0.4) + code_style.paragraph_format.space_before = Pt(0) + code_style.paragraph_format.space_after = Pt(0) + cpr = code_style.element.get_or_add_pPr() + cind = OxmlElement("w:ind") + cind.set(qn("w:firstLine"), "0") + cind.set(qn("w:firstLineChars"), "0") + cpr.append(cind) + + quote_style = doc.styles.add_style("QuoteBlock", WD_STYLE_TYPE.PARAGRAPH) + quote_style.base_style = doc.styles["Normal"] + set_east_asian(quote_style, "Arial", "楷体", Pt(11), color="444444") + quote_style.paragraph_format.left_indent = Cm(0.6) + qpr = quote_style.element.get_or_add_pPr() + qind = OxmlElement("w:ind") + qind.set(qn("w:firstLine"), "0") + qpr.append(qind) + + # ---- 封面 ---- + for _ in range(5): + doc.add_paragraph() + t = doc.add_paragraph(style="Title") + t.alignment = WD_ALIGN_PARAGRAPH.CENTER + t.add_run("YAI Agent Core\n源码逐行讲解") + sub = doc.add_paragraph() + sub.alignment = WD_ALIGN_PARAGRAPH.CENTER + r = sub.add_run("00–14 分册合订 · 边学边造配套讲义") + r.font.size = Pt(13) + meta = doc.add_paragraph() + meta.alignment = WD_ALIGN_PARAGRAPH.CENTER + meta_text = ( + "对应代码主线(MCP Client · OpenAPI 发现 · SQLite 持久化记忆)" + " | Gi-Tuu | 2026-09" + ) + r = meta.add_run(meta_text) + r.font.size = Pt(10.5) + r.font.color.rgb = RGBColor.from_string("666666") + + # ---- 目录(独立一节)---- + doc.add_section(WD_SECTION.NEW_PAGE) + toc_title = doc.add_paragraph() + tt_run = toc_title.add_run("目录") + tt_run.bold = True + tt_run.font.size = Pt(16) + rpr = tt_run._r.get_or_add_rPr() + rf = OxmlElement("w:rFonts") + rf.set(qn("w:ascii"), "Arial") + rf.set(qn("w:hAnsi"), "Arial") + rf.set(qn("w:eastAsia"), "黑体") + rpr.append(rf) + toc_par = doc.add_paragraph() + fld_begin = OxmlElement("w:fldChar") + fld_begin.set(qn("w:fldCharType"), "begin") + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = ' TOC \\o "1-2" \\h \\z \\u ' + fld_sep = OxmlElement("w:fldChar") + fld_sep.set(qn("w:fldCharType"), "separate") + hint = OxmlElement("w:t") + hint.text = "(在 Word 中右键此处选择“更新域”即可生成带页码目录)" + fld_end = OxmlElement("w:fldChar") + fld_end.set(qn("w:fldCharType"), "end") + run_el = toc_par.add_run()._r + for el in (fld_begin, instr, fld_sep, hint, fld_end): + run_el.append(el) + + # ---- 正文(独立一节,页码从 1 开始)---- + body_section = doc.add_section(WD_SECTION.NEW_PAGE) + body_section.footer.is_linked_to_previous = False + footer_par = body_section.footer.paragraphs[0] + footer_par.alignment = WD_ALIGN_PARAGRAPH.CENTER + fb = OxmlElement("w:fldChar") + fb.set(qn("w:fldCharType"), "begin") + it = OxmlElement("w:instrText") + it.set(qn("xml:space"), "preserve") + it.text = " PAGE " + fe = OxmlElement("w:fldChar") + fe.set(qn("w:fldCharType"), "end") + fr = footer_par.add_run()._r + for el in (fb, it, fe): + fr.append(el) + pgnum = OxmlElement("w:pgNumType") + pgnum.set(qn("w:start"), "1") + body_section._sectPr.append(pgnum) + + def render_blocks(blocks: list[tuple[str, object]]) -> None: + for kind, payload in blocks: + if kind.startswith("h"): + doc.add_paragraph(str(payload), style=f"Heading {kind[1]}") + elif kind == "p": + par = doc.add_paragraph() + add_inline(par, str(payload)) + elif kind == "quote": + par = doc.add_paragraph(style="QuoteBlock") + add_inline(par, str(payload)) + elif kind == "ul": + for item in payload: # type: ignore[assignment] + par = doc.add_paragraph(style="List Bullet") + add_inline(par, item) + elif kind == "ol": + num_id = new_restart_num_id() + for item in payload: # type: ignore[assignment] + par = doc.add_paragraph(style="List Number") + ppr = par._p.get_or_add_pPr() + numpr = OxmlElement("w:numPr") + ilvl = OxmlElement("w:ilvl") + ilvl.set(qn("w:val"), "0") + nid = OxmlElement("w:numId") + nid.set(qn("w:val"), str(num_id)) + numpr.append(ilvl) + numpr.append(nid) + ppr.append(numpr) + add_inline(par, item) + elif kind == "code": + for code_line in str(payload).split("\n"): + par = doc.add_paragraph(style="CodeBlock") + run = par.add_run(code_line if code_line else " ") + preserve_spaces(run) + shade_paragraph(par, "F4F5F7") + elif kind == "table": + header, body = parse_table(payload) # type: ignore[arg-type] + table = doc.add_table(rows=1, cols=len(header)) + table.style = "Table Grid" + table.alignment = WD_TABLE_ALIGNMENT.CENTER + hdr = table.rows[0].cells + for j, h in enumerate(header): + hdr[j].text = "" + run = hdr[j].paragraphs[0].add_run(h) + run.bold = True + run.font.size = Pt(10.5) + tcpr = hdr[j]._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:fill"), "D9D9D9") + tcpr.append(shd) + for row in body: + cells = table.add_row().cells + for j, val in enumerate(row): + if j >= len(cells): + break + cells[j].text = "" + add_inline(cells[j].paragraphs[0], val, base_size=Pt(10.5)) + doc.add_paragraph() + + for idx, fname in enumerate(CHAPTERS): + text = (SRC_DIR / fname).read_text(encoding="utf-8") + render_blocks(parse_md(text)) + if idx != len(CHAPTERS) - 1: + doc.add_page_break() + + doc.save(OUT_DOCX) + print(f"saved: {OUT_DOCX}") + + +if __name__ == "__main__": + build() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_xagent_submission.py b/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_xagent_submission.py new file mode 100644 index 0000000..5ea8299 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/scripts/build_xagent_submission.py @@ -0,0 +1,266 @@ +"""构建 X-Agent MCP Hackathon 提交快照并做离线预检(纯标准库)。 + +做什么: + 1. 取当前 git HEAD 的 40 位 SHA 作为 reviewCommit; + 2. 用 `git archive HEAD` 把【已提交的源码】原样导出到 source/(不含 .git、 + 未跟踪文件、.env,天然满足"评审基线 = pinned commit"); + 3. 拷贝 SUBMISSION.md / RIGHTS.md / verification/,渲染 submission.json; + 4. 按官方 scripts/validate-submission.mjs 的同等规则做离线预检 + (结构、manifest、文件大小/数量、密钥特征、curl 证据); + 5. --online 时额外校验:GitHub commit 可公开访问、线上 health/proof 与 + reviewCommit、slug 三处一致。 + +用法: + python scripts/build_xagent_submission.py # 构建 + 离线预检 + python scripts/build_xagent_submission.py --online # 再加线上一致性校验 + +产物:dist/xagent-submission/yai-agent-core/(dist/ 已 gitignore)。 +提交时把该目录整体复制到 xagt-plugin 仓库的 submissions/mcp-hackathon/ 下提 PR。 +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import shutil +import subprocess +import tarfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SLUG = "yai-agent-core" +META_DIR = ROOT / "submissions" / "mcp-hackathon" / SLUG +OUT_ROOT = ROOT / "dist" / "xagent-submission" +OUT_DIR = OUT_ROOT / SLUG + +# 下面这组常量与官方 validate-submission.mjs 对齐(2026-09 核对)。 +MAX_FILES = 2000 +MAX_FILE_BYTES = 5 * 1024 * 1024 +MAX_TOTAL_BYTES = 20 * 1024 * 1024 +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +SHA_RE = re.compile(r"^[a-f0-9]{40}$") +FORBIDDEN_DIRS = {"node_modules", ".git", "dist", "build", ".next"} +IMPL_EXT_RE = re.compile( + r"\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|kts|c|cc|cpp|h|hpp|cs|fs|rb|php|swift" + r"|scala|sh|bash|sol|vy|ex|exs|erl|clj|lua|r|jl|ipynb|html|vue|svelte|sql)$", + re.IGNORECASE, +) +SECRET_PATTERNS = [ + re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), + re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), +] + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], cwd=ROOT, capture_output=True, text=True, check=True + ).stdout.strip() + + +def fail(msg: str) -> None: + raise SystemExit(f"[预检失败] {msg}") + + +def build() -> str: + sha = git("rev-parse", "HEAD") + if not SHA_RE.match(sha): + fail(f"HEAD 不是合法 40 位 SHA:{sha!r}") + + # 工作区未提交改动只警告:评审基线是已推送的 commit,快照里不会带上这些改动。 + dirty = git("status", "--porcelain") + if dirty: + print("[警告] 工作区有未提交改动,不会进入快照(git archive 只取 HEAD):") + for line in dirty.splitlines()[:10]: + print(" ", line) + + if OUT_ROOT.exists(): + shutil.rmtree(OUT_ROOT) + (OUT_DIR / "verification").mkdir(parents=True) + + # 1) 元数据文件 + for fname in ("SUBMISSION.md", "RIGHTS.md"): + shutil.copy2(META_DIR / fname, OUT_DIR / fname) + shutil.copy2( + META_DIR / "verification" / "README.md", + OUT_DIR / "verification" / "README.md", + ) + + # 2) 渲染 submission.json(把 __REVIEW_COMMIT__ 换成 HEAD SHA) + template = json.loads((META_DIR / "submission.template.json").read_text("utf-8")) + template["reviewCommit"] = sha + (OUT_DIR / "submission.json").write_text( + json.dumps(template, ensure_ascii=False, indent=2) + "\n", "utf-8" + ) + + # 3) git archive 导出 pinned commit 的完整源码到 source/ + tar_bytes = subprocess.run( + ["git", "archive", "--format=tar", "HEAD"], + cwd=ROOT, capture_output=True, check=True, + ).stdout + source_dir = OUT_DIR / "source" + with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:") as tar: + # 安全:拒绝绝对路径与 .. 路径(git archive 不会有,双保险) + for member in tar.getmembers(): + if member.name.startswith(("/",)) or ".." in Path(member.name).parts: + fail(f"归档内出现非法路径:{member.name}") + tar.extractall(source_dir, filter="data") # py3.12+ 的 data 过滤更安全 + + print(f"[构建] reviewCommit = {sha}") + print(f"[构建] 快照目录 = {OUT_DIR}") + return sha + + +def preflight_offline() -> dict: + """镜像官方校验器的离线检查(不含网络)。""" + checks: list[str] = [] + + # --- 必备文件 --- + required = ["submission.json", "SUBMISSION.md", "RIGHTS.md", + "verification/README.md"] + for rel in required: + p = OUT_DIR / rel + if not p.is_file() or p.stat().st_size == 0: + fail(f"{rel} 缺失或为空") + checks.append("必备文件齐全且非空") + + # --- manifest --- + manifest = json.loads((OUT_DIR / "submission.json").read_text("utf-8")) + if manifest.get("schemaVersion") != 1: + fail("submission.json schemaVersion 必须为 1") + if manifest.get("slug") != SLUG or not SLUG_RE.match(SLUG): + fail("slug 非法或与目录名不一致") + for key in ("name", "slug", "sourceRepository", "reviewCommit", "apiBaseUrl", + "healthCheckUrl", "deploymentProofUrl"): + if not isinstance(manifest.get(key), str) or not manifest[key].strip(): + fail(f"submission.json 缺少字段 {key}") + if not SHA_RE.match(manifest["reviewCommit"]): + fail("reviewCommit 必须是 40 位 SHA") + if not re.match(r"^https://github\.com/[^/]+/[^/]+/?$", manifest["sourceRepository"]): + fail("sourceRepository 必须是公开的 https github 仓库 URL") + for key in ("apiBaseUrl", "healthCheckUrl", "deploymentProofUrl"): + url = manifest[key] + if not url.startswith("https://") or "localhost" in url: + fail(f"{key} 必须是公网 HTTPS URL") + origins = {re.match(r"https://[^/]+", manifest[k]).group(0) for k in + ("apiBaseUrl", "healthCheckUrl", "deploymentProofUrl")} + if len(origins) != 1: + fail("API / health / deployment-proof 必须同源") + if not manifest["deploymentProofUrl"].endswith("/.well-known/xagent-verification.json"): + fail("deploymentProofUrl 路径必须是 /.well-known/xagent-verification.json") + checks.append("submission.json 结构与 URL 契约合法") + + # --- 遍历 source/:大小、数量、禁目录、实现文件、密钥扫描 --- + total = 0 + count = 0 + has_impl = False + for path in sorted((OUT_DIR / "source").rglob("*")): + if path.is_symlink(): + fail(f"不允许符号链接:{path.relative_to(OUT_DIR)}") + if path.is_dir(): + if path.name in FORBIDDEN_DIRS: + fail(f"不允许的目录:{path.relative_to(OUT_DIR)}") + continue + rel = path.relative_to(OUT_DIR).as_posix() + count += 1 + size = path.stat().st_size + total += size + if size > MAX_FILE_BYTES: + fail(f"单文件超过 5MiB:{rel}") + if total > MAX_TOTAL_BYTES: + fail("源码总体积超过 20MiB") + if count > MAX_FILES: + fail("文件数超过 2000") + if IMPL_EXT_RE.search(rel): + has_impl = True + name = path.name.lower() + if name.startswith(".env") and "example" not in name: + fail(f"疑似密钥文件:{rel}") + if re.search(r"\.(pem|p12|pfx|key)$", name): + fail(f"疑似密钥文件类型:{rel}") + try: + text = path.read_text("utf-8") + except (UnicodeDecodeError, PermissionError): + continue # 二进制文件跳过文本特征扫描 + for pat in SECRET_PATTERNS: + if pat.search(text): + fail(f"命中密钥特征 {pat.pattern}:{rel}") + if not has_impl: + fail("source/ 内没有实现文件") + checks.append(f"source/ 共 {count} 个文件、{total/1024:.0f} KiB,无禁目录/密钥特征") + + # --- verification 证据 --- + ver = (OUT_DIR / "verification" / "README.md").read_text("utf-8") + if not re.search(r"curl\s", ver) or "health" not in ver: + fail("verification/README.md 必须包含可复现 curl 命令与 health 证据") + checks.append("verification/README.md 含 curl 与 health 证据") + + print("[离线预检] 全部通过:") + for c in checks: + print(" ✓", c) + return manifest + + +def http_json(url: str) -> dict: + req = urllib.request.Request(url, headers={"User-Agent": "yai-submission-check", + "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 - 固定官方 URL + return json.loads(resp.read().decode("utf-8")) + + +def preflight_online(manifest: dict) -> None: + sha = manifest["reviewCommit"] + # 1) GitHub commit 必须可公开访问 + owner_repo = manifest["sourceRepository"].removeprefix("https://github.com/").strip("/") + commit_url = f"https://api.github.com/repos/{owner_repo}/commits/{sha}" + try: + http_json(commit_url) + print("[线上预检] ✓ GitHub 上可公开验证 reviewCommit") + except Exception as exc: # noqa: BLE001 + fail(f"GitHub commit 不可公开验证:{exc}") + + # 2) health:status 健康且 commit 一致 + health = http_json(manifest["healthCheckUrl"]) + health_commit = health.get("commit") or health.get("version") + if health.get("status") not in ("ok", "healthy"): + fail(f"health status 非 ok/healthy:{health.get('status')!r}") + if health_commit != sha: + fail( + f"health commit {health_commit!r} != reviewCommit {sha!r}" + "(是否忘了等部署完成,或正式提交时没关 autoDeploy?)" + ) + print("[线上预检] ✓ /health 在线且 commit 与 reviewCommit 一致") + + # 3) deployment proof:schemaVersion / slug / commit 一致 + proof = http_json(manifest["deploymentProofUrl"]) + if (proof.get("schemaVersion") != 1 or proof.get("slug") != manifest["slug"] + or proof.get("commit") != sha): + fail(f"部署证明不一致:{proof}") + print("[线上预检] ✓ /.well-known/xagent-verification.json 的 slug/commit 一致") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--online", action="store_true", help="额外做线上一致性校验") + args = parser.parse_args() + + build() + manifest = preflight_offline() + if args.online: + preflight_online(manifest) + print( + "\n[下一步] 1) 确认该 commit 已推送且线上部署完成(正式提交时在 Render 关 autoDeploy);\n" + " 2) fork https://github.com/xagentAI/xagt-plugin ;\n" + " 3) 把 dist/xagent-submission/yai-agent-core 整个目录复制到\n" + " fork 的 submissions/mcp-hackathon/ 下;\n" + " 4) 提 PR(只动这一个目录),PR 描述按官方模板勾选确认项。" + ) + + +if __name__ == "__main__": + main() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/scripts/lo_update_and_export.py b/submissions/mcp-hackathon/yai-agent-core/source/scripts/lo_update_and_export.py new file mode 100644 index 0000000..de313a6 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/scripts/lo_update_and_export.py @@ -0,0 +1,68 @@ +"""通过 UNO 连接已在监听的 LibreOffice:更新目录域、回存 docx、导出 PDF。 + +用法(先启动监听): + soffice --headless --invisible --norestore "--accept=socket,host=localhost,port=2002;urp;" + "C:\\Program Files\\LibreOffice\\program\\python.exe" scripts/lo_update_and_export.py +""" + +import sys +import time +from pathlib import Path + +import uno +from com.sun.star.beans import PropertyValue + + +def prop(name: str, value): + p = PropertyValue() + p.Name = name + p.Value = value + return p + + +def main() -> None: + docx_path = Path(sys.argv[1]).resolve() + pdf_path = Path(sys.argv[2]).resolve() + + local_ctx = uno.getComponentContext() + resolver = local_ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.bridge.UnoUrlResolver", local_ctx + ) + ctx = None + for _ in range(30): + try: + ctx = resolver.resolve( + "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext" + ) + break + except Exception: + time.sleep(1) + if ctx is None: + raise RuntimeError("无法连接 LibreOffice 监听端口") + + desktop = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", ctx + ) + doc = desktop.loadComponentFromURL( + uno.systemPathToFileUrl(str(docx_path)), "_blank", 0, + (prop("Hidden", True),), + ) + + # 更新全部目录/索引 + indexes = doc.getDocumentIndexes() + for i in range(indexes.getCount()): + indexes.getByIndex(i).update() + # 更新普通域(页码等) + doc.getTextFields().refresh() + + doc.store() # 回存 docx(目录缓存随之写入) + doc.storeToURL( + uno.systemPathToFileUrl(str(pdf_path)), + (prop("FilterName", "writer_pdf_Export"),), + ) + doc.close(False) + print("indexes updated; docx stored; pdf exported") + + +if __name__ == "__main__": + main() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/scripts/serve_example.py b/submissions/mcp-hackathon/yai-agent-core/source/scripts/serve_example.py new file mode 100644 index 0000000..a7e004d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/scripts/serve_example.py @@ -0,0 +1,157 @@ +"""在线 API 启动示例(X-Agent 部署形态)。 + +本地直跑(宿主机统一用 8001,容器内才是 8000): + uv run uvicorn scripts.serve_example:app --host 127.0.0.1 --port 8001 +容器化: + docker compose up --build # 容器内监听 8000,宿主机映射 8001:8000 +环境变量(写在项目根 .env 即可,脚本启动时自动加载): + OPENAI_API_KEY / OPENAI_BASE_URL / LLM_MODEL 真实模型(缺省走离线演示模型) + YAI_GIT_COMMIT=<40位 commit> YAI_PROJECT_SLUG= X-Agent 验证端点 + commit 解析顺序:YAI_GIT_COMMIT → 平台注入(如 RENDER_GIT_COMMIT)→ 当前 git HEAD → dev + (只接受完整 40 位哈希,镜像默认值 dev 等占位会被跳过) +""" + +from __future__ import annotations + +import contextlib +import os +import re +import subprocess +import sys +from pathlib import Path + +_SHA40 = re.compile(r"[0-9a-f]{40}") + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "examples")) + +from host_a_notes import capabilities # noqa: E402 +from runner_common import build_model, load_dotenv # noqa: E402 +from yai_core import AgentCore # noqa: E402 +from yai_core.batteries.fastapi_server import RateLimitConfig, create_app # noqa: E402 +from yai_core.integrations.openapi import attach_openapi_tools # noqa: E402 +from yai_core.integrations.openapi import ( # noqa: E402 + config_from_env as openapi_config_from_env, +) +from yai_core.memory import InMemoryStore, SqliteStore, retention_from_env # noqa: E402 + + +def _git_commit() -> str: + """读取当前 git HEAD 的完整 40 位 commit;非 git 环境回退 dev。""" + try: + out = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + timeout=5, + ) + return out.stdout.strip() + except (OSError, subprocess.SubprocessError): + return "dev" + + +def _valid_sha(value: str | None) -> str | None: + """只接受完整 40 位小写哈希;dev、空串、短 SHA 等占位值一律视为无效。""" + if value and _SHA40.fullmatch(value.strip()): + return value.strip() + return None + + +load_dotenv() +# commit 解析链:YAI_GIT_COMMIT(构建期固化)→ 平台注入(Render 为 RENDER_GIT_COMMIT) +# → 当前 git HEAD(本地联调)→ dev。 +# 注意镜像 ENV 里烤着默认值 dev(非合法哈希),必须跳过它,平台注入的真实 SHA 才能生效。 +os.environ["YAI_GIT_COMMIT"] = ( + _valid_sha(os.environ.get("YAI_GIT_COMMIT")) + or _valid_sha(os.environ.get("RENDER_GIT_COMMIT")) + or _git_commit() +) + +_model, _backend = build_model() +print(f"serve_example 后端:{_backend}") +print(f"验证端点 commit:{os.environ['YAI_GIT_COMMIT']}") + +# llm_router=True:先让模型做一次轻量分类(strategy/tier/reason), +# 分类失败/超时自动回退确定性规则,事件流里带 source=llm|rules 可审计。 +# 持久化记忆 opt-in:设置 YAI_DB_PATH 才落 SQLite;不设置时用进程内内存。 +# 历史保留策略 opt-in:YAI_HISTORY_MAX_MESSAGES / YAI_HISTORY_TTL_SECONDS; +# env 只在这一层解析一次(非法值只 warning 一次),两种 Store 对等生效。 +_retention = retention_from_env() +_sqlite_store = SqliteStore.from_env(retention=_retention) +if _sqlite_store is not None: + _store = _sqlite_store +else: + _store = InMemoryStore(max_messages=_retention[0], ttl_seconds=_retention[1]) +if _retention != (None, None): + print( + f"历史保留策略:max_messages={_retention[0]},ttl_seconds={_retention[1]}" + "(opt-in,按轮对齐裁剪;SQLite 构造时已自动 prune 一次)" + ) +core = AgentCore.auto(capabilities, _model, llm_router=True, memory=_store) + + +@contextlib.asynccontextmanager +async def app_lifespan(_app): + """启停生命周期:挂载外部 MCP;停机时断开连接并关闭记忆存储。 + + MCP 与 OpenAPI 两个集成相互独立:缺 extra / 外部服务不可达 / 描述拉取失败 + 都只告警、不阻断启动。本地 native 工具与验证端点必须始终可用。 + """ + bridges = [] + try: + # MCP 与 OpenAPI 各自独立:任一集成缺依赖 / 挂载失败都只告警,不影响另一个与本地工具。 + try: + from yai_core.integrations.mcp import attach_mcp_tools, config_from_env + except ImportError as exc: # 没装 [mcp] extra 是合法部署形态 + print(f"[serve_example] 未安装 [mcp] extra,跳过 MCP 挂载:{exc}") + else: + cfg = config_from_env(alias="remote") + if cfg is not None: + try: + bridge = await attach_mcp_tools(core.registry, cfg) + bridges.append(bridge) + names = [s.name for s in bridge.specs] + print(f"MCP 已挂载({cfg.alias}):{names}") + except Exception as exc: # noqa: BLE001 - 外部依赖不可用是运行时常态 + print(f"[警告] 外部 MCP 挂载失败,仅提供本地工具:{type(exc).__name__}: {exc}") + + # OpenAPI 发现(opt-in):配置 OPENAPI_SPEC_URL/PATH 才挂载。 + # 在线演示服务强制只读:只注册 GET/HEAD,写操作绝不暴露到公网演示端点。 + openapi_config = openapi_config_from_env(alias="rest") + if openapi_config is not None: + openapi_config.read_only = True + try: + openapi_bridge = await attach_openapi_tools(core.registry, openapi_config) + bridges.append(openapi_bridge) + print( + f"[serve_example] OpenAPI 已挂载:{openapi_config.url or openapi_config.path}," + f"工具 {len(openapi_bridge.specs)} 个(read_only=True)" + ) + for note in openapi_bridge.notes: + print(f" note: {note}") + except ImportError as exc: + print(f"[serve_example] 未安装 [openapi] extra,跳过 OpenAPI 挂载:{exc}") + except Exception as exc: # OpenAPI 故障不拖垮本地服务 + print(f"[serve_example] OpenAPI 挂载失败,降级跳过:{exc}") + yield + finally: + for bridge in bridges: + with contextlib.suppress(Exception): + await bridge.aclose() + # close() 不是 MemoryStore 契约方法:探测式调用,InMemoryStore 没有就跳过。 + close_store = getattr(_store, "close", None) + if close_store is not None: + close_store() + + +# 评审期限流(默认开启):显式 from_env 并打印一行策略,便于部署核对。 +_rate_limit = RateLimitConfig.from_env() +print( + f"限流策略:enabled={_rate_limit.enabled}," + f"per_minute={_rate_limit.per_minute},window_seconds={_rate_limit.window_seconds}" + "(仅 POST /v1/agent/run;GET 验证端点不受限)" +) +app = create_app(core, lifespan=app_lifespan, rate_limit=_rate_limit) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/scripts/smoke_test.py b/submissions/mcp-hackathon/yai-agent-core/source/scripts/smoke_test.py new file mode 100644 index 0000000..70d5d27 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/scripts/smoke_test.py @@ -0,0 +1,52 @@ +"""端到端冒烟:同一 Core 嵌入三个不同宿主,零 Agent 代码各自长出能力。 + +运行:python scripts/smoke_test.py +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "examples")) + +from demo_model import OfflineScriptedModel # noqa: E402 +from host_a_notes import capabilities as notes_app # noqa: E402 +from host_b_data import capabilities as data_app # noqa: E402 +from host_c_companion import capabilities as companion_app # noqa: E402 +from yai_core import AgentCore, EventType # noqa: E402 + +_VISIBLE = ( + EventType.STRATEGY_SELECTED, + EventType.PLAN_CREATED, + EventType.TOOL_CALL, + EventType.MODEL_MESSAGE, + EventType.DONE, +) + + +async def run_host(title: str, host: object, task: str) -> None: + print(f"\n===== {title} =====") + core = AgentCore.auto(host, OfflineScriptedModel()) + print(f"自动发现工具: {[t['name'] for t in core.list_tools()]}") + async for event in core.astream(task): + if event.type in _VISIBLE: + print(f" {event.type.value}: {event.data}") + + +async def main() -> None: + await run_host("宿主 A 笔记应用", notes_app, "搜索笔记里和比赛有关的内容") + await run_host("宿主 B 数据应用", data_app, "先筛选硬件品类,然后统计销售额") + await run_host( + "宿主 C AI 陪伴(AMBRACE 预演)", + companion_app, + "先查看小拥的状态,然后检索和约定有关的记忆,给出主动关怀建议", + ) + print("\n冒烟测试通过:同一内核已自适应三个不同宿主。") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/__init__.py new file mode 100644 index 0000000..308241e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/__init__.py @@ -0,0 +1,44 @@ +"""YAI Agent Core —— 进程内嵌入式自适应 Agent 内核。 + +宿主软件只声明能力,Core 自动发现、自适应规划与执行。 +""" + +from yai_core.core import AgentCore +from yai_core.discovery import build_spec, discover +from yai_core.kernel import AdaptiveRouter, AgentLoop, Context, RouteDecision +from yai_core.llm.openai_compat import OpenAICompatProvider +from yai_core.tools import ToolExecutor, ToolRegistry +from yai_core.types import ( + AgentEvent, + ChatMessage, + EventType, + ModelResponse, + RunResult, + Strategy, + ToolCallRequest, + ToolSpec, +) + +__version__ = "0.1.0" + +__all__ = [ + "AgentCore", + "AdaptiveRouter", + "RouteDecision", + "AgentLoop", + "Context", + "ToolRegistry", + "ToolExecutor", + "OpenAICompatProvider", + "build_spec", + "discover", + "AgentEvent", + "ChatMessage", + "EventType", + "ModelResponse", + "RunResult", + "Strategy", + "ToolCallRequest", + "ToolSpec", + "__version__", +] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/__init__.py new file mode 100644 index 0000000..28e6f2d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/__init__.py @@ -0,0 +1 @@ +"""Batteries:可选的外围能力(服务器、MCP client 等),不属于内核。""" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/__init__.py new file mode 100644 index 0000000..86b732b --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/__init__.py @@ -0,0 +1,4 @@ +from yai_core.batteries.fastapi_server.app import create_app +from yai_core.batteries.fastapi_server.ratelimit import RateLimitConfig + +__all__ = ["create_app", "RateLimitConfig"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/app.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/app.py new file mode 100644 index 0000000..b537de7 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/app.py @@ -0,0 +1,104 @@ +"""FastAPI Battery:把内嵌 Core 暴露为在线 API。 + +内置 X-Agent AI MCP Hackathon 要求的两个验证端点: +- GET /health -> {"status":"ok","commit":"<40位>"} +- GET /.well-known/xagent-verification.json -> {"schemaVersion":1,"slug":...,"commit":...} + +注意:本模块属于可选 Battery,允许依赖 fastapi/pydantic;yai_core 内核仍零硬依赖。 +""" + +from __future__ import annotations + +import os +from typing import Any + +try: + from fastapi import FastAPI + from fastapi.responses import JSONResponse + from pydantic import BaseModel +except ImportError as exc: # pragma: no cover - 仅在缺少可选依赖时触发 + raise ImportError( + "FastAPI Battery 需要可选依赖:uv pip install -e '.[server]'" + ) from exc + +from yai_core.batteries.fastapi_server.ratelimit import ( + RateLimitConfig, + SlidingWindowLimiter, + client_ip, +) + +# 限流只拦这一条 POST:三个 GET 端点(health / verification / tools)是 +# X-Agent 评审硬门槛路径,任何超限状态下都必须无条件 200。 +_LIMITED_METHOD = "POST" +_LIMITED_PATHS = frozenset({"/v1/agent/run"}) + + +class RunRequest(BaseModel): + task: str + + +def create_app( + core: Any, lifespan: Any = None, *, rate_limit: RateLimitConfig | None = None +) -> FastAPI: + app = FastAPI(title="YAI Agent Core API", version="0.1.0", lifespan=lifespan) + commit = os.getenv("YAI_GIT_COMMIT", "dev") + slug = os.getenv("YAI_PROJECT_SLUG", "yai-agent-core") + + # 评审期限流(opt-out via env):None -> 从环境变量读默认策略; + # enabled=False 时不注册中间件,零开销、零行为变化。 + config = rate_limit if rate_limit is not None else RateLimitConfig.from_env() + if config.enabled: + limiter = SlidingWindowLimiter(config) + + @app.middleware("http") + async def _rate_limit_middleware(request, call_next): + if request.method == _LIMITED_METHOD and request.url.path in _LIMITED_PATHS: + key = client_ip( + request.headers.get("x-forwarded-for"), + request.client.host if request.client else None, + ) + allowed, retry_after = limiter.check(key) + if not allowed: + # 被限请求在中间件层直接返回:不进 core.run,一分模型钱都不花。 + return JSONResponse( + status_code=429, + content={ + "error": "rate_limited", + "detail": "请求过于频繁,请稍后重试", + "retry_after": retry_after, + }, + headers={"Retry-After": str(retry_after)}, + ) + return await call_next(request) + + @app.get("/health") + async def health() -> dict: + return {"status": "ok", "commit": commit} + + @app.get("/.well-known/xagent-verification.json") + async def verification() -> dict: + return {"schemaVersion": 1, "slug": slug, "commit": commit} + + @app.get("/v1/tools") + async def list_tools() -> list[dict]: + return core.list_tools() + + @app.post("/v1/agent/run") + async def run_agent(req: RunRequest) -> dict: + result = await core.run(req.task) + return { + "strategy": result.strategy.value, + "final_text": result.final_text, + "events": [ + {"type": e.type.value, "data": _jsonable(e.data)} for e in result.events + ], + } + + return app + + +def _jsonable(data: dict) -> dict: + out: dict[str, Any] = {} + for k, v in data.items(): + out[k] = v if isinstance(v, str | int | float | bool | list | dict | None) else str(v) + return out diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/ratelimit.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/ratelimit.py new file mode 100644 index 0000000..298d723 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/batteries/fastapi_server/ratelimit.py @@ -0,0 +1,114 @@ +"""评审期限流:每 IP 滑动窗口限流 Battery(v0.3,纯标准库)。 + +威胁模型(如实声明):本模块只防"误刷"——手滑重试、爬虫、失控循环; +**不防**蓄意伪造 X-Forwarded-For 的分布式攻击(那需要鉴权/人机验证,超出 v0.3)。 +客户端身份取 XFF 最左非空 IP(Render 边缘代理会附加 XFF),缺失回退对端地址。 + +TODO(v0.4):全局兜底限流(跨实例/全实例总配额)暂不做——免费层单实例, +分布式计数属后续议题;目前只有每 IP 维度。 +""" + +from __future__ import annotations + +import math +import os +import time +import warnings +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +from threading import Lock + +_ENABLED_ENV = "YAI_RATE_LIMIT_ENABLED" +_PER_MINUTE_ENV = "YAI_RATE_LIMIT_PER_MINUTE" +_WINDOW_ENV = "YAI_RATE_LIMIT_WINDOW_SECONDS" + + +def _truthy_enabled(raw: str | None) -> bool: + """enabled 解析:未设置视为开启;显式 0/false/no/off 才关闭。""" + if raw is None or not raw.strip(): + return True + return raw.strip().lower() not in {"0", "false", "no", "off"} + + +def _parse_positive_int(raw: str | None, env_name: str, default: int) -> int: + """正整数解析:未设置/空白回退默认(静默);非法值回退默认 + warning。""" + if raw is None or not raw.strip(): + return default + try: + value = int(raw.strip()) + except ValueError: + warnings.warn(f"{env_name}={raw!r} 不是合法整数,回退默认值 {default}", stacklevel=2) + return default + if value <= 0: + warnings.warn(f"{env_name}={value} 必须为正整数,回退默认值 {default}", stacklevel=2) + return default + return value + + +@dataclass +class RateLimitConfig: + """限流配置;默认值(开启 / 30 次 / 60 秒)是对外承诺,改动须先改 10-plan。""" + + enabled: bool = True + per_minute: int = 30 + window_seconds: int = 60 + time_func: Callable[[], float] = field(default=time.monotonic) + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> RateLimitConfig: + """从环境变量构造;env=None 读真实 os.environ,测试注入假字典。""" + environ = env if env is not None else os.environ + return cls( + enabled=_truthy_enabled(environ.get(_ENABLED_ENV)), + per_minute=_parse_positive_int( + environ.get(_PER_MINUTE_ENV), _PER_MINUTE_ENV, 30 + ), + window_seconds=_parse_positive_int( + environ.get(_WINDOW_ENV), _WINDOW_ENV, 60 + ), + ) + + +class SlidingWindowLimiter: + """每 key 一个时间戳 deque;请求时先清窗口外时间戳,再判断是否超限。 + + 状态只在内存(进程重启清零),不落盘、不引 Redis。 + """ + + def __init__(self, config: RateLimitConfig) -> None: + self._per_minute = config.per_minute + self._window = config.window_seconds + self._time_func = config.time_func + self._hits: dict[str, deque[float]] = {} + # 防御性互斥:目标路由是 async 且 deque 操作无 await 间隙,单事件循环下 + # 理论原子;但 TestClient / 将来同步路由会跨线程,锁成本近乎为零。 + self._lock = Lock() + + def check(self, key: str) -> tuple[bool, int]: + """返回 (是否放行, 超限时建议等待的整秒数)。""" + now = self._time_func() + with self._lock: + hits = self._hits.setdefault(key, deque()) + # 窗口边界用 <= 清除:正好等于窗口的请求视为过期。 + cutoff = now - self._window + while hits and hits[0] <= cutoff: + hits.popleft() + if len(hits) >= self._per_minute: + oldest = hits[0] + retry_after = max(1, math.ceil(oldest + self._window - now)) + return False, retry_after + hits.append(now) + return True, 0 + + +def client_ip(x_forwarded_for: str | None, peer_host: str | None) -> str: + """XFF 取最左非空段;缺失回退对端地址;再缺省 "unknown"。""" + if x_forwarded_for: + for part in x_forwarded_for.split(","): + candidate = part.strip() + if candidate: + return candidate + if peer_host: + return peer_host + return "unknown" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/__init__.py new file mode 100644 index 0000000..9026842 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/__init__.py @@ -0,0 +1,4 @@ +from yai_core.channels.cli import CliChannel +from yai_core.channels.collect import CollectChannel + +__all__ = ["CliChannel", "CollectChannel"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/cli.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/cli.py new file mode 100644 index 0000000..6deb5a1 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/cli.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import asyncio + +from yai_core.types import AgentEvent, EventType + +_LABELS = { + EventType.STRATEGY_SELECTED: "策略", + EventType.PLAN_CREATED: "计划", + EventType.MODEL_MESSAGE: "回复", + EventType.TOOL_CALL: "调用工具", + EventType.TOOL_RESULT: "工具结果", + EventType.PERMISSION_ASKED: "权限确认", + EventType.CLARIFY_REQUESTED: "需要澄清", + EventType.ERROR: "错误", +} + + +class CliChannel: + """终端通道:把 Observer 事件流打印成人可读的过程。""" + + async def emit(self, event: AgentEvent) -> None: + label = _LABELS.get(event.type, event.type.value) + if event.type == EventType.TOOL_CALL: + print(f" [{label}] {event.data['tool']}({event.data['arguments']})") + elif event.type == EventType.STRATEGY_SELECTED: + print(f" [{label}] {event.data['strategy']}") + elif event.type == EventType.MODEL_MESSAGE: + print(f" [{label}] {event.data.get('text', '')}") + else: + print(f" [{label}] {event.data}") + + async def ask(self, question: str) -> str: + return await asyncio.to_thread(input, f" [澄清] {question} > ") + + async def confirm(self, tool_name: str, arguments: dict) -> bool: + answer = await asyncio.to_thread( + input, f" [确认] 允许执行 {tool_name}({arguments})? [y/N] " + ) + return answer.strip().lower() in ("y", "yes", "是") diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/collect.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/collect.py new file mode 100644 index 0000000..285772b --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/channels/collect.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from yai_core.types import AgentEvent + + +class CollectChannel: + """收集全部事件的通道:测试、API Battery、批处理使用。""" + + def __init__(self, *, auto_confirm: bool = True, default_answer: str = "") -> None: + self.events: list[AgentEvent] = [] + self.auto_confirm = auto_confirm + self.default_answer = default_answer + + async def emit(self, event: AgentEvent) -> None: + self.events.append(event) + + async def ask(self, question: str) -> str: + return self.default_answer + + async def confirm(self, tool_name: str, arguments: dict) -> bool: + return self.auto_confirm diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/core.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/core.py new file mode 100644 index 0000000..474a330 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/core.py @@ -0,0 +1,97 @@ +"""AgentCore 门面:宿主接入的唯一入口。 + + from yai_core import AgentCore + core = AgentCore.auto(my_app) # 内省宿主能力,自动注册工具 + result = await core.run("整理本周笔记") # 自适应选策略/工具/模型 +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from yai_core.channels import CollectChannel +from yai_core.discovery import discover +from yai_core.kernel import AdaptiveRouter, AgentLoop +from yai_core.memory import InMemoryStore +from yai_core.policy import AllowlistPolicy +from yai_core.spi import Channel, MemoryStore, ModelProvider, PermissionPolicy +from yai_core.tools import ToolExecutor, ToolRegistry +from yai_core.types import AgentEvent, RunResult, ToolSpec + + +class AgentCore: + def __init__( + self, + model: ModelProvider, + *, + channel: Channel | None = None, + memory: MemoryStore | None = None, + policy: PermissionPolicy | None = None, + router: AdaptiveRouter | None = None, + auto_approve_tools: bool = True, + llm_router: bool = False, + ) -> None: + self.model = model + self.registry = ToolRegistry() + self.channel = channel or CollectChannel() + self.memory = memory or InMemoryStore() + self.policy = policy or AllowlistPolicy(mode="allow_all" if auto_approve_tools else "auto") + # llm_router=True 时把模型注入路由器:先 LLM 分类、失败回退规则; + # 默认关闭,保持零额外模型调用、离线测试完全确定。 + if router is not None: + self.router = router + else: + self.router = AdaptiveRouter(model=model if llm_router else None) + self.executor = ToolExecutor(self.registry, self.policy, self.channel) + self._loop = AgentLoop( + model=self.model, + registry=self.registry, + executor=self.executor, + channel=self.channel, + memory=self.memory, + router=self.router, + ) + + # ---------- 接入 ---------- + + @classmethod + def auto(cls, host: object, model: ModelProvider, **kwargs: object) -> AgentCore: + """内省宿主模块/对象,自动发现并注册全部公开能力。""" + core = cls(model, **kwargs) # type: ignore[arg-type] + core.register_tools(discover(host)) + return core + + def register_tools(self, specs: list[ToolSpec]) -> None: + self.registry.register_many(specs) + + def list_tools(self) -> list[dict]: + return [ + {"name": s.name, "description": s.description, "source": s.source} + for s in self.registry.all() + ] + + # ---------- 运行 ---------- + + async def astream(self, task: str) -> AsyncIterator[AgentEvent]: + async for event in self._loop.astream(task): + await self.channel.emit(event) + yield event + + async def run(self, task: str) -> RunResult: + events: list[AgentEvent] = [] + final_text = "" + strategy = None + async for event in self.astream(task): + events.append(event) + data = event.data + if "strategy" in data: + from yai_core.types import Strategy + + strategy = Strategy(data["strategy"]) + if event.type.value == "done": + final_text = data.get("final_text", "") + return RunResult( + strategy=strategy or self.router.classify(task, self.registry), + events=events, + final_text=final_text, + ) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/__init__.py new file mode 100644 index 0000000..0e330c2 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/__init__.py @@ -0,0 +1,5 @@ +"""能力自发现:把宿主已有的函数/对象自动转成 ToolSpec。""" + +from yai_core.discovery.introspect import build_spec, discover + +__all__ = ["build_spec", "discover"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/introspect.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/introspect.py new file mode 100644 index 0000000..84e49b4 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/discovery/introspect.py @@ -0,0 +1,100 @@ +"""Python 函数内省:type hints + docstring -> JSON Schema 工具规格。 + +这是"宿主只声明能力、Core 自动长出 Agent"的第一块基石。 +v0.2 计划:openapi.py(OpenAPI 文档 -> 工具)、mcp.py(MCP tools/list -> 工具)。 +""" + +from __future__ import annotations + +import inspect +import types +from typing import Any, get_args, get_origin + +from yai_core.types import ToolSpec + +_PY_TO_JSON: dict[type, str] = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", +} + + +def _annotation_to_json_type(annotation: Any) -> tuple[str, bool]: + """返回 (json 类型, 是否可空)。无法识别时回退为 string。""" + if annotation is inspect.Parameter.empty: + return "string", False + # Optional[X] / X | None + origin = get_origin(annotation) + if origin in (types.UnionType, getattr(__import__("typing"), "Union", object)): + args = [a for a in get_args(annotation) if a is not type(None)] + nullable = len(args) != len(get_args(annotation)) + if args: + return _PY_TO_JSON.get(args[0], "string"), nullable + return _PY_TO_JSON.get(annotation, "string"), False + + +def build_spec(fn: Any, *, name: str | None = None, source: str = "native") -> ToolSpec: + """把一个 Python 可调用对象转换成 ToolSpec。""" + if not callable(fn): + raise TypeError(f"build_spec 需要可调用对象,得到 {type(fn)!r}") + + sig = inspect.signature(fn) + properties: dict[str, Any] = {} + required: list[str] = [] + + for param_name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + json_type, nullable = _annotation_to_json_type(param.annotation) + prop: dict[str, Any] = {"type": json_type} + if param.default is inspect.Parameter.empty: + required.append(param_name) + else: + prop["default"] = param.default + if nullable: + prop["type"] = [json_type, "null"] + properties[param_name] = prop + + doc = inspect.getdoc(fn) or "" + description = doc.split("\n\n", 1)[0].strip() or name or fn.__name__ + + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + + return ToolSpec( + name=name or fn.__name__, + description=description, + input_schema=schema, + handler=fn, + source=source, # type: ignore[arg-type] + ) + + +def discover(host: Any) -> list[ToolSpec]: + """从模块 / 类实例 / 对象上自动发现公开可调用能力。 + + - 模块:只收该模块内定义的函数(避免把 import 进来的函数误注册) + - 对象实例:收公开方法(不以 _ 开头) + """ + specs: list[ToolSpec] = [] + + if inspect.ismodule(host): + module_name = host.__name__ + members = inspect.getmembers(host, inspect.isfunction) + for member_name, fn in members: + if member_name.startswith("_"): + continue + if getattr(fn, "__module__", None) != module_name: + continue + specs.append(build_spec(fn)) + return specs + + for member_name, fn in inspect.getmembers(host, inspect.ismethod): + if member_name.startswith("_"): + continue + specs.append(build_spec(fn)) + return specs diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/__init__.py new file mode 100644 index 0000000..ee305dd --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/__init__.py @@ -0,0 +1,5 @@ +"""可选集成层:把外部生态能力接入内核,全部懒加载、零硬依赖。 + +- integrations.mcp:MCP Client,把外部 MCP Server 的工具注册进 ToolRegistry。 +- integrations.openapi:OpenAPI 发现,把任意 OpenAPI 3 REST API 自动注册为工具。 +""" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/__init__.py new file mode 100644 index 0000000..26c20d0 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/__init__.py @@ -0,0 +1,17 @@ +"""MCP Client 集成:把外部 MCP Server 的工具变成 Core 自己的工具。""" + +from yai_core.integrations.mcp.client import ( + McpServerConfig, + McpToolBridge, + attach_mcp_tools, + config_from_env, + sanitize_schema, +) + +__all__ = [ + "McpServerConfig", + "McpToolBridge", + "attach_mcp_tools", + "config_from_env", + "sanitize_schema", +] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/client.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/client.py new file mode 100644 index 0000000..e6bd247 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/mcp/client.py @@ -0,0 +1,179 @@ +"""MCP Client 桥接层:外部 MCP Server 工具 → ToolRegistry 里的 ToolSpec。 + +设计要点(对应内核红线): +1. 顶层不 import mcp:内核本体零第三方硬依赖,mcp 是可选依赖(pyproject 的 + [mcp] extra),只在 connect() 内部懒加载;没装时给出可操作的报错。 +2. 工具同构:MCP 工具被转换成 ToolSpec(source="mcp"),handler 是一个异步闭包。 + Router / Loop / Executor 完全感知不到工具在本机还是远端 MCP Server 上—— + 它们只面对统一的 ToolSpec,这就是"Native 与 MCP 统一"的落点。 +3. 结果归一:MCP CallToolResult 的 content 文本块 / structured_content / + is_error 三种形态在这里被翻译成 Executor 熟悉的"返回值或抛异常"。 +""" + +from __future__ import annotations + +import os +import shlex +from dataclasses import dataclass, field +from typing import Any + +from yai_core.tools.schema import EMPTY_OBJECT_SCHEMA, sanitize_schema +from yai_core.types import ToolSpec + +# 兼容别名:本模块下方代码沿用旧名 _EMPTY_SCHEMA,保持零改动。 +_EMPTY_SCHEMA = EMPTY_OBJECT_SCHEMA + + +@dataclass +class McpServerConfig: + """一个 MCP Server 的连接配置,url / command / server 三选一。 + + - url:Streamable HTTP(如 https://example.com/mcp),部署形态最常用; + - command(+args/env):拉起本地 stdio 子进程作为 MCP Server; + - server:已构造好的 MCPServer 实例,内存直连(主要给离线测试用); + - prefix:工具名前缀(如 "demo__"),多个 Server 工具重名时去重;默认不加。 + """ + + alias: str + url: str | None = None + command: str | None = None + args: list[str] = field(default_factory=list) + env: dict[str, str] | None = None + prefix: str = "" + server: Any | None = None + + +def config_from_env( + *, + alias: str = "remote", + url_env: str = "MCP_SERVER_URL", + command_env: str = "MCP_SERVER_COMMAND", + env: dict[str, str] | None = None, +) -> McpServerConfig | None: + """从环境变量构造 MCP Server 配置;两个变量都没设置时返回 None。 + + - :Streamable HTTP URL,部署形态最常用(如线上挂公共 MCP Server); + - :stdio 启动命令(空格分词,Windows 按系统规则切分)。 + 让"是否接外部 MCP、接哪个"成为部署期配置,代码不用改。 + """ + environ = os.environ if env is None else env + url = environ.get(url_env) + if url: + return McpServerConfig(alias=alias, url=url) + command_line = environ.get(command_env) + if command_line: + parts = shlex.split(command_line, posix=(os.name != "nt")) + if parts: + return McpServerConfig(alias=alias, command=parts[0], args=parts[1:]) + return None + + +def _flatten_call_result(result: Any) -> Any: + """把 MCP CallToolResult 翻译成工具返回值(可被 json.dumps 序列化)。 + + 顺序:is_error → 抛异常(交给 ToolExecutor 走失败回灌,模型能看到错误); + 否则优先 content 里的文本块(MCP 面向 LLM 的规范输出); + 没有文本再回退 structured_content;都没有返回空字符串。 + """ + content = getattr(result, "content", []) or [] + if getattr(result, "is_error", False): + texts = [b.text for b in content if getattr(b, "text", None)] + raise RuntimeError("; ".join(texts) or "MCP 工具返回错误") + texts = [b.text for b in content if getattr(b, "text", None)] + if texts: + return "\n".join(texts) + structured = getattr(result, "structured_content", None) + if structured is not None: + return structured + return "" + + +class McpToolBridge: + """一个 MCP Server 连接 ↔ 注册到 ToolRegistry 的一组 ToolSpec。 + + 用法:: + + bridge = McpToolBridge(McpServerConfig(alias="demo", url="https://x/mcp")) + specs = await bridge.connect() # 建连 + list_tools + 生成 ToolSpec + core.register_tools(specs) + ... + await bridge.aclose() + + 也支持 async with 自动关连接。 + """ + + def __init__(self, config: McpServerConfig) -> None: + self.config = config + self._client: Any = None + self._specs: list[ToolSpec] = [] + + async def __aenter__(self) -> McpToolBridge: + await self.connect() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + async def connect(self) -> list[ToolSpec]: + # 懒加载:只有真正使用 MCP 集成时才要求安装 mcp 包。 + try: + from mcp import Client, StdioServerParameters + except ImportError as exc: + raise ImportError( + "MCP 集成需要可选依赖:uv sync --extra mcp(或 pip install 'yai-agent-core[mcp]')" + ) from exc + + self._client = Client(self._build_target(StdioServerParameters)) + await self._client.__aenter__() + listed = await self._client.list_tools() + self._specs = [self._to_spec(tool) for tool in listed.tools] + return self._specs + + def _build_target(self, stdio_params_cls: Any) -> Any: + """把配置翻译成 mcp.Client 接受的三种 target:URL / stdio 参数 / Server 实例。""" + c = self.config + chosen = sum(x is not None for x in (c.url, c.command, c.server)) + if chosen != 1: + raise ValueError("McpServerConfig 必须且只能提供 url / command / server 之一") + if c.url is not None: + return c.url + if c.server is not None: + return c.server + return stdio_params_cls(command=c.command, args=c.args, env=c.env) + + def _to_spec(self, tool: Any) -> ToolSpec: + """一个 MCP Tool 描述 → 一个统一 ToolSpec。""" + remote_name = tool.name + local_name = f"{self.config.prefix}{remote_name}" + schema = sanitize_schema(getattr(tool, "input_schema", None)) or dict(_EMPTY_SCHEMA) + + async def handler(**kwargs: Any) -> Any: + # 闭包按值捕获 remote_name:本地注册名可能带前缀,远端只认工具原名。 + result = await self._client.call_tool(remote_name, kwargs) + return _flatten_call_result(result) + + return ToolSpec( + name=local_name, + description=getattr(tool, "description", "") or remote_name, + input_schema=schema, + handler=handler, + source="mcp", + ) + + async def aclose(self) -> None: + if self._client is not None: + await self._client.__aexit__(None, None, None) + self._client = None + + @property + def specs(self) -> list[ToolSpec]: + """connect 之后可用:本次从该 Server 注册的工具规格(副本)。""" + return list(self._specs) + + +async def attach_mcp_tools(registry: Any, config: McpServerConfig) -> McpToolBridge: + """便捷函数:连接 MCP Server 并把工具直接注册进 ToolRegistry,返回 bridge 供关闭。""" + bridge = McpToolBridge(config) + specs = await bridge.connect() + registry.register_many(specs) + return bridge diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/__init__.py new file mode 100644 index 0000000..4803a0a --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/__init__.py @@ -0,0 +1,29 @@ +"""OpenAPI 发现集成:把任意符合 OpenAPI 3 的 REST API 自动注册为 Core 工具。 + +与 MCP 桥同构:懒加载 httpx/pyyaml([openapi] 可选依赖),发现结果统一成 +ToolSpec(source="openapi"),Router / Loop / Executor 感知不到工具来自 REST +(alias 仅用于日志与 notes)。 +""" + +from yai_core.integrations.openapi.client import ( + OpenApiToolBridge, + attach_openapi_tools, +) +from yai_core.integrations.openapi.discovery import OperationPlan, plan_operations +from yai_core.integrations.openapi.spec import ( + OpenApiSpecConfig, + config_from_env, + load_spec, + resolve_refs, +) + +__all__ = [ + "OpenApiSpecConfig", + "OpenApiToolBridge", + "OperationPlan", + "attach_openapi_tools", + "config_from_env", + "load_spec", + "plan_operations", + "resolve_refs", +] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/client.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/client.py new file mode 100644 index 0000000..a175f85 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/client.py @@ -0,0 +1,235 @@ +"""执行阶段:OpenAPI 工具桥。 + +一个 OpenAPI 描述 = 一个桥:connect() 载入/展开/发现并注册 ToolSpec, +每个工具是一个闭包 handler,负责鉴权、填路径、拼 query、发 body、归一响应。 +httpx 只在 connect() 内懒加载;测试通过 transport= 注入 httpx.MockTransport,全程离线。 +""" + +from __future__ import annotations + +import os +from typing import Any +from urllib.parse import quote + +from yai_core.integrations.openapi.discovery import OperationPlan, plan_operations +from yai_core.integrations.openapi.spec import ( + OpenApiSpecConfig, + load_spec, + resolve_base_url, + resolve_refs, +) +from yai_core.types import ToolSpec + +_SUPPORTED_SECURITY = {"http", "apiKey"} + + +class OpenApiToolBridge: + """发现并执行一个 OpenAPI 描述暴露的 REST 操作。""" + + def __init__(self, config: OpenApiSpecConfig, *, transport: Any = None) -> None: + self.config = config + self._transport = transport # 测试注入 httpx.MockTransport;生产为 None + self._client: Any = None + self._specs: list[ToolSpec] = [] + self._notes: list[str] = [] + self._spec: dict | None = None + self._plans: list[OperationPlan] = [] + self._base_url: str | None = None + self._security_schemes: dict[str, dict] = {} + + @property + def specs(self) -> list[ToolSpec]: + return list(self._specs) + + @property + def notes(self) -> list[str]: + return list(self._notes) + + async def connect(self) -> list[ToolSpec]: + try: + import httpx + except ImportError as exc: + raise ImportError( + "OpenAPI 集成需要可选依赖:uv sync --extra openapi" + "(或 pip install 'yai-agent-core[openapi]')" + ) from exc + + raw = load_spec(self.config, transport=self._transport) + spec, ref_notes = resolve_refs(raw) + self._notes.extend(ref_notes) + plans, plan_notes = plan_operations(spec, self.config) + self._notes.extend(plan_notes) + + self._spec = spec + self._plans = plans + self._base_url = resolve_base_url(self.config, spec) + self._security_schemes = ( + (spec.get("components") or {}).get("securitySchemes") or {} + ) + self._audit_security(plans) + + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(self.config.timeout), + transport=self._transport, + headers=dict(self.config.extra_headers), + ) + self._specs = [self._plan_to_spec(plan) for plan in plans] + return self.specs + + def _audit_security(self, plans: list[OperationPlan]) -> None: + """注册期审计:oauth2/basic/mutualTLS 暂不支持,记 note 但不阻断发现。""" + seen: set[str] = set() + for plan in plans: + for requirement in plan.security: + for scheme_name in requirement: + scheme = self._security_schemes.get(scheme_name) + if scheme is None: + continue + kind = scheme.get("type") + if kind not in _SUPPORTED_SECURITY and scheme_name not in seen: + seen.add(scheme_name) + self._notes.append( + f"鉴权方案 {scheme_name}({kind})暂不支持," + "相关工具将按匿名请求发送" + ) + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> OpenApiToolBridge: + await self.connect() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + # ---------- 执行期小步骤(每个都可独立推理) ---------- + + def _token_from_env(self) -> str | None: + env_name = self.config.auth_token_env + if not env_name: + return None + raw = (os.environ.get(env_name) or "").strip() + return raw or None + + async def _auth_for(self, plan: OperationPlan) -> tuple[dict, dict]: + """按 security 要求挑第一个能满足的方案;都不满足且不允许匿名 → 调用期报错。""" + headers: dict[str, str] = {} + query: dict[str, str] = {} + requirements = plan.security or [] + allows_anonymous = any(not requirement for requirement in requirements) + for requirement in requirements: + for scheme_name, _scopes in requirement.items(): + scheme = self._security_schemes.get(scheme_name) + if scheme is None: + continue + kind = scheme.get("type") + if kind == "http" and str(scheme.get("scheme", "")).lower() == "bearer": + token = self._token_from_env() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers, query + elif kind == "apiKey": + token = self._token_from_env() + if token: + where = scheme.get("in") + key_name = scheme.get("name", "X-Api-Key") + if where == "header": + headers[key_name] = token + elif where == "query": + query[key_name] = token + return headers, query + if requirements and not allows_anonymous: + raise RuntimeError( + f"工具 {plan.name} 需要鉴权,但环境变量 {self.config.auth_token_env} 未设置令牌" + ) + return headers, query + + def _build_path(self, plan: OperationPlan, kwargs: dict) -> str: + path = plan.path_template + for name in plan.path_params: + if name not in kwargs: + raise RuntimeError(f"调用 {plan.name} 缺少路径参数:{name}") + # safe="":路径段里的 / 也要编码,避免参数值破坏路径结构。 + path = path.replace("{" + name + "}", quote(str(kwargs[name]), safe="")) + return path + + def _build_query(self, plan: OperationPlan, kwargs: dict) -> dict: + query: dict[str, Any] = {} + for name in plan.query_params: + if name in kwargs and kwargs[name] is not None: + value = kwargs[name] + if isinstance(value, bool): + value = "true" if value else "false" # httpx 默认会渲染成 True/False + query[name] = value # list 值由 httpx 展开成重复键 + return query + + def _build_body(self, plan: OperationPlan, kwargs: dict) -> Any: + if not plan.body_fields: + return None + if plan.body_wrapped: + return kwargs.get("body") + return {name: kwargs[name] for name in plan.body_fields if name in kwargs} + + def _plan_to_spec(self, plan: OperationPlan) -> ToolSpec: + async def handler(**kwargs: Any) -> Any: + if self._client is None: + raise RuntimeError("OpenApiToolBridge 尚未 connect()") + if not self._base_url: + raise RuntimeError( + "spec 未声明 servers[0].url 且未配置 base_url_override,无法调用工具" + ) + auth_headers, auth_query = await self._auth_for(plan) + path = self._build_path(plan, kwargs) + query = self._build_query(plan, kwargs) + query.update(auth_query) + body = self._build_body(plan, kwargs) + + url = self._base_url.rstrip("/") + path + request_headers = dict(auth_headers) + if body is not None: + request_headers.setdefault("Content-Type", "application/json") + + resp = await self._client.request( + plan.method, + url, + params=query or None, + json=body if body is not None else None, + headers=request_headers, + ) + if resp.status_code == 204: + return "" + if resp.status_code < 200 or resp.status_code >= 300: + # 非 2xx 走工具错误通道(Executor 捕获 RuntimeError → tool_result 失败事件)。 + raise RuntimeError( + f"HTTP {resp.status_code} from {plan.method} {path}: {resp.text[:500]}" + ) + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type.lower(): + return resp.json() + text = resp.text + if len(text) > self.config.max_result_chars: + return text[: self.config.max_result_chars] + "…[truncated]" + return text + + return ToolSpec( + name=plan.name, + description=plan.description, + input_schema=plan.input_schema, + handler=handler, + # 来源可枚举,与 types.ToolSpec.source 的 Literal 契约对齐;alias 只用于日志/notes。 + source="openapi", + ) + + +async def attach_openapi_tools( + registry: Any, config: OpenApiSpecConfig, *, transport: Any = None +) -> OpenApiToolBridge: + """发现并把工具注册进 registry,返回桥(宿主在停机时 await bridge.aclose())。""" + bridge = OpenApiToolBridge(config, transport=transport) + specs = await bridge.connect() + for spec in specs: + registry.register(spec) + return bridge diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/discovery.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/discovery.py new file mode 100644 index 0000000..4a61123 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/discovery.py @@ -0,0 +1,218 @@ +"""发现阶段:把 OpenAPI paths/operations 翻译成工具执行计划(OperationPlan)。 + +本模块纯函数、零 IO、零第三方依赖,输出交给 client.py 闭包执行。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from yai_core.integrations.openapi.spec import ( + _HTTP_METHODS, + _READ_ONLY_METHODS, + OpenApiSpecConfig, +) +from yai_core.tools.schema import EMPTY_OBJECT_SCHEMA, sanitize_schema + + +@dataclass +class OperationPlan: + """一个 REST 操作翻译成工具的中间表示。""" + + name: str + description: str + input_schema: dict[str, Any] + method: str + path_template: str + path_params: tuple[str, ...] + query_params: tuple[str, ...] + body_fields: tuple[str, ...] + body_wrapped: bool + body_required: bool + security: list[dict] = field(default_factory=list) + + +def _camel_to_snake(segment: str) -> str: + """petId → pet_id;listPets → list_pets(小写边界前插下划线)。""" + return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", segment).lower() + + +def _clean_operation_id(operation_id: str) -> str: + """operationId 合法化:驼峰转蛇形、非法字符变下划线、截断 64 字符。""" + name = _camel_to_snake(operation_id) + name = re.sub(r"[^a-z0-9_-]+", "_", name).strip("_") + return name[:64].strip("_") + + +def _synthetic_name(method: str, path: str) -> str: + """没有 operationId 时合成:GET /pets/{petId} → get_pets_pet_id。""" + parts = [method.lower()] + for segment in path.strip("/").split("/"): + if not segment: + continue + segment = segment.strip("{}").replace("-", "_") + parts.append(_camel_to_snake(segment)) + return re.sub(r"[^a-z0-9_]+", "_", "_".join(parts))[:64].strip("_") + + +def _dedupe_name(name: str, used: set[str], notes: list[str]) -> str: + """同一 spec 内重名追加 __2/__3(跨来源重名由 ToolRegistry 直接报错,不在这里吞)。""" + if name not in used: + used.add(name) + return name + suffix = 2 + while f"{name}__{suffix}" in used: + suffix += 1 + final = f"{name}__{suffix}" + used.add(final) + notes.append(f"同一 spec 内工具名冲突,{name} 的第 {suffix - 1} 个重名操作改名为 {final}") + return final + + +def _parameter_property(parameter: dict) -> tuple[str, dict, bool]: + name = parameter["name"] + schema = sanitize_schema(parameter.get("schema") or {}) + if parameter.get("description") and isinstance(schema, dict): + schema.setdefault("description", parameter["description"]) + return name, schema, bool(parameter.get("required", False)) + + +def _merge_body( + properties: dict, required: list[str], body_schema: dict, notes: list[str] +) -> tuple[tuple[str, ...], bool]: + """requestBody 的 schema 并入入参:object 拍平合并;其他类型包成 {"body": ...}。""" + body_schema = sanitize_schema(body_schema) + if isinstance(body_schema, dict) and body_schema.get("type") == "object": + body_props = body_schema.get("properties") or {} + for name, sub in body_props.items(): + if name in properties: + notes.append(f"body 字段与路径/查询参数同名 {name},body 侧覆盖") + properties[name] = sub + for name in body_schema.get("required") or []: + if name not in required: + required.append(name) + return tuple(body_props.keys()), False + properties["body"] = body_schema + return ("body",), True + + +def _path_allowed(path: str, config: OpenApiSpecConfig) -> bool: + """先 include 白名单(前缀匹配),再 exclude 黑名单。""" + if config.include_paths and not any( + path.startswith(prefix) for prefix in config.include_paths + ): + return False + if config.exclude_paths and any(path.startswith(prefix) for prefix in config.exclude_paths): + return False + return True + + +def plan_operations( + spec: dict, config: OpenApiSpecConfig +) -> tuple[list[OperationPlan], list[str]]: + """遍历 paths,产出操作计划列表与 notes(read_only 过滤、截断都在这里)。""" + notes: list[str] = [] + plans: list[OperationPlan] = [] + used_names: set[str] = set() + paths = spec.get("paths") or {} + + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + if "$ref" in path_item: + notes.append(f"路径级 $ref 不展开,跳过:{path}") + continue + if not _path_allowed(path, config): + continue + shared_parameters = path_item.get("parameters") or [] + for method, operation in path_item.items(): + method_l = method.lower() + if method_l not in _HTTP_METHODS or not isinstance(operation, dict): + continue + if config.read_only and method_l not in _READ_ONLY_METHODS: + continue + + properties: dict[str, Any] = {} + required: list[str] = [] + path_params: list[str] = [] + query_params: list[str] = [] + + # pathItem 级与 operation 级 parameters 合并(后者优先语义由后写覆盖体现)。 + for parameter in [*shared_parameters, *(operation.get("parameters") or [])]: + if not isinstance(parameter, dict): + continue + where = parameter.get("in") + if where not in {"path", "query"}: + continue # header / cookie 参数不暴露给模型(v0.2 边界) + name, sub_schema, is_required = _parameter_property(parameter) + properties[name] = sub_schema + if where == "path": + path_params.append(name) + if name not in required: + required.append(name) # OpenAPI 规定 path 参数恒为 required + else: + query_params.append(name) + if is_required and name not in required: + required.append(name) + + body_fields: tuple[str, ...] = () + body_wrapped = False + body_required = False + request_body = operation.get("requestBody") + if isinstance(request_body, dict): + content = request_body.get("content") or {} + json_entry = content.get("application/json") + if json_entry and isinstance(json_entry.get("schema"), dict): + body_fields, body_wrapped = _merge_body( + properties, required, json_entry["schema"], notes + ) + body_required = bool(request_body.get("required", False)) + if body_required and body_wrapped and "body" not in required: + required.append("body") + + operation_id = operation.get("operationId") + raw_name = ( + _clean_operation_id(operation_id) + if operation_id + else _synthetic_name(method_l, path) + ) + name = _dedupe_name(raw_name, used_names, notes) + if config.prefix: + name = f"{config.prefix}_{name}" + + summary = (operation.get("summary") or "").strip() + description = (operation.get("description") or "").strip() + tail = f"{summary} {description}".strip() + tool_description = f"[{method.upper()} {path}] {tail}".strip()[:1000] + + input_schema = sanitize_schema( + {"type": "object", "properties": properties, "required": required} + ) + if not properties: + input_schema = dict(EMPTY_OBJECT_SCHEMA) + + plans.append( + OperationPlan( + name=name, + description=tool_description, + input_schema=input_schema, + method=method.upper(), + path_template=path, + path_params=tuple(path_params), + query_params=tuple(query_params), + body_fields=body_fields, + body_wrapped=body_wrapped, + body_required=body_required, + # operation 级 security 缺省回退全局 security。 + security=operation.get("security", spec.get("security")) or [], + ) + ) + + if len(plans) > config.max_operations: + notes.append( + f"操作数 {len(plans)} 超过 max_operations={config.max_operations},已截断" + ) + plans = plans[: config.max_operations] + return plans, notes diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/spec.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/spec.py new file mode 100644 index 0000000..d929458 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/integrations/openapi/spec.py @@ -0,0 +1,203 @@ +"""OpenAPI 描述载入、版本校验与内部 $ref 展开。 + +顶层只 import 标准库;httpx / pyyaml 一律函数内懒加载(内核零硬依赖红线)。 +""" + +from __future__ import annotations + +import copy +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +_READ_ONLY_METHODS = frozenset({"get", "head"}) +_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options"}) + + +@dataclass +class OpenApiSpecConfig: + """一个 OpenAPI 描述的接入配置。url / path / spec 三选一。""" + + alias: str = "rest" + url: str | None = None + path: str | Path | None = None + spec: dict | None = None + prefix: str = "" + base_url_override: str | None = None + auth_token_env: str | None = "OPENAPI_AUTH_TOKEN" + extra_headers: dict[str, str] = field(default_factory=dict) + read_only: bool = False + max_operations: int = 40 + include_paths: tuple[str, ...] = () + exclude_paths: tuple[str, ...] = () + timeout: float = 15.0 + max_spec_bytes: int = 2 * 1024 * 1024 + max_result_chars: int = 16_000 + + +def _truthy(value: str | None) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def config_from_env( + *, + alias: str = "rest", + url_env: str = "OPENAPI_SPEC_URL", + path_env: str = "OPENAPI_SPEC_PATH", + token_env: str = "OPENAPI_AUTH_TOKEN", + base_url_env: str = "OPENAPI_BASE_URL", + read_only_env: str = "OPENAPI_READ_ONLY", + env: dict[str, str] | None = None, +) -> OpenApiSpecConfig | None: + """从环境变量构造配置;URL 与 PATH 都没配时返回 None(装配方据此跳过)。""" + environ = os.environ if env is None else env + url = (environ.get(url_env) or "").strip() or None + raw_path = (environ.get(path_env) or "").strip() or None + if url is None and raw_path is None: + return None + base = (environ.get(base_url_env) or "").strip() or None + # 只记录变量名,不判断令牌是否存在:缺令牌由调用期报错,且报错要点名这个变量。 + return OpenApiSpecConfig( + alias=alias, + url=url, + path=Path(raw_path) if raw_path else None, + base_url_override=base, + auth_token_env=token_env, + read_only=_truthy(environ.get(read_only_env)), + ) + + +def _load_from_path(path: Path, max_bytes: int) -> dict: + p = Path(path).expanduser() + data = p.read_bytes() + if len(data) > max_bytes: + raise ValueError(f"OpenAPI 描述超过 {max_bytes} 字节上限:{p}") + suffix = p.suffix.lower() + if suffix in {".yaml", ".yml"}: + try: + import yaml # 懒加载:只有 YAML 描述才需要 pyyaml + except ImportError as exc: + raise ImportError( + "解析 YAML 形式的 OpenAPI 描述需要可选依赖:uv sync --extra openapi" + ) from exc + loaded = yaml.safe_load(data.decode("utf-8")) + else: + loaded = json.loads(data.decode("utf-8")) + if not isinstance(loaded, dict): + raise ValueError("OpenAPI 描述根节点必须是对象") + return loaded + + +def _load_from_url(config: OpenApiSpecConfig, transport: Any) -> dict: + try: + import httpx # 懒加载 + except ImportError as exc: + raise ImportError( + "OpenAPI 集成需要可选依赖:uv sync --extra openapi" + "(或 pip install 'yai-agent-core[openapi]')" + ) from exc + # transport 仅供测试注入 httpx.MockTransport;生产为 None,走真实网络。 + with httpx.Client(timeout=config.timeout, transport=transport) as client: + resp = client.get(config.url) # type: ignore[arg-type] + if resp.status_code < 200 or resp.status_code >= 300: + raise RuntimeError(f"拉取 OpenAPI 描述失败:HTTP {resp.status_code}") + if len(resp.content) > config.max_spec_bytes: + raise ValueError(f"OpenAPI 描述超过 {config.max_spec_bytes} 字节上限") + ctype = resp.headers.get("content-type", "") + if "yaml" in ctype or str(config.url).endswith((".yaml", ".yml")): + try: + import yaml + except ImportError as exc: + raise ImportError( + "解析 YAML 形式的 OpenAPI 描述需要可选依赖:uv sync --extra openapi" + ) from exc + loaded = yaml.safe_load(resp.text) + else: + loaded = resp.json() + if not isinstance(loaded, dict): + raise ValueError("OpenAPI 描述根节点必须是对象") + return loaded + + +def load_spec(config: OpenApiSpecConfig, *, transport: Any = None) -> dict: + """三选一载入(dict / 文件 / URL)并校验是 OpenAPI 3;非法输入给可操作错误。""" + if config.spec is not None: + raw = config.spec + elif config.path is not None: + raw = _load_from_path(config.path, config.max_spec_bytes) + elif config.url is not None: + raw = _load_from_url(config, transport) + else: + raise ValueError("OpenApiSpecConfig 必须提供 url / path / spec 之一") + if not isinstance(raw, dict): + raise ValueError("OpenAPI 描述根节点必须是对象") + if "openapi" not in raw: + if str(raw.get("swagger", "")).startswith("2"): + raise ValueError("不支持 Swagger 2.0,请先转换为 OpenAPI 3(3.0.x / 3.1.x)") + raise ValueError("缺少 openapi 版本字段,不是合法的 OpenAPI 3 描述") + if not str(raw["openapi"]).startswith("3."): + raise ValueError(f"仅支持 OpenAPI 3.x,收到版本:{raw['openapi']}") + return raw + + +def _resolve_pointer(root: dict, pointer: str) -> Any: + """按 JSON Pointer(#/a/b/1)在根节点里取目标。""" + cur: Any = root + for part in pointer.lstrip("#/").split("/"): + part = part.replace("~1", "/").replace("~0", "~") # Pointer 转义 + cur = cur[part] + return cur + + +def resolve_refs(spec: dict) -> tuple[dict, list[str]]: + """内联展开文档内部 $ref,返回(深拷贝后的新 spec, notes)。 + + - 内部 $ref(#/...):递归内联;正在展开的引用链上再次命中 = 循环,保留 $ref 原样并记 note; + - 外部 $ref(http/另一文件):不解析,原样保留并记 note(v0.2 不跨文档抓取)。 + """ + notes: list[str] = [] + root = copy.deepcopy(spec) + + def walk(node: Any, stack: frozenset[str]) -> Any: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + if not ref.startswith("#"): + note = f"外部 $ref 不解析,原样保留:{ref}" + if note not in notes: + notes.append(note) + return {"$ref": ref} + if ref in stack: + note = f"检测到循环 $ref:{ref},该处保留原样" + if note not in notes: + notes.append(note) + return {"$ref": ref} + target = _resolve_pointer(root, ref) + resolved = walk(target, stack | {ref}) + siblings = {k: v for k, v in node.items() if k != "$ref"} + if isinstance(resolved, dict) and siblings: + # 3.1 允许 $ref 与兄弟键共存:内联目标后用兄弟键覆盖同名字段。 + return {**resolved, **{k: walk(v, stack) for k, v in siblings.items()}} + return resolved + return {k: walk(v, stack) for k, v in node.items()} + if isinstance(node, list): + return [walk(v, stack) for v in node] + return node + + return walk(root, frozenset()), notes + + +def resolve_base_url(config: OpenApiSpecConfig, spec: dict) -> str | None: + """base_url 优先级:显式 override > servers[0].url;相对 server 地址按 spec URL 解析。""" + if config.base_url_override: + return config.base_url_override + servers = spec.get("servers") or [] + if servers and isinstance(servers[0], dict) and servers[0].get("url"): + url = servers[0]["url"] + if config.url and not url.startswith("http"): + return urljoin(config.url, url) + return url + return None diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/__init__.py new file mode 100644 index 0000000..ba7f670 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/__init__.py @@ -0,0 +1,7 @@ +"""YAI Kernel:自适应路由 + Agent Loop + 上下文管理。""" + +from yai_core.kernel.context import Context +from yai_core.kernel.loop import AgentLoop +from yai_core.kernel.router import AdaptiveRouter, RouteDecision + +__all__ = ["AdaptiveRouter", "AgentLoop", "Context", "RouteDecision"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/context.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/context.py new file mode 100644 index 0000000..2f26b8c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/context.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from yai_core.types import ChatMessage + + +class Context: + """单次运行的对话上下文。v0.1 用朴素字符估算做 token 预算,v2 换 tiktoken。""" + + def __init__(self, system_prompt: str, *, max_chars: int = 24000) -> None: + self.max_chars = max_chars + self.messages: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt)] + + def add(self, message: ChatMessage) -> None: + self.messages.append(message) + self._compact() + + def _compact(self) -> None: + """超预算时丢弃最旧的整轮历史:system 永留,且至少保留最后一轮。 + + v0.3 修复:旧实现按下标逐个丢,可能从 assistant(tool_calls)/tool 配对中间 + 切开,发给 OpenAI 兼容接口会直接 400。现改为把候补丢弃边界**向后对齐到 + 轮首**(role == "user"),规则与 memory/retention.py 同源;为避免 kernel + 反向依赖 memory 包,这里保留一份私有实现,两处改动必须同步(第 13 章讲义)。 + 候补边界之后没有新一轮时,宁可超预算也保留最后一轮(含其工具消息)。 + """ + total = sum(len(m.content) for m in self.messages) + i = 1 + while total > self.max_chars and i < len(self.messages) - 1: + total -= len(self.messages[i].content) + i += 1 + if i <= 1: + return + aligned = i + while aligned < len(self.messages) and self.messages[aligned].role != "user": + aligned += 1 + if aligned >= len(self.messages): + # 候补边界之后没有新一轮:保留最后一轮,不做裁剪。 + return + self.messages = [self.messages[0], *self.messages[aligned:]] + + def llm_messages(self) -> list[dict]: + return [m.to_llm_dict() for m in self.messages] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/loop.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/loop.py new file mode 100644 index 0000000..2824c1c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/loop.py @@ -0,0 +1,177 @@ +"""Agent Loop:按 Adaptive Router 选定的策略执行任务。 + +- direct: 一次模型调用直接回答 +- react: 推理 <-> 工具调用循环,直到模型不再调用工具 +- plan: 先生成步骤计划,再带计划进入 react 循环 +- clarify: 通过 Channel 反问后重新路由 +""" + +from __future__ import annotations + +import json +import re +from collections.abc import AsyncIterator + +from yai_core.kernel.context import Context +from yai_core.kernel.router import AdaptiveRouter +from yai_core.spi import Channel, MemoryStore, ModelProvider +from yai_core.tools.executor import ToolExecutor +from yai_core.tools.registry import ToolRegistry +from yai_core.types import ( + AgentEvent, + ChatMessage, + EventType, + Strategy, +) + +_SYSTEM_TEMPLATE = """你是运行在宿主软件内部的 AI 助手。\ +你只能通过"工具"操作宿主的能力,不要编造工具不存在的数据。\ +当任务完成时,直接给出可交付的最终结果。 + +宿主当前提供的能力: +{tools} +""" + + +class AgentLoop: + def __init__( + self, + model: ModelProvider, + registry: ToolRegistry, + executor: ToolExecutor, + channel: Channel, + memory: MemoryStore, + router: AdaptiveRouter | None = None, + *, + max_iters: int = 6, + ) -> None: + self.model = model + self.registry = registry + self.executor = executor + self.channel = channel + self.memory = memory + self.router = router or AdaptiveRouter() + self.max_iters = max_iters + + async def astream(self, task: str) -> AsyncIterator[AgentEvent]: + decision = await self.router.aclassify(task, self.registry) + strategy = decision.strategy + # 决策来源(llm/rules)、理由与模型档位随事件流出:每次自适应决策都可审计。 + yield AgentEvent( + EventType.STRATEGY_SELECTED, + { + "strategy": strategy.value, + "source": decision.source, + "reason": decision.reason, + "tier": decision.tier, + }, + ) + + if strategy == Strategy.CLARIFY: + yield AgentEvent(EventType.CLARIFY_REQUESTED, {"question": task}) + answer = await self.channel.ask(task) + async for ev in self.astream(answer): + yield ev + return + + ctx = Context(_SYSTEM_TEMPLATE.format(tools=self.registry.describe())) + for msg in self.memory.history(): + ctx.add(msg) + ctx.add(ChatMessage(role="user", content=task)) + + if strategy == Strategy.PLAN: + async for ev in self._make_plan(ctx, task, tier=decision.tier): + yield ev + # 计划只是"助手说过的话",必须再推一把,模型才会进入工具执行; + # 否则真实模型会把计划本身当成最终答复(离线脚本模型曾掩盖此问题)。 + ctx.add( + ChatMessage( + role="user", + content="请按上面的计划逐步调用工具执行,拿到全部结果后给出最终汇报。", + ) + ) + + final_text = "" + async for ev in self._react_cycle(ctx, use_tools=strategy != Strategy.DIRECT): + yield ev + if ev.type == EventType.MODEL_MESSAGE: + final_text = ev.data.get("text", final_text) + + await self.memory.append_history(ChatMessage(role="user", content=task)) + await self.memory.append_history(ChatMessage(role="assistant", content=final_text)) + yield AgentEvent(EventType.DONE, {"strategy": strategy.value, "final_text": final_text}) + + async def _make_plan( + self, ctx: Context, task: str, *, tier: str = "strong" + ) -> AsyncIterator[AgentEvent]: + prompt = ( + "把下面的任务拆成 2-5 个可执行步骤,每行一个步骤,用 1. 2. 3. 编号," + "只输出步骤本身:\n" + task + ) + ctx.add(ChatMessage(role="user", content=prompt)) + # 档位由路由器建议(LLM 路由可给 standard/strong),规则路由的 plan 默认 strong。 + resp = await self.model.achat(ctx.llm_messages(), tools=None, tier=tier) + steps = [s.strip() for s in re.findall(r"\d+[.、)]\s*(.+)", resp.content)] + if not steps: + steps = [task] + plan_text = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(steps)) + ctx.add(ChatMessage(role="assistant", content=plan_text)) + yield AgentEvent(EventType.PLAN_CREATED, {"steps": steps}) + + async def _react_cycle( + self, ctx: Context, *, use_tools: bool + ) -> AsyncIterator[AgentEvent]: + tools_schema = self.registry.llm_schemas() if use_tools else None + for _ in range(self.max_iters): + try: + resp = await self.model.achat(ctx.llm_messages(), tools=tools_schema) + except Exception as exc: # noqa: BLE001 + yield AgentEvent(EventType.ERROR, {"error": f"{type(exc).__name__}: {exc}"}) + return + + if not resp.tool_calls: + text = resp.content.strip() + ctx.add(ChatMessage(role="assistant", content=text)) + yield AgentEvent(EventType.MODEL_MESSAGE, {"text": text}) + return + + ctx.add( + ChatMessage( + role="assistant", + content=resp.content, + tool_calls=[ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.name, "arguments": json.dumps( + tc.arguments, ensure_ascii=False + )}, + } + for tc in resp.tool_calls + ], + ) + ) + for call in resp.tool_calls: + tool_events, ok, result_text = await self.executor.execute( + call.name, call.arguments + ) + for tool_event in tool_events: + yield tool_event + ctx.add( + ChatMessage( + role="tool", + content=result_text, + tool_call_id=call.id, + name=call.name, + ) + ) + + # 达到迭代上限:要求模型基于已有观察收尾 + ctx.add( + ChatMessage( + role="user", + content="已达到工具调用上限,请基于已有结果直接给出最终答案。", + ) + ) + resp = await self.model.achat(ctx.llm_messages(), tools=None) + yield AgentEvent(EventType.MODEL_MESSAGE, {"text": resp.content.strip()}) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/router.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/router.py new file mode 100644 index 0000000..1cf0570 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/kernel/router.py @@ -0,0 +1,153 @@ +"""Adaptive Router:把任务路由到 direct / react / plan / clarify。 + +v0.1 使用确定性规则(可测试、零成本、离线可跑); +v0.2 增加 LLM 分类器(一次轻量模型调用,输出结构化策略 JSON), + 分类失败时自动回退到本规则实现——这就是"模型自适应"的兜底设计。 + +- classify():纯规则,同步、零成本、永远可用(最终兜底); +- aclassify():优先 LLM 分类(配置了模型时),任何异常/超时/非法输出都回退规则。 + 两条路径都返回 RouteDecision(策略 + 决策来源 + 理由 + 模型档位), + 保证"每一次自适应决策都可观测、可解释"。 +""" + +from __future__ import annotations + +import asyncio +import json +import re +from dataclasses import dataclass + +from yai_core.tools.registry import ToolRegistry +from yai_core.types import Strategy + +# 多步骤信号:出现时倾向先规划再执行 +_PLAN_HINTS = ( + "然后", "接着", "之后", "再把", "分步", "步骤", "先", "并且", "同时", + "对比", "整理成", "汇总成", "最终", "一共", "分别", +) +# 行动信号:需要调用工具 +_ACTION_HINTS = ( + "查", "找", "搜", "列出", "统计", "计算", "导出", "获取", "读取", + "记录", "新增", "整理", "分析", "筛选", "生成", + # 显式的工具/MCP 调用意图(v0.2 接外部 MCP Server 后补齐) + "问一下", "查询", "调用", "工具", "mcp", +) +# 过于模糊、需要反问 +_CLARIFY_HINTS = ("随便", "你看着办", "什么都行", "帮我弄一下") + + +@dataclass +class RouteDecision: + """一次路由决策的完整记录:策略、决策来源、理由、建议模型档位。""" + + strategy: Strategy + source: str # "llm":模型分类;"rules":规则(含 LLM 失败后的兜底) + reason: str + tier: str = "standard" # "standard"(便宜快)/ "strong"(规划等重活) + + +class AdaptiveRouter: + def __init__( + self, + *, + min_plan_steps: int = 2, + model: object | None = None, + classify_timeout: float = 15.0, + ) -> None: + self.min_plan_steps = min_plan_steps + # model 可选:注入了才启用 LLM 分类;不注入时 aclassify 直接走规则。 + self.model = model + self.classify_timeout = classify_timeout + + # ---------- 规则路径(同步、永远可用的兜底) ---------- + + def classify(self, task: str, registry: ToolRegistry) -> Strategy: + return self._rules(task, registry).strategy + + def _rules(self, task: str, registry: ToolRegistry) -> RouteDecision: + text = task.strip() + if not text: + return RouteDecision(Strategy.CLARIFY, "rules", "任务为空,需要反问澄清") + if any(h in text for h in _CLARIFY_HINTS) and len(text) < 12: + return RouteDecision(Strategy.CLARIFY, "rules", "任务过于模糊,先澄清") + if len(registry) == 0: + # 宿主没有任何能力 -> 只能直接回答(能力边界优先于用户意图) + return RouteDecision(Strategy.DIRECT, "rules", "宿主无任何工具,直接回答") + # 拉丁字母提示词(如 mcp)大小写不敏感;中文提示词按原文匹配。 + lowered = text.lower() + wants_action = any( + (h in lowered) if h.isascii() else (h in text) for h in _ACTION_HINTS + ) + multi_step = sum(1 for h in _PLAN_HINTS if h in text) >= 1 + if multi_step and wants_action: + return RouteDecision( + Strategy.PLAN, "rules", "命中多步骤+行动信号,先规划再执行", "strong" + ) + if wants_action: + return RouteDecision(Strategy.REACT, "rules", "命中行动信号,进入工具循环") + # 有工具但任务像闲聊/问答:直接答(模型仍可在 react 循环里自行决定用工具) + return RouteDecision(Strategy.DIRECT, "rules", "无需工具的直接问答") + + # ---------- LLM 路径(失败一律回退规则) ---------- + + async def aclassify(self, task: str, registry: ToolRegistry) -> RouteDecision: + text = task.strip() + # 空任务、未配置模型、宿主无工具:直接走规则,不浪费一次模型调用。 + if not text or self.model is None or len(registry) == 0: + return self._rules(task, registry) + try: + raw = await asyncio.wait_for( + self._llm_classify(text, registry), timeout=self.classify_timeout + ) + decision = self._parse(raw, registry) + return decision + except Exception as exc: # noqa: BLE001 - 分类是增强不是依赖,任何失败都兜底 + fallback = self._rules(task, registry) + fallback.reason = f"LLM 分类失败({type(exc).__name__}),回退规则:{fallback.reason}" + return fallback + + async def _llm_classify(self, task: str, registry: ToolRegistry) -> str: + """一次轻量模型调用,要求只输出路由 JSON。""" + tools_text = "\n".join( + f"- {s.name}: {s.description}" for s in registry.all() + ) + prompt = ( + "你是嵌入式 Agent 的任务路由器。根据任务与宿主可用工具,只输出一个 JSON 对象," + "不要输出 JSON 以外的任何内容:\n" + '{"strategy": "direct|react|plan|clarify", ' + '"tier": "standard|strong", "reason": "不超过30字的中文理由"}\n' + "策略判定标准:\n" + "- direct:闲聊、知识问答、写作、解释,不需要调用工具;\n" + "- react:需要调用工具(查询/搜索/计算/读取/调用 MCP 等),单步即可完成;\n" + "- plan:明显多步骤、需要先拆解计划再逐步执行;\n" + "- clarify:任务过于模糊、缺少必要对象,无法执行。\n" + f"宿主可用工具:\n{tools_text}\n" + f"任务:{task}" + ) + messages = [ + {"role": "system", "content": "你是严谨的路由器,只输出合法 JSON。"}, + {"role": "user", "content": prompt}, + ] + resp = await self.model.achat(messages, tools=None, tier="standard") + return resp.content + + def _parse(self, content: str, registry: ToolRegistry) -> RouteDecision: + """解析模型输出为 RouteDecision;非法/越界输出抛异常交 aclassify 兜底。""" + text = content.strip() + # 容忍模型套 ```json 代码块或在 JSON 前后啰嗦:截取第一个 { 到最后一个 }。 + fenced = re.search(r"\{.*\}", text, flags=re.DOTALL) + if fenced is None: + raise ValueError("分类输出中没有 JSON 对象") + data = json.loads(fenced.group(0)) + strategy_value = data.get("strategy") + if strategy_value not in {s.value for s in Strategy}: + raise ValueError(f"非法策略值:{strategy_value!r}") + strategy = Strategy(strategy_value) + # 能力边界兜底:模型让调工具但宿主没有工具,强制 direct。 + if len(registry) == 0 and strategy in (Strategy.REACT, Strategy.PLAN): + raise ValueError("宿主无工具却被路由到工具策略") + tier = data.get("tier", "standard") + if tier not in ("standard", "strong"): + tier = "standard" + reason = str(data.get("reason") or "").strip() or "LLM 分类(模型未给理由)" + return RouteDecision(strategy, "llm", reason, tier) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/__init__.py new file mode 100644 index 0000000..f840a4a --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/__init__.py @@ -0,0 +1,3 @@ +from yai_core.llm.openai_compat import OpenAICompatProvider + +__all__ = ["OpenAICompatProvider"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/openai_compat.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/openai_compat.py new file mode 100644 index 0000000..aa90064 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/llm/openai_compat.py @@ -0,0 +1,62 @@ +"""OpenAI 兼容模型后端:DeepSeek / 通义千问 / OpenAI / 本地 vLLM 等同一套代码。""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from yai_core.types import ModelResponse, ToolCallRequest + + +class OpenAICompatProvider: + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + model: str | None = None, + strong_model: str | None = None, + ) -> None: + try: + from openai import AsyncOpenAI # noqa: PLC0415 + except ImportError as exc: + raise ImportError( + "使用 OpenAICompatProvider 需要安装可选依赖:uv pip install -e '.[llm]'" + ) from exc + + self.client = AsyncOpenAI( + api_key=api_key or os.getenv("OPENAI_API_KEY", "missing"), + base_url=base_url or os.getenv("OPENAI_BASE_URL"), + ) + self.model = model or os.getenv("LLM_MODEL", "deepseek-chat") + self.strong_model = strong_model or os.getenv("LLM_STRONG_MODEL") or self.model + + async def achat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + *, + tier: str = "standard", + ) -> ModelResponse: + # messages 由 Context.llm_messages() 产出,已经是 OpenAI 线格式 dict + kwargs: dict[str, Any] = { + "model": self.strong_model if tier == "strong" else self.model, + "messages": messages, + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = "auto" + + completion = await self.client.chat.completions.create(**kwargs) + msg = completion.choices[0].message + + tool_calls: list[ToolCallRequest] = [] + for tc in getattr(msg, "tool_calls", None) or []: + try: + args = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + tool_calls.append(ToolCallRequest(id=tc.id, name=tc.function.name, arguments=args)) + + return ModelResponse(content=msg.content or "", tool_calls=tool_calls, raw=completion) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/__init__.py new file mode 100644 index 0000000..53c877c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/__init__.py @@ -0,0 +1,5 @@ +from yai_core.memory.inmemory import InMemoryStore +from yai_core.memory.retention import retention_from_env +from yai_core.memory.sqlite_store import SqliteStore + +__all__ = ["InMemoryStore", "SqliteStore", "retention_from_env"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/inmemory.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/inmemory.py new file mode 100644 index 0000000..680787c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/inmemory.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta + +from yai_core.memory.retention import count_cut, ttl_cut, utc_now +from yai_core.types import ChatMessage + + +class InMemoryStore: + """默认记忆实现:会话历史 + 简单 KV。v2 再换向量实现。 + + v0.3 起构造函数支持历史保留策略(全部 opt-in,默认 None = 永不裁剪, + 行为与旧版逐字节一致): + - max_messages:保留最近 N 条消息(按轮对齐,整轮删除,至少留最后一轮); + - ttl_seconds:超过 N 秒的历史在写入时与显式 prune() 时清理; + - clock:可注入时钟(测试用假时钟推进时间,禁止 sleep 等真实时间)。 + ChatMessage 没有时间字段(不改 types.py),时间戳用与 _history 严格 + 同长同序的并行列表 _timestamps 保存。 + """ + + def __init__( + self, + *, + max_messages: int | None = None, + ttl_seconds: int | None = None, + clock: Callable[[], datetime] | None = None, + ) -> None: + self._history: list[ChatMessage] = [] + self._timestamps: list[datetime] = [] # 与 _history 严格同长同序 + self._kv: dict[str, str] = {} + self._max_messages = max_messages + self._ttl_seconds = ttl_seconds + self._clock = clock or utc_now + + async def append_history(self, message: ChatMessage) -> None: + self._history.append(message) + self._timestamps.append(self._clock()) + # opt-in:未配置任何策略时 prune() 立即返回 0,等价于旧版"只追加"。 + self.prune() + + def history(self) -> list[ChatMessage]: + return list(self._history) + + def prune(self) -> int: + """按保留策略裁剪历史前缀,返回删除条数;KV 不动。 + + 非 MemoryStore 契约方法(与 SqliteStore.close() 同一先例:宿主探测式 + 调用,spi/memory.py 的 Protocol 不扩面)。两种策略同时命中时取更大的 + 删除上界——两个上界都落在轮首,较大者仍是轮首,工具调用对依然完整。 + """ + roles = [m.role for m in self._history] + k_count = count_cut(roles, self._max_messages) + k_ttl = None + if self._ttl_seconds is not None: + cutoff = self._clock() - timedelta(seconds=self._ttl_seconds) + # 严格 <:时间戳恰好等于 cutoff 的消息保留(边界语义钉在测试里)。 + expired = [ts < cutoff for ts in self._timestamps] + k_ttl = ttl_cut(roles, expired) + cuts = [k for k in (k_count, k_ttl) if k is not None] + if not cuts: + return 0 + k = max(cuts) + del self._history[:k] + del self._timestamps[:k] + return k + + async def put(self, key: str, value: str) -> None: + self._kv[key] = value + + async def get(self, key: str) -> str | None: + return self._kv.get(key) + + async def clear(self) -> None: + self._history.clear() + self._timestamps.clear() + self._kv.clear() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/retention.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/retention.py new file mode 100644 index 0000000..ba5760c --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/retention.py @@ -0,0 +1,121 @@ +"""历史保留策略纯函数:条数裁剪与 TTL 的删除边界计算(v0.3,opt-in)。 + +只放纯函数与 env 解析,不碰任何存储实现:InMemoryStore 与 SqliteStore 共用 +同一套"轮边界对齐"规则,保证两种实现的裁剪结果逐字节一致。 + +轮(round)的定义:一条 role == "user" 的消息到下一条 user 消息之前为一轮; +历史开头的非 user 消息归入"第 0 轮",与相邻轮同生共死。 +为什么必须按轮裁剪:assistant(tool_calls) 与其后的 tool 消息必须成对出现, +从中间切开后发给 OpenAI 兼容接口会直接 400(详见 walkthrough 第 13 章)。 +""" + +from __future__ import annotations + +import os +import warnings +from datetime import UTC, datetime + +_MAX_ENV = "YAI_HISTORY_MAX_MESSAGES" +_TTL_ENV = "YAI_HISTORY_TTL_SECONDS" + + +def utc_now() -> datetime: + """统一 UTC 时钟;SQLite 的 CURRENT_TIMESTAMP 同为 UTC 文本,可直接比较。""" + return datetime.now(UTC) + + +def _parse_positive_int(raw: str | None, env_name: str) -> int | None: + """解析正整数环境变量。 + + 未设置/空白 -> None(静默,等同未配置); + 0/负数/非整数 -> None + warning(不崩、不静默、不裁剪)。 + """ + if raw is None or not raw.strip(): + return None + try: + value = int(raw.strip()) + except ValueError: + warnings.warn( + f"{env_name}={raw!r} 不是合法整数,忽略(不裁剪历史)", stacklevel=2 + ) + return None + if value <= 0: + warnings.warn( + f"{env_name}={value} 必须为正整数,忽略(不裁剪历史)", stacklevel=2 + ) + return None + return value + + +def retention_from_env( + env: dict[str, str] | None = None, +) -> tuple[int | None, int | None]: + """读历史保留策略,返回 (max_messages, ttl_seconds),未配置项为 None。 + + env=None 读真实 os.environ;测试注入假字典,绝不碰真实环境变量。 + 这是两个 Store 与 serve_example 共用的唯一 env 解析入口。 + """ + environ = os.environ if env is None else env + return ( + _parse_positive_int(environ.get(_MAX_ENV), _MAX_ENV), + _parse_positive_int(environ.get(_TTL_ENV), _TTL_ENV), + ) + + +def _next_round_start(roles: list[str], start: int) -> int | None: + """从 start(含)向后找第一个轮首下标(role == "user"),找不到返回 None。""" + for i in range(start, len(roles)): + if roles[i] == "user": + return i + return None + + +def _prev_round_start(roles: list[str], start: int) -> int | None: + """从 start(含)向前找最近的轮首下标;start 允许等于 len(整体过期场景)。""" + for i in range(min(start, len(roles) - 1), -1, -1): + if roles[i] == "user": + return i + return None + + +def count_cut(roles: list[str], max_messages: int | None) -> int | None: + """条数裁剪:返回删除上界 k(删 [0, k)),不裁返回 None。 + + 候选边界 = len(roles) - max_messages,再向后对齐到轮首: + - 对齐保证删除点落在一轮开头,不切散 assistant(tool_calls)/tool 配对; + - 找不到后续轮首(只有一轮、或单轮自身超限)-> 不裁,宁可超限也不破坏序列; + - max_messages 是"上限"而非"目标值",对齐后实际保留条数可能少于上限。 + """ + if max_messages is None or max_messages <= 0: + return None + candidate = len(roles) - max_messages + if candidate <= 0: + return None + k = _next_round_start(roles, candidate) + if k is None or k <= 0: + return None + return k + + +def ttl_cut(roles: list[str], expired: list[bool]) -> int | None: + """TTL 裁剪:返回删除上界 k(删 [0, k)),不裁返回 None。 + + expired[i] 表示第 i 条是否已过期(严格早于 cutoff;恰好等于 cutoff 不算过期)。 + 先数"从头开始连续过期"的条数 n,再把边界向前对齐到轮首: + - 全部过期(n == len):返回 len,整库清空——空历史合法,TTL 语义高于 + "至少保留最后一轮"(该约束只作用于条数裁剪); + - 否则边界必须落在轮首且 k > 0;只过期到首轮中间(k=0)则不裁,避免孤儿 tool。 + """ + n = 0 + for is_expired in expired: + if not is_expired: + break + n += 1 + if n == 0: + return None + if n == len(roles): + return n + k = _prev_round_start(roles, n) + if k is None or k <= 0: + return None + return k diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/sqlite_store.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/sqlite_store.py new file mode 100644 index 0000000..83a8d31 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/memory/sqlite_store.py @@ -0,0 +1,304 @@ +"""SQLite 持久化记忆:MemoryStore 契约的第一个持久化实现(opt-in)。 + +为什么放在 memory/ 而不是 integrations/:sqlite3 是 Python 标准库, +零第三方依赖、零可选 extra,不属于"外部生态集成",与 InMemoryStore 同层。 + +设计要点(对应 10-plan-sqlite-memory.md §4 与 10-plan-history-trim-ttl.md §4): +1. 完整实现 spi/memory.py 的 MemoryStore 契约(五方法),Loop / Core / 三个 host + 示例零改动即可把"便签本"换成"笔记本"; +2. history() 按契约保持同步方法;其余 async 方法内执行同步小事务(单条毫秒级, + v0.2 不引入 aiosqlite,边界与取舍写进 walkthrough 第 11 章); +3. scope 实现会话/宿主隔离,默认 "default",不传参与 InMemoryStore 的单空间行为对齐; +4. PRAGMA user_version 作为 schema 迁移钩子(v0.3 仍是 v1,历史裁剪不动 schema); +5. check_same_thread=False 只是放开 sqlite 的线程限制,真正的互斥由 self._lock 保证 + (uvicorn 会把同步函数丢到线程池,多线程可能同时碰一个连接); +6. v0.3 历史保留策略(opt-in):max_messages/ttl_seconds/clock 与 InMemoryStore 对等; + created_at 由注入时钟在 Python 侧写入(与内存实现的时间戳列表同口径,假时钟可测), + 列默认 CURRENT_TIMESTAMP 保留给外部/手工插入;构造时自动 prune 一次覆盖重启场景。 +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +from collections.abc import Callable +from datetime import datetime, timedelta +from pathlib import Path + +from yai_core.memory.retention import ( + count_cut, + retention_from_env, + ttl_cut, + utc_now, +) +from yai_core.types import ChatMessage + +# 当前代码支持的 schema 版本;_ensure_schema 用它判断"未来版本数据库"并拒绝打开。 +_SCHEMA_VERSION = 1 + +# 建表语句整体作为一个脚本执行;PRAGMA user_version 不能参数化,必须写字面量。 +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL DEFAULT 'default', + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + tool_calls TEXT, + tool_call_id TEXT, + name TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) +); +CREATE INDEX IF NOT EXISTS idx_history_scope_id ON history(scope, id); + +CREATE TABLE IF NOT EXISTS kv ( + scope TEXT NOT NULL DEFAULT 'default', + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (scope, key) +); + +PRAGMA user_version = 1; +""" + +_DB_PATH_ENV = "YAI_DB_PATH" + +# created_at 的 UTC 文本格式,与 SQLite CURRENT_TIMESTAMP 输出完全一致,可直接字符串比较。 +_TS_FORMAT = "%Y-%m-%d %H:%M:%S" + + +class SqliteStore: + """会话历史 + KV 落 SQLite 的 MemoryStore 实现。 + + 用法:: + + store = SqliteStore("data/yai.db") # 文件形态(自动建父目录) + store = SqliteStore(":memory:") # 内存形态(测试主入口) + store = SqliteStore.from_env() # 读 YAI_DB_PATH,未设置返回 None + # 历史保留策略(opt-in,与 InMemoryStore 对等): + store = SqliteStore("data/yai.db", max_messages=200, ttl_seconds=604800) + """ + + def __init__( + self, + path: str | Path = ":memory:", + *, + scope: str = "default", + max_messages: int | None = None, + ttl_seconds: int | None = None, + clock: Callable[[], datetime] | None = None, + ) -> None: + self._scope = scope + self._max_messages = max_messages + self._ttl_seconds = ttl_seconds + self._clock = clock or utc_now + # 所有 DB 访问(含同步 history())都在这把锁内,保证多线程下连接安全。 + self._lock = threading.Lock() + self._is_memory = str(path) == ":memory:" + if self._is_memory: + db_path = ":memory:" + else: + # expanduser 支持 "~/...";父目录不存在就建,宿主少写一行初始化代码。 + p = Path(path).expanduser() + p.parent.mkdir(parents=True, exist_ok=True) + db_path = str(p) + # check_same_thread=False:允许线程池线程使用连接;互斥交给 self._lock。 + self._conn: sqlite3.Connection | None = sqlite3.connect( + db_path, check_same_thread=False + ) + # Row 工厂:读出的行可以用 row["role"] 按列名访问,少出错。 + self._conn.row_factory = sqlite3.Row + if not self._is_memory: + # WAL:读写不互斥、崩溃更稳;:memory: 不需要也设不了。 + self._conn.execute("PRAGMA journal_mode=WAL") + # NORMAL:WAL 下安全且更快(fsync 频率降低)。 + self._conn.execute("PRAGMA synchronous=NORMAL") + self._ensure_schema() + # 启动清理:覆盖"服务重启后过期/超量历史仍在库"的场景(仅配置了策略时动作)。 + if self._max_messages is not None or self._ttl_seconds is not None: + self.prune() + + @classmethod + def from_env( + cls, + env: dict[str, str] | None = None, + *, + retention: tuple[int | None, int | None] | None = None, + ) -> SqliteStore | None: + """从 YAI_DB_PATH 构造;未设置或为空白时返回 None(装配方据此回退 InMemoryStore)。 + + env=None 读真实 os.environ;测试注入假字典,绝不碰真实环境变量。 + retention 可由装配方预先解析后显式传入(serve_example 只解析一次 env, + 避免非法值重复 warning);缺省在本方法内调用 retention_from_env。 + """ + environ = os.environ if env is None else env + raw = (environ.get(_DB_PATH_ENV) or "").strip() + if not raw: + return None + if retention is None: + retention = retention_from_env(environ) + max_messages, ttl_seconds = retention + return cls(raw, max_messages=max_messages, ttl_seconds=ttl_seconds) + + def _ensure_schema(self) -> None: + """建表(版本 0)或核对版本;版本高于本代码支持值时直接报错,拒绝乱开。""" + with self._lock: + conn = self._require_conn_locked() + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if version == 0: + conn.executescript(_SCHEMA_SQL) + conn.commit() + elif version > _SCHEMA_VERSION: + raise RuntimeError( + f"数据库 schema 版本 {version} 高于本代码支持的 {_SCHEMA_VERSION}," + "请升级 yai-agent-core" + ) + # version == _SCHEMA_VERSION:幂等,什么都不做(重复初始化不报错、不丢数据) + + def _require_conn_locked(self) -> sqlite3.Connection: + """关闭后任何方法都给统一错误;调用方必须已持有 self._lock。""" + if self._conn is None: + raise RuntimeError("SqliteStore 已关闭") + return self._conn + + async def append_history(self, message: ChatMessage) -> None: + # tool_calls 是 list[dict],落库序列化为 JSON 文本;None 才存 NULL。 + # 易错点:空列表 [] 要存成 "[]",读回仍是 [],不能折叠成 NULL(对等测试红线)。 + tool_calls = ( + None + if message.tool_calls is None + else json.dumps(message.tool_calls, ensure_ascii=False, default=str) + ) + # created_at 用注入时钟在 Python 侧写:与 InMemoryStore 的时间戳同口径, + # TTL 才能用假时钟做确定性测试;列默认 CURRENT_TIMESTAMP 仍兜底外部插入。 + created_at = self._clock().strftime(_TS_FORMAT) + with self._lock: + conn = self._require_conn_locked() + conn.execute( + "INSERT INTO history(scope, role, content, tool_calls, tool_call_id," + " name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + self._scope, + message.role, + message.content, + tool_calls, + message.tool_call_id, + message.name, + created_at, + ), + ) + conn.commit() + self._prune_locked() + + def history(self) -> list[ChatMessage]: + """同步方法(MemoryStore 契约如此,Loop 启动时直接迭代回放)。 + + 顺序以自增 id 为准而不是 created_at:同一秒内多条消息时时间戳没有区分度。 + 返回全新列表、全新对象,外部改不到内部状态(与 InMemoryStore 返回副本一致)。 + """ + with self._lock: + conn = self._require_conn_locked() + rows = conn.execute( + "SELECT role, content, tool_calls, tool_call_id, name FROM history" + " WHERE scope = ? ORDER BY id ASC", + (self._scope,), + ).fetchall() + messages: list[ChatMessage] = [] + for row in rows: + raw_calls = row["tool_calls"] + tool_calls = json.loads(raw_calls) if raw_calls is not None else None + messages.append( + ChatMessage( + role=row["role"], + content=row["content"], + tool_calls=tool_calls, + tool_call_id=row["tool_call_id"], + name=row["name"], + ) + ) + return messages + + def _prune_locked(self) -> int: + """按保留策略删除本 scope 的历史前缀,返回删除条数;KV 不动。 + + 调用方必须已持有 self._lock。统一走"取行 → Python 算轮对齐边界 → 按 id + 删前缀",条数裁剪与 TTL 共用一条路径(裸 DELETE created_at 会绕过轮对齐, + 可能切散工具调用对)。TTL 判定全部过期时(k == 行数)整 scope 清空。 + """ + conn = self._require_conn_locked() + rows = conn.execute( + "SELECT id, role, created_at FROM history WHERE scope = ? ORDER BY id ASC", + (self._scope,), + ).fetchall() + if not rows: + return 0 + roles = [r["role"] for r in rows] + k_count = count_cut(roles, self._max_messages) + k_ttl = None + if self._ttl_seconds is not None: + cutoff = self._clock() - timedelta(seconds=self._ttl_seconds) + cutoff_text = cutoff.strftime(_TS_FORMAT) + # created_at 为 UTC 文本,同格式字符串比较即时间比较;严格 <,等于 cutoff 保留。 + expired = [r["created_at"] < cutoff_text for r in rows] + k_ttl = ttl_cut(roles, expired) + cuts = [k for k in (k_count, k_ttl) if k is not None] + if not cuts: + return 0 + k = max(cuts) + if k >= len(rows): + # TTL 判定全部过期:整 scope 历史清空(KV 保留)。 + cur = conn.execute("DELETE FROM history WHERE scope = ?", (self._scope,)) + else: + # rows[k] 是保留段首行;删掉它之前(同 scope)的所有行,即整轮前缀。 + first_kept_id = rows[k]["id"] + cur = conn.execute( + "DELETE FROM history WHERE scope = ? AND id < ?", + (self._scope, first_kept_id), + ) + deleted = cur.rowcount if cur.rowcount is not None else 0 + conn.commit() + return deleted + + def prune(self) -> int: + """非 MemoryStore 契约方法:显式触发一次历史裁剪,返回删除条数;KV 不动。""" + with self._lock: + return self._prune_locked() + + async def put(self, key: str, value: str) -> None: + """KV 写入(UPSERT):同 scope 同 key 覆盖并刷新 updated_at。""" + with self._lock: + conn = self._require_conn_locked() + conn.execute( + "INSERT INTO kv(scope, key, value) VALUES (?, ?, ?)" + " ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value," + " updated_at = CURRENT_TIMESTAMP", + (self._scope, key, value), + ) + conn.commit() + + async def get(self, key: str) -> str | None: + """KV 读取;未命中返回 None(不是 KeyError),与 InMemoryStore 对齐。""" + with self._lock: + conn = self._require_conn_locked() + row = conn.execute( + "SELECT value FROM kv WHERE scope = ? AND key = ?", + (self._scope, key), + ).fetchone() + return None if row is None else row["value"] + + async def clear(self) -> None: + """清空**当前 scope** 的历史与 KV;其他会话/宿主的数据不动。""" + with self._lock: + conn = self._require_conn_locked() + conn.execute("DELETE FROM history WHERE scope = ?", (self._scope,)) + conn.execute("DELETE FROM kv WHERE scope = ?", (self._scope,)) + conn.commit() + + def close(self) -> None: + """关闭连接(非 MemoryStore 契约方法,供宿主 lifespan 停机调用,幂等)。""" + with self._lock: + if self._conn is not None: + self._conn.close() + self._conn = None diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/__init__.py new file mode 100644 index 0000000..653408d --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/__init__.py @@ -0,0 +1,3 @@ +from yai_core.policy.allowlist import AllowlistPolicy + +__all__ = ["AllowlistPolicy"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/allowlist.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/allowlist.py new file mode 100644 index 0000000..8e7244e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/policy/allowlist.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from yai_core.spi import PermissionDecision + + +class AllowlistPolicy: + """默认权限策略。 + + - mode="auto": 白名单内工具自动放行,其余询问 + - mode="allow_all": 全部自动放行(本地演示/测试用) + - mode="deny_all": 除白名单外全部拒绝 + """ + + def __init__( + self, + allowed: Iterable[str] = (), + *, + mode: str = "auto", + ) -> None: + self.allowed = set(allowed) + self.mode = mode + + async def check(self, tool_name: str, arguments: dict) -> PermissionDecision: + if tool_name in self.allowed: + return PermissionDecision.ALLOW + if self.mode == "allow_all": + return PermissionDecision.ALLOW + if self.mode == "deny_all": + return PermissionDecision.DENY + return PermissionDecision.ASK diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/__init__.py new file mode 100644 index 0000000..4388b54 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/__init__.py @@ -0,0 +1,14 @@ +"""SPI(Service Provider Interface):宿主可替换、Core 提供默认实现的契约层。""" + +from yai_core.spi.channel import Channel +from yai_core.spi.memory import MemoryStore +from yai_core.spi.model import ModelProvider +from yai_core.spi.policy import PermissionDecision, PermissionPolicy + +__all__ = [ + "Channel", + "MemoryStore", + "ModelProvider", + "PermissionDecision", + "PermissionPolicy", +] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/channel.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/channel.py new file mode 100644 index 0000000..3f2697a --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/channel.py @@ -0,0 +1,22 @@ +"""通道契约:Core 不假设 UI 形态(CLI / Web / Flutter 都实现同一接口)。""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from yai_core.types import AgentEvent + + +@runtime_checkable +class Channel(Protocol): + async def emit(self, event: AgentEvent) -> None: + """接收 Observer 事件流(思考过程、工具状态、错误等)。""" + ... + + async def ask(self, question: str) -> str: + """策略为 clarify 时,向宿主/用户反问并取回回答。""" + ... + + async def confirm(self, tool_name: str, arguments: dict[str, Any]) -> bool: + """权限策略为 ask 时,请求宿主确认是否执行该工具。""" + ... diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/memory.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/memory.py new file mode 100644 index 0000000..8cfa804 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/memory.py @@ -0,0 +1,20 @@ +"""记忆存储契约:v0.1 只要求会话历史与简单 KV,向量记忆是 v2。""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from yai_core.types import ChatMessage + + +@runtime_checkable +class MemoryStore(Protocol): + async def append_history(self, message: ChatMessage) -> None: ... + + def history(self) -> list[ChatMessage]: ... + + async def put(self, key: str, value: str) -> None: ... + + async def get(self, key: str) -> str | None: ... + + async def clear(self) -> None: ... diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/model.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/model.py new file mode 100644 index 0000000..fba91d6 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/model.py @@ -0,0 +1,32 @@ +"""模型提供者契约:Core 不绑定任何模型厂商。""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from yai_core.types import ModelResponse + + +@runtime_checkable +class ModelProvider(Protocol): + """任何实现该协议的对象都可作为 Core 的模型后端。 + + - OpenAI 兼容实现见 ``yai_core.llm.openai_compat`` + - 测试/离线演示可使用脚本化假模型 + """ + + async def achat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + *, + tier: str = "standard", + ) -> ModelResponse: + """单轮对话。 + + Args: + messages: OpenAI 线格式消息字典列表(由 Context.llm_messages() 产出)。 + tools: OpenAI 形状的工具描述列表;None 表示本轮不提供工具。 + tier: "standard" / "strong",供模型路由(便宜模型 vs 强模型)。 + """ + ... diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/policy.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/policy.py new file mode 100644 index 0000000..c3c5aed --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/spi/policy.py @@ -0,0 +1,17 @@ +"""权限策略契约:高风险工具调用必须可被宿主拦截/确认。""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Protocol, runtime_checkable + + +class PermissionDecision(StrEnum): + ALLOW = "allow" + DENY = "deny" + ASK = "ask" + + +@runtime_checkable +class PermissionPolicy(Protocol): + async def check(self, tool_name: str, arguments: dict[str, Any]) -> PermissionDecision: ... diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/__init__.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/__init__.py new file mode 100644 index 0000000..5965e83 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/__init__.py @@ -0,0 +1,7 @@ +"""工具层:注册表 + 执行器(Tool Bus)+ 公共 schema 清洗。""" + +from yai_core.tools.executor import ToolExecutor +from yai_core.tools.registry import ToolRegistry +from yai_core.tools.schema import EMPTY_OBJECT_SCHEMA, sanitize_schema + +__all__ = ["ToolExecutor", "ToolRegistry", "sanitize_schema", "EMPTY_OBJECT_SCHEMA"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/executor.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/executor.py new file mode 100644 index 0000000..b8d4f80 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/executor.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import inspect +import json +from typing import Any + +from yai_core.spi import Channel, PermissionDecision, PermissionPolicy +from yai_core.tools.registry import ToolRegistry +from yai_core.types import AgentEvent, EventType + + +class ToolExecutor: + """Tool Bus:权限检查 -> 执行(同步/异步 handler 均可)-> 结构化结果。 + + 执行过程中产生的 Observer 事件由 execute 返回, + 由 AgentLoop 统一 yield(保证事件流只有一条路径)。 + """ + + def __init__( + self, + registry: ToolRegistry, + policy: PermissionPolicy, + channel: Channel, + ) -> None: + self.registry = registry + self.policy = policy + self.channel = channel + + async def execute( + self, name: str, arguments: dict[str, Any] + ) -> tuple[list[AgentEvent], bool, str]: + """返回 (过程事件列表, 是否成功, 文本结果),永不向 Agent Loop 抛异常。""" + events: list[AgentEvent] = [] + if not self.registry.has(name): + return events, False, f"工具 {name!r} 不存在" + + decision = await self.policy.check(name, arguments) + if decision == PermissionDecision.ASK: + events.append( + AgentEvent(EventType.PERMISSION_ASKED, {"tool": name, "arguments": arguments}) + ) + approved = await self.channel.confirm(name, arguments) + if not approved: + return events, False, f"用户/宿主拒绝执行工具 {name}" + if decision == PermissionDecision.DENY: + return events, False, f"权限策略拒绝执行工具 {name}" + + events.append(AgentEvent(EventType.TOOL_CALL, {"tool": name, "arguments": arguments})) + spec = self.registry.get(name) + try: + result = spec.handler(**arguments) + if inspect.isawaitable(result): + result = await result + text = json.dumps(result, ensure_ascii=False, default=str) + except Exception as exc: # noqa: BLE001 - 工具错误必须回灌给模型而不是崩溃 + events.append( + AgentEvent(EventType.TOOL_RESULT, {"tool": name, "ok": False, + "error": str(exc)}) + ) + return events, False, f"工具 {name} 执行出错: {type(exc).__name__}: {exc}" + + events.append( + AgentEvent(EventType.TOOL_RESULT, {"tool": name, "ok": True, "preview": text[:200]}) + ) + return events, True, text diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/registry.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/registry.py new file mode 100644 index 0000000..30ce080 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/registry.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from yai_core.types import ToolSpec + + +class ToolRegistry: + """统一工具注册表:Native / OpenAPI / MCP 工具在此同构。""" + + def __init__(self) -> None: + self._tools: dict[str, ToolSpec] = {} + + def register(self, spec: ToolSpec) -> None: + if spec.name in self._tools: + raise ValueError(f"工具 {spec.name!r} 已注册,名称必须唯一") + self._tools[spec.name] = spec + + def register_many(self, specs: Iterable[ToolSpec]) -> None: + for spec in specs: + self.register(spec) + + def get(self, name: str) -> ToolSpec: + if name not in self._tools: + raise KeyError(f"未知工具 {name!r},当前可用:{list(self._tools)}") + return self._tools[name] + + def has(self, name: str) -> bool: + return name in self._tools + + def all(self) -> list[ToolSpec]: + return list(self._tools.values()) + + def llm_schemas(self) -> list[dict]: + return [spec.llm_schema() for spec in self._tools.values()] + + def describe(self) -> str: + """给系统提示词用的工具清单文本。""" + if not self._tools: + return "(当前宿主没有提供任何工具)" + return "\n".join(f"- {s.name}: {s.description}" for s in self._tools.values()) + + def __len__(self) -> int: + return len(self._tools) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/schema.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/schema.py new file mode 100644 index 0000000..c96e579 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/tools/schema.py @@ -0,0 +1,65 @@ +"""JSON Schema 清洗:喂给模型的工具入参 schema 只保留标准关键字。 + +MCP SDK 自动生成的 input_schema 带 title 等实现私有键,OpenAPI 描述里也常混入 +xml/example/deprecated 等 OpenAPI 专有注解;个别 OpenAI 兼容接口对多余键敏感, +注册进 ToolRegistry 前统一递归清洗一遍。MCP 桥与 OpenAPI 桥共用本模块。 +""" + +from __future__ import annotations + +from typing import Any + +# JSON Schema 标准关键字白名单(draft 2020-12 常用子集,覆盖工具入参所需)。 +_SCHEMA_KEYS = frozenset( + { + "type", + "properties", + "required", + "items", + "enum", + "description", + "default", + "const", + "anyOf", + "oneOf", + "allOf", + "not", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "multipleOf", + "format", + "additionalProperties", + "$defs", + "$ref", + } +) + +# 空对象 schema 的公开常量(操作没有任何入参时使用)。 +EMPTY_OBJECT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}} + + +def sanitize_schema(schema: Any) -> Any: + """递归只保留 JSON Schema 标准关键字;dict/list 之外的原值原样返回。 + + 注意 properties / $defs 的键是"属性名/定义名"而不是 schema 关键字, + 必须原样保留,只清洗它们的值。 + """ + if isinstance(schema, dict): + cleaned: dict[str, Any] = {} + for key, value in schema.items(): + if key in ("properties", "$defs") and isinstance(value, dict): + cleaned[key] = {name: sanitize_schema(sub) for name, sub in value.items()} + elif key in _SCHEMA_KEYS: + cleaned[key] = sanitize_schema(value) + return cleaned or dict(EMPTY_OBJECT_SCHEMA) + if isinstance(schema, list): + return [sanitize_schema(item) for item in schema] + return schema diff --git a/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/types.py b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/types.py new file mode 100644 index 0000000..55534dc --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/src/yai_core/types.py @@ -0,0 +1,107 @@ +"""YAI Agent Core 的核心数据类型。 + +刻意只依赖标准库:内核本体零第三方依赖,第三方能力全部通过 SPI 注入。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Literal + +Role = Literal["system", "user", "assistant", "tool"] + + +class Strategy(StrEnum): + """Adaptive Router 可选的执行策略。""" + + DIRECT = "direct" # 无需工具,模型直接回答 + REACT = "react" # 工具循环:推理 -> 调用 -> 观察 -> 再推理 + PLAN = "plan" # 先拆解计划,再按计划执行工具循环 + CLARIFY = "clarify" # 意图不清,先向宿主/用户反问 + + +class EventType(StrEnum): + """Observer 事件流类型——每一次"自适应决策"都必须可观测。""" + + STRATEGY_SELECTED = "strategy_selected" + PLAN_CREATED = "plan_created" + MODEL_MESSAGE = "model_message" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + PERMISSION_ASKED = "permission_asked" + CLARIFY_REQUESTED = "clarify_requested" + ERROR = "error" + DONE = "done" + + +@dataclass +class ToolSpec: + """统一工具规格。Native / OpenAPI / MCP 工具在 Registry 中同构。""" + + name: str + description: str + input_schema: dict[str, Any] # JSON Schema(与 MCP tools 形状一致) + handler: Callable[..., Any] # 同步函数;异步函数同样支持 + source: Literal["native", "openapi", "mcp"] = "native" + + def llm_schema(self) -> dict[str, Any]: + """转换成 OpenAI 兼容的 function-calling 工具描述。""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.input_schema, + }, + } + + +@dataclass +class ChatMessage: + role: Role + content: str = "" + # assistant 发起的工具调用:[{"id","name","arguments"}] + tool_calls: list[dict[str, Any]] | None = None + tool_call_id: str | None = None + name: str | None = None + + def to_llm_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"role": self.role, "content": self.content} + if self.tool_calls: + d["tool_calls"] = self.tool_calls + if self.tool_call_id: + d["tool_call_id"] = self.tool_call_id + if self.name: + d["name"] = self.name + return d + + +@dataclass +class ToolCallRequest: + id: str + name: str + arguments: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ModelResponse: + """ModelProvider 的统一返回,屏蔽各家 SDK 差异。""" + + content: str = "" + tool_calls: list[ToolCallRequest] = field(default_factory=list) + raw: Any = None + + +@dataclass +class AgentEvent: + type: EventType + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RunResult: + strategy: Strategy + events: list[AgentEvent] + final_text: str diff --git a/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md new file mode 100644 index 0000000..637af5e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/RIGHTS.md @@ -0,0 +1,56 @@ +# RIGHTS · 权利与授权声明 + +## 1. 提交人 + +- 提交团队:**YAI**(单人团队) +- 提交人 / 版权人:**Gi-Tuu**(https://github.com/Gi-Tuu) +- 项目仓库:https://github.com/Gi-Tuu/yai-agent-core +- 提交人声明:本项目全部代码、文档、示例均由本人原创编写,对代码、服务、依赖与数据拥有提交所需的全部权利;不存在未披露的第三方代码抄袭、虚假部署或身份操纵。 + +## 2. 项目许可证 + +本项目以 **MIT License** 开源,许可证全文见 `source/LICENSE`: + +> Copyright (c) 2026 Gi-Tuu + +任何人(含 X-Agent 评审与后续 MCP 标准化流程)可在 MIT 条款下使用、复制、修改、合并、发布、再许可与分发。 + +## 3. 第三方依赖许可证 + +内核本体(`src/yai_core/` 的核心部分)**零第三方运行时依赖**,仅使用 Python 标准库。 +下列依赖全部为可选 extras(按需安装、模块内懒加载),均为 MIT / BSD / Apache 类宽松许可证,完整版本锁定见 `source/uv.lock`,各依赖的许可证原文随其分发包提供: + +| 依赖(extras) | 用途 | 许可证 | +|---|---|---| +| openai(llm) | OpenAI 兼容模型客户端(线上接 DeepSeek) | Apache-2.0 | +| fastapi(server) | HTTP API 框架 | MIT | +| uvicorn[standard](server) | ASGI 服务器 | BSD-3-Clause | +| pydantic(server) | 请求模型与数据校验 | MIT | +| starlette(fastapi 传递依赖) | ASGI 工具集 | BSD-3-Clause | +| mcp(mcp) | 官方 Model Context Protocol Python SDK v2 | MIT | +| anyio / httpx / httpcore(传递依赖) | 异步与 HTTP 运行时 | MIT / BSD-3-Clause | +| pydantic-core / typing-extensions / sniffio / h11 / idna 等 | 运行时支撑库 | MIT / BSD / PSF 类宽松许可 | + +开发期工具(pytest、ruff、python-docx 等)仅用于测试与文档构建,**不进入运行时镜像与服务**。 +如评审需要逐包许可证清单,可在隔离环境执行 `uv tree` / 查看 `uv.lock` 完整复现。 + +## 4. 数据与外部服务 + +- 线上服务不采集、不存储、不转发任何用户个人数据;内置示例数据(3 条演示笔记、演示天气文案)均为虚构。 +- 运行时唯一的外部出站请求是发往模型 API(线上为 DeepSeek,https://api.deepseek.com)的任务推理请求,遵循该服务方的公开服务条款;提交人对发送内容负责,不包含任何第三方隐私数据。 +- 不使用任何未授权的付费资源、私有数据集或抓取数据。 + +## 5. 提交与存档授权 + +提交人授权 X-Agent 官方及评审流程: + +1. 在隔离评审环境中拉取、构建、运行本提交的 `source/` 与在线服务,进行硬门槛校验、源码评审、安全与数据审查; +2. 在通过评审后,将与评审基线 commit 一致的完整源码复制到官方存档 ref、生成不可变的验收 release; +3. 对入选项目按公开规则进行 MCP 标准化适配(适配器与提交包由 X-Agent 侧拥有,底层能力的真实性、运行与维护责任由提交人承担)。 + +提交人理解:通过评审不构成 OKX 收录、上架、流量或收益的承诺;撤回未合并 PR 即视为退赛;已验收/获奖的源码将保留在官方存档中。 + +## 6. 商标与署名 + +- "YAI Agent Core" 与 "AMBRACE(拥爱)"为提交人自有项目名称; +- DeepSeek、OKX、X-Agent、Render、GitHub 等名称归各自权利人所有,本项目仅作技术兼容性描述,不暗示任何背书或关联关系。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md new file mode 100644 index 0000000..df69135 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/SUBMISSION.md @@ -0,0 +1,111 @@ +# YAI Agent Core · X-Agent MCP Hackathon 提交说明 + +- **项目名**:YAI Agent Core(yai-agent-core) +- **提交团队**:YAI(单人开发者 Gi-Tuu,https://github.com/Gi-Tuu) +- **一句话定位**:进程内嵌入式、自适应的 Agent 内核——已有软件只需声明自己的普通业务函数(或挂载 MCP Server),Core 自动发现能力、自适应选择执行策略并完成任务,**宿主不写一行 Agent Loop / Planner / 工具选择代码**。 +- **在线 API**:https://yai-agent-core.onrender.com +- **源码仓库**:https://github.com/Gi-Tuu/yai-agent-core(MIT) +- **评审基线 commit**:以 `submission.json` 的 `reviewCommit`(40 位 SHA)为准,与 `/health`、`/.well-known/xagent-verification.json` 三处一致。 + +--- + +## 1. 解决什么真实问题 + +市面上的 Agent 形态分两类:平台型(Dify/Coze,要把东西搬进别人的云)和框架型(LangGraph/OpenAI Agents SDK,开发者仍要自己定义 Agent、工具、编排)。 +大量已有软件(笔记应用、数据后台、陪伴 App)想要 Agent 能力,但不想重写、不想上云、也不想在业务代码里堆 Agent 胶水代码。 + +YAI Agent Core 类比"Agent 世界的 SQLite":以库的形式运行在宿主进程内,范式是 **host-declares-capability / core-adapts**: + +1. 宿主只提供普通 Python 函数(type hints + docstring),Core 内省生成工具规格; +2. 外部 MCP Server 的工具经 MCP Client 同构接入(v0.2 已落地); + Core 同时内置 OpenAPI 发现(可选):任意 OpenAPI 3 REST API 提供描述即可被自动注册为工具, + 与 MCP 工具在同一 Tool Bus 上同构调度(read_only 只读模式可用于公网演示); +3. Adaptive Router 把任务路由到 `direct / react / plan / clarify` 四种策略: + v0.2 起先由模型做一次轻量分类(输出 `{strategy, reason, tier}` JSON), + 超时/异常/非法输出自动回退确定性规则,**两条路径都返回带来源的决策记录**; +4. Agent Loop 完成 ReAct 工具循环 / 计划拆解执行(规划轮使用路由建议的模型档位); +5. 每一次策略选择(含 `source=llm|rules` 与理由)、工具调用、权限确认都作为事件流出, + **过程可审计,不是黑盒**。 + +它不是又一个需要开发者拼装的 Agent 框架,而是可以"装进软件里"的内核;项目最终将回流嵌入开源 AI Companion 项目 AMBRACE。 + +## 2. 在线能力(评审可直接调用) + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/health` | GET | 健康检查,返回 `status` 与本次部署的 40 位 commit | +| `/.well-known/xagent-verification.json` | GET | 部署证明:`schemaVersion=1` + `slug` + `commit` | +| `/v1/tools` | GET | 当前宿主注册的全部工具(含 `source: native/mcp/openapi` 来源标注;线上实例同时挂载本地笔记工具与公共 DeepWiki MCP Server 的 3 个远程工具) | +| `/v1/agent/run` | POST | 入参 `{"task": "..."}`,返回策略、最终结果与**全过程事件流**;任务涉及外部仓库问答时会真实发起 MCP 工具调用 | + +可复现的 curl 命令与期望输出见 `verification/README.md`。 + +> 免费实例 15 分钟无流量会休眠,首次请求冷启动约 30–90 秒,请耐心等待或先请求一次 `/health` 唤醒。 + +## 3. 工程与架构 + +``` +src/yai_core/ +├── types.py # ToolSpec / AgentEvent / Strategy 等核心数据结构(零依赖) +├── spi/ # Model / Channel / Memory / Policy 四个可替换契约 +├── discovery/ # 宿主函数内省 → ToolSpec(能力自发现) +├── tools/ # ToolRegistry(统一花名册)+ ToolExecutor(权限→执行→事件) +├── kernel/ # AdaptiveRouter(策略路由)+ Context + AgentLoop(主循环) +├── llm/ # OpenAI 兼容模型后端(DeepSeek 等,可选依赖、懒加载) +├── memory/ policy/ channels/ # 默认实现:内存记忆 + SQLite 持久化(opt-in)/ 白名单权限 / CLI·收集通道 +├── integrations/ +│ ├── mcp/ # MCP Client 桥接(可选 [mcp] 依赖、懒加载) +│ └── openapi/ # OpenAPI 3 发现 → 工具(可选 [openapi] 依赖、懒加载) +└── batteries/fastapi_server/ # 在线 API Battery(可选 [server] 依赖,含限流中间件;历史保留由宿主按 env 注入) +``` + +关键工程原则: + +- **内核本体零第三方硬依赖**:`pyproject.toml` 的 `dependencies` 为空;openai / fastapi / mcp / httpx 全部归入可选 extras(llm / server / mcp / openapi)并在模块内懒加载,有 AST 测试防止顶层误引入。 +- **工具同构**:本地函数与外部 MCP 工具在 ToolRegistry 中都是 ToolSpec,Router/Loop/Executor 对工具位置零感知。 +- **错误回灌而非崩溃**:工具(含 MCP 工具)异常被捕获为失败结果回灌模型,事件流照常完整。 +- **可复现**:`uv.lock` 锁定全部依赖;Dockerfile 多阶段构建、`docker compose` 一键起;Render Blueprint(`render.yaml`)即点即部署。 + +## 4. 测试与可观测 + +- 离线测试 **125 项**全部通过(截至 v0.3,以 `pytest -q` 实跑为准;不需要网络与 API Key):用 ScriptedModel 假模型、内存态 MCP Server 假外部服务、`httpx.MockTransport` 假 REST 端点,覆盖路由、ReAct/Plan 循环、权限、MCP/OpenAPI 桥接、记忆保留策略、限流与 API 端点。 +- GitHub Actions CI 矩阵:Ubuntu × Python 3.11/3.12/3.13 + Windows × 3.13。 +- 每次运行返回完整事件序列(strategy_selected / plan_created / tool_call / tool_result / done…),可直接作为评审的"能力证据"。 +- 长期运行的记忆治理:历史支持条数上限与 TTL(opt-in,按对话轮对齐裁剪,不拆散工具调用对),SQLite 存储在写入与启动时自动清理过期历史,裁剪规则为纯函数并有对等测试。 + +## 5. MCP 产品化准备(对应 15 分评分项) + +- 工具边界清晰:每个工具有 name / description / JSON Schema 入参,MCP 工具 schema 经白名单清洗后与本地工具同构; +- 已实现 MCP Client(官方 Python SDK v2),支持 Streamable HTTP、stdio 子进程、内存直连三种传输; +- **线上 API 自身即 MCP 消费方**:部署期通过 `MCP_SERVER_URL` 环境变量挂载公共免鉴权 MCP Server(DeepWiki),评审可直接 POST 任务让线上服务真实调用远程 MCP 工具,挂载失败不影响本地工具与验证端点(优雅降级); +- 错误语义明确:MCP `is_error` 统一翻译为失败结果并产生 `tool_result(ok=false)` 事件; +- 权限、副作用边界在 Tool Bus 一层统一收口;超时(规划中)同样收口于此;已实现评审期限流(每 IP 每分钟 30 次、429 + Retry-After,GET 验证端点不受限); +- 入选后可直接配合 X-Agent 做工具边界与 I/O schema 标准化,本项目自身不绑定任何特定 MCP Server。 + +## 6. 安全与数据处理(评审须知) + +- 线上服务不提供账号体系、不采集个人信息:仅使用演示宿主自带的 3 条虚构笔记;会话历史按部署配置可落盘(`YAI_DB_PATH`,Render 免费层为临时盘、实例重建即清空),不用于任何训练或分析用途。 +- 出站请求仅发往配置的 LLM 端点(线上为 DeepSeek 官方 API);代码内无任何遥测/统计 SDK。 +- API Key 仅存在于部署平台 Secret 与本地 `.env`(gitignore),不入库、不入镜像层(`.dockerignore` 排除)。 +- 评审期端点开放调用(满足"可在线调用"要求),已加按 IP 的零依赖限流(每 IP 每分钟 30 次,超限返回 429 与 Retry-After;health/verification/tools 三个验证端点不受限);不提供登录墙。 +- 依赖与授权清单见 `RIGHTS.md`。 + +## 7. 本地复现(隔离环境,5–10 分钟) + +```bash +git clone https://github.com/Gi-Tuu/yai-agent-core && cd yai-agent-core +uv venv && uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +uv run pytest # 125 passed,离线 +uv run ruff check src tests examples scripts +uv run python scripts/smoke_test.py # 同一内核自适应三个不同宿主(离线) +docker compose up --build # 容器化(容器内 8000,宿主 127.0.0.1:8001) +``` + +接真实模型:复制 `.env.example` 为 `.env` 填入 OpenAI 兼容 Key(DeepSeek 等),运行 +`uv run python examples/host_d_mcp/run.py` 可看到本地工具与 MCP 工具在同一注册表里协同。 + +## 8. 已知限制(诚实声明) + +- 免费层部署会休眠、冷启动约 30–90 秒(波动较大,建议先请求 `/health` 预热);正式评审期将迁移常驻 VPS(手册见仓库 `docs/competitions/deployment.md`)。 +- 路由为"单次轻量 LLM 分类 + 确定性规则兜底",不做多层反思/多智能体编排;持久化记忆为 SQLite(opt-in),免费层临时盘随实例重建清空、且不支持多进程共享,长期留存需挂盘或迁移常驻 VPS;限流按 IP 滑动窗口,不防御伪造 XFF,当前无账号体系与鉴权(列入后续版本计划)。 +- 不做 MCP Server、Multi-Agent、自进化写工具、向量记忆、内置 UI、coding agent(明确的 v0.1 红线,避免过度设计)。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/submission.template.json b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/submission.template.json new file mode 100644 index 0000000..fb6f094 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/submission.template.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "name": "YAI Agent Core", + "slug": "yai-agent-core", + "sourceRepository": "https://github.com/Gi-Tuu/yai-agent-core", + "reviewCommit": "__REVIEW_COMMIT__", + "apiBaseUrl": "https://yai-agent-core.onrender.com", + "healthCheckUrl": "https://yai-agent-core.onrender.com/health", + "deploymentProofUrl": "https://yai-agent-core.onrender.com/.well-known/xagent-verification.json" +} diff --git a/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/verification/README.md b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/verification/README.md new file mode 100644 index 0000000..bce6785 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/submissions/mcp-hackathon/yai-agent-core/verification/README.md @@ -0,0 +1,104 @@ +# 在线验证手册(verification) + +- **API 同源基址**:`https://yai-agent-core.onrender.com` +- **评审基线 commit**:以 `../submission.json` 的 `reviewCommit`(40 位 SHA)为准。 +- **冷启动提示**:免费实例 15 分钟无流量会休眠,首次请求约 30–90 秒冷启动;建议先 curl 一次 health 唤醒,再做后续验证。 +- 以下 curl 命令在任意装有 curl 的环境可直接复现(Windows PowerShell / macOS / Linux 均可)。 + +## 1. health 健康检查(硬门槛) + +```bash +curl -sS https://yai-agent-core.onrender.com/health +``` + +期望返回(commit 必须与 reviewCommit 完全一致): + +```json +{"status": "ok", "commit": "<40位 reviewCommit>"} +``` + +## 2. 部署证明端点(硬门槛,与 health 同源同 commit) + +```bash +curl -sS https://yai-agent-core.onrender.com/.well-known/xagent-verification.json +``` + +期望返回: + +```json +{"schemaVersion": 1, "slug": "yai-agent-core", "commit": "<40位 reviewCommit>"} +``` + +## 3. 工具清单(能力自发现 + 来源标注) + +```bash +curl -sS https://yai-agent-core.onrender.com/v1/tools +``` + +期望返回宿主演示应用自动发现的 3 个 native 工具(count_notes / list_notes / search_notes) +以及线上通过 MCP Client 挂载的 3 个 `source: "mcp"` 远程工具(ask_question / +read_wiki_contents / read_wiki_structure,来自公共免鉴权的 DeepWiki MCP Server)。 + +## 4. 真实 Agent 任务(POST,会真实调用 DeepSeek 模型) + +> 限流说明:`POST /v1/agent/run` 每 IP 每分钟 30 次;超限返回 429 与 `Retry-After`, +> 稍后重试即可。健康与验证端点(本手册第 1–3 节)不受限。 + +```bash +curl -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run \ + -H "Content-Type: application/json" \ + -d '{"task": "搜索笔记里关于比赛的内容并总结"}' \ + --max-time 180 +``` + +期望返回 JSON,包含: + +- `strategy`:自适应路由结果(该任务通常为 `plan` 或 `react`); +- `events[0]`(`strategy_selected`)带 `source`(`llm` 模型分类 / `rules` 规则兜底)、 + `reason`(中文决策理由)与 `tier`(standard/strong)——决策本身可审计; +- `final_text`:模型基于真实工具结果给出的中文总结; +- `events`:完整过程事件序列(`strategy_selected` → `tool_call` → `tool_result`(ok=true)→ … → `done`),证明工具被真实调用而非提示词演示。 + +PowerShell 若对内联 JSON 引号处理不一致,可改用: + +```powershell +$body = @{ task = "搜索笔记里关于比赛的内容并总结" } | ConvertTo-Json -Compress +curl.exe -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run ` + -H "Content-Type: application/json" --data $body --max-time 180 +``` + +## 4b. 真实外部 MCP 工具调用(MCP 产品化硬证据) + +```bash +curl -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run \ + -H "Content-Type: application/json" \ + -d '{"task": "用 MCP 工具问一下 GitHub 仓库 modelcontextprotocol/python-sdk:MCP 的 Client 怎么初始化?用中文给结论。"}' \ + --max-time 180 +``` + +期望:`strategy` 为 `react`;`events` 中出现对 `ask_question` 的 +`tool_call` / `tool_result(ok=true)`(该工具的来源标注 `source: "mcp"` 在第 3 节 `/v1/tools` 清单中查看,事件体本身不带 source 字段);`final_text` 是基于远程 MCP 返回内容的中文回答。 +该调用链为:本服务 → DeepSeek 规划工具调用 → Streamable HTTP 调远程 MCP Server → 结果回灌 → 模型总结。 + +## 5. 本地/隔离环境复现 + +```bash +git clone https://github.com/Gi-Tuu/yai-agent-core +cd yai-agent-core +git checkout +uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +uv run pytest # 125 项离线测试全绿(截至 v0.3,以 pytest -q 实跑为准),不需要 API Key + # 注:extras 组合与 CI 一致(dev/llm/server/mcp/openapi),实跑 125 passed,无 skip +uv run ruff check src tests examples scripts +uv run python scripts/smoke_test.py # 三宿主自适应冒烟(离线) +docker compose up --build # 容器化:容器内 8000,宿主 127.0.0.1:8001 +# 本地容器验证:curl http://127.0.0.1:8001/health +``` + +## 6. 证据留存 + +- 部署平台:Render 免费层 Docker Web Service(Blueprint 见 `source/render.yaml`), + 平台注入 `RENDER_GIT_COMMIT`,服务启动时按 `YAI_GIT_COMMIT → RENDER_GIT_COMMIT → git HEAD → dev` + 解析且只接受 40 位哈希,保证 health 上报的就是本次部署的真实 commit; +- CI:每次推送在 GitHub Actions(Ubuntu 3.11/3.12/3.13 + Windows 3.13)执行 ruff + pytest,状态见仓库 README 徽章与 Actions 页; +- 正式评审期如迁移常驻 VPS,仅更换部署环境,端点契约、slug、commit 校验逻辑不变,并会通过补充 PR 披露。 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/fixtures/petstore.min.json b/submissions/mcp-hackathon/yai-agent-core/source/tests/fixtures/petstore.min.json new file mode 100644 index 0000000..3f2e985 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/fixtures/petstore.min.json @@ -0,0 +1,74 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Petstore Min", "version": "1.0" }, + "servers": [{ "url": "http://petstore.test/api/v3" }], + "security": [{ "bearerAuth": [] }], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "列出宠物", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "tags", "in": "query", "schema": { "type": "array", "items": { "type": "string" } } }, + { "name": "verbose", "in": "query", "schema": { "type": "boolean" } }, + { "name": "X-Trace", "in": "header", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "ok" } } + }, + "post": { + "operationId": "createPet", + "summary": "创建宠物", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } + }, + "responses": { "201": { "description": "created" } } + } + }, + "/pets/{petId}": { + "parameters": [ + { "name": "petId", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "get": { "summary": "查宠物", "responses": { "200": { "description": "ok" }, "404": { "description": "missing" } } }, + "delete": { "operationId": "deletePet", "summary": "删宠物", "responses": { "204": { "description": "gone" } } } + }, + "/echo/{kind}": { + "post": { + "operationId": "echoBody", + "parameters": [ + { "name": "kind", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } + }, + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "category": { "$ref": "#/components/schemas/Category" } + } + }, + "Category": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "children": { "type": "array", "items": { "$ref": "#/components/schemas/Category" } } + } + } + }, + "securitySchemes": { + "bearerAuth": { "type": "http", "scheme": "bearer" }, + "keyHeader": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } + } + } +} diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_battery_api.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_battery_api.py new file mode 100644 index 0000000..c6666f5 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_battery_api.py @@ -0,0 +1,101 @@ +"""FastAPI Battery 端点测试:离线脚本模型 + TestClient,不需要 API Key 与网络。""" + +from __future__ import annotations + +import contextlib + +import pytest +from fastapi.testclient import TestClient + +from yai_core import AgentCore, ModelResponse, build_spec +from yai_core.batteries.fastapi_server import create_app + + +class ScriptedModel: + def __init__(self, text: str) -> None: + self._text = text + + async def achat(self, messages, tools=None, *, tier="standard"): + return ModelResponse(content=self._text) + + +def list_notes() -> list: + """列出全部笔记。""" + return [{"id": 1, "title": "Core 骨架"}] + + +def _client(monkeypatch) -> TestClient: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + monkeypatch.setenv("YAI_PROJECT_SLUG", "yai-test") + core = AgentCore(ScriptedModel("你好,我是内嵌助手。")) + core.register_tools([build_spec(list_notes)]) + return TestClient(create_app(core)) + + +def test_health(monkeypatch) -> None: + resp = _client(monkeypatch).get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert len(body["commit"]) == 40 # X-Agent 硬门槛:40 位 commit + + +def test_xagent_verification(monkeypatch) -> None: + resp = _client(monkeypatch).get("/.well-known/xagent-verification.json") + assert resp.status_code == 200 + body = resp.json() + assert body == {"schemaVersion": 1, "slug": "yai-test", "commit": "0" * 40} + + +def test_tools_endpoint_lists_discovered_capabilities(monkeypatch) -> None: + resp = _client(monkeypatch).get("/v1/tools") + assert resp.status_code == 200 + names = [t["name"] for t in resp.json()] + assert "list_notes" in names + + +def test_agent_run_endpoint_returns_events_and_text(monkeypatch) -> None: + resp = _client(monkeypatch).post("/v1/agent/run", json={"task": "你好"}) + assert resp.status_code == 200 + body = resp.json() + assert body["strategy"] == "direct" + assert body["final_text"].startswith("你好") + event_types = [e["type"] for e in body["events"]] + assert "strategy_selected" in event_types + assert event_types[-1] == "done" + + +def test_lifespan_attaches_mcp_tools(monkeypatch) -> None: + """lifespan 挂载的外部 MCP 工具要出现在 /v1/tools(线上部署形态的离线回放)。""" + pytest.importorskip("mcp") + from mcp.server import MCPServer + + from yai_core.integrations.mcp import McpServerConfig, attach_mcp_tools + + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + monkeypatch.setenv("YAI_PROJECT_SLUG", "yai-test") + + server = MCPServer("TestWiki") + + @server.tool() + def ask_question(repoName: str, question: str) -> str: + """Ask a question about a repository.""" + return f"wiki answer for {repoName}: {question}" + + core = AgentCore(ScriptedModel("ok")) + core.register_tools([build_spec(list_notes)]) + + @contextlib.asynccontextmanager + async def lifespan(_app): + bridge = await attach_mcp_tools( + core.registry, McpServerConfig(alias="wiki", server=server) + ) + try: + yield + finally: + await bridge.aclose() + + with TestClient(create_app(core, lifespan=lifespan)) as client: + tools = {t["name"]: t for t in client.get("/v1/tools").json()} + assert "list_notes" in tools # native 工具仍在 + assert tools["ask_question"]["source"] == "mcp" # MCP 工具同构挂载 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_context_compact.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_context_compact.py new file mode 100644 index 0000000..14a97cd --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_context_compact.py @@ -0,0 +1,67 @@ +"""Context._compact 轮边界丢弃专项:压缩不得拆散 assistant(tool_calls)/tool 对。""" + +from __future__ import annotations + +from yai_core.kernel.context import Context +from yai_core.types import ChatMessage + + +def _tool_round(marker: str, big: int) -> list[ChatMessage]: + """构造一轮含工具调用的历史:user(大) -> assistant(tool_calls) -> tool(大)。""" + return [ + ChatMessage(role="user", content=marker * big), + ChatMessage( + role="assistant", + content="", + tool_calls=[{"id": marker, "name": "t", "arguments": {}}], + ), + ChatMessage(role="tool", content=marker * big, tool_call_id=marker), + ] + + +def test_compact_drops_whole_round_and_keeps_system() -> None: + ctx = Context("系统提示", max_chars=10) + ctx.messages.extend(_tool_round("旧", 50)) + ctx.messages.extend( + [ + ChatMessage(role="user", content="新任务"), + ChatMessage(role="assistant", content="完成"), + ] + ) + ctx._compact() + assert [m.role for m in ctx.messages] == ["system", "user", "assistant"] + assert ctx.messages[0].content == "系统提示" + assert ctx.messages[1].content == "新任务" + + +def test_compact_within_budget_unchanged() -> None: + ctx = Context("系统提示", max_chars=24000) + ctx.messages.extend(_tool_round("x", 10)) + before = list(ctx.messages) + ctx._compact() + assert ctx.messages == before + + +def test_compact_keeps_last_round_even_if_over_budget() -> None: + ctx = Context("系统提示", max_chars=10) + ctx.messages.extend(_tool_round("大", 50)) # 只有一轮且超预算 + ctx._compact() + # 宁可超预算也不裁:system + 完整工具轮(4 条) + assert [m.role for m in ctx.messages] == [ + "system", + "user", + "assistant", + "tool", + ] + + +def test_compact_triggered_via_add_path() -> None: + ctx = Context("系统提示", max_chars=10) + ctx.messages.extend(_tool_round("旧", 50)) + # 公共 add 路径追加新一轮时触发压缩,旧工具轮整轮消失、新轮完整 + ctx.add(ChatMessage(role="user", content="新")) + ctx.add(ChatMessage(role="assistant", content="好")) + roles = [m.role for m in ctx.messages] + assert roles[0] == "system" + assert "tool" not in roles # 旧工具轮被整轮删掉,没有孤儿 tool 残留 + assert roles[-2:] == ["user", "assistant"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_discovery.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_discovery.py new file mode 100644 index 0000000..f71726b --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_discovery.py @@ -0,0 +1,25 @@ +from yai_core import build_spec + + +def search_notes(keyword: str, limit: int = 5) -> list: + """按关键词搜索笔记。""" + return [] + + +def test_build_spec_basic_types_and_required() -> None: + spec = build_spec(search_notes) + assert spec.name == "search_notes" + assert spec.description == "按关键词搜索笔记。" + props = spec.input_schema["properties"] + assert props["keyword"]["type"] == "string" + assert props["limit"]["type"] == "integer" + assert props["limit"]["default"] == 5 + assert spec.input_schema["required"] == ["keyword"] + + +def test_llm_schema_shape() -> None: + spec = build_spec(search_notes) + llm = spec.llm_schema() + assert llm["type"] == "function" + assert llm["function"]["name"] == "search_notes" + assert "parameters" in llm["function"] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_loop.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_loop.py new file mode 100644 index 0000000..1ac5523 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_loop.py @@ -0,0 +1,138 @@ +"""端到端测试:用脚本化假模型离线验证 react 循环,不需要 API Key。""" + +import asyncio + +from yai_core import AgentCore, ModelResponse, ToolCallRequest, build_spec + + +class ScriptedModel: + """按预设顺序返回响应的假模型,用于离线测试。""" + + def __init__(self, responses: list[ModelResponse]) -> None: + self._responses = responses + self.calls = 0 + + async def achat(self, messages, tools=None, *, tier="standard"): + resp = self._responses[min(self.calls, len(self._responses) - 1)] + self.calls += 1 + return resp + + +NOTES = {"周报": ["周一完成 Core 骨架", "周三跑通工具调用"], "其他": ["买菜"]} + + +def search_notes(keyword: str) -> list: + """搜索宿主笔记库。""" + return NOTES.get(keyword, []) + + +def test_react_loop_calls_tool_and_finishes() -> None: + model = ScriptedModel( + [ + ModelResponse( + content="", + tool_calls=[ToolCallRequest(id="c1", name="search_notes", + arguments={"keyword": "周报"})], + ), + ModelResponse(content="本周周报包含 2 条记录:Core 骨架、工具调用。"), + ] + ) + core = AgentCore(model) + core.register_tools([build_spec(search_notes)]) + + result = asyncio.run(core.run("搜索笔记里关于周报的内容并总结")) + + event_types = [e.type.value for e in result.events] + assert "strategy_selected" in event_types + assert "tool_call" in event_types + assert "tool_result" in event_types + assert event_types[-1] == "done" + assert "周报" in result.final_text + assert model.calls == 2 + + +def test_plan_strategy_still_executes_tools() -> None: + """回归:plan 拆完步骤后必须真正进入工具循环,不能把计划当最终答复。""" + + def search_notes(keyword: str) -> list: + """搜索宿主笔记库。""" + return ["X-Agent 0919 截止"] if keyword == "比赛" else [] + + model = ScriptedModel( + [ + ModelResponse(content="1. 搜索比赛笔记\n2. 统计总数并汇报"), # 规划轮(无 tools) + ModelResponse( + content="", + tool_calls=[ToolCallRequest(id="p1", name="search_notes", + arguments={"keyword": "比赛"})], + ), + ModelResponse(content="找到 1 条比赛相关笔记。"), + ] + ) + core = AgentCore(model) + core.register_tools([build_spec(search_notes)]) + + result = asyncio.run(core.run("先搜索比赛笔记,然后汇报")) + + assert result.strategy.value == "plan" + event_types = [e.type.value for e in result.events] + assert "plan_created" in event_types + assert "tool_call" in event_types + assert "tool_result" in event_types + assert "比赛" in result.final_text + + +def test_direct_answer_without_tools() -> None: + model = ScriptedModel([ModelResponse(content="你好,我是内嵌助手。")]) + core = AgentCore(model) + result = asyncio.run(core.run("你好")) + assert result.strategy.value == "direct" + assert result.final_text.startswith("你好") + + +def test_llm_router_decision_flows_through_loop() -> None: + """开启 llm_router 后:分类来源/理由/档位进事件,plan 用分类建议的 strong 档。""" + + class ClassifierThenPlan(ScriptedModel): + async def achat(self, messages, tools=None, *, tier="standard"): + self.tiers.append(tier) + self.calls += 1 + # 第 1 次调用是路由分类(系统提示含"路由器") + if self.calls == 1: + return ModelResponse( + content='{"strategy": "plan", "tier": "strong",' + ' "reason": "先拆解再执行"}' + ) + # 第 2 次是规划轮 + if self.calls == 2: + return ModelResponse(content="1. 搜索比赛笔记\n2. 汇报结果") + # 之后走父类的脚本队列(工具调用 + 最终答复) + return self._responses[min(self.calls - 1, len(self._responses) - 1)] + + def __init__(self, responses) -> None: + super().__init__(responses) + self.tiers = [] + + model = ClassifierThenPlan( + [ + ModelResponse( + content="", + tool_calls=[ToolCallRequest(id="p1", name="search_notes", + arguments={"keyword": "比赛"})], + ), + ModelResponse(content="找到 1 条比赛笔记。"), + ] + ) + core = AgentCore(model, llm_router=True) + core.register_tools([build_spec(search_notes)]) + + result = asyncio.run(core.run("先搜索比赛笔记,然后汇报")) + + selected = next(e for e in result.events if e.type.value == "strategy_selected") + assert selected.data["source"] == "llm" + assert selected.data["strategy"] == "plan" + assert selected.data["tier"] == "strong" + # 分类用 standard(轻量快),规划轮按建议用 strong,工具循环回 standard + assert model.tiers[0] == "standard" + assert model.tiers[1] == "strong" + assert result.final_text.startswith("找到") diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_mcp_bridge.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_mcp_bridge.py new file mode 100644 index 0000000..a42da56 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_mcp_bridge.py @@ -0,0 +1,197 @@ +"""MCP Client 桥接层测试:内存直连 MCPServer,全程不起子进程、不触网。 + +未安装 mcp 可选依赖时整文件自动跳过(内核本体仍保持零依赖可测)。 +""" + +import ast +import json +import pathlib + +import pytest + +pytest.importorskip("mcp") # 没装 [mcp] extra 的环境(如最小 CI)跳过本文件 + +from mcp.server import MCPServer # noqa: E402 +from mcp.server.mcpserver.exceptions import ToolError # noqa: E402 +from pydantic import BaseModel # noqa: E402 + +from yai_core.channels import CollectChannel # noqa: E402 +from yai_core.integrations.mcp import ( # noqa: E402 + McpServerConfig, + McpToolBridge, + attach_mcp_tools, + config_from_env, + sanitize_schema, +) +from yai_core.policy import AllowlistPolicy # noqa: E402 +from yai_core.tools import ToolExecutor, ToolRegistry # noqa: E402 + + +class Book(BaseModel): + title: str + year: int + + +def build_server() -> MCPServer: + """构造内存 MCP Server:一个文本工具、一个结构化工具、一个必错工具。""" + server = MCPServer("TestShop") + + @server.tool() + def echo(text: str) -> str: + """Echo back the text.""" + return f"echo: {text}" + + @server.tool() + def make_book(title: str, year: int = 2026) -> Book: + """Make a structured book.""" + return Book(title=title, year=year) + + @server.tool() + def boom() -> str: + """Always fails.""" + raise ToolError("boom-message") + + return server + + +def test_connect_registers_mcp_tools() -> None: + import asyncio + + async def scenario() -> None: + bridge = McpToolBridge(McpServerConfig(alias="t", server=build_server())) + specs = await bridge.connect() + assert {s.name for s in specs} == {"echo", "make_book", "boom"} + assert all(s.source == "mcp" for s in specs) + # SDK 自动生成的 schema 带 title 私有键,必须被清洗掉 + echo = next(s for s in specs if s.name == "echo") + assert "title" not in echo.input_schema + assert echo.input_schema["required"] == ["text"] + registry = ToolRegistry() + registry.register_many(specs) + assert registry.has("echo") + await bridge.aclose() + + asyncio.run(scenario()) + + +def test_prefix_namespacing_keeps_remote_name() -> None: + """本地名加前缀防重名,但闭包内部仍调用远端原名。""" + import asyncio + + async def scenario() -> None: + cfg = McpServerConfig(alias="t", server=build_server(), prefix="demo__") + async with McpToolBridge(cfg) as bridge: + registry = ToolRegistry() + registry.register_many(bridge.specs) + assert registry.has("demo__echo") + spec = registry.get("demo__echo") + text = await spec.handler(text="hi") + assert text == "echo: hi" + + asyncio.run(scenario()) + + +def test_handler_text_and_structured_results() -> None: + import asyncio + + async def scenario() -> None: + async with McpToolBridge(McpServerConfig(alias="t", server=build_server())) as b: + specs = {s.name: s for s in b.specs} + assert await specs["echo"].handler(text="hi") == "echo: hi" + book_text = await specs["make_book"].handler(title="Dune", year=1965) + assert "Dune" in book_text and "1965" in book_text + + asyncio.run(scenario()) + + +def test_error_tool_raises_for_executor() -> None: + """MCP is_error 结果必须翻译成异常,由 Executor 统一走失败回灌。""" + import asyncio + + async def scenario() -> None: + async with McpToolBridge(McpServerConfig(alias="t", server=build_server())) as b: + boom = next(s for s in b.specs if s.name == "boom") + with pytest.raises(RuntimeError, match="boom-message"): + await boom.handler() + + asyncio.run(scenario()) + + +def test_executor_runs_mcp_tools_end_to_end() -> None: + """MCP 工具经过 ToolExecutor 与 Native 工具行为一致(含权限/事件/错误)。""" + import asyncio + + async def scenario() -> None: + registry = ToolRegistry() + bridge = await attach_mcp_tools( + registry, McpServerConfig(alias="t", server=build_server()) + ) + executor = ToolExecutor(registry, AllowlistPolicy(mode="allow_all"), CollectChannel()) + + events, ok, text = await executor.execute("echo", {"text": "hi"}) + assert ok is True + # Executor 对一切返回值统一 json.dumps,字符串结果即 JSON 字符串 + assert json.loads(text) == "echo: hi" + assert events[0].data["tool"] == "echo" + + events2, ok2, text2 = await executor.execute("boom", {}) + assert ok2 is False + assert "boom-message" in text2 + assert events2[-1].data["ok"] is False + + await bridge.aclose() + + asyncio.run(scenario()) + + +def test_config_requires_exactly_one_target() -> None: + import asyncio + + async def scenario() -> None: + with pytest.raises(ValueError, match="之一"): + await McpToolBridge(McpServerConfig(alias="t")).connect() + with pytest.raises(ValueError, match="之一"): + await McpToolBridge( + McpServerConfig(alias="t", url="http://x/mcp", server=build_server()) + ).connect() + + asyncio.run(scenario()) + + +def test_sanitize_schema() -> None: + raw = { + "title": "echoArguments", + "type": "object", + "properties": {"text": {"title": "Text", "type": "string"}}, + "required": ["text"], + } + clean = sanitize_schema(raw) + assert "title" not in clean and "title" not in clean["properties"]["text"] + assert clean["type"] == "object" + assert sanitize_schema({}) == {"type": "object", "properties": {}} + assert sanitize_schema(["a", 1]) == ["a", 1] + + +def test_config_from_env() -> None: + """部署期通过环境变量决定接哪个 MCP Server;未配置时返回 None(不报错)。""" + assert config_from_env(env={}) is None + + url_cfg = config_from_env(alias="deepwiki", env={"MCP_SERVER_URL": "https://x/mcp"}) + assert url_cfg is not None and url_cfg.url == "https://x/mcp" and url_cfg.alias == "deepwiki" + + cmd_cfg = config_from_env(env={"MCP_SERVER_COMMAND": "python server.py --stdio"}) + assert cmd_cfg is not None and cmd_cfg.command == "python" + assert cmd_cfg.args == ["server.py", "--stdio"] + + +def test_no_toplevel_mcp_import_keeps_kernel_dependency_free() -> None: + """红线:client.py 顶层不允许 import mcp,mcp 只能在函数体内懒加载。""" + client_path = pathlib.Path(__file__).parents[1] / "src" / "yai_core" / ( + "integrations/mcp/client.py" + ) + tree = ast.parse(client_path.read_text(encoding="utf-8")) + for node in tree.body: # 只检查模块顶层语句 + if isinstance(node, ast.Import): + assert all(not a.name.startswith("mcp") for a in node.names) + if isinstance(node, ast.ImportFrom): + assert node.module is None or not node.module.startswith("mcp") diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_memory_retention.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_memory_retention.py new file mode 100644 index 0000000..3a4b06e --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_memory_retention.py @@ -0,0 +1,314 @@ +"""历史保留策略测试:纯函数边界 + 两种 Store 参数化对等 + SQLite 专项。 + +全程离线:内存库 / tmp_path 文件库 / 注入假时钟,零网络、零 Key、零 sleep。 +""" + +from __future__ import annotations + +import asyncio +import warnings +from datetime import UTC, datetime, timedelta + +import pytest + +from yai_core.memory import InMemoryStore, SqliteStore, retention_from_env +from yai_core.memory.retention import count_cut, ttl_cut +from yai_core.types import ChatMessage + + +def run(coro): + """同步测试里跑一个协程的小工具(仓库既有风格,不引入 pytest-asyncio)。""" + return asyncio.run(coro) + + +class FakeClock: + """可手动推进的时钟:测试推进时间而不是 sleep 等真实窗口。""" + + def __init__(self, start: datetime) -> None: + self.now = start + + def __call__(self) -> datetime: + return self.now + + def advance(self, **kwargs) -> None: + self.now += timedelta(**kwargs) + + +def append_roles(store, roles: list[str]) -> None: + """按简写角色序列追加消息;a_tc=带 tool_calls 的 assistant,tool=其工具结果。""" + for idx, role in enumerate(roles): + if role == "a_tc": + message = ChatMessage( + role="assistant", + content="", + tool_calls=[{"id": f"c{idx}", "name": "t", "arguments": {}}], + ) + elif role == "tool": + message = ChatMessage( + role="tool", content="结果", tool_call_id=f"c{idx - 1}" + ) + else: + message = ChatMessage( + role={"u": "user", "a": "assistant"}[role], + content=f"{role}{idx}", + ) + run(store.append_history(message)) + + +def assert_well_formed(messages: list[ChatMessage]) -> None: + """工具调用对完整性:无孤儿 tool、无被截断的 tool_calls 助手消息。""" + for i, message in enumerate(messages): + if message.role == "tool": + prev = messages[i - 1] + assert prev.role == "assistant" and prev.tool_calls, f"孤儿 tool 消息 @ {i}" + if message.role == "assistant" and message.tool_calls: + assert i + len(message.tool_calls) < len(messages), f"截断的 tool_calls @ {i}" + for _j in range(len(message.tool_calls)): + assert messages[i + 1 + _j].role == "tool" + + +def install_deleted_counter(store) -> dict[str, int]: + """累计 append 触发的内部裁剪删除条数(两种实现挂钩点不同)。""" + total = {"n": 0} + orig_prune = store.prune + + def counting_prune() -> int: + result = orig_prune() + total["n"] += result + return result + + store.prune = counting_prune + orig_locked = getattr(store, "_prune_locked", None) + if orig_locked is not None: + + def counting_locked() -> int: + result = orig_locked() + total["n"] += result + return result + + store._prune_locked = counting_locked + return total + + +@pytest.fixture(params=["memory", "sqlite"]) +def make_store(request, tmp_path): + """同一组对等用例分别构造 InMemoryStore 与 SqliteStore(每个用例独立 db 文件)。""" + opened: list = [] + + def _make(**kwargs): + if request.param == "memory": + store = InMemoryStore(**kwargs) + else: + store = SqliteStore(tmp_path / f"ret_{len(opened)}.db", **kwargs) + opened.append(store) + return store + + yield _make + + for store in opened: + close = getattr(store, "close", None) + if close is not None: + close() + + +# ---------- A 组:纯函数边界(不依赖任何 Store) ---------- + +def test_count_cut_round_alignment() -> None: + three_rounds = ["user", "assistant"] * 3 + assert count_cut(three_rounds, 2) == 4 + assert count_cut(three_rounds, 3) == 4 # 候选落在 assistant 上,后移到下一轮首 + assert count_cut(three_rounds, 6) is None # 恰好不裁 + assert count_cut(three_rounds, None) is None + # 单轮(即使自身超限)不裁,至少保留最后一轮 + assert count_cut(["user", "assistant", "assistant", "assistant", "assistant"], 2) is None + # 历史开头的非 user 消息归"第 0 轮",随第一轮一起删 + assert count_cut(["assistant", "user", "assistant", "user", "assistant"], 2) == 3 + assert count_cut(["user", "assistant"], 2) is None + assert count_cut([], 5) is None + + +def test_ttl_cut_round_alignment() -> None: + roles = ["user", "assistant"] * 3 + assert ttl_cut(roles, [True] * 4 + [False] * 2) == 4 + assert ttl_cut(roles, [True] * 2 + [False] * 4) == 2 # 恰好整轮过期 + assert ttl_cut(roles, [True] + [False] * 5) is None # 只过期到首轮中间,不裁 + assert ttl_cut(roles, [False] * 6) is None + assert ttl_cut(roles, [True] * 6) == 6 # 全部过期:整库清空(空历史合法) + # 前导非 user 消息(第 0 轮)全部过期时随全库清空 + assert ttl_cut(["assistant", "user", "assistant"], [True] * 3) == 3 + + +def test_retention_from_env_states() -> None: + assert retention_from_env({}) == (None, None) + assert retention_from_env({"YAI_HISTORY_MAX_MESSAGES": "200"}) == (200, None) + assert retention_from_env({"YAI_HISTORY_TTL_SECONDS": "604800"}) == (None, 604800) + # 空白等同未设置,静默 + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert retention_from_env({"YAI_HISTORY_MAX_MESSAGES": " "}) == (None, None) + assert caught == [] + # 0 / 负数 / 非整数:回退 None 且每项各一条 warning + for bad in ("0", "-3", "abc"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert retention_from_env( + {"YAI_HISTORY_MAX_MESSAGES": bad, "YAI_HISTORY_TTL_SECONDS": bad} + ) == (None, None) + assert len(caught) == 2 + + +# ---------- B 组:两种 Store 参数化对等 ---------- + +def test_default_keeps_everything(make_store) -> None: + store = make_store() + append_roles(store, ["u", "a"] * 25) + assert len(store.history()) == 50 + + +def test_count_trim_keeps_last_round_and_reports_deleted(make_store) -> None: + store = make_store(max_messages=2) + deleted = install_deleted_counter(store) + append_roles(store, ["u", "a", "u", "a", "u", "a"]) + assert [m.role for m in store.history()] == ["user", "assistant"] + assert deleted["n"] == 4 # 增量裁剪累计删掉 4 条 + assert_well_formed(store.history()) + + +def test_exact_boundary_no_delete(make_store) -> None: + store = make_store(max_messages=6) + append_roles(store, ["u", "a"] * 3) + assert len(store.history()) == 6 + assert store.prune() == 0 + + +def test_single_round_over_limit_not_trimmed(make_store) -> None: + store = make_store(max_messages=2) + append_roles(store, ["u", "a", "a", "a", "a"]) + assert len(store.history()) == 5 + assert store.prune() == 0 + + +def test_tool_pairs_never_split(make_store) -> None: + # 朴素按条硬删会在第 3/4 条切开工具对;轮对齐后必须完整 + store = make_store(max_messages=3) + append_roles(store, ["u", "a_tc", "tool", "u", "a"]) + assert_well_formed(store.history()) + + store = make_store(max_messages=4) + append_roles(store, ["u", "a_tc", "tool", "u", "a_tc", "tool", "u", "a"]) + assert_well_formed(store.history()) + assert [m.role for m in store.history()] == ["user", "assistant"] + + +def test_ttl_drops_expired_round_keeps_recent(make_store) -> None: + # 基准取真实 UTC 当前时刻并抹掉微秒:SQLite 落库为秒级 UTC 文本,必须同基准 + clock = FakeClock(datetime.now(UTC).replace(microsecond=0)) + store = make_store(ttl_seconds=60, clock=clock) + append_roles(store, ["u", "a"]) + clock.advance(seconds=120) + append_roles(store, ["u", "a"]) + assert [m.role for m in store.history()] == ["user", "assistant"] + assert_well_formed(store.history()) + + +def test_ttl_equal_cutoff_is_retained(make_store) -> None: + clock = FakeClock(datetime.now(UTC).replace(microsecond=0)) + store = make_store(ttl_seconds=60, clock=clock) + append_roles(store, ["u", "a"]) + clock.advance(seconds=60) # cutoff 恰好等于消息时间戳:严格 < 语义下保留 + assert store.prune() == 0 + assert len(store.history()) == 2 + + +def test_ttl_all_expired_clears_store(make_store) -> None: + clock = FakeClock(datetime.now(UTC).replace(microsecond=0)) + store = make_store(ttl_seconds=60, clock=clock) + append_roles(store, ["u", "a"]) + clock.advance(seconds=61) # 唯一一轮也全部过期 -> 整库清空 + assert store.prune() == 2 + assert store.history() == [] + + +def test_prune_idempotent(make_store) -> None: + store = make_store(max_messages=2) + append_roles(store, ["u", "a"] * 3) + first = store.prune() + assert first == 0 # append 时已裁完,显式再裁无东西可删 + assert store.prune() == 0 + + +def test_clear_resets_history_and_timestamps(make_store) -> None: + store = make_store(max_messages=2) + append_roles(store, ["u", "a"]) + run(store.clear()) + assert store.history() == [] + # 清空后重新写入,裁剪逻辑仍正常(时间戳列表没有残留) + append_roles(store, ["u", "a", "u", "a", "u", "a"]) + assert [m.role for m in store.history()] == ["user", "assistant"] + + +# ---------- C 组:SqliteStore 专项 ---------- + +def test_scope_isolation_under_retention(tmp_path) -> None: + db = tmp_path / "scope.db" + a = SqliteStore(db, scope="a", max_messages=2) + b = SqliteStore(db, scope="b", max_messages=2) + try: + append_roles(a, ["u", "a", "u", "a", "u", "a"]) + append_roles(b, ["u", "a"]) + assert [m.role for m in a.history()] == ["user", "assistant"] + assert len(b.history()) == 2 # A 的裁剪不动 B + finally: + a.close() + b.close() + + +def test_startup_prune_on_construction(tmp_path) -> None: + db = tmp_path / "startup.db" + old = SqliteStore(db) # 旧实例:未配置保留策略 + append_roles(old, ["u", "a"]) + old.close() + # 新实例:把时钟拨到 30 天后并配置 1 小时 TTL,构造期 prune 应清空过期历史 + future = FakeClock(datetime.now(UTC).replace(microsecond=0) + timedelta(days=30)) + fresh = SqliteStore(db, ttl_seconds=3600, clock=future) + try: + assert fresh.history() == [] + finally: + fresh.close() + + +def test_close_then_prune_raises(tmp_path) -> None: + store = SqliteStore(tmp_path / "closed.db", max_messages=2) + store.close() + with pytest.raises(RuntimeError, match="已关闭"): + store.prune() + + +def test_from_env_passes_retention(tmp_path) -> None: + store = SqliteStore.from_env( + env={ + "YAI_DB_PATH": str(tmp_path / "env.db"), + "YAI_HISTORY_MAX_MESSAGES": "2", + "YAI_HISTORY_TTL_SECONDS": "60", + } + ) + assert store is not None + try: + assert store._max_messages == 2 + assert store._ttl_seconds == 60 + finally: + store.close() + + +def test_from_env_explicit_retention_tuple(tmp_path) -> None: + # 无路径仍返回 None(即使显式传了 retention) + assert SqliteStore.from_env(env={}, retention=(4, None)) is None + store = SqliteStore.from_env( + env={"YAI_DB_PATH": str(tmp_path / "env2.db")}, retention=(4, None) + ) + assert store is not None + try: + assert store._max_messages == 4 + assert store._ttl_seconds is None + finally: + store.close() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openai_compat.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openai_compat.py new file mode 100644 index 0000000..a6f0f55 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openai_compat.py @@ -0,0 +1,131 @@ +"""OpenAICompatProvider 协议层测试:用假 openai 模块离线验证 DeepSeek 同款链路。 + +不发任何网络请求,验证四件事: +1. 第一次请求携带 OpenAI function-calling 的 tools 描述; +2. provider 能把 SDK 返回的 tool_calls 解析成 ToolCallRequest; +3. 宿主函数被真正执行; +4. 第二次请求把结果以 role="tool" + tool_call_id 回灌(DeepSeek/OpenAI 硬性协议)。 +""" + +from __future__ import annotations + +import asyncio +import sys +import types +from typing import Any + +from yai_core import AgentCore, OpenAICompatProvider, build_spec + +# ---------- 假 openai SDK ---------- + +class _FakeFunction: + def __init__(self, name: str, arguments: str) -> None: + self.name = name + self.arguments = arguments + + +class _FakeToolCall: + def __init__(self, call_id: str, name: str, arguments: str) -> None: + self.id = call_id + self.type = "function" + self.function = _FakeFunction(name, arguments) + + +class _FakeMessage: + def __init__(self, content: str | None, tool_calls: list[_FakeToolCall] | None = None) -> None: + self.content = content + self.tool_calls = tool_calls + + +class _FakeChoice: + def __init__(self, message: _FakeMessage) -> None: + self.message = message + + +class _FakeCompletions: + def __init__(self, script: list[_FakeMessage], captured: list[dict[str, Any]]) -> None: + self._script = script + self._captured = captured + self.calls = 0 + + async def create(self, **kwargs: Any) -> Any: + self._captured.append(kwargs) + message = self._script[min(self.calls, len(self._script) - 1)] + self.calls += 1 + return types.SimpleNamespace(choices=[_FakeChoice(message)]) + + +class _FakeAsyncOpenAI: + script: list[_FakeMessage] = [] + captured: list[dict[str, Any]] = [] + + def __init__(self, **kwargs: Any) -> None: + self.chat = types.SimpleNamespace( + completions=_FakeCompletions(_FakeAsyncOpenAI.script, _FakeAsyncOpenAI.captured) + ) + + +def _install_fake_openai( + monkeypatch, script: list[_FakeMessage], captured: list[dict[str, Any]] +) -> None: + _FakeAsyncOpenAI.script = script + _FakeAsyncOpenAI.captured = captured + fake_module = types.ModuleType("openai") + fake_module.AsyncOpenAI = _FakeAsyncOpenAI # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "openai", fake_module) + + +# ---------- 宿主能力(普通函数) ---------- + +def count_notes() -> int: + """统计笔记总数。""" + return 3 + + +def test_tool_call_roundtrip_and_tool_message_feedback(monkeypatch) -> None: + script = [ + # 第一轮:模型决定调 count_notes + _FakeMessage(None, [_FakeToolCall("call_1", "count_notes", "{}")]), + # 第二轮:模型拿到工具结果后收尾 + _FakeMessage("你一共有 3 条笔记。", None), + ] + captured: list[dict[str, Any]] = [] + _install_fake_openai(monkeypatch, script, captured) + + provider = OpenAICompatProvider(api_key="test-key", base_url="http://localhost:1234") + core = AgentCore(provider) + core.register_tools([build_spec(count_notes)]) + + result = asyncio.run(core.run("统计一下笔记数量")) + + event_types = [e.type.value for e in result.events] + assert result.strategy.value == "react" + assert "tool_call" in event_types + assert "tool_result" in event_types + assert "3 条" in result.final_text + + # 第一次请求必须带 tools 清单,且形状是 OpenAI function-calling 规范 + first_request = captured[0] + assert first_request["tools"][0]["type"] == "function" + assert first_request["tools"][0]["function"]["name"] == "count_notes" + + # 第二次请求必须把工具结果以 role=tool 回灌,id 与模型下发的一致 + second_messages = captured[1]["messages"] + tool_messages = [m for m in second_messages if m["role"] == "tool"] + assert len(tool_messages) == 1 + assert tool_messages[0]["tool_call_id"] == "call_1" + assert tool_messages[0]["content"] == "3" + + +def test_plain_answer_sends_no_tools(monkeypatch) -> None: + captured: list[dict[str, Any]] = [] + _install_fake_openai(monkeypatch, [_FakeMessage("你好,我是内嵌助手。", None)], captured) + + provider = OpenAICompatProvider(api_key="test-key", base_url="http://localhost:1234") + core = AgentCore(provider) + result = asyncio.run(core.run("你好")) + + assert result.strategy.value == "direct" + assert result.final_text.startswith("你好") + # direct 策略不应下发 tools + assert "tools" not in captured[0] diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openapi_bridge.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openapi_bridge.py new file mode 100644 index 0000000..34fa4a4 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_openapi_bridge.py @@ -0,0 +1,357 @@ +"""OpenAPI 发现桥测试:spec 解析、$ref、发现命名、MockTransport 调用、鉴权、端到端。 + +离线原则:所有 HTTP 走 httpx.MockTransport;不触网、不依赖真实 Key。 +""" + +import ast +import json +import pathlib + +import pytest + +pytest.importorskip("httpx") # 没装 [dev]/[openapi] extra 的环境整体跳过 +import httpx # noqa: E402 + +from yai_core import AgentCore, ModelResponse, ToolCallRequest # noqa: E402 +from yai_core.integrations.openapi import ( # noqa: E402 + OpenApiSpecConfig, + OpenApiToolBridge, + attach_openapi_tools, + config_from_env, + load_spec, + plan_operations, + resolve_refs, +) + +FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "petstore.min.json" + + +@pytest.fixture +def spec_dict() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +@pytest.fixture +def resolved(spec_dict): + spec, notes = resolve_refs(spec_dict) + return spec, notes + + +def _handler_factory(captured: list): + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + path = request.url.path + if request.method == "GET" and path == "/api/v3/pets": + return httpx.Response(200, json=[{"id": 1, "name": "旺财"}]) + if request.method == "POST" and path == "/api/v3/pets": + return httpx.Response(201, json={"created": True}) + if request.method == "GET" and path.startswith("/api/v3/pets/"): + if path.endswith("/999"): + return httpx.Response(404, text="not found") + return httpx.Response(200, json={"id": 7, "name": "小黑"}) + if request.method == "DELETE": + return httpx.Response(204) + if request.method == "POST" and path.startswith("/api/v3/echo/"): + return httpx.Response(200, json=json.loads(request.content.decode())) + return httpx.Response(404, text="unmatched") + + return handler + + +def _transport(captured): + return httpx.MockTransport(_handler_factory(captured)) + + +def _bridge(spec_dict, captured): + return OpenApiToolBridge( + OpenApiSpecConfig(spec=spec_dict, alias="rest"), + transport=_transport(captured), + ) + + +# ---------- 1. $ref 展开与循环保留 ---------- + +def test_resolve_internal_refs_and_keep_cycles(resolved): + spec, notes = resolved + pet = spec["components"]["schemas"]["Pet"] + assert pet["properties"]["name"]["type"] == "string" + category = pet["properties"]["category"] # 内部 $ref 已内联 + assert category["properties"]["name"]["type"] == "string" + recursive = category["properties"]["children"]["items"] + assert recursive.get("$ref") == "#/components/schemas/Category" # 循环保留原样 + assert any("循环" in note for note in notes) + + +# ---------- 2. 发现与命名 ---------- + +def test_operation_naming(resolved): + spec, _ = resolved + plans, _ = plan_operations(spec, OpenApiSpecConfig(spec=spec)) + names = [p.name for p in plans] + assert names == ["list_pets", "create_pet", "get_pets_pet_id", "delete_pet", "echo_body"] + methods = {p.name: p.method for p in plans} + assert methods["get_pets_pet_id"] == "GET" + + +# ---------- 3. 入参合并 ---------- + +def test_parameters_and_body_merge(resolved): + spec, _ = resolved + plans, _ = plan_operations(spec, OpenApiSpecConfig(spec=spec)) + by_name = {p.name: p for p in plans} + + list_plan = by_name["list_pets"] + assert set(list_plan.query_params) == {"limit", "tags", "verbose"} + assert "X-Trace" not in list_plan.input_schema["properties"] # header 参数不暴露 + assert "limit" not in list_plan.input_schema.get("required", []) + + create_plan = by_name["create_pet"] + assert set(create_plan.body_fields) == {"id", "name", "category"} + assert create_plan.body_wrapped is False + assert set(create_plan.input_schema["required"]) == {"id", "name"} + # $ref 展开后的嵌套 schema 仍在 + assert create_plan.input_schema["properties"]["category"]["type"] == "object" + + get_one = by_name["get_pets_pet_id"] + assert get_one.path_params == ("petId",) + assert "petId" in get_one.input_schema["required"] # path 参数强制必填 + + echo = by_name["echo_body"] + assert echo.body_wrapped is True and echo.body_fields == ("body",) + assert "body" in echo.input_schema["required"] + + +# ---------- 4. read_only / 截断 / 前缀过滤 ---------- + +def test_read_only_filters_writes(resolved): + spec, _ = resolved + plans, _ = plan_operations(spec, OpenApiSpecConfig(spec=spec, read_only=True)) + assert all(p.method in {"GET", "HEAD"} for p in plans) + assert "create_pet" not in [p.name for p in plans] + + +def test_max_operations_truncation(resolved): + spec, _ = resolved + plans, notes = plan_operations(spec, OpenApiSpecConfig(spec=spec, max_operations=2)) + assert len(plans) == 2 + assert any("截断" in note for note in notes) + + +def test_include_exclude_paths(resolved): + spec, _ = resolved + inc, _ = plan_operations(spec, OpenApiSpecConfig(spec=spec, include_paths=("/pets",))) + assert all(p.path_template.startswith("/pets") for p in inc) + exc, _ = plan_operations(spec, OpenApiSpecConfig(spec=spec, exclude_paths=("/pets",))) + assert all(not p.path_template.startswith("/pets") for p in exc) + + +# ---------- 5. MockTransport 端到端调用 ---------- + +def _run(coro): + import asyncio + + return asyncio.run(coro) + + +def test_get_query_bool_list_and_bearer(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "test-token") + captured: list = [] + bridge = _bridge(spec_dict, captured) + specs = _run(bridge.connect()) + by_name = {s.name: s for s in specs} + assert all(s.source == "openapi" for s in specs) + + result = _run(by_name["list_pets"].handler(limit=2, tags=["cat", "dog"], verbose=True)) + req = captured[-1] + assert req.method == "GET" + assert req.url.path == "/api/v3/pets" + params = req.url.params + assert params.get("limit") == "2" + assert params.get_list("tags") == ["cat", "dog"] + assert params.get("verbose") == "true" + assert req.headers["Authorization"] == "Bearer test-token" + assert result == [{"id": 1, "name": "旺财"}] + _run(bridge.aclose()) + + +def test_post_flattened_object_body(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "test-token") + captured: list = [] + bridge = _bridge(spec_dict, captured) + specs = _run(bridge.connect()) + _run({s.name: s for s in specs}["create_pet"].handler( + id=42, name="布偶", category={"name": "猫"} + )) + req = captured[-1] + assert req.method == "POST" and req.url.path == "/api/v3/pets" + assert json.loads(req.content) == {"id": 42, "name": "布偶", "category": {"name": "猫"}} + assert req.headers["Content-Type"] == "application/json" + _run(bridge.aclose()) + + +def test_path_fill_204_404_and_wrapped_body(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "test-token") + captured: list = [] + bridge = _bridge(spec_dict, captured) + specs = _run(bridge.connect()) + by_name = {s.name: s for s in specs} + + _run(by_name["get_pets_pet_id"].handler(petId=7)) + assert captured[-1].url.path == "/api/v3/pets/7" + + assert _run(by_name["delete_pet"].handler(petId=7)) == "" # 204 → 空串 + + with pytest.raises(RuntimeError, match="HTTP 404"): + _run(by_name["get_pets_pet_id"].handler(petId=999)) + + _run(by_name["echo_body"].handler(kind="x", body=["a", "b"])) # 非 object body 包裹 + assert json.loads(captured[-1].content) == ["a", "b"] + _run(bridge.aclose()) + + +def test_apikey_header_auth(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "test-token") + key_spec = json.loads(json.dumps(spec_dict)) + key_spec["security"] = [{"keyHeader": []}] + captured: list = [] + bridge = OpenApiToolBridge( + OpenApiSpecConfig(spec=key_spec), transport=_transport(captured) + ) + specs = _run(bridge.connect()) + _run({s.name: s for s in specs}["list_pets"].handler(limit=1)) + assert captured[-1].headers["X-Api-Key"] == "test-token" + _run(bridge.aclose()) + + +def test_missing_token_raises_at_call_time(spec_dict, monkeypatch): + monkeypatch.delenv("OPENAPI_AUTH_TOKEN", raising=False) + captured: list = [] + bridge = _bridge(spec_dict, captured) + specs = _run(bridge.connect()) # 注册期不报错 + with pytest.raises(RuntimeError, match="OPENAPI_AUTH_TOKEN"): + _run({s.name: s for s in specs}["list_pets"].handler()) + _run(bridge.aclose()) + + +def test_missing_token_message_names_env_var(spec_dict, monkeypatch): + """回归(40-review P3-1):config_from_env 路径下缺令牌,报错要点名环境变量而非 None。""" + monkeypatch.delenv("OPENAPI_AUTH_TOKEN", raising=False) + env_cfg = config_from_env(env={"OPENAPI_SPEC_PATH": "spec.json"}) + cfg = OpenApiSpecConfig(spec=spec_dict, auth_token_env=env_cfg.auth_token_env) + bridge = OpenApiToolBridge(cfg, transport=_transport([])) + specs = _run(bridge.connect()) + with pytest.raises(RuntimeError, match="OPENAPI_AUTH_TOKEN"): + _run({s.name: s for s in specs}["list_pets"].handler()) + _run(bridge.aclose()) + + +def test_result_text_truncation(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "t") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="x" * 20000, headers={"content-type": "text/plain"}) + + bridge = OpenApiToolBridge( + OpenApiSpecConfig(spec=spec_dict, max_result_chars=100), + transport=httpx.MockTransport(handler), + ) + specs = _run(bridge.connect()) + out = _run({s.name: s for s in specs}["list_pets"].handler()) + assert out.endswith("…[truncated]") and len(out) == len("…[truncated]") + 100 + _run(bridge.aclose()) + + +# ---------- 6. config_from_env / load_spec 校验 / YAML ---------- + +def test_config_from_env_states(): + assert config_from_env(env={}) is None + cfg = config_from_env( + env={"OPENAPI_SPEC_URL": "http://x/openapi.json", "OPENAPI_READ_ONLY": "yes"} + ) + assert cfg.url == "http://x/openapi.json" and cfg.read_only is True + cfg_path = config_from_env(env={"OPENAPI_SPEC_PATH": "spec.yaml"}) + assert str(cfg_path.path) == "spec.yaml" + + +def test_reject_swagger2_and_non_openapi3(): + with pytest.raises(ValueError, match="Swagger 2.0"): + load_spec(OpenApiSpecConfig(spec={"swagger": "2.0", "paths": {}})) + with pytest.raises(ValueError, match="3.x"): + load_spec(OpenApiSpecConfig(spec={"openapi": "2.5"})) + + +def test_yaml_spec_loads(tmp_path): + pytest.importorskip("yaml") + p = tmp_path / "spec.yaml" + p.write_text( + "openapi: 3.0.3\ninfo:\n title: T\n version: '1.0'\n" + "paths:\n /ping:\n get:\n operationId: ping\n" + " summary: ping\n responses:\n '200':\n description: ok\n", + encoding="utf-8", + ) + spec = load_spec(OpenApiSpecConfig(path=p)) + assert "/ping" in spec["paths"] + + +def test_missing_httpx_error_message(monkeypatch): + """缺 httpx 时 connect() 的报错必须提示 extra 名(模拟 builtins 导入失败代价高, + 这里做字符串契约校验:错误文案在源码中固定)。""" + client_path = pathlib.Path(__file__).parents[1] / "src" / "yai_core" / ( + "integrations/openapi/client.py" + ) + text = client_path.read_text(encoding="utf-8") + assert "uv sync --extra openapi" in text + + +# ---------- 7. 注册进 AgentCore 的端到端 react ---------- + +class _ScriptedToolModel: + def __init__(self): + self.calls = 0 + + async def achat(self, messages, tools=None, *, tier="standard"): + self.calls += 1 + if self.calls == 1: + return ModelResponse( + content="", + tool_calls=[ToolCallRequest(id="c1", name="list_pets", arguments={"limit": 1})], + ) + return ModelResponse(content="查到了宠物") + + +def test_agent_core_end_to_end_with_openapi_tool(spec_dict, monkeypatch): + monkeypatch.setenv("OPENAPI_AUTH_TOKEN", "test-token") + captured: list = [] + core = AgentCore(_ScriptedToolModel()) + bridge = _run( + attach_openapi_tools(core.registry, OpenApiSpecConfig(spec=spec_dict), + transport=_transport(captured)) + ) + assert "list_pets" in core.registry.describe() + assert core.registry._tools["list_pets"].source == "openapi" + + import asyncio + + result = asyncio.run(core.run("列一下宠物")) + assert result.final_text == "查到了宠物" + event_types = [e.type.value for e in result.events] + assert "tool_call" in event_types and "tool_result" in event_types + assert captured[-1].url.path == "/api/v3/pets" + _run(bridge.aclose()) + + +# ---------- 8. 红线:openapi 模块顶层不得 import httpx / yaml ---------- + +@pytest.mark.parametrize( + "relative", + ["integrations/openapi/spec.py", "integrations/openapi/discovery.py", + "integrations/openapi/client.py"], +) +def test_no_toplevel_third_party_imports(relative): + path = pathlib.Path(__file__).parents[1] / "src" / "yai_core" / relative + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: # 只检查模块顶层语句 + if isinstance(node, ast.Import): + assert all(not a.name.startswith(("httpx", "yaml")) for a in node.names) + if isinstance(node, ast.ImportFrom): + assert node.module is None or not node.module.startswith(("httpx", "yaml")) diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_rate_limit.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_rate_limit.py new file mode 100644 index 0000000..000cbb0 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_rate_limit.py @@ -0,0 +1,214 @@ +"""限流 Battery 测试:滑动窗口、429 + Retry-After、GET 放行、假时钟全离线。 + +统一范式:TestClient + 计数版 ScriptedModel + 假时钟(now 列表),禁止 time.sleep。 +""" + +from __future__ import annotations + +import warnings + +from fastapi.testclient import TestClient + +from yai_core import AgentCore, ModelResponse, build_spec +from yai_core.batteries.fastapi_server import RateLimitConfig, create_app +from yai_core.batteries.fastapi_server.ratelimit import SlidingWindowLimiter, client_ip + + +class CountingModel: + """记录 achat 调用次数:断言被限请求一分模型钱都不花。""" + + def __init__(self) -> None: + self.calls = 0 + + async def achat(self, messages, tools=None, *, tier="standard"): + self.calls += 1 + return ModelResponse(content="好的") + + +def list_notes() -> list: + """列出全部笔记。""" + return [{"id": 1, "title": "Core 骨架"}] + + +class FakeClock: + """可推进的假时钟(秒);测试推进时间而不是 sleep 等真实窗口。""" + + def __init__(self) -> None: + self.now = [0.0] + + def __call__(self) -> float: + return self.now[0] + + def advance(self, seconds: float) -> None: + self.now[0] += seconds + + +def make_client(model: CountingModel, config: RateLimitConfig | None) -> TestClient: + core = AgentCore(model) + core.register_tools([build_spec(list_notes)]) + return TestClient(create_app(core, rate_limit=config)) + + +def limited_config(clock: FakeClock, per_minute: int = 2) -> RateLimitConfig: + """小配额便于测试:per_minute 次 200,之后 429。""" + return RateLimitConfig( + enabled=True, per_minute=per_minute, window_seconds=60, time_func=clock + ) + + +# ---------- 1/2. 超限 429 + Retry-After;推进假时钟后恢复 ---------- + +def test_over_limit_returns_429_then_recovers(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + clock = FakeClock() + model = CountingModel() + client = make_client(model, limited_config(clock, per_minute=2)) + + assert client.post("/v1/agent/run", json={"task": "1"}).status_code == 200 + assert client.post("/v1/agent/run", json={"task": "2"}).status_code == 200 + + limited = client.post("/v1/agent/run", json={"task": "3"}) + assert limited.status_code == 429 + body = limited.json() + assert body["error"] == "rate_limited" + assert isinstance(body["retry_after"], int) and body["retry_after"] >= 1 + assert limited.headers["Retry-After"] == str(body["retry_after"]) # 同一计算源 + + clock.advance(seconds=61) # 越过窗口:滑动窗口语义下恢复 + assert client.post("/v1/agent/run", json={"task": "4"}).status_code == 200 + + +# ---------- 3. 429 状态下三个 GET 端点仍 200(评审硬门槛) ---------- + +def test_get_endpoints_never_limited(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + monkeypatch.setenv("YAI_PROJECT_SLUG", "yai-test") + clock = FakeClock() + client = make_client(CountingModel(), limited_config(clock, per_minute=1)) + + assert client.post("/v1/agent/run", json={"task": "1"}).status_code == 200 + assert client.post("/v1/agent/run", json={"task": "2"}).status_code == 429 + + assert client.get("/health").status_code == 200 + assert client.get("/.well-known/xagent-verification.json").status_code == 200 + assert client.get("/v1/tools").status_code == 200 + + +# ---------- 4. enabled=False 全放行 ---------- + +def test_disabled_config_allows_everything(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + clock = FakeClock() + config = RateLimitConfig( + enabled=False, per_minute=1, window_seconds=60, time_func=clock + ) + client = make_client(CountingModel(), config) + for _i in range(5): + assert client.post("/v1/agent/run", json={"task": "x"}).status_code == 200 + + +# ---------- 5. 每 IP 隔离 ---------- + +def test_per_ip_isolation(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + clock = FakeClock() + client = make_client(CountingModel(), limited_config(clock, per_minute=1)) + + ok_a = client.post( + "/v1/agent/run", json={"task": "a"}, headers={"X-Forwarded-For": "1.1.1.1"} + ) + ok_b = client.post( + "/v1/agent/run", json={"task": "b"}, headers={"X-Forwarded-For": "2.2.2.2"} + ) + limited_a = client.post( + "/v1/agent/run", json={"task": "a2"}, headers={"X-Forwarded-For": "1.1.1.1"} + ) + assert ok_a.status_code == 200 + assert ok_b.status_code == 200 # 另一个 IP 有独立配额 + assert limited_a.status_code == 429 # 同一 IP 超限 + + +# ---------- 6. client_ip() 六种输入 ---------- + +def test_client_ip_inputs() -> None: + assert client_ip(None, "10.0.0.1") == "10.0.0.1" # 无 XFF:回退对端 + assert client_ip("1.2.3.4", "10.0.0.1") == "1.2.3.4" # 单级 + assert client_ip("1.2.3.4, 5.6.7.8", "10.0.0.1") == "1.2.3.4" # 多级取最左 + assert client_ip(" , 5.6.7.8", "10.0.0.1") == "5.6.7.8" # 含空段:跳过取非空 + assert client_ip(" , ", "10.0.0.1") == "10.0.0.1" # 全空:回退对端 + assert client_ip(None, None) == "unknown" # 双缺省 + + +# ---------- 7. 被限请求不触达模型 ---------- + +def test_limited_request_never_reaches_model(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + clock = FakeClock() + model = CountingModel() + client = make_client(model, limited_config(clock, per_minute=2)) + + statuses = [ + client.post("/v1/agent/run", json={"task": str(i)}).status_code + for i in range(4) + ] + assert statuses == [200, 200, 429, 429] + assert model.calls == 2 # 4 次请求只调用 2 次模型 + + +# ---------- 8. env 三态与非法值 ---------- + +def test_from_env_states(monkeypatch) -> None: + monkeypatch.delenv("YAI_RATE_LIMIT_ENABLED", raising=False) + monkeypatch.delenv("YAI_RATE_LIMIT_PER_MINUTE", raising=False) + monkeypatch.delenv("YAI_RATE_LIMIT_WINDOW_SECONDS", raising=False) + config = RateLimitConfig.from_env() + assert (config.enabled, config.per_minute, config.window_seconds) == (True, 30, 60) + + config = RateLimitConfig.from_env( + env={ + "YAI_RATE_LIMIT_ENABLED": "0", + "YAI_RATE_LIMIT_PER_MINUTE": "10", + "YAI_RATE_LIMIT_WINDOW_SECONDS": "30", + } + ) + assert (config.enabled, config.per_minute, config.window_seconds) == (False, 10, 30) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + config = RateLimitConfig.from_env( + env={"YAI_RATE_LIMIT_PER_MINUTE": "-3", "YAI_RATE_LIMIT_WINDOW_SECONDS": "x"} + ) + assert (config.enabled, config.per_minute, config.window_seconds) == (True, 30, 60) + assert len(caught) == 2 + + +# ---------- 9. 窗口边界:正好等于窗口的请求视为过期 ---------- + +def test_window_boundary_inclusive_expiry(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + clock = FakeClock() + limiter = SlidingWindowLimiter(limited_config(clock, per_minute=1)) + + allowed, _ = limiter.check("1.1.1.1") + assert allowed is True + clock.advance(seconds=60) # 恰好等于窗口:<= 清除语义下视为过期 + allowed, retry_after = limiter.check("1.1.1.1") + assert allowed is True + + # 超限瞬间 retry_after 至少为 1 + clock.now[0] = 60.5 + limiter.check("2.2.2.2") + allowed, retry_after = limiter.check("2.2.2.2") + assert allowed is False and retry_after >= 1 + + +# ---------- 10. 默认开启不破坏既有零配置调用 ---------- + +def test_default_create_app_still_works(monkeypatch) -> None: + monkeypatch.setenv("YAI_GIT_COMMIT", "0" * 40) + monkeypatch.delenv("YAI_RATE_LIMIT_ENABLED", raising=False) + core = AgentCore(CountingModel()) + core.register_tools([build_spec(list_notes)]) + client = TestClient(create_app(core)) # 与 test_battery_api.py 同款零配置调用 + assert client.get("/health").status_code == 200 + assert client.post("/v1/agent/run", json={"task": "你好"}).status_code == 200 diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_router.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_router.py new file mode 100644 index 0000000..c3ff4ea --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_router.py @@ -0,0 +1,168 @@ +import asyncio + +import pytest + +from yai_core import AdaptiveRouter, ModelResponse, Strategy, ToolRegistry, build_spec + + +def _registry_with_tools() -> ToolRegistry: + reg = ToolRegistry() + + def search_notes(keyword: str) -> list: + """搜索笔记。""" + return [] + + reg.register(build_spec(search_notes)) + return reg + + +class ScriptedClassifier: + """测试用分类模型:返回预设内容,可记录调用并可模拟异常/超时。""" + + def __init__(self, content: str = "", *, raises: Exception | None = None, + sleep: float = 0.0) -> None: + self._content = content + self._raises = raises + self._sleep = sleep + self.calls = 0 + self.tiers: list[str] = [] + + async def achat(self, messages, tools=None, *, tier="standard"): + self.calls += 1 + self.tiers.append(tier) + if self._raises is not None: + raise self._raises + if self._sleep: + await asyncio.sleep(self._sleep) + return ModelResponse(content=self._content) + + +def test_no_tools_goes_direct() -> None: + router = AdaptiveRouter() + assert router.classify("你好", ToolRegistry()) == Strategy.DIRECT + + +def test_action_goes_react() -> None: + router = AdaptiveRouter() + assert router.classify("搜索笔记里关于周报的内容", _registry_with_tools()) == Strategy.REACT + + +def test_multistep_goes_plan() -> None: + router = AdaptiveRouter() + task = "先搜索订单,然后统计数量并整理成报告" + assert router.classify(task, _registry_with_tools()) == Strategy.PLAN + + +def test_vague_goes_clarify() -> None: + router = AdaptiveRouter() + assert router.classify("随便", _registry_with_tools()) == Strategy.CLARIFY + + +def test_explicit_mcp_intent_goes_react() -> None: + # 显式要求用(MCP)工具提问,即使没有"查/搜"等动词也必须进工具循环, + # 否则 direct 路径不传 tools,模型只能把工具调用写成文本(v0.2 实测回归)。 + router = AdaptiveRouter() + task = "用 MCP 工具问一下 GitHub 仓库 python-sdk:Client 怎么初始化?" + assert router.classify(task, _registry_with_tools()) == Strategy.REACT + + +def test_explicit_mcp_intent_case_insensitive() -> None: + router = AdaptiveRouter() + assert router.classify("调用 mcp 工具查询天气", _registry_with_tools()) == Strategy.REACT + + +def test_mcp_multistep_goes_plan() -> None: + router = AdaptiveRouter() + task = "先用 MCP 工具问一下这个仓库的结构,然后整理成一句话总结" + assert router.classify(task, _registry_with_tools()) == Strategy.PLAN + + +# ---------- aclassify:LLM 分类 + 规则兜底 ---------- + +def test_aclassify_without_model_uses_rules() -> None: + router = AdaptiveRouter() + decision = asyncio.run(router.aclassify("搜索笔记", _registry_with_tools())) + assert decision.source == "rules" + assert decision.strategy == Strategy.REACT + + +def test_aclassify_llm_valid_decision() -> None: + model = ScriptedClassifier( + '{"strategy": "react", "tier": "standard", "reason": "需要调用搜索工具"}' + ) + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("帮我看看笔记里有啥", _registry_with_tools())) + assert decision.source == "llm" + assert decision.strategy == Strategy.REACT + assert decision.tier == "standard" + assert "搜索工具" in decision.reason + assert model.calls == 1 # 只做一次轻量分类调用 + + +def test_aclassify_llm_tolerates_fences_and_prose() -> None: + model = ScriptedClassifier('好的,结果如下:\n```json\n{"strategy": "plan", ' + '"tier": "strong", "reason": "多步任务"}\n```') + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("先查再汇总", _registry_with_tools())) + assert decision.strategy == Strategy.PLAN and decision.tier == "strong" + + +def test_aclassify_llm_garbage_falls_back_to_rules() -> None: + model = ScriptedClassifier("我不太确定你在说什么") + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("搜索笔记里的周报", _registry_with_tools())) + assert decision.source == "rules" # 解析失败 -> 规则兜底 + assert decision.strategy == Strategy.REACT + assert "回退" in decision.reason + + +def test_aclassify_llm_exception_falls_back_to_rules() -> None: + model = ScriptedClassifier(raises=RuntimeError("network down")) + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("搜索笔记", _registry_with_tools())) + assert decision.source == "rules" and decision.strategy == Strategy.REACT + + +def test_aclassify_llm_timeout_falls_back_to_rules() -> None: + model = ScriptedClassifier('{"strategy": "react"}', sleep=0.3) + router = AdaptiveRouter(model=model, classify_timeout=0.05) + decision = asyncio.run(router.aclassify("搜索笔记", _registry_with_tools())) + assert decision.source == "rules" and decision.strategy == Strategy.REACT + + +def test_aclassify_llm_invalid_strategy_falls_back() -> None: + model = ScriptedClassifier('{"strategy": "fly-to-moon", "reason": "瞎编"}') + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("搜索笔记", _registry_with_tools())) + assert decision.source == "rules" + + +def test_aclassify_no_tools_skips_model_call() -> None: + # 能力边界优先:宿主没工具时,连分类模型都不该调用。 + model = ScriptedClassifier('{"strategy": "react"}') + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("搜索一下", ToolRegistry())) + assert decision.strategy == Strategy.DIRECT and model.calls == 0 + + +def test_aclassify_empty_task_skips_model_call() -> None: + model = ScriptedClassifier('{"strategy": "clarify"}') + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify(" ", _registry_with_tools())) + assert decision.strategy == Strategy.CLARIFY and model.calls == 0 + + +def test_rules_plan_defaults_to_strong_tier() -> None: + router = AdaptiveRouter() + decision = asyncio.run( + router.aclassify("先搜索订单,然后统计数量并整理成报告", _registry_with_tools()) + ) + assert decision.strategy == Strategy.PLAN and decision.tier == "strong" + + +@pytest.mark.parametrize("bad_tier", ["ultra", "", 3]) +def test_aclassify_bad_tier_normalizes(bad_tier) -> None: + model = ScriptedClassifier(f'{{"strategy": "react", "tier": "{bad_tier}"}}') + router = AdaptiveRouter(model=model) + decision = asyncio.run(router.aclassify("查笔记", _registry_with_tools())) + assert decision.tier == "standard" diff --git a/submissions/mcp-hackathon/yai-agent-core/source/tests/test_sqlite_memory.py b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_sqlite_memory.py new file mode 100644 index 0000000..9246fe7 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/tests/test_sqlite_memory.py @@ -0,0 +1,240 @@ +"""SqliteStore 测试:契约对等(与 InMemoryStore 跑同一组断言)+ 持久化专项。 + +全程离线:内存库与 tmp_path 文件库,零网络、零 Key。 +""" + +import asyncio +import sqlite3 + +import pytest + +from yai_core import AgentCore, ChatMessage, ModelResponse +from yai_core.memory import InMemoryStore, SqliteStore + + +def run(coro): + """同步测试里跑一个协程的小工具(仓库既有风格,不引入 pytest-asyncio)。""" + return asyncio.run(coro) + + +@pytest.fixture(params=["memory", "sqlite"]) +def store(request, tmp_path): + """同一组对等用例分别跑 InMemoryStore 与 SqliteStore。""" + if request.param == "memory": + yield InMemoryStore() + else: + s = SqliteStore(tmp_path / "t.db") + yield s + s.close() + + +# ---------- A 组:契约对等(两种实现必须行为一致) ---------- + +def test_empty_history(store) -> None: + assert store.history() == [] + + +def test_append_history_order_and_fields(store) -> None: + run(store.append_history(ChatMessage(role="user", content="第一条"))) + run(store.append_history(ChatMessage(role="assistant", content="第二条"))) + hist = store.history() + assert [m.role for m in hist] == ["user", "assistant"] + assert [m.content for m in hist] == ["第一条", "第二条"] + + +def test_kv_put_get_overwrite_and_missing(store) -> None: + assert run(store.get("k")) is None + run(store.put("k", "v1")) + assert run(store.get("k")) == "v1" + run(store.put("k", "v2")) + assert run(store.get("k")) == "v2" + + +def test_clear_empties_history_and_kv(store) -> None: + run(store.append_history(ChatMessage(role="user", content="x"))) + run(store.put("k", "v")) + run(store.clear()) + assert store.history() == [] + assert run(store.get("k")) is None + + +def test_tool_calls_roundtrip_including_empty_list(store) -> None: + with_calls = ChatMessage( + role="assistant", + content="", + tool_calls=[{"id": "1", "name": "t", "arguments": {"a": 1}}], + ) + run(store.append_history(with_calls)) + assert store.history()[0].tool_calls == with_calls.tool_calls + + # 空列表必须原样往返(存 "[]"),不能被折叠成 None。 + run(store.append_history(ChatMessage(role="assistant", content="", tool_calls=[]))) + assert store.history()[1].tool_calls == [] + + +def test_optional_fields_roundtrip(store) -> None: + run( + store.append_history( + ChatMessage(role="tool", content="结果", tool_call_id="c1", name="t") + ) + ) + m = store.history()[0] + assert m.tool_call_id == "c1" + assert m.name == "t" + + run(store.append_history(ChatMessage(role="user", content=""))) + missing = store.history()[1] + assert missing.tool_call_id is None + assert missing.name is None + + +# ---------- B 组:SqliteStore 专项 ---------- + +def test_persistence_across_instances(tmp_path) -> None: + db = tmp_path / "p.db" + first = SqliteStore(db) + run(first.append_history(ChatMessage(role="user", content="持久的我"))) + run(first.put("k", "v")) + first.close() + + second = SqliteStore(db) # 重新打开同一文件,数据必须还在 + try: + assert second.history()[0].content == "持久的我" + assert run(second.get("k")) == "v" + finally: + second.close() + + +def test_scope_isolation_and_scoped_clear(tmp_path) -> None: + db = tmp_path / "s.db" + a = SqliteStore(db, scope="a") + b = SqliteStore(db, scope="b") + try: + run(a.append_history(ChatMessage(role="user", content="A 的消息"))) + run(b.put("kb", "vb")) + + assert len(a.history()) == 1 + assert b.history() == [] + assert run(a.get("kb")) is None + assert run(b.get("kb")) == "vb" + + run(a.clear()) # 只清 scope=a + assert a.history() == [] + assert b.history() == [] + assert run(b.get("kb")) == "vb" # b 的 KV 不受影响 + finally: + a.close() + b.close() + + +def test_memory_databases_are_independent() -> None: + a = SqliteStore(":memory:") + b = SqliteStore(":memory:") + try: + run(a.append_history(ChatMessage(role="user", content="只在 A"))) + assert len(a.history()) == 1 + assert b.history() == [] + finally: + a.close() + b.close() + + +def test_reinit_same_file_is_idempotent(tmp_path) -> None: + db = tmp_path / "r.db" + first = SqliteStore(db) + run(first.put("k", "v")) + first.close() + + second = SqliteStore(db) + third = SqliteStore(db) # 同文件重复构造:不报错、不丢数据 + try: + assert run(second.get("k")) == "v" + assert run(third.get("k")) == "v" + finally: + second.close() + third.close() + + +def test_from_env_states(tmp_path) -> None: + assert SqliteStore.from_env(env={}) is None + assert SqliteStore.from_env(env={"YAI_DB_PATH": " "}) is None + + store = SqliteStore.from_env(env={"YAI_DB_PATH": str(tmp_path / "env.db")}) + assert store is not None + try: + run(store.put("k", "v")) + assert run(store.get("k")) == "v" + finally: + store.close() + + +def test_parent_dir_created(tmp_path) -> None: + nested = tmp_path / "nested" / "deep" / "m.db" + store = SqliteStore(nested) + try: + assert nested.parent.exists() + finally: + store.close() + + +def test_schema_version_baseline_and_future_guard(tmp_path) -> None: + db = tmp_path / "v.db" + store = SqliteStore(db) + store.close() + + conn = sqlite3.connect(db) + assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + conn.execute("PRAGMA user_version = 2") # 模拟"未来版本"数据库 + conn.commit() + conn.close() + + with pytest.raises(RuntimeError, match="schema 版本"): + SqliteStore(db) + + +def test_operations_after_close_raise(tmp_path) -> None: + store = SqliteStore(tmp_path / "c.db") + store.close() + with pytest.raises(RuntimeError, match="已关闭"): + store.history() + + +# ---------- C 组:Loop 集成(证明消费点零改动) ---------- + +class _OneShotModel: + def __init__(self, content: str) -> None: + self.content = content + + async def achat(self, messages, tools=None, *, tier="standard"): + return ModelResponse(content=self.content) + + +class _CapturingModel: + """记录第二轮真正发给模型的消息(dict 形态),用于断言历史回放。""" + + def __init__(self) -> None: + self.seen: list[dict] = [] + + async def achat(self, messages, tools=None, *, tier="standard"): + self.seen.extend(messages) + return ModelResponse(content="第二轮答复") + + +def test_loop_replays_persisted_history(tmp_path) -> None: + store = SqliteStore(tmp_path / "loop.db") + + first = AgentCore(_OneShotModel("第一轮答复"), memory=store) + r1 = asyncio.run(first.run("你好")) + assert r1.final_text == "第一轮答复" + + second_model = _CapturingModel() + second = AgentCore(second_model, memory=store) # 复用同一个 store + r2 = asyncio.run(second.run("在吗")) + + contents = [m["content"] for m in second_model.seen] + # 第二轮上下文里必须能看到上一轮的 user 与 assistant(持久记忆的端到端证据) + assert "你好" in contents + assert "第一轮答复" in contents + assert "在吗" in contents + assert r2.final_text == "第二轮答复" + store.close() diff --git a/submissions/mcp-hackathon/yai-agent-core/source/uv.lock b/submissions/mcp-hackathon/yai-agent-core/source/uv.lock new file mode 100644 index 0000000..c30e3a0 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/source/uv.lock @@ -0,0 +1,1579 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[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 = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" }, + { url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" }, + { url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" }, + { url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" }, + { url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, + { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" }, + { url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "openai" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/77/3508ca0f04f42124bb89ab6e3e853c144660b9a680165beb49cd48634bce/openai-3.9.0.tar.gz", hash = "sha256:fea0fe63d04a27e9da588f673e291183fbf4813767e9df743e351bb548afc60b", size = 1481863, upload-time = "2026-09-08T16:43:15.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ec/59307303cdb38d89f73555245cec54ab5fe34628c39b78b24ea48d0bbed8/openai-3.9.0-py3-none-any.whl", hash = "sha256:8e0e9a1abea664507f03fdad204579092d0fdf1aff54e8335c2098041d4c0345", size = 1741583, upload-time = "2026-09-08T16:43:12.369Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +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 = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[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.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/1b/344ab22cea729e872f759b926441f7b822ab6cd106db527736afc066927f/websockets-17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9", size = 214667, upload-time = "2026-08-26T14:55:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/bace574b6ae80e1a8d6935b8c5f03fb67236233ec572e976fe826ff719cf/websockets-17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86", size = 214944, upload-time = "2026-08-26T14:55:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/ee/87/08e35ca4a0ffafb500a16ff461bf9561ad2b755362adb5d077d4dba9affc/websockets-17.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d", size = 224004, upload-time = "2026-08-26T14:55:24.748Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/00ae2e147eaa086fe8bdddd36f57216ce72b9a9dfc0b17c717005ebdacaf/websockets-17.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695", size = 224278, upload-time = "2026-08-26T14:55:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/7aeb4e00defa68826f449392922a382ce7fdf542fe52190558dc1714e284/websockets-17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134", size = 225511, upload-time = "2026-08-26T14:55:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/655be3d93c3edbe1a51606073b5454ea6b1b32d87aa26253a6df952417b7/websockets-17.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7", size = 228802, upload-time = "2026-08-26T14:55:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/98549a2afa9d68fe1b5a8e0a61cd461a43ea1ab7209bce675eea67c79190/websockets-17.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6", size = 226075, upload-time = "2026-08-26T14:55:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6b/cbc27e014d6c292b9b2709cfd32781a2b61eb30cd4c9130e7c57e41a204a/websockets-17.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750", size = 224846, upload-time = "2026-08-26T14:55:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/e57925f7a423d90f24559e85bac21a7f0b44c0cf4a5c0babc0759ca54bab/websockets-17.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8", size = 222136, upload-time = "2026-08-26T14:55:32.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/b9de0400addb542ba7c819022abc3afa46cd7e518068881bceadac69d995/websockets-17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098", size = 225000, upload-time = "2026-08-26T14:55:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bb/af2828a1d7f2beb792af6ba56d7b02d56262070266b95d2af9ef391fbfb0/websockets-17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a", size = 223592, upload-time = "2026-08-26T14:55:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/7379f254730c1dc7d8e4dd8d686868d8f7be55bdc94c6d3a44538840f639/websockets-17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c", size = 224360, upload-time = "2026-08-26T14:55:35.777Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/fcd91320dd71dda7046df9bc60f60c70ee15c052dee21e31ed6221dc8b5d/websockets-17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405", size = 225404, upload-time = "2026-08-26T14:55:36.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b2/655a4f939388079f80f1b3f8a1b9d40783e70a376e7423988cc9a590a09f/websockets-17.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66", size = 222982, upload-time = "2026-08-26T14:55:38.006Z" }, + { url = "https://files.pythonhosted.org/packages/87/75/37c84c4371c6aa668910d7841036c4397ea10554c07030603ccd5e44b02a/websockets-17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0", size = 224017, upload-time = "2026-08-26T14:55:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/20/8a9a94323bfcfe03bde3f9d98926bea4855856359702fe3ca0d07051ef5d/websockets-17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb", size = 224252, upload-time = "2026-08-26T14:55:40.498Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/aa66e6500188cd40306abeb92c9a738ca6dd7029d8d8532c538055ab5daf/websockets-17.1-cp311-cp311-win32.whl", hash = "sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142", size = 217484, upload-time = "2026-08-26T14:55:41.763Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/cdf3b551f0b9177023afd3a45d3b431a0d4064951008c4321a8b42ac2288/websockets-17.1-cp311-cp311-win_amd64.whl", hash = "sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee", size = 217779, upload-time = "2026-08-26T14:55:42.942Z" }, + { url = "https://files.pythonhosted.org/packages/e0/13/51253dbed7d16a4bb87b05110ad3bf12165f410e915f6da1edd4186d8dc1/websockets-17.1-cp311-cp311-win_arm64.whl", hash = "sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c", size = 217710, upload-time = "2026-08-26T14:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e4/af4abbcf07eac6a725ec6f865611526b2b0c23d482723de551bec667880d/websockets-17.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918", size = 214602, upload-time = "2026-08-26T14:58:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/819fba7ba35f92b639333da7355041c07dd50048f9c76fba0b8e292a6483/websockets-17.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886", size = 214874, upload-time = "2026-08-26T14:58:26.689Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/d370ab794f47fbeea648d17ad08caf0bb50131d6c04b7ad83e6af63c405a/websockets-17.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8", size = 215821, upload-time = "2026-08-26T17:25:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/9b/6b/251b00fe634e2a9c2cb5d6390e0e97cec55e3d18dd09b4b976620eed5d7b/websockets-17.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf", size = 215714, upload-time = "2026-08-26T17:25:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/37fe0c96c206b4208a072c3a74add6a72af4b8228be3f5435163c5a6d099/websockets-17.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e", size = 216608, upload-time = "2026-08-26T17:25:28.134Z" }, + { url = "https://files.pythonhosted.org/packages/be/7e/75a0a491b512412e08333b9f8412757af6186fe1c598186261002de1a793/websockets-17.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d", size = 217870, upload-time = "2026-08-26T17:25:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + +[[package]] +name = "yai-agent-core" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, +] +docs = [ + { name = "python-docx" }, +] +llm = [ + { name = "openai" }, +] +mcp = [ + { name = "mcp" }, +] +openapi = [ + { name = "httpx" }, + { name = "pyyaml" }, +] +server = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'openapi'", specifier = ">=0.27" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0,<3" }, + { name = "openai", marker = "extra == 'llm'", specifier = ">=1.40" }, + { name = "pydantic", marker = "extra == 'server'", specifier = ">=2.7" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "python-docx", marker = "extra == 'docs'", specifier = ">=1.1" }, + { name = "pyyaml", marker = "extra == 'openapi'", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.30" }, +] +provides-extras = ["llm", "server", "mcp", "openapi", "dev", "docs"] diff --git a/submissions/mcp-hackathon/yai-agent-core/submission.json b/submissions/mcp-hackathon/yai-agent-core/submission.json new file mode 100644 index 0000000..9c4ab33 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/submission.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "name": "YAI Agent Core", + "slug": "yai-agent-core", + "sourceRepository": "https://github.com/Gi-Tuu/yai-agent-core", + "reviewCommit": "81729f9d39d1f2f0db279265ab6cc5d387a3921f", + "apiBaseUrl": "https://yai-agent-core.onrender.com", + "healthCheckUrl": "https://yai-agent-core.onrender.com/health", + "deploymentProofUrl": "https://yai-agent-core.onrender.com/.well-known/xagent-verification.json" +} diff --git a/submissions/mcp-hackathon/yai-agent-core/verification/README.md b/submissions/mcp-hackathon/yai-agent-core/verification/README.md new file mode 100644 index 0000000..bce6785 --- /dev/null +++ b/submissions/mcp-hackathon/yai-agent-core/verification/README.md @@ -0,0 +1,104 @@ +# 在线验证手册(verification) + +- **API 同源基址**:`https://yai-agent-core.onrender.com` +- **评审基线 commit**:以 `../submission.json` 的 `reviewCommit`(40 位 SHA)为准。 +- **冷启动提示**:免费实例 15 分钟无流量会休眠,首次请求约 30–90 秒冷启动;建议先 curl 一次 health 唤醒,再做后续验证。 +- 以下 curl 命令在任意装有 curl 的环境可直接复现(Windows PowerShell / macOS / Linux 均可)。 + +## 1. health 健康检查(硬门槛) + +```bash +curl -sS https://yai-agent-core.onrender.com/health +``` + +期望返回(commit 必须与 reviewCommit 完全一致): + +```json +{"status": "ok", "commit": "<40位 reviewCommit>"} +``` + +## 2. 部署证明端点(硬门槛,与 health 同源同 commit) + +```bash +curl -sS https://yai-agent-core.onrender.com/.well-known/xagent-verification.json +``` + +期望返回: + +```json +{"schemaVersion": 1, "slug": "yai-agent-core", "commit": "<40位 reviewCommit>"} +``` + +## 3. 工具清单(能力自发现 + 来源标注) + +```bash +curl -sS https://yai-agent-core.onrender.com/v1/tools +``` + +期望返回宿主演示应用自动发现的 3 个 native 工具(count_notes / list_notes / search_notes) +以及线上通过 MCP Client 挂载的 3 个 `source: "mcp"` 远程工具(ask_question / +read_wiki_contents / read_wiki_structure,来自公共免鉴权的 DeepWiki MCP Server)。 + +## 4. 真实 Agent 任务(POST,会真实调用 DeepSeek 模型) + +> 限流说明:`POST /v1/agent/run` 每 IP 每分钟 30 次;超限返回 429 与 `Retry-After`, +> 稍后重试即可。健康与验证端点(本手册第 1–3 节)不受限。 + +```bash +curl -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run \ + -H "Content-Type: application/json" \ + -d '{"task": "搜索笔记里关于比赛的内容并总结"}' \ + --max-time 180 +``` + +期望返回 JSON,包含: + +- `strategy`:自适应路由结果(该任务通常为 `plan` 或 `react`); +- `events[0]`(`strategy_selected`)带 `source`(`llm` 模型分类 / `rules` 规则兜底)、 + `reason`(中文决策理由)与 `tier`(standard/strong)——决策本身可审计; +- `final_text`:模型基于真实工具结果给出的中文总结; +- `events`:完整过程事件序列(`strategy_selected` → `tool_call` → `tool_result`(ok=true)→ … → `done`),证明工具被真实调用而非提示词演示。 + +PowerShell 若对内联 JSON 引号处理不一致,可改用: + +```powershell +$body = @{ task = "搜索笔记里关于比赛的内容并总结" } | ConvertTo-Json -Compress +curl.exe -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run ` + -H "Content-Type: application/json" --data $body --max-time 180 +``` + +## 4b. 真实外部 MCP 工具调用(MCP 产品化硬证据) + +```bash +curl -sS -X POST https://yai-agent-core.onrender.com/v1/agent/run \ + -H "Content-Type: application/json" \ + -d '{"task": "用 MCP 工具问一下 GitHub 仓库 modelcontextprotocol/python-sdk:MCP 的 Client 怎么初始化?用中文给结论。"}' \ + --max-time 180 +``` + +期望:`strategy` 为 `react`;`events` 中出现对 `ask_question` 的 +`tool_call` / `tool_result(ok=true)`(该工具的来源标注 `source: "mcp"` 在第 3 节 `/v1/tools` 清单中查看,事件体本身不带 source 字段);`final_text` 是基于远程 MCP 返回内容的中文回答。 +该调用链为:本服务 → DeepSeek 规划工具调用 → Streamable HTTP 调远程 MCP Server → 结果回灌 → 模型总结。 + +## 5. 本地/隔离环境复现 + +```bash +git clone https://github.com/Gi-Tuu/yai-agent-core +cd yai-agent-core +git checkout +uv sync --extra dev --extra llm --extra server --extra mcp --extra openapi +uv run pytest # 125 项离线测试全绿(截至 v0.3,以 pytest -q 实跑为准),不需要 API Key + # 注:extras 组合与 CI 一致(dev/llm/server/mcp/openapi),实跑 125 passed,无 skip +uv run ruff check src tests examples scripts +uv run python scripts/smoke_test.py # 三宿主自适应冒烟(离线) +docker compose up --build # 容器化:容器内 8000,宿主 127.0.0.1:8001 +# 本地容器验证:curl http://127.0.0.1:8001/health +``` + +## 6. 证据留存 + +- 部署平台:Render 免费层 Docker Web Service(Blueprint 见 `source/render.yaml`), + 平台注入 `RENDER_GIT_COMMIT`,服务启动时按 `YAI_GIT_COMMIT → RENDER_GIT_COMMIT → git HEAD → dev` + 解析且只接受 40 位哈希,保证 health 上报的就是本次部署的真实 commit; +- CI:每次推送在 GitHub Actions(Ubuntu 3.11/3.12/3.13 + Windows 3.13)执行 ruff + pytest,状态见仓库 README 徽章与 Actions 页; +- 正式评审期如迁移常驻 VPS,仅更换部署环境,端点契约、slug、commit 校验逻辑不变,并会通过补充 PR 披露。