diff --git a/AGENTS.md b/AGENTS.md index a886d2e1a..82739895e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml ## Change Rules - HTTP API changes that should be agent-accessible must update the matching MCP tool in `src/langbot/pkg/api/mcp/server.py` and the relevant skill under `skills/` in the same pass. -- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`; do not add legacy `dbmXXX` migrations. +- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`. LangBot 4.x does not support upgrading 3.x databases. - New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services. - User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does). - Code comments and docstrings must be English. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ded90e7ea..853225ae4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,12 +52,13 @@ LangBot/ │ │ ├── api/ # HTTP API + MCP server mount │ │ ├── platform/ # IM adapters and runtime bot manager │ │ ├── pipeline/ # Message routing and pipeline stages -│ │ ├── provider/ # LLM runners, model manager, tools +│ │ ├── provider/ # Model providers and Host-owned tools +│ │ ├── agent/ # Agent/AgentRunner orchestration and run state │ │ ├── plugin/ # LangBot-side Plugin Runtime connector/handler │ │ ├── box/ # LangBot-side Box service/connector │ │ ├── skill/ # Skill metadata/activation integration │ │ ├── rag/ , vector/ # Knowledge-base and vector DB integration -│ │ ├── persistence/ # SQLAlchemy/SQLModel, Alembic, legacy migrations +│ │ ├── persistence/ # SQLAlchemy/SQLModel and Alembic migrations │ │ ├── storage/ # Local/S3 file storage abstraction │ │ └── config/, entity/, utils/, telemetry/, survey/ │ ├── libs/ # Vendored third-party platform SDKs @@ -80,7 +81,7 @@ Platform adapter → Controller → RuntimePipeline → PipelineStage chain - → RequestRunner / ToolManager / PluginRuntimeConnector / BoxService + → AgentRunner orchestrator / ToolManager / PluginRuntimeConnector / BoxService → response via adapter ``` @@ -107,7 +108,7 @@ Inbound platform messages enter through adapter-specific SDK callbacks. The comm 3. `MessageAggregator` batches/normalizes messages before adding a `Query` to `QueryPool`. 4. `Controller` in `pkg/pipeline/controller.py` selects queries subject to global pipeline concurrency and per-session concurrency. 5. `RuntimePipeline` in `pkg/pipeline/pipelinemgr.py` runs configured pipeline stages using a responsibility-chain style executor that supports generator stages. -6. The chat stage emits plugin events, calls a configured `RequestRunner`, handles streaming/non-streaming responses, records telemetry, and appends conversation history. +6. The chat stage emits plugin events and projects the current query into the AgentRunner Host orchestrator. The selected plugin AgentRunner returns streaming or final results while the Host owns authorization, tools, telemetry, and conversation history. 7. Output stages send text, cards, chunks, files, or error notices back through the original platform adapter. Pipeline components are registered by decorators and package import side effects. When adding a new stage, loader, runner, or adapter, check the corresponding preregistration mechanism instead of inventing a second registry. @@ -136,12 +137,12 @@ Important pieces: Pipelines are configuration-driven. Prefer adding a stage or extending an existing stage family over hard-coding behavior in platform adapters. -## Provider, RAG, and Tools +## Agents, Providers, RAG, and Tools -Provider code lives under `pkg/provider/`. +Agent orchestration lives under `pkg/agent/`; model providers and tools live under `pkg/provider/`. - `modelmgr/` manages configured model providers and requesters. -- `runners/` implements request runners such as the local agent runner and external workflow integrations. +- `pkg/agent/runner/` discovers plugin AgentRunner components, resolves bindings, constructs run-scoped context/resources, and records execution state. - `tools/toolmgr.py` aggregates tools from native tools, plugin tools, external MCP servers, and skill-authoring tools. - `tools/loaders/mcp.py` is the MCP client side: external MCP servers that LangBot connects to for agent tools. - RAG lives across `pkg/rag/`, `pkg/vector/`, model services, and plugin KnowledgeEngine actions. @@ -206,8 +207,8 @@ Persistence is centered on `pkg/persistence/mgr.py`. - SQLite is the default database; PostgreSQL is supported. - Models live under `pkg/entity/persistence/`. -- Fresh schemas are created from metadata, then legacy migrations run up to the frozen 3.x baseline, then Alembic migrations run to head. -- New schema changes should use Alembic under `pkg/persistence/alembic/versions/`; do not extend the frozen legacy migration chain. +- Fresh schemas are created from current metadata, then Alembic migrations run to head. LangBot 4.x does not upgrade 3.x databases. +- New schema changes use Alembic under `pkg/persistence/alembic/versions/`; there is no legacy migration chain in 4.x. Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`. @@ -239,7 +240,7 @@ When one of these changes, update the others if the behavior or contract changed - New LLM tool source: extend `pkg/provider/tools/loaders/` and `ToolManager` intentionally. - New plugin component/API/protocol: change `langbot-plugin-sdk` first or in lockstep, then update LangBot bridge code. - New Box capability: change both `pkg/box/` and `langbot-plugin-sdk/src/langbot_plugin/box/`, plus config and tests. -- New database schema: add an Alembic migration, not a legacy `dbmXXX` migration. +- New database schema: add an Alembic migration. ## Design Biases diff --git a/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md new file mode 100644 index 000000000..70f917870 --- /dev/null +++ b/docs/agent-runner-pluginization/AGENT_CONTEXT_PROTOCOL.md @@ -0,0 +1,149 @@ +# Agent-owned Context 协议设计 + +本文档描述插件化 AgentRunner 场景下的上下文边界**设计理由**。结论先行:LangBot 不应成为最终 agentic context manager;它提供 context substrate,AgentRunner 或其背后的 runtime 自己决定如何管理历史、压缩、召回和 KV cache。 + +> 涉及的数据结构(`AgentRunContext`、`ContextAccess`、`AgentRunAPIProxy` 等)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。本文只讲语义和约束,不重抄 schema。 + +## 1. 设计原则 + +### 1.1 Agent 拥有上下文策略 + +不同 runner 背后的 runtime 差异很大: + +- 官方 local-agent 可能依赖 LangBot 的模型、工具、知识库和存储。 +- Claude Code SDK / Codex 类 runtime 有自己的 session、transcript、tool loop 和上下文压缩。 +- Pi Agent SDK 或外部 agent 平台可能只需要当前事件和一个外部 conversation key。 + +因此 LangBot 不应强行决定最终传给模型的历史窗口。Host 只提供:当前事件的完整结构化信息、稳定身份和会话引用、可授权读取的 history / event / state API、sandbox/workspace 文件能力、可投影给外部 harness 的 scoped context / SDK-owned MCP bridge / resource handles、payload hard cap 和权限 guardrail。 + +### 1.2 Host 不定义通用历史窗口 + +历史窗口策略不是 AgentRunner 协议或 Query entry adapter 的核心概念。Host 只提供 history pull API、cursor、hard cap 和权限边界;runner 自己决定是否读取、读取多少、如何截断和压缩。 + +正确的问题不是"LangBot 每轮裁几轮历史给 agent",而是: + +- 这类 runner 是否自管 context? +- 事件到来时 host 应 inline 哪些最小信息? +- agent 需要更多上下文时通过什么 API 拉取? +- host 如何保证安全、可审计和可分页? + +### 1.3 Host 保存事实源,Agent 管理 working context + +三类数据要分开: + +- `EventLog`: Host 保存原始事件、工具调用、投递结果、错误和系统事件。 +- `Transcript`: Host 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 +- `Working context`: Agent 本轮实际送进模型或 runtime 的上下文,由 AgentRunner 决定。 + +LangBot 不提供 host-side inline history window。简单 runner 如果需要历史窗口,应在 runner 内部通过 Host history API 拉取并裁剪。 + +## 2. Event 到来时传什么 + +默认 `AgentRunContext`(PROTOCOL_V1 §5.2)应尽量小且稳定。默认规则: + +- Host MUST NOT inline full history by default. +- Host SHOULD inline only current event / input and context handles. +- Runner owns working-context assembly. +- Runner MAY use Host history / event / state / storage API and sandbox/workspace file tools when authorized. +- Official runners MUST consume Host infrastructure through the same public API as third-party runners. + +### 2.1 必须 inline 的内容 + +当前 event 的类型/id/时间/source;当前输入文本和结构化内容;附件/文件/图片的 metadata、path 或 URL;actor / subject / conversation / thread / bot / workspace;delivery 能力;已授权资源列表;context cursors 和可用 API 能力;Agent/runner config。这些是 agent 决定下一步所需的最低信息。 + +### 2.2 默认不 inline 的内容 + +完整历史消息、大文件全文、大工具结果、全量知识库内容、平台原始 payload 大对象、每轮重新生成的大段 summary。这些会破坏跨进程序列化成本、泄露范围、KV cache 稳定性,也会迫使 host 替 agent 做 context 策略。 + +### 2.3 不提供 Host Inline History Window + +`AgentRunContext` 不包含 `bootstrap` 字段。Host 不下发历史窗口,也不通过 Pipeline 配置决定窗口大小。runner 若需要类似 `recent_tail` 的策略,应在自己的 manifest/config schema 中声明参数,并在 runner 内部通过 history API 读取、裁剪和压缩。Host 只负责权限、分页、hard cap 和事实源。 + +## 3. ContextAccess 的作用 + +`ContextAccess`(PROTOCOL_V1 §5.8)是 host 交给 agent 的上下文读取入口描述,告诉 agent:当前事件位于哪条 conversation / thread、若需要更多历史从哪个 cursor 开始拉、host inline 了什么没 inline 什么、当前 run 有哪些 context API 权限。 + +## 4. Agent 如何获取更多上下文 + +所有 API 都走 `AgentRunAPIProxy`(PROTOCOL_V1 §8),由 host 用 `run_id` 校验。 + +外部 harness 不能直接访问 LangBot 资源。无论是 history、event、state、model、tool、knowledge base,还是 LangBot skills,都必须通过 SDK runtime 转发到 Host API,并由 Host 按 active `run_id`、runner identity、binding resource policy 和 caller plugin identity 校验。当前运行文件进入授权 sandbox/workspace 后,再由 runner 用 read/write/exec 类工具按需访问。harness 自己的 native tools 只属于 harness 执行环境,不能绕过 SDK runtime 访问 LangBot 内部资源。 + +### 4.1 History + +```python +await api.history_page(conversation_id=ctx.context.conversation_id, + before_cursor=ctx.context.latest_cursor, + limit=50, direction="backward", include_attachments=False) +``` + +返回 `HistoryPage`(schema 见 PROTOCOL_V1 §8)。 + +约束:`limit` 有 host hard cap;默认只能读当前 conversation / thread;跨会话读取需 binding policy / run authorization snapshot 授权;可返回 attachment ref,不默认返回大文件内容。 + +### 4.2 Search + +```python +await api.history_search(query="用户之前提到的数据库连接信息", + filters={"conversation_id": ..., "event_types": ["message.received"]}, + top_k=10) +``` + +Search 可先用数据库全文索引,后续接 embedding recall。它是 host 检索能力,不等于 agent 的长期记忆策略。 + +### 4.3 Event / State + +- Event API(`events.get` / `events.page`)用于读取非消息事件、工具事件、系统事件。Agent 不应把所有事件都当成 user/assistant message。 +- State API(`state.get` / `set`)是可选寄宿能力。自管 runtime 可以完全不用;依附 LangBot 的官方 runner 可以使用,例如 `external.session_id`、`summary.checkpoint`。 + +### 4.4 大文件与工具协作 + +大文件、多模态输入和工具产物不要内联进 prompt 或 tool result:message/content 里只放小文本和必要摘要;当前事件附件由 Host staged 到授权 sandbox/workspace,并在 input attachment 中给出轻量 metadata/path。工具之间传递大结果时传 sandbox path 或 attachment ref,不传完整 blob。Host 只保证当前 run 授权范围,默认不允许插件直接读任意本地路径;临时文件由 sandbox 生命周期和清理机制管理。 + +### 4.5 External harness context projection + +外部 harness 的总体边界以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8 为准。本节只描述 context projection 的推荐形态。 + +Claude Code、Codex、Kimi Code 这类 runtime 通常已有自己的 session、工具 loop、MCP 加载、上下文压缩和工作目录。LangBot 不应把它们改造成"host prompt assembler",而应提供可审计的事件和资源投影。推荐 projection 形态: + +- `agent-context.json`:结构化 JSON,包含 `run_id`、`event`、`actor`、`subject`、`input`、`delivery`、`resources`、`context`、`state`、`runtime`。 +- `LANGBOT_CONTEXT.md`:人类可读摘要。 +- `resources`:只包含本次 run 授权后的资源句柄和能力摘要,不暴露 Host 内部私有对象、secret 或资源内容。 +- `skills`:LangBot skills 不是直接投影给 harness native tool loop 的文件能力,而是**一组被授权的 tool**。发现走 `list_skills`(或 `langbot_list_assets` 增加 skills 一类),激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write,统一通过 `ctx.resources.tools`、`AgentRunAPIProxy` 或 SDK-owned MCP bridge 暴露。Host 不向 prompt 注入 skill 索引(无 progressive-disclosure 注入);harness 通过调用发现工具主动查询 skill 清单。`agent-context.json` 的 `skills` 字段仅作发现工具的数据来源与可选 `suggested_skill_prompt` 的输入。 +- `MCP config`:只投影 per-run、scoped 的 SDK-owned bridge 或外部 MCP 连接配置;LangBot 资源访问必须回到 SDK runtime / Host API,不允许 harness 通过自带 MCP/native tool 直接读 Host 内部资源。 +- `state pointers`:外部 session id、working directory、checkpoint 等小型 JSON 状态通过 Host state API 保存。 + +当前官方外部 harness 路径由 ACP / Claude Code / Codex 等 runner 插件承担(现状见 OFFICIAL_RUNNER_PLUGINS §7)。这类 projection 是"把 LangBot 事实源和授权资源句柄交给 harness",不是"把 LangBot 资源本体或内部权限交给 harness",也不是"由 LangBot 决定最终模型上下文"。 + +## 5. Runner 上下文边界 + +Host 只给当前事件、当前输入和 context handles。Runner 是否能拉取历史、事件、state 或 storage、是否能访问 sandbox/workspace 文件,以运行时 `ctx.context.available_apis` 和工具授权为准;runner 自己决定是否拉取历史、是否搜索、何时摘要、如何构造最终 prompt。 + +## 6. KV cache 友好的上下文管理 + +支持 Claude Code SDK、Codex、Pi Agent SDK 等 runtime 时,必须避免每轮由 LangBot 重组大块 prompt: + +- 稳定 session key:`workspace/bot/binding/runner/conversation/thread`。 +- 每轮只传 delta:当前 event、attachment refs/path、少量 runtime metadata。 +- 历史 append-only:不要每轮改写同一段 history 文本。 +- Summary checkpoint 稳定:只有压缩发生时产生新 checkpoint。 +- 大文件和工具结果写入 sandbox/workspace。 +- Tool/context API schema 稳定,数据通过 API 拉取而非塞入 prompt。 +- 对自管 runtime,优先让它复用自身 session/cache,而不是强制 LangBot 每轮重放 transcript。 +- 模型窗口元信息应作为 resource/runtime metadata 暴露给 runner,由 runner 决定预算和压缩策略。 + +稳定 session key 的用途是隔离外部 runtime 的 resume/cache/state,不是改变 PROTOCOL_V1 §13 定义的 Agent 复用和 dispatch 边界。只有当某个外部 harness 的同一 native session 不支持并发 turn 时,runner 或 future runtime control plane 才应按 external session key 做 turn-level 串行化。 + +对长期运行的 external harness / daemon,推荐运行形态是 reader 与 writer 分离:一个 session reader 独占读取 stdout/SSE/native event stream,并把 native event 转成 `AgentRunResult` 或 task progress;用户输入只作为 turn write 进入该 session。当前一次性 CLI subprocess runner 可以继续在单次 `run(ctx)` 内同步收集 stdout,但后续改成长连接时不应让多个 request 同时读取同一 native stream。 + +## 7. Host guardrail + +Agent 自管 context 不代表无限制访问。LangBot 仍必须控制:每次 run 的 active `run_id`、runner identity、当前 binding 的 resource policy、conversation / actor / subject scope、page size / sandbox file read size / API rate limit、跨会话读取权限、数据脱敏和敏感变量过滤、审计日志。Host 不负责"最佳上下文策略",但负责"不越权、不爆内存、不不可审计"。 + +外部 harness 的 native tools、shell、MCP 或 skill 机制不构成 LangBot 资源授权边界。只要访问的是 LangBot 持有的资源,就必须经 SDK runtime 转发并接受 Host 校验;完整边界见 HOST_SDK §4.8。 + +## 8. 官方 runner 与业务编排边界 + +官方 runner 插件可以把状态寄宿在 LangBot,但必须和第三方 runner 一样通过公开 Host API 消费。LangBot core 不内置官方 agent 的业务流程(prompt 组装、tool loop、RAG 编排、summary/compaction、"local-agent 专用"状态字段)。 + +官方 local-agent 应作为"依附 LangBot 基础设施的复杂 runner 参考实现":transcript/history 通过 `api.history_page()` / `api.history_search()` 读取,summary/checkpoint/外部 session id/用户偏好通过 `api.state_get()` / `api.state_set()` 或 storage 方法保存,图片/文件/工具大结果通过 sandbox/workspace read/write 工具访问,模型/工具/知识库通过 `api.invoke_llm()` / `api.call_tool()` / `api.retrieve_knowledge()` 调用。这样 LangBot 保持为通用 agent host,不变成内置 agent 框架。具体迁移要求见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。 diff --git a/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md new file mode 100644 index 000000000..1b4c3b5f6 --- /dev/null +++ b/docs/agent-runner-pluginization/AGENT_RUNNER_QA_GUIDE.md @@ -0,0 +1,227 @@ +# Agent Runner QA 指南 + +本文档是 agent-runner 插件化下一轮测试的唯一 QA 入口。它合并并取代旧的 Phase 1 验收矩阵与 2026-05-18 / 2026-05-29 两份本地 QA 报告。 + +目标不是保留完整历史流水账,而是指导测试 agent 用最小但高价值的路径判断当前分支是否仍然健康。 + +## 1. 测试边界 + +当前主线验证的是 AgentRunner Protocol v1: + +```text +event -> binding -> runner.run(ctx) -> result stream +``` + +本指南验证: + +- Host 能通过当前 Query entry adapter 进入 event-first `run(event, binding)` 主链路。 +- Runner 来自插件 registry,而不是旧内置 runner 分支。 +- `local-agent` 能消费 Host 模型、工具、知识库、history、state、sandbox 文件等基础设施。 +- 外部 harness runner(ACP / Claude Code / Codex 等直接 runner 插件)能消费 event-first context,并把外部 session 指针写回 host-owned state。 +- 错误、权限裁剪、无输出、timeout 等路径不会破坏主聊天流程。 + +本指南不验证: + +- Runtime Control Plane v2。 +- EventGateway / EventRouter 完整落地由外部 EBA 分支联调;本指南只验证本分支 Host 底座。 +- 发布级 path isolation、secret filtering、MCP allowlist、资源配额和 workspace cleanup。 +- 所有外部服务 runner 的真实凭据联调。 + +这些属于后续能力或发布门槛,分别见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 与 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +## 2. 状态定义 + +测试报告只使用以下状态: + +| 状态 | 含义 | +| --- | --- | +| PASS | 按步骤执行,用户可见行为和日志证据都满足通过条件。 | +| FAIL | 环境可用,但行为不满足通过条件。 | +| BLOCKED | 凭据、CLI、外部服务、测试数据或本地配置缺失导致无法执行。必须写清阻塞原因。 | +| N/A | 当前 runner 或平台明确不支持该能力。必须引用 manifest、文档或配置说明。 | + +不能使用“看起来正常”“大概通过”“基本没问题”等模糊状态。 + +## 3. 执行顺序 + +推荐按以下顺序执行,前一层失败时不要继续扩大测试面: + +1. Host / SDK / runner 单测。 +2. WebUI 登录与 Pipeline Debug Chat 基础 smoke。 +3. `local-agent` 高价值场景。 +4. 外部 code-agent harness smoke。 +5. 权限和错误路径补充检查。 +6. 汇总 PASS / FAIL / BLOCKED,并给出下一步建议。 + +用户可见流程必须通过 WebUI 或真实消息平台验证。API / curl 只能作为诊断证据,不能单独让 UI case PASS。 + +## 4. 必跑基线 + +### 4.1 单测基线 + +在 LangBot 仓库运行: + +```bash +uv run --frozen pytest tests/unit_tests/agent +``` + +如果本次改动只触及默认配置或 API service,也至少补跑相关目标测试,例如: + +```bash +uv run pytest tests/unit_tests/api/test_pipeline_service_defaults.py +``` + +通过条件: + +- agent 单测全 PASS,或失败项已确认与本次 agent-runner 路径无关。 +- 若失败来自 `context_builder`、`orchestrator`、`session_registry`、`resource_builder`、`plugin/handler.py` 的 run action 权限路径,不应进入 UI smoke。 + +### 4.2 环境基线 + +用 `langbot-skills` 做环境检查: + +```bash +cd "$LANGBOT_SKILLS_REPO" +bin/lbs env doctor +bin/lbs case list +``` + +`LANGBOT_SKILLS_REPO` 指向当前工作区里的 `langbot-skills` 仓库。优先使用已有 case,而不是临时发明测试路径。 + +推荐首批 case: + +- `webui-login-state` +- `pipeline-debug-chat` +- `local-agent-basic-debug-chat` +- `local-agent-rag-debug-chat`(改动涉及 RAG / knowledge) +- `local-agent-plugin-tool-call-debug-chat`(改动涉及 tool / resource policy) + +## 5. WebUI 主链路 Smoke + +### 5.1 Runner registry + +步骤: + +1. 打开 WebUI Pipeline 配置页。 +2. 查看 AI runner 下拉列表。 +3. 选择 `plugin:langbot-team/LocalAgent/default`。 +4. 保存并刷新页面。 + +通过条件: + +- runner 选项来自插件 registry。 +- 保存后配置仍为 `ai.runner.id` + `ai.runner_config[id]`。 +- `runner_config` 表示 Agent/runner config,不表示插件实例状态。 +- 不读取或回写旧 `ai.runner.runner` 字段。 +- 不出现旧内置 runner stage 名(例如裸 `local-agent`)作为当前选中项或配置 surface。 +- 插件没有循环重启或 metadata 加载失败。 + +### 5.2 主聊天路径 + +步骤: + +1. 使用绑定 `plugin:langbot-team/LocalAgent/default` 的 Pipeline。 +2. 在 Debug Chat 发送确定性普通文本。 +3. 查看 WebUI 回复和后端日志。 + +通过条件: + +- 用户可见回复正常。 +- 后端日志显示走 `AgentRunOrchestrator` / `RUN_AGENT`。 +- 不走旧内置 local-agent 主执行分支。 +- conversation transcript 写入用户消息和助手消息。 + +## 6. `local-agent` 高价值测试 + +只保留最能覆盖架构边界的场景。 + +| ID | 场景 | 操作 | 通过条件 | +| --- | --- | --- | --- | +| LA-01 | 绑定 prompt | 配置 system prompt 后发送文本。 | runner 使用 `ctx.config.prompt`,不读取 `ctx.adapter.extra["prompt"]`;回复体现绑定 prompt。 | +| LA-02 | history API | 连续两轮对话,第二轮引用第一轮 marker。 | runner 通过 Host history API 或自管上下文读取历史,不依赖 inline history window。 | +| LA-03 | 流式 / 非流式 | 分别用支持流式和关闭流式的路径发送文本。 | 流式 UI 不重复、不空白;非流式只输出最终消息。 | +| LA-04 | 工具调用 | 绑定测试工具,发送会触发工具的 prompt。 | `ctx.resources.tools` 只包含授权工具;工具调用 started/completed;最终回复包含工具结果。 | +| LA-05 | RAG | 绑定测试知识库,发送命中文档的 prompt。 | `ctx.resources.knowledge_bases` 包含所选知识库;runner 通过授权 API 检索;回复使用检索内容。 | +| LA-06 | 多模态 | 发送图片输入。 | `ctx.input.contents` 保留图片;支持视觉模型时正常处理,不支持时受控失败。 | +| LA-07 | fallback / 错误 | 模拟 primary 模型失败或 runner 抛错。 | fallback 或 `run.failed` 行为受控;后续请求不受影响。 | +| LA-08 | 无输出保护 | 测试 runner 完成但不产出消息。 | 不产生空白成功回复;按受控失败或明确缺陷处理。 | +| LA-09 | steering / 运行中追加消息 | 使用支持 steering 的 runner,第一条消息触发长 run;run 未结束时在同 conversation 追加第二条消息。 | 第二条消息被 active run claim,不启动并发 run;runner 通过 `steering_pull` 看到追加输入;EventLog 有 `queued` -> `steering.injected`,若未消费则有 `steering.dropped` 终态;后续普通消息仍可处理。 | + +Rerank、remove-think、文件输入等场景只在本次改动直接涉及时补测,不作为每轮必跑项。 + +## 7. Code-agent Harness Smoke + +这些测试用于验证 ACP、Claude Code、Codex 这类自管 runtime 能走同一条 Host 协议路径。若目标 harness 没有 CLI/daemon、登录态、代理配置或远端 workspace,标记 BLOCKED,不要伪造 PASS。 + +Smoke 前应优先保留一层轻量单测或 fixture 测试:session 创建/复用、消息发送、结果解析、`run_id` 注入和 LangBot MCP gateway 必须有稳定测试覆盖。WebUI smoke 证明真实链路可用,但不能替代转换层和错误映射测试。 + +### 7.1 外部 harness runner + +步骤: + +1. 确认目标 harness(例如 ACP daemon、Claude Code 或 Codex)在对应机器上可执行且已登录。 +2. 绑定目标 runner,例如 `plugin:langbot-team/ACPAgentRunner/default`、`plugin:langbot-team/ClaudeCodeAgent/default` 或 `plugin:langbot-team/CodexAgent/default`。 +3. 配置 runner 必要字段,例如 remote target、workspace、provider、startup timeout、reuse session 等。 +4. 在 Debug Chat 执行一次确定性真实 smoke。 +5. 检查 LangBot MCP gateway、`run_id` 回填和 host-owned state。 + +通过条件: + +- WebUI 可见回复包含预期 sentinel。 +- 发送给 harness 的消息包含当前 LangBot `run_id` 和可访问资源摘要。 +- Harness 通过 gateway 调用 `langbot_history_page`、`langbot_retrieve_knowledge` 或 `langbot_call_tool` 时必须携带正确 `run_id`;错误 run id 被拒绝。 +- `external.session_id` 写入 host-owned state。 +- 外部 harness 错误、timeout、empty output 都转成受控 `run.failed`。 +- resume 到同一 external session 时,全局锁边界符合 PROTOCOL_V1 §13。 + +### 7.2 API 型外部 runner + +Dify、n8n、Coze、DashScope、Langflow、Tbox 等外部服务 runner 不作为每轮必跑项。只有在本次改动触及对应 runner 或凭据已经可用时执行 smoke。 + +通过条件: + +- runner 可选,配置可保存。 +- 请求成功,或外部服务错误被清晰返回。 +- 外部服务凭据缺失时标记 BLOCKED,并记录缺失项。 + +## 8. 权限与隔离补充 + +以下优先用单测 / targeted fixture 覆盖,不要求每次通过 UI 人工构造恶意 runner。 + +| 场景 | 推荐证据 | +| --- | --- | +| 未授权模型调用被拒绝 | `plugin/handler.py` run action 权限测试或目标单测。 | +| 未授权工具调用被拒绝 | `ctx.resources.tools` 与 host action 拒绝日志。 | +| 未授权知识库检索被拒绝 | `ctx.resources.knowledge_bases` 与 host action 拒绝日志。 | +| run_id 结束后复用被拒绝 | session registry 注销测试。 | +| 插件身份不匹配被拒绝 | `caller_plugin_identity` mismatch 测试。 | +| 绑定插件身份的 run_id 省略 caller identity 被拒绝 | `_validate_run_authorization(..., caller_plugin_identity=None)` 返回错误。 | +| 未注册 Runtime 连接伪造插件身份被剥离 | SDK runtime forwarding 测试:请求自带 `caller_plugin_identity` 时,未注册连接转发前必须 `pop`,已注册连接必须覆盖为真实插件身份。 | +| storage/state scope 越权被拒绝 | state/storage proxy 单测。 | +| steering claim 异常不杀 consumer loop | controller 单测:无效 runner / registry 异常只让当前消息回到普通 session 槽位路径,消息消费循环继续。 | +| steering queue 未消费有终态 | session registry / orchestrator 单测:队列有上限;run unregister 时未 pull 项写 `steering.dropped` 审计。 | + +如果这些单测失败,不能用 WebUI 正常回复替代。 + +## 9. 证据要求 + +每轮测试报告至少记录: + +- LangBot commit、SDK commit、相关 runner 插件 commit。 +- Pipeline UUID/name、runner id、关键 runner config 摘要。 +- WebUI 截图或 Playwright 操作记录。 +- 后端日志中对应 query id / run id 的关键行。 +- `langbot-skills` case/report 路径。 +- 外部 harness runner 的 context 文件、session id、working directory、CLI 错误摘要。 +- FAIL/BLOCKED 的复现步骤和归属仓库建议。 + +报告结论必须回答: + +- 是否建议继续进入下一阶段测试。 +- 是否存在主聊天路径阻塞。 +- 是否只是凭据 / 外部服务 / 本机 CLI 缺失导致 BLOCKED。 +- 是否需要进入 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 的发布级验收。 + +## 10. 历史高价值记录 + +历史高价值记录与当前 runner 验收状态见 [STATUS.md](./STATUS.md)。本指南只保留可重复执行的测试步骤和证据要求。 diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md new file mode 100644 index 000000000..7905e8278 --- /dev/null +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -0,0 +1,101 @@ +# Event Based Agent 接入设计 + +> 本文记录 EBA 如何接入当前 AgentRunner Protocol v1 / Host 底座。EventGateway、EventRouter、Event subscription/notification 由外部 EBA 分支实现并联调;本分支只保留 event-first 入口和 envelope/binding models。 +> +> 数据结构唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)(runner 可见)与 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)(Host 内部模型);本文只讲 EBA 语义,不重抄 schema。 +> 与当前 runner 外化分支、后续 Agent Platform / Runtime Control Plane 的边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 + +本文描述 EBA 接入时,事件如何进入 LangBot、如何在平级的 Pipeline / Agent 处理器之间路由,以及 Agent 分支如何复用插件化 AgentRunner 基础设施。本分支不实现完整 EventBus / EventRouter / Platform API;这些能力正在外部 EBA 分支联调。这里的目标是把处理器路由与 runner 协议边界说清楚。 + +## 1. 设计目标 + +- 消息、撤回、入群、好友申请、定时任务、API 调用都能抽象为 host event。 +- EventRouter 可以根据 event type、bot、workspace、conversation、actor、subject 选择一个 Pipeline 或 Agent 处理器。 +- Pipeline 目标执行完整消息 Stage 链;Agent 目标通过统一 orchestrator 调用 AgentRunner。 +- 非消息事件不伪造成用户文本消息。 +- 平台动作执行通过显式 capability / permission / result type 预留,不混入普通文本回复。 + +## 2. 事件不是消息 + +`message.received` 只是事件的一种。协议不应假设:一定有用户文本、一定有 conversation history、一定要返回一条聊天消息、actor 一定等于 sender、subject 一定等于当前消息。 + +| event_type | actor | subject | input | +| --- | --- | --- | --- | +| `message.received` | 发消息的人 | 当前消息 | 文本、图片、文件等 | +| `message.recalled` | 撤回操作者,未知时为系统 | 被撤回消息 | 通常为空 | +| `group.member_joined` | 新成员或邀请人 | 群/成员关系 | 通常为空 | +| `friend.request_received` | 申请人 | 好友申请 | 验证消息或申请理由 | +| `schedule.triggered` | 系统 | 定时任务 | 任务 payload | +| `api.invoked` | API caller | API request | request payload | + +## 3. 稳定事件名 + +先保留的稳定事件名(作为插件协议的一部分保持稳定): + +- `message.received` +- `message.recalled` +- `group.member_joined` +- `friend.request_received` + +平台原始事件名只能进入 `ctx.event.source_event_type` / `raw_ref`,不能成为 `ctx.event.event_type` 的公共契约。 + +## 4. Event Envelope 与 Binding + +- 入口事件用 `AgentEventEnvelope`(HOST_SDK §4.1)承载;顶层字段使用 LangBot 稳定协议名,平台原始事件名和原始 payload 放 `metadata` / `raw_ref`。 +- EBA 持久路由通过 `event_pattern`、`filters`、`target_type` 和 `target_uuid` 选择处理器。只有 `target_type=agent`,或 Pipeline AI Stage 需要调用 runner 时,才进一步解析 `AgentBinding`(HOST_SDK §4.2)。 + +EBA 每个事件只选择一个有效处理器;AgentRunner 调用的基数、Agent 复用和 fan-out 边界以 PROTOCOL_V1 §13 为准。 + +路由 scope 示例:workspace 全局、bot 级、platform channel 级、conversation / group / thread 级、user / actor 级。Pipeline 是 `message.*` 场景的一等处理器,适合需要预处理、AI、后处理、扩展和输出控制的消息链路;Agent 是 runner 驱动的一等处理器,可处理其声明支持的消息与非消息事件。二者都不会被转换成对方。 + +Event Source 可包括:`platform_adapter`(飞书、QQ、微信、Telegram 等)、`webui`、`http_api`、`scheduler`、`system`。EventRouter 不应写死平台 adapter 的类名。 + +## 5. EventRouter 调用链 + +```text +Platform Adapter / WebUI / API + -> Event Gateway normalize payload + -> EventLog append raw event + -> EventRouter resolve one Processor target + -> target_type=pipeline: MessageAggregator -> QueryPool -> Pipeline stages + -> target_type=agent: resolve AgentBinding -> AgentRunOrchestrator + -> AgentRunContextBuilder -> PluginRuntimeConnector.run_agent() + -> AgentRunResult stream + -> DeliveryController render / platform action +``` + +约束:Pipeline 和 Agent 是 EventRouter 的平级目标;Pipeline 仅接受消息事件,Agent 受其事件能力声明约束。任何 AgentRunner 调用都必须复用现有 orchestrator,不能为 EBA 单独实现另一套 plugin runner 协议;非消息事件不能绕过 resource authorization;delivery 和 platform action 走统一权限模型;外部 harness runner 也通过同一套 envelope/binding/context/result 协议接入。observer / fan-out / parallel arbitration 的额外语义仍按 PROTOCOL_V1 §13 处理。 + +## 6. 平台动作执行 + +EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行)将用于请求 host 执行平台动作: + +```json +{ "type": "action.requested", + "data": { "action": "friend.request.accept", + "target": {"platform": "wechat", "request_id": "..."}, + "payload": {"reason": "policy matched"} } } +``` + +Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。 + +Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 +当前 Host 会把 adapter 声明的通用 API 投影到 +`DeliveryContext.platform_capabilities.supported_apis`,并据此设置 +`supports_edit` / `supports_reaction`。该投影只供 runner 选择输出形态,不构成 +平台动作授权;合成测试 adapter 会移除副作用能力并抑制实际出站调用。 + +## 7. 与 Context 协议的关系 + +EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/staged file ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。 + +## 8. 当前集成状态 + +当前分支已完成 EventRouter、Pipeline / Agent 平级处理器路由、Bot +`event_bindings` 持久化与 WebUI、AgentBinding 投影、路由 dry-run、合成测试事件、 +运行状态和真实 OneBot 非消息事件到 Agent 的闭环。Pipeline 消息链和独立 Agent +均复用同一个 AgentRunner orchestrator / context / result 协议。 + +尚未落地的是 platform action permission model 和 `action.requested` 执行器;在显式 +action allowlist、binding policy、adapter capability 和审批模型完成前,该 result 仍只 +记录 telemetry,不执行平台副作用。 diff --git a/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md new file mode 100644 index 000000000..ea18b56c3 --- /dev/null +++ b/docs/agent-runner-pluginization/EXTENSION_SCOPE_MATRIX.md @@ -0,0 +1,51 @@ +# AgentRunner 外化扩展边界矩阵 + +本文用于回答一个问题:本分支只做 AgentRunner 外化时,哪些能力已经作为扩展底座完成,哪些由外部 EBA / Agent Platform / Runtime Control Plane 分支接入,后续分支接入时应该走哪个扩展点。 + +结论:本分支不实现完整 Agent Platform,也不实现完整 EBA。EBA 完整事件网关与事件路由由外部 EBA 分支联调。本分支必须把 runner 外化的 Host / SDK 边界做干净,让外部分支只需要接入持久模型、事件路由或 runtime task,而不需要重写 `AgentRunner Protocol v1`。 + +调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的单一事实源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;本矩阵只说明后续能力应该接入哪个扩展点。 + +## 1. 分支边界 + +| 范围 | 本分支职责 | 不在本分支做 | +| --- | --- | --- | +| AgentRunner Protocol v1 | 定义 Host 调用 runner 的稳定合同:discovery、`AgentRunContext`、result stream、Host pull API、错误和权限边界。 | 不定义 Agent Platform 的产品数据库模型;不定义 runtime task queue。 | +| Host runner 外化底座 | 提供 `AgentEventEnvelope`、`AgentBinding` 运行投影、`run(event, binding)`、resource authorization、run-scoped session、EventLog / Transcript / State / sandbox 文件边界。 | 不实现 EventGateway、scheduler、integration provider、Agent 管控面 UI。 | +| Pipeline 的 AgentRunner 接入 | Pipeline 作为一等消息处理器执行完整 Stage 链;仅在 AI Stage 调用 runner 时,`QueryEntryAdapter` 把当前 Query/config 投影成 event + binding。 | 不把整个 Pipeline 当成临时 Agent;不复制 Pipeline 配置来自动创建 Agent。 | +| 官方 runner 插件 | 作为协议消费者验证 local-agent / 外部 harness runner 能接入 Host 基础设施。 | 不让官方 runner 的内部实现反向决定 Host / SDK 协议形态。 | + +## 2. 扩展矩阵 + +| 能力 | 当前分支状态 | 后续归属 | 后续接入方式 | 禁止事项 | +| --- | --- | --- | --- | --- | +| Product `Agent` | 已有 `agents` 产品表 / API 和运行期 `AgentConfig` / `AgentBinding` 投影;完整 binding persistence / EventRouter / UI 闭环仍未完成。 | Agent Platform / binding persistence UI。 | 持久 Agent 保存 runner id、runner config、resource/state/delivery policy;运行前投影为 `AgentBinding`。 | 不把持久 Agent schema 加进 SDK 协议;插件实例边界见 PROTOCOL_V1 §13。 | +| Agent 处理器调用 runner | 已有单次运行前的 `AgentBinding` 解析投影;AgentRunner 调度语义见 PROTOCOL_V1 §13。 | EBA / Agent Platform。 | EventRouter 先选中 Agent 处理器,再根据 bot、channel、workspace、conversation、event type 解析有效 `AgentBinding`。Pipeline 目标走独立 Stage 链。 | 不用 `AgentBinding` 取代 EBA 的 Pipeline / Agent 处理器选择;不在本矩阵重定义 fan-out / observer 语义。 | +| Agent session / run | 已有持久 `AgentRun` / `AgentRunEvent` ledger 和 active `AgentRunSessionRegistry`;还没有独立 `AgentSession` / task 产品模型。 | Agent Platform / Runtime Control Plane。 | 如需要可新增 `AgentSession` / task 表,但执行仍回到 `run(event, binding)` 或 runtime-managed 等价入口。 | 不把持久 session 字段塞进 `AgentRunContext` 顶层;不要求所有 runner 长期持有 LangBot session。 | +| EventLog / Transcript / Sandbox files | 已完成 Host-owned store、history pull API 和 sandbox 文件边界;runner 不直接写 DB。 | 本分支持续维护底座;Agent Platform 可复用。 | 外部 EBA、scheduler、integration、runtime task 都写同一套 EventLog / Transcript;当前 run 文件通过 sandbox/workspace staging 共享。 | 不让 runner / sandbox 直接访问 Host DB;不把大 payload 内联进 prompt。 | +| Host-owned state / storage | 已有 state snapshot、`state.updated` 处理和 State API;storage 作为授权能力保留。 | 本分支持续维护底座;Runtime / Platform 可复用。 | 外部 session id、working directory、checkpoint 等小 JSON 用 state;当前 run 大对象用 sandbox/workspace 文件。 | 不把跨轮次状态存在插件实例内;不绕过 run-scoped authorization。 | +| EventGateway / EventRouter | 本分支只提供 event-first envelope 和 `run(event, binding)` 入口。 | EBA 分支(联调中)。 | EventGateway 规范化平台/WebUI/API/scheduler 事件;EventRouter 解析一个 binding;调用现有 orchestrator。 | 不为 EBA 新增另一套 runner 调用协议;不把非消息事件伪装成 user message。 | +| Scheduler / Automation | 不实现。文档中只把 `scheduler` 作为 future event source。 | EBA / Agent Platform。 | 定时任务触发 `schedule.triggered` host event,复用 EventGateway -> EventRouter -> `run(event, binding)`。 | 不直接调用某个 runner 插件;不绕过 EventLog / authorization。 | +| Integration provider | 不实现。IM platform adapter 仍是当前平台接入系统。 | EBA / Agent Platform。 | OAuth/webhook/outbound provider 应先转成 canonical host event 或 platform action,再交给 AgentRunner。 | 不把 Linear/Slack/GitHub 等 provider 私有 payload 扩散到 runner 协议顶层。 | +| Platform action / delivery | `action.requested` 已预留但当前仅 telemetry,不执行。`DeliveryContext` 只作为上下文/策略投影。 | EBA / platform action executor。 | 后续 executor 校验 runner capability、binding policy、actor/bot/workspace 权限和审批后执行。 | 不让 runner 直接调用平台 adapter 私有 API;不把平台动作伪装成文本回复副作用。 | +| Runtime registry / worker / task queue | 已落地 Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime registry / heartbeat / claim lease;当前官方外部 harness 仍通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API runner 调用目标运行环境,不在本分支维护完整通用 worker 队列。 | Runtime Control Plane v2。 | 后续可在现有 Host 事实源上补 queued run producer、daemon wakeup、claim execution loop、progress/audit 和运维诊断。 | 不把 heartbeat/task/warm pool 放进 Protocol v1;不让管理插件拥有 runtime/task 事实源。 | +| Warm pool / reconcile / diagnose | 不实现。 | Runtime Control Plane v2 / deployment layer。 | 作为 task/runtime 的运维能力,围绕 Host-owned runtime/task/audit 表实现。 | 不把 runtime 运维语义写进普通 runner 协议;不把 pod/task 细节泄漏给普通 runner。 | +| Agent memory | 不实现通用长期记忆产品层;提供 history/state/storage 和 sandbox 文件基础能力。 | Agent Platform 或具体 runner/plugin。 | 平台 memory 可通过 Host storage/state 或独立产品表实现,runner 通过授权 API 拉取。 | 不在 Host core 内置通用 agentic memory 策略;不默认把 memory 全量 inline 到 context。 | +| External harness native session | ACP / Claude Code / Codex 等 runner 支持 external session id state handoff 和 LangBot resource projection。 | 官方 runner 后续增强;Runtime Control Plane v2 可接管执行。 | 外部 harness 调用继续走 `runner.run(ctx)`;如后续引入长连接/daemon 模式,按 external session key 串行 turn,reader 独占 native stream。 | 不把具体 provider native wire 变成 LangBot 协议;全局锁边界见 PROTOCOL_V1 §13。 | + +## 3. 后续分支接入规则 + +外部 EBA、Agent Platform 或 Runtime Control Plane 分支接入时,默认遵守以下规则: + +- 新入口只生产或解析 Host 内部模型:`AgentEventEnvelope`、持久 Agent 投影出的 `AgentBinding`、以及必要的 delivery/resource/state policy。 +- runner 调用仍走 `AgentRunOrchestrator.run(event, binding)`,除非 Runtime Control Plane 明确引入 runtime-managed 执行模式;即便如此,runner 可见合同仍应保持 Protocol v1。 +- Host-owned facts 继续写入 EventLog / Transcript / State,当前 run 文件继续走 sandbox/workspace;产品层可以新增更高阶视图,但不能替代这些事实源。 +- 新能力如果需要持久化,优先加 Host-owned 表或 service;不要把事实源藏在插件 storage 或 runner subprocess 内。 +- 新 result type 可以按 Protocol v1 的演进规则增加;不能用入口 adapter 私有字段绕过 schema。 +- 任何 fan-out、observer agent、parallel arbitration、platform action execution 都必须单独定义 delivery、state conflict、approval 和 audit 语义。 + +## 4. 与 Agent Platform 产品层的关系 + +这里的 Agent Platform 指面向 agent 产品层的实体拆分:`Agent` 描述可配置 agent,`Session` / `SessionMessage` 描述会话事实,`Automation` 描述自动触发,`IntegrationBinding` 描述外部集成连接,`Memory` 描述长期记忆,`WarmTask` 描述预热/后台任务。这些拆分对 LangBot 后续产品层有参考价值,但不能直接搬进本分支。 + +LangBot 当前分支的对应目标是更底层的:把 IM/WebUI/API 等入口统一投影到 Host event,把 Agent / binding 配置统一投影到 runner binding,把 runner 能力统一收束到 Protocol v1。完整 Agent Platform 可以在这个底座之上构建,而不应反过来污染本分支的 runner 外化边界。 diff --git a/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md new file mode 100644 index 000000000..f862ade25 --- /dev/null +++ b/docs/agent-runner-pluginization/HOST_SDK_INFRASTRUCTURE.md @@ -0,0 +1,266 @@ +# LangBot Host 与 SDK 基础设施设计 + +本文档描述 LangBot 作为 agent host 的内部能力与分层架构,以及 Host 内部模型。 + +- SDK ↔ Host 的协议数据结构(`AgentRunContext`、`AgentRunnerManifest`、`AgentRunResult`、`AgentRunAPIProxy` 等)的**唯一定义在** [PROTOCOL_V1.md](./PROTOCOL_V1.md);本文只引用,不重抄。 +- 测试执行入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 本文定义的 Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、`AgentRunnerDescriptor`)不属于 SDK 协议字段。 + +## 1. 目标 + +LangBot 要转为 agent host,而不是内置 runner 容器: + +- 接收 IM、WebUI、API 和外部 EBA 分支 EventRouter 产生的事件。 +- 接收 EBA 选中的 Agent 处理器,并根据事件、bot、workspace、scope 解析 AgentRunner binding。 +- 发现、校验和调用插件提供的 AgentRunner。 +- 为每次 run 提供受限资源、状态、存储、上下文引用和生命周期控制。 +- 接收 AgentRunner 返回的事件流,投递到 IM、WebUI 或其他 output surface。 + +## 2. 非目标 + +- 不定义 Pipeline 的 Stage 编排语义;Pipeline 是 EBA 的同级处理器,其 AI Stage 只在需要 runner 时接入本 Host 边界。 +- 不要求所有 AgentRunner 依赖 LangBot 的上下文管理。 +- 不要求官方 local-agent 的旧行为反向塑造 host 协议。 +- 不在 host 中实现通用 agentic prompt assembler。 +- 不强制 runner 使用 LangBot state / storage;只提供可选、受控的寄宿能力。 +- 不实现 EventGateway / EventRouter:它们由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` 入口。 + +## 3. 分层架构 + +```text +IM / WebUI / API / EventRouter (external EBA branch) + | + v +Event Gateway (external EBA branch) + | + v +EventRouter -> one Processor target + |-- target_type=pipeline -> Pipeline Stage chain + | + `-- target_type=agent -> AgentBindingResolver + | + v + AgentRunOrchestrator + |-- AgentRunnerRegistry + |-- AgentResourceBuilder + |-- AgentContextBuilder + |-- AgentRunSessionRegistry + |-- PersistentStateStore / EventLogStore / TranscriptStore + |-- Sandbox / workspace file tools + v + Plugin Runtime / AgentRunner + | + v + AgentRunResult stream + | + v +Delivery / Renderer / Platform API +``` + +Pipeline 与 Agent 是 EventRouter 的平级处理器目标。本文只定义 AgentRunner Host 边界:Agent 目标直接解析 `AgentBinding`;Pipeline 目标执行自己的完整 Stage 链,仅在 AI Stage 调用 runner 时通过 Query entry adapter 构造一次性 `AgentConfig` / `AgentBinding`。该 runner 调用投影不改变 Pipeline 的一等处理器地位,也不会把 Pipeline 持久化为 Agent。AgentRunner 的单绑定调度、Agent 复用、插件实例无状态和 fan-out 边界以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13 为准。EventGateway / EventRouter 由外部 EBA 分支实现并联调。 + +## 4. LangBot 侧能力 + +### 4.1 Event Gateway / EventRouter(External EBA Branch Integration Point) + +> EventGateway / EventRouter 由外部 EBA 分支实现并联调,不在本分支范围。本分支只保留 event-first 入口和 envelope/binding models。 + +Event Gateway 将把入口统一成 host event(IM 平台消息、WebUI debug chat、API 触发、后续非消息事件),输出稳定的 `AgentEventEnvelope`(Host 内部模型): + +```python +class AgentEventEnvelope(BaseModel): + event_id: str + event_type: str + event_time: int | None + source: str + bot_id: str | None + workspace_id: str | None + conversation_id: str | None + thread_id: str | None + actor: ActorRef | None + subject: SubjectRef | None + input: AgentInput # 见 PROTOCOL_V1 §5.6 + delivery: DeliveryContext # 见 PROTOCOL_V1 §5.7 + raw_ref: RawEventRef | None + metadata: dict[str, Any] = {} +``` + +`AgentEventEnvelope` 是 Host 内部入口模型;投影给 runner 的是 `ctx.event`(PROTOCOL_V1 §5.4)。原始平台 payload 存为 raw event 或 staged file reference,不扩散到 runner 协议顶层。 + +**当前 adapter source**:`QueryEntryAdapter.query_to_event(query)` 从 Query 生成 `AgentEventEnvelope`。 + +### 4.2 AgentConfig 与 AgentBinding + +`AgentConfig` 是 Host 内部的一次 AgentRunner 调用配置投影(不暴露给 SDK)。独立 Agent 从自己的持久配置生成它;Pipeline 只在 AI Stage 调用 runner 时,由 Query entry adapter 从该 Stage 的当前配置生成它。两种来源随后都由 BindingResolver 结合事件和 scope 解析为 `AgentBinding`。Pipeline 本身不是 `AgentConfig`,该调用投影也不会创建或更新持久 Agent。 + +```python +class AgentConfig(BaseModel): + agent_id: str | None = None + runner_id: str + runner_config: dict[str, Any] = {} + resource_policy: ResourcePolicy = ResourcePolicy() + state_policy: StatePolicy = StatePolicy() + delivery_policy: DeliveryPolicy = DeliveryPolicy() + event_types: list[str] = ["message.received"] + enabled: bool = True + metadata: dict[str, Any] = {} +``` + +`AgentBinding` 是"什么事件调用哪个 AgentRunner、带什么 Agent 配置"的 Host 内部运行投影(不暴露给 SDK)。它是 EventRouter / 当前 QueryEntryAdapter 在一次运行前解析出的有效绑定。 + +```python +class AgentBinding(BaseModel): + binding_id: str + enabled: bool + scope: BindingScope + event_types: list[str] + filters: list[EventFilter] = [] # EBA 阶段使用,见 EVENT_BASED_AGENT + runner_id: str + runner_config: dict[str, Any] + resource_policy: ResourcePolicy + state_policy: StatePolicy + delivery_policy: DeliveryPolicy +``` + +BindingResolver 的基数、fan-out 和冲突处理约束见 PROTOCOL_V1 §13;本节只定义 Host 内部投影形态。 + +**当前 adapter source**:`QueryEntryAdapter.config_to_agent_config(query, runner_id)` +只在 Pipeline AI Stage 调用 runner 时,把 current config 临时投影为运行期 `AgentConfig`,再由 +`AgentBindingResolver.resolve_one(event, [agent_config])` 解析出唯一 +`AgentBinding`。该调用从 Pipeline 取得 AI runner config +→ runner_config、extension preference → resource_policy、output settings → +delivery_policy,但 Pipeline 仍执行并拥有完整 Stage/config 语义。该适配不会把 Pipeline 持久化为 Agent;独立 Agent 由用户自行新增和绑定。 + +### 4.3 AgentRunnerRegistry + +Registry 收集 runner descriptor(来自插件 runtime、开发期本地插件): + +```python +class AgentRunnerDescriptor(BaseModel): + id: str + source: Literal["plugin"] + label: I18nObject + description: I18nObject | None = None + plugin_author: str + plugin_name: str + runner_name: str + capabilities: AgentRunnerCapabilities # 见 PROTOCOL_V1 §4.3 + permissions: AgentRunnerPermissions # 见 PROTOCOL_V1 §4.4 + config_schema: list[DynamicFormItemSchema] + plugin_version: str | None = None + raw_manifest: dict[str, Any] = {} +``` + +职责:调用 `plugin_connector.list_agent_runners()` 拉取 runner、校验 typed `AgentRunnerManifest`、输出 descriptor、缓存 discovery 结果并提供 `refresh()`。单个插件 manifest 失败只记 warning,不影响其它 runner。`plugin:author/name/runner` 是稳定 id 格式;插件实例边界见 PROTOCOL_V1 §13。 + +Host 内置 runner / adapter 不能作为 `AgentRunnerDescriptor.source` 绕过插件 +runtime、`run_id`、`ctx.resources` 和 `AgentRunAPIProxy` 权限链。若需要 +开发期调试 adapter,应放在 Host 内部测试入口,不进入可选 runner 列表。 + +刷新触发点:插件安装/卸载/升级/重启后;Pipeline metadata 请求时发现缓存为空;可选 TTL(优先保证正确性)。 + +### 4.4 AgentRunOrchestrator + +Orchestrator 是唯一运行入口: + +```text +run(event, binding) + -> resolve runner descriptor + -> build resources + -> build context + -> register run session + -> call plugin runtime + -> normalize result stream + -> update state + -> unregister run session +``` + +它负责:`run_id` 生成和生命周期、timeout/deadline/cancellation、插件异常隔离、result schema 校验和大小限制、`state.updated` 处理、delivery backpressure 和 telemetry。 + +典型 run 时序: + +```text +QueryEntryAdapter / EventRouter + -> AgentRunOrchestrator.run(event, binding) + -> AgentRunnerRegistry.resolve(runner_id) + -> AgentResourceBuilder.freeze_snapshot(binding, event) + -> AgentRunSessionRegistry.register(run_id, runner_id, snapshot) + -> AgentContextBuilder.build(event, binding, snapshot) + -> PluginRuntimeConnector.run_agent(ctx) + -> AgentRunAPIProxy action + -> validate active run session + caller identity + snapshot + -> Host API / Store + <- AgentRunResult stream + -> apply state.updated to PersistentStateStore + -> write message.completed to Transcript + -> keep current-run files and large tool outputs in sandbox/workspace + -> render delivery or raise RunnerExecutionError + -> AgentRunSessionRegistry.unregister(run_id) +``` + +`run_from_query()` 保留为 Query entry adapter 入口,但内部转换成 event + binding 后走统一 `run()`。约束:`ChatMessageHandler` 不解析 `plugin:*`、不实例化 wrapper、不知道 runner 组件细节;`PipelineService` 从 registry 读取 metadata,不直接访问插件 runtime;跨请求持久化状态必须走授权 storage / 外部服务。 + +### 4.5 Resource Authorization + +LangBot 在每次 run 前生成 `ctx.resources`(PROTOCOL_V1 §6),来自 manifest permissions 与 binding policy 的交集: + +1. `descriptor.permissions` 声明 runner 需要的 LangBot 资源访问上限。 +2. binding / resource policy 允许的资源范围。 +3. Agent/runner config 中选择的模型、知识库、文件等资源。 +4. 当前 event / actor / bot / workspace 的实际权限。 +5. `ctx.context.available_apis` 暴露的 pull API 能力。 + +这次裁剪结果必须冻结为 run-scoped authorization snapshot,并由 +`AgentRunSessionRegistry` 按 `run_id` 保存。`ctx.resources` 是投影给 runner +看的同一份授权结果;运行期每个 proxy action 只依据该 snapshot 校验 active +run session、caller plugin identity、resource id、scope、payload size、rate +limit 和 deadline。Handler 不应重新执行授权裁剪,否则 build-time 与 runtime +授权逻辑会漂移。 + +SDK 侧本地校验只用于开发体验,host 侧 run authorization snapshot 才是安全边界。`spec.capabilities` 只帮助 Host 判断 runner 是否需要 tool / knowledge 等资源投影,不能替代 permissions 或 binding policy。skill 不由独立 capability 决定是否投影——它通过统一 tool 授权(`resource_policy.allowed_tool_names`)消费,`skill_authoring` 仅作为「一键授权这组 skill tool + sandbox」的便捷开关。 + +资源裁剪应通用,不写死 local-agent。selector 与资源的映射示例:`model-fallback-selector` → primary/fallback LLM、`llm-model-selector` → LLM、`rerank-model-selector` → rerank 模型、`knowledge-base-multi-selector` → 知识库;新增 selector 时在 resource builder 中统一扩展。 + +构造 `ctx.resources.tools` 时,Host 一次塞齐每个工具的完整 schema(`ToolResource.parameters`),runner 不需再逐个 `get_tool_detail` 拉取,减少 N 次往返。 + +执行/文件/skill/MCP 等能力的接入方向:先由 Host / sandbox 封装成普通 scoped tool,再通过 `ctx.resources.tools` 和 SDK runtime 转发进入 runner;runner 不应识别或硬编码执行环境 provider。外部 harness 的 native tools 不能直接访问 LangBot 资源。skill 的整个生命周期都走统一 tool:发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,包内操作走 native exec/read/write——runner 不需要独立的 skill 渲染或门控。 + +### 4.6 State / Storage + +LangBot 可提供 host-owned state 让 runner 寄宿状态(conversation / actor / subject / runner / binding / workspace state),但**不是强制**。Host 只需提供:授权开关、scope key、get/set/list/delete API(见 PROTOCOL_V1 §8)、持久化 backend、审计和清理策略。外部 agent runtime 可维护自己的 session 和 memory。进程内 state store 只能作为过渡实现,不能作为正式生产语义。 + +部分 host-owned state 由 Host 自身直接写:例如 `activate` tool 在 Host 侧执行时,把已激活 skill 写入 conversation scope 的 `host.activated_skills`。host 直接写与 runner `state.updated` 写到同一 key 时按 **last-write-wins** 合并,runner 可覆盖。 + +### 4.7 EventLog / Transcript / Sandbox Files(事实源) + +- `EventLog`: durable append-only,保存原始事件、系统事件、工具调用、投递结果、错误。 +- `Transcript`: 从 EventLog 投影出的对话视图,用于 UI、审计和按需历史读取。 +- `Sandbox / workspace files`: 当前 run 的上传文件、平台附件、工具大结果和临时产物。Host 负责 staging 与授权边界,runner 通过 read/write/exec 类工具按需访问。 + +三类数据与 working context 的边界、读取约束见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。AgentRunner 可读取这些能力,但不被迫使用 LangBot 作为唯一记忆系统。 + +### 4.8 External harness resource projection + +Claude Code、Codex、Kimi Code 等外部 harness runner 可能不直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源句柄投影到自己的 harness 执行。Host 侧仍保持统一边界:Host 负责构造 event-first context、资源授权、state/storage、EventLog/Transcript、sandbox/workspace 文件边界和审计;Host 或 binding policy 决定哪些 MCP bridge、skill-backed tool、sandbox path、history/state 句柄可投影给 runner;runner plugin 把 scoped projection 转成目标 harness 可消费形式;所有 LangBot 资源访问必须经 SDK runtime / `AgentRunAPIProxy` / SDK-owned MCP bridge 转发并接受 Host 校验;外部 harness 负责自己的 native session、tool loop、压缩、权限模式和 resume,但不能用 native tools 绕过 Host 授权。 + +投影的具体形态(context 文件、resource handles、LangBot MCP gateway、state pointers)见 AGENT_CONTEXT_PROTOCOL §4.5;当前 code-agent harness runner 形态见 OFFICIAL_RUNNER_PLUGINS §7。发布级隔离要求见 SECURITY_HARDENING。 + +## 5. SDK 侧协议 + +SDK 组件入口如下;所有数据结构定义见 PROTOCOL_V1。 + +```python +class AgentRunner(BaseComponent): + __kind__ = "AgentRunner" + + @classmethod + def get_config_schema(cls) -> list[dict]: ... + + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: ... + # ctx: PROTOCOL_V1 §5.2 ; AgentRunResult: PROTOCOL_V1 §7 +``` + +- Manifest / capabilities / effective access:PROTOCOL_V1 §4。Capabilities 来自组件 manifest 的 `spec.capabilities`,不是 SDK 基类 classmethod。 +- `AgentRunContext`:PROTOCOL_V1 §5.2。`messages` / `bootstrap` 不是协议字段。 +- `AgentRunResult`:PROTOCOL_V1 §7。 +- `AgentRunAPIProxy`:PROTOCOL_V1 §8,是 runner 访问 host 能力的唯一入口,所有请求带 `run_id`。 diff --git a/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md new file mode 100644 index 000000000..b342c62e8 --- /dev/null +++ b/docs/agent-runner-pluginization/OFFICIAL_RUNNER_PLUGINS.md @@ -0,0 +1,138 @@ +# 官方 AgentRunner 插件迁移计划 + +本文档描述内置 `RequestRunner` 迁出 LangBot 后,官方 runner 插件如何组织、迁移和验收。它是 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 和 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 的下游落地计划,不是 LangBot 宿主协议的设计前提。QA 入口和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +官方 `local-agent` 可以外移,也可以重写。设计重点不是保留旧内置 runner 的内部结构,而是验证一个依附 LangBot host 基础设施的官方 agent 能否完整工作。同时,LangBot host 协议必须服务 Claude Code SDK、Codex、Pi Agent SDK、外部 Agent 平台等自管 context/runtime 的 runner,不能被官方插件的实现细节绑死。 + +## 1. 仓库组织 + +官方 runner 插件与 LangBot 主仓库、SDK 仓库以不同节奏迭代:LangBot 主仓库只维护宿主协议和调度,SDK 仓库维护 AgentRunner 组件和 runtime 协议,官方 runner 插件承载业务 runner 的具体实现和第三方平台适配。 + +当前推荐"官方插件可独立发布,必要时共享 SDK helper"。开发期采用本地多目录布局: + +```text +langbot-app/ + langbot-local-agent/ # plugin:langbot-team/LocalAgent/default + manifest.yaml + components/agent_runner/default.{yaml,py} + langbot-agent-runner/ # 外部服务 runner 仓库 + acp-agent-runner/ claude-code-agent/ codex-agent/ dify-agent/ n8n-agent/ ... +``` + +后续可聚合进 monorepo,也可继续独立发布——这个选择不影响协议设计。重复逻辑优先沉淀到 SDK 或明确的共享 helper 包,不要把宿主私有结构泄漏给插件。旧 `src/langbot/pkg/provider/runners/*` 只作为历史行为对齐基准;当前未发布分支不提供旧内置 runner 的运行时 fallback。 + +## 2. 插件命名和 runner id + +| 旧 runner | 官方插件 | runner id | +| --- | --- | --- | +| `local-agent` | `langbot-team/LocalAgent` | `plugin:langbot-team/LocalAgent/default` | +| `dify-service-api` | `langbot-team/DifyAgent` | `plugin:langbot-team/DifyAgent/default` | +| `n8n-service-api` | `langbot-team/N8nAgent` | `plugin:langbot-team/N8nAgent/default` | +| `coze-api` | `langbot-team/CozeAgent` | `plugin:langbot-team/CozeAgent/default` | +| - | `langbot-team/ACPAgentRunner` | `plugin:langbot-team/ACPAgentRunner/default` | +| - | `langbot-team/ClaudeCodeAgent` | `plugin:langbot-team/ClaudeCodeAgent/default` | +| - | `langbot-team/CodexAgent` | `plugin:langbot-team/CodexAgent/default` | +| `dashscope-app-api` | `langbot-team/DashScopeAgent` | `plugin:langbot-team/DashScopeAgent/default` | +| `deerflow-api` | `langbot-team/DeerFlowAgent` | `plugin:langbot-team/DeerFlowAgent/default` | +| `langflow-api` | `langbot-team/LangflowAgent` | `plugin:langbot-team/LangflowAgent/default` | +| `tbox-app-api` | `langbot-team/TboxAgent` | `plugin:langbot-team/TboxAgent/default` | +| `weknora-api` | `langbot-team/WeKnoraAgent` | `plugin:langbot-team/WeKnoraAgent/default` | + +每个插件可后续提供多个 runner,但迁移目标的默认 runner 统一叫 `default`。 + +## 3. 迁移批次 + +- **Batch 1(打通协议)**:`local-agent`(能力最完整基准)、`acp-agent-runner` / `claude-code-agent` / `codex-agent`(外部 code-agent harness 路径)、`dify-agent`(传统 service API runner)。 +- **Batch 2(外部 workflow)**:`n8n-agent`、`langflow-agent`(webhook/workflow 输入输出、timeout、外部 conversation id)。 +- **Batch 3(平台 Agent API)**:`coze-agent`、`dashscope-agent`、`tbox-agent`、`deerflow-agent`、`weknora-agent`(平台特有响应格式、引用资料、文件/图片输入、外部 thread/session 状态)。 + +## 4. 每个官方插件的组件要求 + +每个插件至少包含一个 `AgentRunner` 组件,manifest 示例: + +```yaml +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: default + label: { en_US: Dify Agent, zh_Hans: Dify Agent } + description: + en_US: Run a Dify application as a LangBot AgentRunner. + zh_Hans: 将 Dify 应用作为 LangBot AgentRunner 运行。 +spec: + config: [] + capabilities: # 字段语义见 PROTOCOL_V1 §4.3 + streaming: true +execution: + python: { path: ./main.py, attr: DefaultAgentRunner } +``` + +## 5. local-agent 插件方向 + +`local-agent` 是官方插件中能力最完整的消费者,但不是宿主协议的设计中心。它需要证明:一个主要依附 LangBot host 能力的 agent runner 可以通过公开协议完成模型、工具、知识库、状态、history、sandbox 文件访问、上下文压缩和消息投递。 + +迁移或重写需覆盖旧内置 runner 的用户可见能力:model primary/fallback 选择、prompt、knowledge-bases、rerank-model、rerank-top-k、function calling、streaming、multimodal input、conversation history、monitoring metadata。 + +责任边界与 Host API 消费方式见 AGENT_CONTEXT_PROTOCOL §8。关键约束: + +- 从 `ctx.config` 读取静态绑定 `prompt`,**不**读取 `ctx.adapter.extra["prompt"]`;不消费 Query entry adapter 生成的历史窗口。 +- 通过 `AgentRunAPIProxy.history` 拉取 transcript,而不是依赖 host 每轮强塞历史窗口。 +- `ctx.input.contents` 保留图片/文件等多模态内容;RAG 只替换/插入文本部分,不丢图片/文件。 +- 不能绕过 `ctx.resources` 调用未授权模型、工具或知识库。 +- manifest 声明功能能力、LangBot 资源 permissions 和配置表单;实际授权来自 manifest permissions 与 binding resource policy、runner config、`ctx.context.available_apis` 和 Host run session snapshot 的交集。 + +### 5.1 Native Execution / Skills 后续接入 + +本阶段不把 sandbox/skills 做成 AgentRunner 协议字段。后续 sandbox/skills 分支合并后,命令执行、文件操作、skill、MCP managed process 应先由 Host / sandbox 封装成 scoped tools,再通过 `ctx.resources.tools` 和 SDK runtime 转发暴露给 runner。这让 local-agent 只消费授权后的 Host 基础设施,而不是直接持有宿主机执行能力。 + +## 6. 外部 runner 插件要求 + +外部平台 runner 迁移遵循:旧配置字段尽量保持同名便于 migration 复制;输出统一转换为 `AgentRunResult`;外部 API timeout 从 runner config 读取;平台 conversation id 存 plugin storage 或 context runtime state,不依赖 LangBot 内置 conversation uuid 私有结构;流式按平台能力声明,没有流式就只发 `message.completed`。 + +### 6.1 Code-agent harness runner + +Claude Code、Codex、Kimi Code 这类 runner 不一定通过 LangBot 的模型/工具 loop 执行,可以依赖自己的 harness,但仍必须遵守统一 Host 边界。总体边界见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) §4.8;context projection 形态见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) §4.5;发布级要求见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +本文件只补充官方 runner 的实现要求:输入来自 `ctx.event` / `ctx.input`,不依赖 Pipeline 私有 `Query`;外部 session id / workspace / checkpoint 写入 Host state 或 plugin storage;插件实例边界见 PROTOCOL_V1 §13;CLI / subprocess runner 必须处理 timeout、取消、空输出、非零退出和 stderr 映射。 + +实现结构应把 provider-native output 解析与 LangBot result stream 组装分开:Claude stream-json、Codex JSONL、Kimi / OpenCode 事件等只在 runner adapter 内解析,输出统一归一为 `AgentRunResult`(`message.completed` / `message.delta`、`state.updated`、`run.completed` / `run.failed`)。文件和工具大结果留在当前 run 的 sandbox/workspace,通过消息 metadata、attachment ref 或 path 指向。未知 native event 不应导致 run 崩溃;应记录诊断 metadata 或 warning。新增 harness 时优先补 native fixture -> `AgentRunResult` 的转换测试,再接 WebUI smoke。 + +并发约束应按外部 session 粒度表达,而不是按 Agent / runner id / 插件实例表达;Agent 复用和全局锁边界见 PROTOCOL_V1 §13。若 runner 使用 `external.session_id` / `thread_id` resume 到同一 native session,且该 harness 不支持并发 turn,runner 应按稳定 external session key 串行写入;一次性 subprocess runner 可以只在单次 `run(ctx)` 内处理,长连接/daemon runner 则应采用 reader 独占 native stream、turn writer 串行写入的结构。 + +### 6.2 LangBot MCP gateway + +外部 harness 不能直接持有进程内的 `plugin_runtime_handler`,也不能用自己的 native tools 直接访问 LangBot 资源。外部 harness runner 应通过稳定 HTTP MCP gateway 或 SDK-owned bridge 把 harness 的工具请求转回 SDK runtime / Host API: + +- Gateway 由 runner 插件启动,暴露稳定的 `langbot_history_page`、`langbot_retrieve_knowledge`、`langbot_call_tool` 等最小工具面。 +- Harness 每次调用必须携带当前 LangBot `run_id`;Host 仍按 run session、caller identity 和授权快照校验。 +- Gateway 只转发 LangBot 资产访问,不承担外部 harness 的文件、进程或 native tool 权限边界。 + +第一批工具保持很小:history page、knowledge retrieve、authorized tool call。新增工具必须先有 Host action 权限与 run-scoped authorization,再由 gateway 投影。 + +## 7. Code-agent harness runner 当前形态 + +外部 code-agent harness 由直接 runner 插件承接,例如 `acp-agent-runner`、`claude-code-agent`、`codex-agent`,每个 runner 负责把目标 harness 的 native session、workspace、MCP bridge 和输出事件转换为统一 `AgentRunResult`。本地 smoke 验收入口与记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +当前形态: + +- Runner ID 示例:`plugin:langbot-team/ACPAgentRunner/default`、`plugin:langbot-team/ClaudeCodeAgent/default`、`plugin:langbot-team/CodexAgent/default`。 +- Runner 可通过 ACP、远端 daemon、本机 subprocess 或外部 HTTP API 调用 harness;harness 的安装、登录态、workspace 和 provider-native 权限由该运行环境负责。 +- Runner 会把当前 LangBot `run_id`、可访问资源摘要和 gateway 使用规则注入本次消息;harness 通过 gateway 回填 `run_id` 后访问 LangBot 资产。 +- 外部 session id / workspace / checkpoint 写回 Host state 或 plugin storage,后续轮次可复用目标 harness 会话。 + +### 7.1 当前限制 + +这不是发布级安全边界实现;LangBot 只约束 LangBot 持有资产的访问,外部 harness 的文件、进程、workspace、provider-native MCP 和模型凭据由对应 runner 的运行环境承担。当前 `run_id` 可由系统提示词、ACP metadata 或 runner 自有 session metadata 传递给 harness 并由 gateway 校验。runtime 管控面方向见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。 + +## 8. 发布和安装策略 + +最终 LangBot 安装/升级时需保证官方 runner 插件可用,可选方案:首次启动检测缺失并提示安装,或由用户从 marketplace 安装。当前分支未发布,因此不保留历史 Pipeline Agent 配置兼容、旧内置 runner fallback,也不把旧 Pipeline 内的 Agent 配置迁移成独立 Agent。4.x 只读取 `ai.runner.id` 与 `ai.runner_config[id]`;升级后由用户选择或安装需要的 AgentRunner。 + +## 9. 验收标准 + +- 每个目标 runner 都有对应官方 AgentRunner 插件和稳定 runner id;当前配置只使用 `ai.runner.id` + `ai.runner_config[id]`。 +- LangBot 主聊天路径不再通过 `RequestRunner` 执行业务 runner。 +- 官方插件测试覆盖非流式、流式、错误、timeout、配置缺失。 +- `local-agent` 能完成模型 fallback、tool calling、知识库检索、多模态输入、静态绑定 prompt 消费、history API 拉取、rerank。 +- 外部 code-agent harness runner 能消费 event-first context、投影 scoped resources、保存 external session state,并通过 WebUI Debug Chat smoke。 +- `local-agent` 覆盖旧内置 runner 的用户可见核心能力;代码结构和运行路径不需要相同。 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md new file mode 100644 index 000000000..1b6c8aa1c --- /dev/null +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -0,0 +1,742 @@ +# LangBot AgentRunner Protocol v1 + +本文档是 LangBot Host 与插件 SDK / Runtime / AgentRunner 之间协议合同的**唯一规范来源(single source of truth)**。 + +- 本文件描述当前 Protocol v1 稳定合同,不混入验收流水。当前实现状态见 [STATUS.md](./STATUS.md),测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- 本文件之外的任何文档**不得重新定义这里的数据结构**,只能引用,例如"见 PROTOCOL_V1 §4.2"。 +- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)不属于 SDK 协议,定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 + +## 1. 协议目标 + +Protocol v1 只解决四件事: + +- LangBot 如何发现插件提供的 AgentRunner。 +- LangBot 如何把一次事件调用封装成 `AgentRunContext`。 +- AgentRunner 如何以事件流形式返回运行结果。 +- AgentRunner 如何通过受限 API 访问 LangBot host 能力。 + +Protocol v1 **不定义**: + +- LangBot 内部如何持久化 `AgentBinding`(见 HOST_SDK)。 +- AgentRunner 内部如何组装 prompt、压缩历史、管理 memory(见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md))。 +- 官方 runner 的具体实现(见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md))。 +- Pipeline 的长期配置模型。 +- 发布级安全 hardening 的完整实现(见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md))。 + +## 2. 参与方 + +| 名称 | 职责 | +| --- | --- | +| LangBot Host | 事件入口、绑定解析、权限、资源、存储、生命周期、结果投递。 | +| Plugin Runtime | 加载插件,响应 Host 的 runner discovery 和 run 调用。 | +| AgentRunner | 插件提供的 agent 执行组件。 | +| AgentRunAPIProxy | AgentRunner 访问 Host 能力的受限 API。 | +| AgentBinding | Host 内部的事件到 runner 绑定配置,不直接暴露给 SDK(见 HOST_SDK §4.2)。 | + +产品层同时保留 Pipeline 与独立 `Agent`:现有 Pipeline 不迁移为 Agent; +用户可以新建 Agent 并绑定到 bot / IM channel,一个 Agent 可以被多个 bot / channel 复用。Host 内部的 +`AgentBinding` 是一次事件运行前解析出的有效绑定,只影响 Host 构造出的 +`ctx.config`、`ctx.resources`、`ctx.context` 和 `ctx.delivery`。SDK 不需要知道 +Agent / binding 的持久化形态。 + +外部 harness runner(Claude Code、Codex、Kimi Code 等)也是 `AgentRunner`:它们消费 event-first `AgentRunContext`、返回 `AgentRunResult`,并通过 Host 授权的 state/storage API 保存跨轮次指针;当前运行文件和工具大结果进入 sandbox/workspace。它们内部可以继续使用自己的 session、tool loop、MCP、上下文压缩和权限模型。 + +## 3. 协议演进 + +当前 AgentRunner 合同不暴露显式 `protocol_version` 字段。协议演进先按字段级兼容规则处理: + +- 新增可选字段保持向后兼容。 +- 删除字段或改变既有字段语义,需要在 SDK 发布前完成;发布后应走新的显式兼容方案。 +- 结果流演进:Host **必须忽略未知 result type 并记录 warning**(除非该 type 明确要求强校验)。SDK envelope 接收入站未知 `type` 字符串,runner 侧可按原字符串转发或忽略;新增 result type 不提升大版本。 +- SDK 入站 context 类实体偏宽松,用于兼容 Host 附加的非核心字段;Host 返回 DTO(history/event/steering/run/runtime/stats/error 等)忽略未知字段,保证 Host 增加可选返回字段时旧 SDK 不会解析失败;manifest、runner result payload 等由插件/runner 提交给 Host 的输入合同仍偏严格。安全边界仍在 Host,SDK 校验只提升开发体验。 + +## 4. Discovery 协议 + +### 4.1 LIST_AGENT_RUNNERS + +Host 调用 Plugin Runtime 获取当前插件暴露的 runner 列表,请求无额外 payload。返回: + +```python +class ListAgentRunnersResponse(BaseModel): + runners: list[AgentRunnerDiscovery] + +class AgentRunnerDiscovery(BaseModel): + plugin_author: str + plugin_name: str + runner_name: str + manifest: AgentRunnerManifest +``` + +`manifest` 是 SDK typed `AgentRunnerManifest`,由 Runtime 从插件组件 manifest 解析并校验后返回。`plugin_author` / `plugin_name` / `runner_name` 保留为 transport 寻址字段;Host 以它们生成稳定 runner id,并把 `manifest.id` 校验为 `plugin:author/name/runner`。单个 runner manifest 解析失败时 Runtime/Host 记录 warning 并跳过该 runner,不影响同一插件或其它插件的 runner discovery。 + +### 4.2 AgentRunnerManifest + +这里的 manifest 指 Runtime 返回给 Host 的 typed runner manifest: + +```python +class AgentRunnerManifest(BaseModel): + id: str + name: str + label: I18nObject + description: I18nObject | None = None + capabilities: AgentRunnerCapabilities = AgentRunnerCapabilities() + permissions: AgentRunnerPermissions = AgentRunnerPermissions() + config_schema: list[DynamicFormItemSchema] = [] + metadata: dict[str, Any] = {} +``` + +- runner id 由 Host 生成,格式 `plugin:author/name/runner`。 +- `name` 是插件内 runner 名称,例如 `default`。 +- `config_schema` 只描述绑定配置表单,不代表插件实例状态。 +- `capabilities` 是 Host 用于 UI 和资源投影的 typed bool model;它不是权限授予。 +- `permissions` 是 runner 申请的 LangBot 资源访问上限;实际授权仍必须与 binding policy 求交。 +- `metadata` 只放展示、诊断、非稳定扩展信息。 + +### 4.3 Capabilities + +```python +class AgentRunnerCapabilities(BaseModel): + streaming: bool = False + tool_calling: bool = False + knowledge_retrieval: bool = False + multimodal_input: bool = False + skill_authoring: bool = False + interrupt: bool = False + steering: bool = False + + model_config = ConfigDict(extra="forbid") +``` + +- `streaming`: runner 可以返回 `message.delta`。 +- `tool_calling`: runner 可能调用 Host tool API。 +- `knowledge_retrieval`: runner 可能调用 Host knowledge API。 +- `multimodal_input`: runner 可以处理非纯文本 input / attachment。 +- `skill_authoring`:(降级为便捷开关,非访问硬前提)声明该 runner 期望使用 LangBot skill 工具链。skill 本身通过**统一 tool 授权**获得——发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,操作走 native exec/read/write,全部计入 `resource_policy.allowed_tool_names`。该 capability 仅作为「一键授权这组 skill tool + sandbox」的便捷开关,不再单独决定 skill 是否可用。 +- `interrupt`: runner 支持取消或中断。 +- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。 + +Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。 + +### 4.4 Permissions 与 Effective Access + +```python +class AgentRunnerPermissions(BaseModel): + models: list[Literal["invoke", "stream", "rerank"]] = [] + tools: list[Literal["detail", "call"]] = [] + knowledge_bases: list[Literal["list", "retrieve"]] = [] + history: list[Literal["page", "search"]] = [] + events: list[Literal["get", "page"]] = [] + storage: list[Literal["plugin", "workspace"]] = [] + files: list[Literal["config", "knowledge"]] = [] + + model_config = ConfigDict(extra="forbid") +``` + +平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。 + +Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照: + +```text +effective_access = manifest.permissions ∩ binding.resource_policy ∩ current scope/config +``` + +具体落地: + +1. `AgentResourceBuilder` 先用 manifest permissions 与 binding resource policy / runner config 求交,生成 `ctx.resources`。 +2. `AgentContextBuilder` 用 manifest permissions 与 binding state/storage policy 求交,生成 `ctx.context.available_apis`。 +3. `AgentRunSessionRegistry` 冻结 run-scoped resources 与 available APIs。 +4. Runtime handler / `AgentRunAPIProxy` 按 active `run_id`、runner identity、caller plugin identity、resource id、scope、payload size、rate limit 和 deadline 校验每次调用。 + +反承诺:manifest permissions **只约束 LangBot 持有的资源访问**。它不承诺限制外部 harness 的 native shell、文件系统、CLI、MCP、网络或本机权限;这些能力由 operator/runtime/sandbox 另行约束,见 HOST_SDK §4.8 与 SECURITY_HARDENING。 + +默认原则: + +- Host 不得默认 inline 全量历史。 +- Host 只 inline 当前 event / input 和 context handles。 +- Runner 拥有 working context assembly。 +- Runner 可在授权后通过 Host history / event / state API 拉取更多上下文,并通过授权 sandbox/workspace 工具访问当前运行文件。 +- 历史窗口策略不属于 Protocol v1 字段,也不属于 Host 通用语义。 + +context 边界的设计理由见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 + +## 5. Run 协议 + +### 5.1 RUN_AGENT + +Host 调用 Runtime: + +```python +class AgentRunRequest(BaseModel): + runner_id: str + runner_name: str + context: AgentRunContext +``` + +Runtime 返回 `AgentRunResult` 异步流。底层 transport 可继续用 `plugin_author` / `plugin_name` / `runner_name` 定位组件,但协议语义以 `runner_id` 和 `context` 为准。 + +### 5.2 AgentRunContext + +这是 SDK 看到的**唯一权威 context 定义**。 + +```python +class AgentRunContext(BaseModel): + run_id: str + trigger: AgentTrigger + event: AgentEventContext + conversation: ConversationContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + input: AgentInput + delivery: DeliveryContext + resources: AgentResources + context: ContextAccess + state: AgentRunState + runtime: AgentRuntimeContext + config: dict[str, Any] = {} + adapter: AdapterContext | None = None + metadata: dict[str, Any] = {} +``` + +核心约束: + +- `event` 是必选字段,Protocol v1 是 event-first。 +- `input` 表示当前事件的主输入,不等于历史消息。 +- `bootstrap` / `messages` **不是协议字段**;Host 不内联历史窗口。 +- `adapter` 只放入口 adapter 的非核心元数据,runner 不应依赖它做长期能力。 +- `config` 是 Agent/runner config,不是插件实例状态。 + +### 5.3 AgentTrigger + +```python +class AgentTrigger(BaseModel): + type: str + source: Literal["platform", "webui", "api", "scheduler", "system", "host_adapter"] + timestamp: int | None = None +``` + +`trigger.type` 应与 `event.event_type` 一致或更粗粒度。例如入口适配器触发消息时: + +```json +{ "type": "message.received", "source": "host_adapter" } +``` + +### 5.4 AgentEventContext + +```python +class AgentEventContext(BaseModel): + event_id: str + event_type: str + event_time: int | None = None + source: str + source_event_type: str | None = None + raw_ref: RawEventRef | None = None + data: dict[str, Any] = {} +``` + +- `event_type` 使用 LangBot 稳定协议名,例如 `message.received`。稳定事件名清单见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 +- 平台原始事件名放入 `source_event_type`。 +- 大型原始 payload 必须放入 `raw_ref` 或 staged file,不应直接塞入 `data`。 + +### 5.5 Conversation / Actor / Subject + +```python +class ConversationContext(BaseModel): + conversation_id: str | None = None + thread_id: str | None = None + launcher_type: str | None = None + launcher_id: str | None = None + sender_id: str | None = None + bot_id: str | None = None + workspace_id: str | None = None + session_id: str | None = None + +class ActorContext(BaseModel): + actor_type: str + actor_id: str | None = None + actor_name: str | None = None + metadata: dict[str, Any] = {} + +class SubjectContext(BaseModel): + subject_type: str + subject_id: str | None = None + data: dict[str, Any] = {} +``` + +示例: + +- 消息事件:actor 是发消息的人,subject 是当前消息。 +- 入群事件:actor 是新成员或邀请人,subject 是群/成员关系。 +- 定时事件:actor 可以是 system,subject 是 schedule。 + +### 5.6 AgentInput + +```python +class AgentInput(BaseModel): + text: str | None = None + contents: list[ContentElement] = [] + attachments: list[InputAttachment] = [] +``` + +- 文本、多模态、附件都属于当前 event input。 +- 大文件、图片、音频、工具大结果应进入授权 sandbox/workspace,input attachment 只携带轻量 metadata/path/url/content。 +- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。 + +### 5.7 DeliveryContext + +```python +class DeliveryContext(BaseModel): + surface: str + reply_target: dict[str, Any] | None = None + supports_streaming: bool = False + supports_edit: bool = False + supports_reaction: bool = False + max_message_size: int | None = None + platform_capabilities: dict[str, Any] = {} +``` + +Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。 +平台事件进入独立 Agent 时,Host 会从当前 adapter 的 `get_supported_apis()` 投影 +`supports_edit`、`supports_reaction`,并把去重后的 API 名称写入 +`platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予 +平台动作权限;`action.requested` 仍受 §7.3 的 reserved 约束。合成路由测试使用的 +adapter 会过滤所有已知副作用 API,因此测试事件不会向 runner 宣告真实出站能力。 + +### 5.8 ContextAccess + +```python +class ContextAccess(BaseModel): + conversation_id: str | None = None + thread_id: str | None = None + latest_cursor: str | None = None + event_seq: int | None = None + transcript_seq: int | None = None + has_history_before: bool = False + inline_policy: InlineContextPolicy + available_apis: ContextAPICapabilities + +class InlineContextPolicy(BaseModel): + mode: Literal["none", "current_event", "recent_tail", "summary_tail"] + delivered_count: int = 0 + source_total_count: int | None = None + messages_complete: bool = False + reason: str | None = None + +class ContextAPICapabilities(BaseModel): + prompt_get: bool = False + history_page: bool = False + history_search: bool = False + event_get: bool = False + event_page: bool = False + state: bool = False + storage: bool = False + steering_pull: bool = False +``` + +`ContextAccess` 告诉 runner:Host inline 了什么、没 inline 什么、需要更多上下文时走哪些 API。它是 runner 按需读取上下文的入口说明,不是 Host 的业务上下文编排策略。 + +### 5.9 AgentRuntimeContext + +```python +class AgentRuntimeContext(BaseModel): + langbot_version: str | None = None + trace_id: str | None = None + deadline_at: float | None = None + metadata: dict[str, Any] = {} +``` + +### 5.10 AgentRunState + +```python +class AgentRunState(BaseModel): + conversation: dict[str, Any] = {} + actor: dict[str, Any] = {} + subject: dict[str, Any] = {} + runner: dict[str, Any] = {} +``` + +State 是可选 host-owned snapshot。Runner 也可以完全自管状态。 + +## 6. Resources + +```python +class ToolResource(BaseModel): + tool_name: str + tool_type: str | None = None + description: str | None = None + parameters: dict[str, Any] | None = None # 完整 JSON schema,由 Host 一次塞齐 + operations: list[Literal["detail", "call"]] = [] + +class SkillResource(BaseModel): + skill_name: str + display_name: str | None = None + description: str | None = None + +class AgentResources(BaseModel): + models: list[ModelResource] = [] + tools: list[ToolResource] = [] + knowledge_bases: list[KnowledgeBaseResource] = [] + skills: list[SkillResource] = [] + storage: StorageResource = StorageResource() + platform_capabilities: dict[str, Any] = {} +``` + +`tools` 携带每个授权工具的完整 schema(`parameters`),由 Host 在构造 `ctx.resources` 时一次塞齐,runner 不需再逐个调用 `get_tool_detail` 拉取,减少 N 次往返。 + +`skills` 是本次 run 中 pipeline-visible 的 skill facts(`skill_name`、`display_name`、`description`)。**skill 通过统一 tool 形式消费,不是独立资源类别**:发现走 `list_skills` tool(或 `langbot_list_assets` 增加 skills 一类),激活走 `activate`,操作走 native exec/read/write。Host **不**把 skill 索引注入 system prompt,也不做 progressive-disclosure 注入;LLM 通过调用发现工具主动查询 skill 清单。Host **可选**在 ctx 提供预渲染的 `suggested_skill_prompt`(首轮延迟优化,runner 可忽略 / override),但它不是访问前提。`skills` 字段本身仅作为发现工具的数据来源与该可选预渲染的输入。 + +资源列表是本次 run 的授权结果。History / Event / State / Storage 访问通过 `ctx.context.available_apis` 和 Host 侧 run session 校验控制,不作为可枚举 resource list 暴露。Runner 只能通过 `AgentRunAPIProxy` 访问这些能力。当前事件的文件和工具大结果优先进入授权 sandbox/workspace,由 runner 通过 read/write/exec 类工具按需读取。 + +## 7. Result Stream + +### 7.1 AgentRunResult envelope + +```python +JSONValue = str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"] + +ResultType = Literal[ + "message.delta", + "message.completed", + "tool.call.started", + "tool.call.completed", + "state.updated", + "action.requested", + "run.completed", + "run.failed", +] + +class AgentRunResult(BaseModel): + run_id: str + type: AgentRunResultType | str + data: dict[str, Any] = {} + usage: LLMTokenUsage | None = None + sequence: int | None = None + timestamp: int | None = None +``` + +SDK 当前实现是单一 envelope:`type` 枚举 + `data` dict。Payload 由 SDK typed model 构造并 dump,但 wire 不改成 discriminated union;这样新旧版本偏斜时 Host 仍可按 §3 忽略未知 `type`。 + +`usage` 是 runner 可选上报的 token 使用量,沿用 SDK `LLMTokenUsage`: + +```python +class LLMTokenUsage(BaseModel): + prompt_tokens: int | None = None + completion_tokens: int | None = None + total_tokens: int | None = None + # provider-specific detail/cached/reasoning counters are preserved as extra fields +``` + +约束: + +- 运行时能观测到 provider/runtime usage 时,SHOULD 在 terminal `run.completed.usage` 上报本次 run 的最终聚合 token usage。 +- `run.failed.usage` MAY 上报失败前已经产生的部分 usage。 +- 不能观测 usage 的 runner 合法地省略该字段;缺失表示 unknown,Host 不得按 0 处理。 +- ACP 等外部协议不保证统一 usage;ACP runner 只能在具体 provider/native event 提供 usage 时填充本字段。 +- cost 不作为 runner result 的权威字段。Host 后续应基于 usage、model identity、时间和自身价格表计算账单成本;provider 原始 cost 如需保留,可放在 `usage` extra 字段中作为非权威 telemetry。 + +Host 边界分级校验: + +- `message.delta`、`message.completed`、`state.updated`、`action.requested`、`run.completed`、`run.failed` 属于会影响投递或 Host 副作用的严格 payload;校验失败时丢弃该 result 并记录 warning。 +- `tool.call.started`、`tool.call.completed` 当前只作为 telemetry,payload 宽松兼容。 +- 未知 `type` 忽略并记录 warning。 + +### 7.2 稳定 result payloads + +| type | `data` payload | +| --- | --- | +| `message.delta` | `{ "chunk": MessageChunk }` | +| `message.completed` | `{ "message": Message }` | +| `tool.call.started` | `{ "tool_call_id": str, "tool_name": str, "parameters": dict }` | +| `tool.call.completed` | `{ "tool_call_id": str, "tool_name": str, "result": dict \| None, "error": str \| None }` | +| `state.updated` | `{ "scope": "conversation" \| "actor" \| "subject" \| "runner", "key": str, "value": JSONValue }` | +| `action.requested` | `{ "action": str, "target": dict \| None, "payload": dict \| None }` | +| `run.completed` | `{ "finish_reason": str, "message"?: Message }` | +| `run.failed` | `{ "code": str, "error": str, "retryable": bool }` | + +Runner 生成的大文件、工具输出和临时产物不通过 result event 回传;应写入当前 run 的授权 sandbox/workspace,再用消息文本、metadata 或 attachment reference 指向它们。 + +### 7.3 稳定 result types + +| type | 说明 | 当前消费 | +| --- | --- | --- | +| `message.delta` | 流式消息片段。 | ✅ | +| `message.completed` | 完整消息。 | ✅ | +| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | +| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | +| `state.updated` | runner 请求更新 host-owned state。 | ✅ | +| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | +| `run.completed` | run 正常结束。 | ✅ | +| `run.failed` | run 失败。 | ✅ | + +`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。 + +Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。 + +除 runner 经 `state.updated` 写之外,Host 自身也可直接写部分 host-owned state。例如 `activate` tool 在 Host 侧执行时,直接把已激活 skill 写入 conversation scope 的 `host.activated_skills` 快照。当 host 直接写与 runner `state.updated` 写到同一 key 时,按 **last-write-wins** 合并——runner 可以覆盖 host 写的快照。 + +### 7.4 Stream delivery semantics + +- Host 按 Runtime stream 顺序消费 result。当前 v1 不定义跨连接 replay,也不承诺 at-least-once;从 Host 视角,收到的 result 最多应用一次。 +- `sequence` 是单个 `run_id` 内的结果序号。in-process / stdio 这类天然有序的在线 stream 可以省略;任何会缓冲、重放、跨进程队列或 runtime-managed task 的 transport 必须提供从 1 开始严格递增的 `sequence`。 +- Host 看到已提供 `sequence` 的 result 时,应按 `(run_id, sequence)` 做重复检测,并在缺号或乱序时记录 warning;除非 transport 明确声明 replay 语义,Host 不应自行等待缺失序号重排用户可见输出。 +- `run.failed.data.retryable` 只表示整次 run 理论上可由上层重试;Protocol v1 不自动重试 run,也不自动重试 proxy action。 +- History / Event / Transcript cursor 是 opaque token。runner 不得解析 cursor,也不得假设 cursor 在不同 API、conversation、thread 或 retention window 之间可比较;当前实现即使返回数字字符串,也只是实现细节。 + +### 7.5 示例 + +```json +{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } } +{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } } +{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } } +{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } } +``` + +## 8. AgentRunAPIProxy + +所有 proxy action 必须携带 `run_id`。Host 必须校验:active run session 存在、caller plugin identity 匹配、resource 在本次 `ctx.resources` 中授权、scope 不越界、payload size / rate limit / deadline 合法。 + +```python +# Model +await api.invoke_llm(llm_model_uuid, messages, funcs=None, extra_args=None) +await api.invoke_llm_with_usage(llm_model_uuid, messages, funcs=None, extra_args=None) +async for chunk in api.invoke_llm_stream(llm_model_uuid, messages, funcs=None, extra_args=None): + ... +async for event in api.invoke_llm_stream_events(llm_model_uuid, messages, funcs=None, extra_args=None): + ... +await api.invoke_rerank(rerank_model_id, query, documents, top_k=None) + +# Tool +await api.get_tool_detail(tool_name) +await api.call_tool(tool_name, parameters) + +# Knowledge +await api.retrieve_knowledge(kb_id, query_text, top_k=5, filters=None) + +# History(返回 Transcript projection,不返回原始平台 payload) +await api.get_prompt() +await api.history_page(conversation_id=None, before_cursor=None, after_cursor=None, + limit=50, direction="backward", include_attachments=False) +await api.history_search(query, filters=None, top_k=10) + +# Event(返回稳定 event envelope 或受限 raw ref,不默认返回大 payload) +await api.event_get(event_id) +await api.event_page(conversation_id=None, event_types=None, before_cursor=None, limit=50) +await api.steering_pull(mode="all", limit=None) + +# State / Storage +await api.state_get(scope, key); await api.state_set(scope, key, value); await api.state_delete(scope, key) +await api.state_list(scope, prefix=None, limit=100) +await api.get_plugin_storage(key); await api.set_plugin_storage(key, value); await api.delete_plugin_storage(key) +await api.get_plugin_storage_keys() +await api.get_workspace_storage(key); await api.set_workspace_storage(key, value); await api.delete_workspace_storage(key) +await api.get_workspace_storage_keys() + +# Host info +await api.get_langbot_version() +``` + +`invoke_llm()` / `invoke_llm_stream()` 的第一个参数在 SDK 中命名为 +`llm_model_uuid`,wire payload 字段也是 `llm_model_uuid`。该值对 runner +仍是 opaque identifier,不应解析其内部格式。 + +`invoke_llm()` 和 `invoke_llm_stream()` 保持兼容:前者返回 `Message`,后者只 +yield `MessageChunk`。需要 provider 真实 token 计量的 runner 应使用 +`invoke_llm_with_usage()` 或 `invoke_llm_stream_events()`。Host response 可在 +原有 `{message: ...}` / `{chunk: ...}` 外额外携带可选 `usage` 字段;streaming +场景允许在所有 chunk 之后追加一个 usage-only event。`usage` 至少保留 +OpenAI-compatible 的 `prompt_tokens`、`completion_tokens`、`total_tokens`, +若 provider 返回 `prompt_tokens_details` / `completion_tokens_details` 或 +cache token counters,Host / SDK 不应丢弃这些字段。没有 usage 的 provider +必须继续返回成功响应,SDK 将 usage 置为 `None`。 + +`get_prompt()` 返回当前 query-backed run 的 Host effective prompt messages: +`list[Message]` 的 JSON 形式。该能力只在 `ctx.context.available_apis.prompt_get` +为 true 时可用;没有 query 缓存、prompt 已过期或非 query entry run 时 Host +可以返回错误或空列表。Runner 应在不可用时回退到自己的 config/prompt 策略。 + +`steering_pull(mode="all")` 是推荐默认:Host 按 claim 顺序返回全部 pending steering 输入并清空对应队列。`mode="one-at-a-time"` 仅用于 runner 主动节流,每次返回一条。Host 不合并多条用户消息;runner 负责在 turn 边界决定模型侧格式。 + +Steering 审计使用 EventLog 而不是 Transcript schema 扩展:被 active run 吸收的原始 `message.received` 事件保留原事件类型,并在 `metadata.steering` 标记 `status="queued"`、`trigger_behavior="absorbed_into_active_run"`、`claimed_by_run_id`、`claimed_runner_id`、`claimed_at`。Runner 成功 pull 后,Host 追加 `steering.injected` EventLog 记录,`metadata.steering.status="injected"` 并引用 `source_event_id`。若 run 结束时仍有已 claim 但未 pull 的 steering 输入,Host 追加 `steering.dropped` EventLog 记录,`metadata.steering.status="dropped"` 并引用 `source_event_id`;这不是用户消息事实的删除,只是 dispatch 终态。Transcript 继续只表示会话事实,不承担 dispatch 行为标记。 + +`state` 与 `storage` 的建议边界:`state` 放小型 JSON(conversation / actor / subject / runner),`storage` 放 blob 或较大数据(插件私有数据、workspace 数据、checkpoint)。 + +Compaction checkpoint 的推荐 state 约定: + +- scope: `conversation` +- key: `runner.compaction.checkpoint` +- value: + +```json +{ + "schema_version": "langbot.local_agent.compaction_checkpoint.v1", + "summary": "...", + "covers_until": "transcript-cursor-or-seq", + "tokens_before": 12345, + "created_at": 1710000000, + "conversation_id": "conv-..." +} +``` + +`covers_until` 是摘要覆盖到的 transcript 游标锚点。Runner 读取 checkpoint 后应只拉取该游标之后的 transcript;若 checkpoint 缺失、schema 不匹配、conversation 不匹配或游标不可用,应回退到无 checkpoint 的尾部历史拉取行为。 + +Proxy 返回数据结构也属于本协议: + +```python +class TranscriptItem(BaseModel): + transcript_id: str + event_id: str + conversation_id: str | None = None + thread_id: str | None = None + role: str + item_type: str = "message" + content: str | None = None + content_json: dict[str, Any] | None = None + attachment_refs: list[dict[str, Any]] = [] + seq: int | None = None + cursor: str | None = None + created_at: int | None = None + metadata: dict[str, Any] = {} + +class HistoryPage(BaseModel): + items: list[TranscriptItem] = [] + next_cursor: str | None = None + prev_cursor: str | None = None + has_more: bool = False + total_count: int | None = None + +class HistorySearchResult(BaseModel): + items: list[TranscriptItem] = [] + total_count: int | None = None + query: str + +class AgentEventRecord(BaseModel): + event_id: str + event_type: str + event_time: int | None = None + source: str + bot_id: str | None = None + workspace_id: str | None = None + conversation_id: str | None = None + thread_id: str | None = None + actor_type: str | None = None + actor_id: str | None = None + actor_name: str | None = None + subject_type: str | None = None + subject_id: str | None = None + input_summary: str | None = None + input_ref: str | None = None + raw_ref: str | None = None + seq: int | None = None + cursor: str | None = None + created_at: int | None = None + metadata: dict[str, Any] = {} + +class EventPage(BaseModel): + items: list[AgentEventRecord] = [] + next_cursor: str | None = None + prev_cursor: str | None = None + has_more: bool = False + total_count: int | None = None + +class SteeringInputItem(BaseModel): + claimed_run_id: str + runner_id: str + claimed_at: int | None = None + event: AgentEventContext + input: AgentInput + conversation: ConversationContext | None = None + actor: ActorContext | None = None + subject: SubjectContext | None = None + metadata: dict[str, Any] = {} + +class SteeringPullResult(BaseModel): + items: list[SteeringInputItem] = [] +``` + +## 9. 错误模型 + +```python +class AgentAPIError(BaseModel): + code: str + message: str + retryable: bool = False + details: dict[str, Any] = {} +``` + +| code | 说明 | +| --- | --- | +| `unauthorized` | 未授权访问资源或 scope。 | +| `not_found` | 资源不存在或对当前 runner 不可见。 | +| `deadline_exceeded` | 超过 run deadline。 | +| `payload_too_large` | 请求或响应过大。 | +| `rate_limited` | Host 限流。 | +| `invalid_argument` | 参数错误。 | +| `runtime_error` | Host 或下游能力错误。 | + +SDK runner-facing proxy 在 Host 返回结构化错误或畸形响应时抛出 `AgentAPIException`,其中 `error` 字段为 `AgentAPIError`。Legacy transport 只返回字符串错误时,SDK 使用 `host.action_error` 包装,避免 runner 继续依赖裸 `KeyError` 或字符串匹配。 + +Runner 失败使用 `run.failed`: + +```json +{ "type": "run.failed", "data": { "code": "runner.error", "error": "failed to call external agent", "retryable": false } } +``` + +## 10. Timeout 与 Cancellation + +- Host 在 `ctx.runtime.deadline_at` 下发总 deadline;SDK proxy 必须用该 deadline 限制单次 action timeout。 +- Host 可以取消 active run;Runtime 应尽力中断 runner。 +- Protocol v1 的 run 绑定当前 Host 进程和当前 runtime channel,不保证跨 Host 重启恢复。Host 重启、runtime channel 断开或 run session 丢失时,Runtime / external harness connector 必须 fail-fast 并尽力取消仍在执行的 runner,不得继续使用旧 `run_id` 调用 Host API。 +- Runner 支持中断时应返回或触发 `run.failed`,code 为 `cancelled`。 +- Host 必须 unregister active run session。 + +## 11. Security 与 Guardrail(协议层) + +Protocol v1 的安全边界在 Host: + +- Runner 不能直接访问未授权 model/tool/kb/history/storage/sandbox。 +- SDK 本地校验只提升开发体验,不能替代 Host 校验。 +- 所有 resource id 对 runner 来说都是 opaque。 +- 默认只能访问当前 conversation / thread 的 history;跨会话、workspace 级访问必须额外授权。 +- 大 payload 不应塞进 result event;当前 run 的文件和工具大结果应进入授权 sandbox/workspace,由 read/write/exec 类工具按需访问。 +- Host 必须记录 run_id、runner_id、action、resource、scope、result。 + +Host 不负责业务编排:不拼接全量历史、不替 runner 做 prompt assembly、不内置 agent memory / tool loop / 上下文压缩策略。这些由官方或第三方 AgentRunner 插件实现。 + +外部 harness runner 的边界统一见 HOST_SDK §4.8。简言之:harness native permission mode、allowed/disallowed tools、shell/MCP 权限只是额外执行约束,不能替代 Host 对 LangBot 资源的授权。 + +> 发布级路径隔离、MCP allowlist、secret redaction、配额、workspace 清理等**不属于** v1 协议闭环,是生产默认启用前的 release gate,见 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 + +## 12. Pipeline AI Stage Adapter 边界 + +Pipeline 与 Agent 是 EBA 中平级的处理器:Pipeline 处理消息事件并执行完整 +Stage 链,Agent 处理其声明支持的消息或非消息事件。本协议只约束 AgentRunner +调用,因此 Pipeline 仅在 AI Stage 调用 runner 时进入 Query entry adapter; +该适配不会把 Pipeline 变成 Agent,也不会创建或更新持久 Agent。adapter 负责: + +- 从 `Query` 构造 `AgentEventContext` 和临时 `AgentBinding`(见 HOST_SDK §4.2)。 +- 从当前 Agent/runner config 构造 `ctx.config`。 +- 将 Query-only 字段放入 `ctx.adapter`,例如 filtered params 放 `ctx.adapter.extra["params"]`。 + +约束: + +- adapter **不**定义历史窗口、prompt 组装或 agentic context 策略。 +- `ctx.adapter.extra` 只允许承载一次性、JSON-safe、入口相关的非核心元数据,例如 `params`;不得承载 `prompt`、history window、RAG 结果、tool schema 或授权资源。 +- 静态绑定 prompt 属于 `ctx.config.prompt`。preprocessing / hook 后的动态有效指令不通过 `ctx.adapter.extra` 主动推送;后续如需要保留这类能力,应通过 Host prompt/instruction pull API 暴露(占位见 HOST_SDK §4.8)。 +- 新 runner 不应长期依赖 `adapter`,应只依赖 event-first context 和 Host API。 + +## 13. 已确认约束 + +- EBA 路由层是 `one event -> one Processor target (Pipeline | Agent)`;同一 bot / channel 可以让不同事件绑定不同类型的处理器。 +- 进入 AgentRunner Protocol 后,调用基数是 `one AgentBinding -> one run_id -> one runner`。这既适用于独立 Agent,也适用于 Pipeline AI Stage 的单次 runner 调用。 +- 一个 Agent 可以被多个 bot / channel 复用。如果 Agent 分支出现多个匹配 binding,BindingResolver 必须按明确规则选出一个或拒绝配置,不应默认 fan-out。 +- observer agent、多 runner fan-out、并行裁决、result 合并等能力需要单独设计 delivery、state、platform action 和 audit 语义,不属于当前 v1 契约。 +- `AgentRunnerDescriptor.source` 只允许 `plugin`;Host 内置 adapter 不能作为 runner source 绕过插件/runtime/proxy 权限链。 +- `ctx.resources` 与 proxy action 校验必须来自同一个 run authorization snapshot;runtime handler 不应重新执行资源裁剪。 +- v1 不要求 Agent、AgentRunner 插件实例或 runner id 全局串行。多个 bot / channel 可复用同一个 Agent;并发隔离依赖 `run_id`、binding、conversation / thread scope 和 Host authorization snapshot。 +- 外部 harness runner 当前是 MVP / dev path,证明协议可接入,不代表发布级安全边界或 Docker 生产可用性完成。 + +## 14. 开放问题 + +- `AgentBinding` 是否需要进入 SDK 文档作为只读诊断信息,还是完全 Host 内部。 +- State 与 Storage 的边界是否需要更强类型。 +- platform action 的审批模型如何表达。 +- Host 侧 scoped MCP / workspace projection 是否需要从 runner config 上移为一等 resource projection API。(skill 一项已收敛:skill 全 tool 化,作为被授权 tool 暴露,不再是独立 projection。) diff --git a/docs/agent-runner-pluginization/README.md b/docs/agent-runner-pluginization/README.md new file mode 100644 index 000000000..c580b13e1 --- /dev/null +++ b/docs/agent-runner-pluginization/README.md @@ -0,0 +1,155 @@ +# Agent Runner 插件化文档入口 + +本文档是 agent-runner 插件化工作的路由页。具体设计拆到独立文档中维护,避免把 LangBot 宿主架构、SDK 协议、上下文管理、EBA 接入边界和官方 runner 迁移混在同一份 README 里。 + +## 背景与问题 + +旧 runner 路径主要围绕 Pipeline / Query 和 `pkg/provider/runners` 内置实现展开,扩展外部 agent runtime 时容易把 runner 选择、上下文裁剪、资源授权和消息投递绑在同一条聊天链路里。这个分支要把 LangBot 收敛成 Agent Host:Host 负责事件、绑定、授权、事实源和结果投递;AgentRunner 作为插件或外部 harness 消费统一协议并自主管理 prompt / history / memory。 + +## 文档维护原则(单一事实源) + +- **协议数据结构(schema)唯一定义在 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。** 其他文档不得重抄 schema,只能引用,例如"见 PROTOCOL_V1 §4.2"。 +- 当前实现状态、spec 差距与 runner 验收状态归 [STATUS.md](./STATUS.md);测试执行入口归 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md),安全发布门槛归 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md)。 +- Host 内部模型(`AgentEventEnvelope`、`AgentBinding`、Descriptor、各 Store)定义在 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md),不属于 SDK 协议。 +- 其余专题文档只讲"为什么/边界/怎么用",避免重复叙述。 + +## 本分支目标 + +**本分支目标:AgentRunner 外化 / 插件化基础设施** + +本分支只做 LangBot 作为 Agent Host 的基础能力建设,让现有 Pipeline 与用户新建的独立 `Agent` 都能调用插件化 AgentRunner;不负责把两者做持久化迁移: + +- LangBot 与 SDK 的稳定协议合同(Protocol v1) +- Host-side `AgentEventEnvelope` / `AgentBinding` 模型 +- `run(event, binding)` event-first 入口 +- `QueryEntryAdapter`:Query → AgentEventEnvelope + AgentBinding +- EventLog / Transcript / PersistentStateStore +- History / Event / State pull APIs +- Sandbox/workspace read/write/exec 文件能力,用于当前 run 的上传文件、工具大结果和临时产物 +- SDK runtime forwarding pull APIs + `caller_plugin_identity` 验证路径 + +## 本分支不实现 + +以下能力由其他分支负责,本分支只保留 integration point。EBA 完整事件网关与事件路由当前由外部 EBA 分支联调: + +- **EventGateway / EventRouter**:完整事件网关实现、事件路由、事件持久化管理 +- **Event subscription / Event notification**:事件订阅、推送通知 +- **BindingResolver persistence UI**:绑定配置的持久化 UI 和 event router 集成(如由其他模块负责) +- **Scheduler / Background event source**:定时任务、后台事件源 +- **完整 Agent Platform / daemon control plane**:Host-owned `AgentRun` / `AgentRunEvent`、run control primitives、最小 runtime heartbeat/claim lease 已作为 v2 foundation 落地;业务队列、Platform UI、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍不属于 Protocol v1 主线。 + +EventGateway / EventRouter 在本文档中描述为 **external EBA branch integration point**,由外部 EBA 分支提供并联调。本分支只定义 host-side envelope/binding models 和 `run(event, binding)` orchestrator 入口。 + +本分支与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 + +## 目标产品模型 + +产品层同时保留 Pipeline 与独立 `Agent`。现有 Pipeline 保持原实体和执行链,也可继续被 bot 绑定;新建 Agent 则携带 runner id、runner config、resource/state/delivery policy 等配置,并可被多个 bot / IM channel 复用。统一产品列表可以聚合展示两类处理器,但不会把 Pipeline 或其内嵌 runner 配置自动迁移成 Agent;用户需要 Agent 时自行新增并绑定。 + +调度基数、Agent 复用、插件实例无状态、Pipeline adapter 和 fan-out 边界的规范来源是 [PROTOCOL_V1.md](./PROTOCOL_V1.md) §13;README 不复写这些约束。 + +## Pipeline 与 AgentRunner 的关系 + +**Pipeline 与 Agent 是 EBA 中平级的处理器;`QueryEntryAdapter` 只适配 Pipeline 内部的 AgentRunner 调用。** + +EBA 先根据 `target_type` 选择 Pipeline 或 Agent。Pipeline 目标执行完整 Stage 链;当 Pipeline 的 AI Stage 调用 runner 时,`run_from_query()` 经 `QueryEntryAdapter` 把 `Query` 转换为 `AgentEventEnvelope` + `AgentBinding`,再委托到统一的 `run(event, binding, ...)`。Agent 目标则直接从自己的持久配置构造 binding。两条路径可以复用同一套 AgentRunner Host capabilities,但 Pipeline 本身不会被投影或持久化为 Agent。 + +下一轮测试路径、状态定义和 smoke 记录见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md)。 + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| Protocol v1 | Host 调用 AgentRunner 的 runner 可见合同:discovery、`AgentRunContext`、result stream、Host pull API 和错误模型。 | +| Processor | EBA 的处理器上位概念;当前平级类型为 Pipeline 与 Agent。 | +| Agent | 目标产品层配置对象,保存 runner id、runner config 和资源/状态/投递策略;不等于插件实例。 | +| AgentConfig | Host 内部的单次 AgentRunner 调用配置投影,可由 Pipeline AI Stage 或持久 Agent 生成;投影本身不会创建 Agent。 | +| AgentBinding / binding | Host 在一次事件运行前解析出的有效绑定,决定调用哪个 runner 以及带什么策略。 | +| envelope | Host 内部事件封装,即 `AgentEventEnvelope`;runner 看到的是由它投影出的 `ctx.event`。 | +| descriptor / manifest | runner discovery 的能力和配置描述;manifest 来自插件,descriptor 是 Host 校验后的注册表视图。 | +| EBA | Event Based Agent,把消息、撤回、入群、定时任务等都统一成 host event 的接入方向;完整网关和路由在外部 EBA 分支联调。 | +| harness runner | ACP、Claude Code、Codex 等已有自身 session / tool loop / MCP / 压缩机制的外部 runtime adapter。 | +| projection | Host 把内部事实源、授权资源或配置裁剪成 runner / harness 可消费视图的过程。 | +| Runtime Control Plane | v2 Host 能力层,当前已落地 Host-owned run/result ledger、run control primitives、最小 runtime heartbeat/claim lease;完整 daemon worker 管控、task wakeup 和 Agent Platform 产品形态不是 Protocol v1 主线。 | + +## 设计文档 + +| 文档 | 关注点 | +| --- | --- | +| [PROTOCOL_V1.md](./PROTOCOL_V1.md) | **🔒 唯一 schema 事实源**。LangBot Host 与 SDK / Runtime / AgentRunner 的协议合同:版本协商、discovery、run context、result stream、proxy actions、错误和 adapter 边界。 | +| [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) | LangBot 宿主能力与分层架构、Host 内部模型(`AgentEventEnvelope` / `AgentBinding` / Descriptor / 各 Store)、runner 发现、绑定、资源授权、状态、存储、生命周期和调用链。 | +| [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) | Agent-owned context 方向:事件到来时 LangBot 传什么,agent 如何按需拉取更多历史 / state、如何访问 sandbox/workspace 文件,以及如何支持 KV cache 友好的上下文管理。 | +| [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md) | AgentRunner 外化与外部 EBA / Agent Platform / Runtime Control Plane 的扩展边界矩阵,说明哪些是本分支底座、哪些由外部分支接入。 | +| [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md) | EBA 接入边界:事件模型、事件来源、触发绑定、非消息事件如何复用 AgentRunner 调度;完整 EventGateway / EventRouter 由外部 EBA 分支联调。 | +| [eba-productization-release.md](./eba-productization-release.md) | EBA 适配器与 AgentRunner 插件化合并后的产品化 / 发布计划,说明非技术用户快速上手差距、Bot 与处理器边界、未来 Solution 分发标的,以及多 namespace SaaS 支持要求。 | +| [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) | Agent Platform v2 / runtime 管控面决策:`AgentRun` / `AgentRunEvent` / run control 已作为 Host 事实源落地,最小 runtime heartbeat/claim lease 已落地;完整 runtime registry / daemon 管控仍是后续可选阶段。 | +| [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md) | 官方 runner 插件迁移,包括 local-agent 和外部 runner。它是下游落地计划,不是 LangBot 基础能力设计的前置约束。 | +| [RUN_STEERING_AND_CHECKPOINT.md](./RUN_STEERING_AND_CHECKPOINT.md) | 运行中消息注入(steering / follow-up)与压缩摘要持久化(compaction checkpoint)的设计与落地状态记录;schema 仍以 PROTOCOL_V1 为准。 | +| [STATUS.md](./STATUS.md) | 当前实现状态、spec 与实现已知差距、runner 验收状态和历史高价值记录。 | +| [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) | Agent Runner QA 指南:保留最高价值测试路径,指导 agent 开展下一轮 WebUI / runner smoke 验证。 | +| [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) | 安全发布级 hardening 的后续发布门槛:路径隔离、权限边界、secret、资源配额、MCP / skill 投影和审计。 | + +## 工作拆分 + +### 1. LangBot + SDK 基础设施 + +目标是把 LangBot 从内置 runner 执行器变成 agent host: + +- LangBot 与 SDK 的稳定协议合同 +- runner manifest / descriptor / registry +- Agent / binding 配置解析 +- run orchestration 和生命周期管理 +- resource authorization 与 `run_id` 级权限校验 +- host-owned state / storage / event log / transcript 能力 +- sandbox/workspace 文件 staging 与 read/write/exec 能力 +- SDK `AgentRunner`、`AgentRunContext`、`AgentRunResult`、`AgentRunAPIProxy` + +协议合同详见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。 + +详见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 + +### 2. Agent-owned context + +LangBot 不应成为最终 agentic context manager。它应提供事实源、默认上下文引用和按需读取 API;agent 或其背后的 runtime 负责历史剪裁、摘要、召回和 KV cache 策略。 + +Host 不定义通用历史窗口字段或策略;runner 通过 Host pull API 按需拉取历史并自行管理 working context。 + +详见 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md)。 + +### 3. Event Based Agent(External Branch) + +消息只是事件的一种。外部 EBA 分支中的 `message.received`、`message.recalled`、`group.member_joined`、`friend.request_received` 等事件都应能通过统一事件 envelope 触发 AgentRunner。 + +EBA dispatch 的基数和 fan-out 边界仍以 PROTOCOL_V1 §13 为准;本文档只列出本分支提供给外部 EBA 分支复用的入口点。 + +**本分支不实现 EBA 完整能力,只提供:** +- event-first envelope (`AgentEventEnvelope`) +- AgentBinding model +- `run(event, binding)` 入口 +- QueryEntryAdapter(当前 AgentEventEnvelope / AgentBinding 的 Query entry adapter source) + +详见 [EVENT_BASED_AGENT.md](./EVENT_BASED_AGENT.md)。 + +### 4. 官方 runner 插件 + +官方 `local-agent` 和外部 runner 迁移是下游工作。它们需要依附 LangBot 提供的宿主能力,但不应反过来决定宿主协议。 + +`local-agent` 可以外移,也可以重写。验收重点是它能完整消费 LangBot 的模型、工具、知识库、存储、事件、history API 和 result stream,而不是保留旧内置 runner 的内部结构。 + +详见 [OFFICIAL_RUNNER_PLUGINS.md](./OFFICIAL_RUNNER_PLUGINS.md)。 + +### 5. Runtime Control Plane v2(Foundation Partial) + +当前 AgentRunner v1 主线仍以 `event -> binding -> runner.run(ctx) -> result stream` 为 runner 可见合同。Host 侧已经新增持久 `AgentRun` / `AgentRunEvent`、result persistence、cancel/finalize/query 等通用 run control primitives,并提供受权限保护的最小 runtime register/heartbeat/list、claim/renew/release 和 reconcile 原语。 + +在这些 Host 能力之上,可以构建独立 agent 管控面插件;插件负责 UI、策略和编排体验,runtime/task 的事实源仍由 Host 持有。完整 daemon supervisor、任务唤醒/长轮询/WebSocket、跨 Host 分布式锁、provider 登录态诊断和产品化业务队列仍是后续工作。 + +详见 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md)。 + +## 约束事实源 + +本分支已确认约束不在 README 重写: + +- Runner 可见协议、result stream 和调度边界见 [PROTOCOL_V1.md](./PROTOCOL_V1.md)。 +- Host 内部 `AgentConfig` / `AgentBinding` 投影见 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md)。 +- 外部 EBA / Agent Platform / Runtime Control Plane 接入边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 diff --git a/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md new file mode 100644 index 000000000..e96ec54e9 --- /dev/null +++ b/docs/agent-runner-pluginization/RUNTIME_CONTROL_PLANE_V2.md @@ -0,0 +1,541 @@ +# Agent Platform / Runtime Control Plane Decision Note + +本文档记录 AgentRunner 插件化之后,LangBot 如何继续演进成 Agent Platform 基础设施层。这里讨论的是 Host capability layer,不是 `AgentRunner Protocol v2`,也不是把某个具体 Agent Platform 产品写进 LangBot core。 + +> 本文是当前决策版。协议数据结构仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试执行入口见 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md);扩展边界见 [EXTENSION_SCOPE_MATRIX.md](./EXTENSION_SCOPE_MATRIX.md)。 +> +> 实现状态说明:本文描述的是 Runtime Control Plane v2 的目标能力和分阶段落地建议。当前 AgentRunner 插件化主线已经具备 event-first context、run-scoped authorization、EventLog / Transcript / State / sandbox 文件等 Host capability,并已落地持久 `AgentRun` / `AgentRunEvent` ledger、run control actions、最小 runtime heartbeat/claim lease 和 admin reconcile 原语。完整 Agent Platform 产品形态、daemon supervisor、runtime wakeup channel 和分布式 runtime 管控仍未完成。当前实现状态以 [STATUS.md](./STATUS.md) 为准。 + +## 1. 当前决策 + +LangBot 后续定位应更像 **Agent Host / infrastructure provider / transfer layer**,而不是把某个完整 Agent Platform 产品固化进 core。 + +结论: + +- **Agent Platform 产品形态做成插件**。插件负责 agent 管理、策略、业务队列、UI、编排、多 agent 协作和产品体验。 +- **Agent Platform 所需的基础事实源做进 Host**。当前 Host 已保存 event、state、transcript、sandbox 文件边界、active run 权限快照、持久 run/result ledger、审计关联和通用控制状态。 +- **最小 runtime registry / heartbeat / claim lease 已作为 Host 原语落地,但不等于完整 daemon worker 管控**。远程 harness / daemon 的进程托管、wakeup channel、provider 登录态诊断和分布式调度仍可以先由 AgentRunner 插件和 SDK remote layer 自己维护。 +- **不把业务调度写进 Host**。Host 提供通用 run/result/control primitives,Platform 插件决定哪些事件触发哪些 agent、如何排队、如何分配、是否 fan-out。 + +推荐分层: + +```text +LangBot Host + Current base: EventLog / runtime AgentBinding / State / Transcript / sandbox files / active run authorization + Current v2 foundation: Run / RunEvent / audit / result persistence / control primitives / minimal runtime heartbeat and claim lease + Planned: Agent / Binding persistence / daemon supervisor / wakeup channel / distributed runtime operations + +Agent Platform plugin + Agent management UI / project-task model / event routing policy + Business queue / multi-agent orchestration / runtime selection policy + +AgentRunner plugin / external harness runtime + Connects ACP / remote daemon / local subprocess / HTTP API + Executes and converts provider-native events to AgentRunResult +``` + +## 2. Platform 与非 Platform 的区别 + +当前 LangBot 已经具备 Agent Host 的核心特征: + +- 抹平不同 AgentRunner。 +- 从 IM / Pipeline 入口触发 runner。 +- 有 event-first context 方向。 +- 有 Host-owned EventLog / Transcript / State 和 sandbox/workspace 文件边界。 +- 有 runner config 下发和 active run-scoped authorization。 +- 有 `run_id` 串联 event、transcript、state、sandbox 文件和内存授权上下文。 + +这还不是完整 Agent Platform。完整 Platform 至少还需要: + +- 可管理的 agent 资产:agent profile、binding、resource policy、runner config、可用状态。 +- 可观察的执行生命周期:run status、result stream、失败原因、文件引用、审计、回放。 +- 可运营的控制面:取消、重试、排队、并发、超时、恢复、诊断。 +- 可产品化的调度体验:事件订阅、路由策略、任务板、多 agent 协作、项目/工作区视图。 + +因此,区别不只是“有没有调度”,而是是否具备: + +```text +managed agent assets + observable run lifecycle + operational run control +``` + +Host 负责这些能力的通用事实源和安全边界;Platform 插件负责把它们组装成具体产品。 + +### 2.1 当前实现边界 + +当前代码中的 `run_id` 已经连接 active run 授权、持久 run ledger 和多个 Host 事实源: + +- `EventLog` 保存输入事件和审计入口,并记录 `run_id` / `runner_id`。 +- `Transcript` 保存对话历史投影,并用 `run_id` 关联 assistant 输出。 +- Sandbox/workspace 保存当前运行输入文件和 runner 产物,并用 `run_id` 做访问边界的一部分。 +- `PersistentStateStore` 保存 runner state,但不等同于 run lifecycle。 +- `AgentRunSessionRegistry` 保存 active run 的内存态授权快照,用于 proxy action 校验;进程结束或 run 结束后不作为可回放事实源。 +- `AgentRun` 保存 run lifecycle、scope、authorization snapshot、queue/claim 状态、cancel intent、usage/cost 和 metadata。 +- `AgentRunEvent` 保存 runner/result/admin event stream,按 `run_id + sequence` 做可回放分页。 +- `AgentRuntime` 保存最小 runtime registry / heartbeat 事实,用于 runtime list、stale mark 和 claim lease reconcile。 + +因此本文后续提到的 `AgentRun` / `AgentRunEvent`、`run_append_result`、`run_finalize`、`run_cancel`、`runtime_register`、`runtime_heartbeat`、`run_claim` 等基础原语已经存在。仍未完成的是独立 platform `run_create` action、Host-owned Agent / Binding 持久模型、业务队列产品形态、daemon supervisor、runtime wakeup channel、跨 Host 分布式锁和 provider/runtime 诊断面。 + +## 3. 基础概念 + +### 3.1 Event + +Event 表示“发生了什么”: + +```text +message.received +github.issue.opened +scheduler.tick +user.approved +system.webhook.received +``` + +EBA 负责把外部输入标准化成 event。Event 本身不是 queue,也不等同于一次 agent 执行。当前 `EventLog` 记录的是输入事件和审计事实;未来 `AgentRunEvent` 记录的是某次 run 的输出事件流,二者不能混用。 + +### 3.2 Run + +Run 表示“某个 agent / binding / runner 针对某个 event 的一次执行”。 + +Run 应由 Host 持久化,成为执行状态、结果、权限和审计的事实源: + +```text +run_id +event_id +agent_id / binding_id +runner_id +status +created_at / started_at / finished_at +error / failure_reason +delivery target +metadata +``` + +当前 `AgentRunSessionRegistry` 只保存 active run 的内存态授权信息,不足以支撑 Platform 的回放、审计、取消、重试和异步执行。 + +### 3.3 RunEvent / RunResult + +RunEvent 是一次 run 过程中产生的结果事件流,对应 runner 返回的 `AgentRunResult`。它不同于 EBA/EventLog 的输入事件: + +```text +message.delta +message.completed +tool.call.started +tool.call.completed +state.updated +action.requested +run.completed +run.failed +``` + +Host 应保存这些输出事件,按 `run_id + sequence` 可回放。Transcript、State 可以由这些 result event 触发写入现有 store,并保留能回溯到 `AgentRunEvent` 的关联。文件和工具大结果留在当前 run 的 sandbox/workspace 中,不作为 result event blob 回传。 + +### 3.4 Queue + +Queue 不是 EBA 的替代品。 + +EBA 负责产生 event;queue 负责处理“这个 event 对应的执行 work item 何时执行、谁来执行、如何取消/重试/恢复”。 + +队列可以分两层: + +- **业务队列**:由 Platform 插件管理,例如项目任务、优先级、agent team、workflow、人工审批。 +- **执行队列 / run queue**:可选 Host 原语,例如 queued / running / completed / failed / cancelled、claim lease、dispatch timeout、orphan recovery。 + +第一阶段不要求 Host 内置完整执行队列。Platform 插件可以先管理业务队列;在 Phase 1 / Phase 2 能力落地前,插件仍只能通过现有 `AgentRunOrchestrator.run(...)` 同步执行路径和现有 Host stores 获得有限的 run 关联能力。 + +### 3.5 Runtime / Daemon + +Runtime / daemon 表示执行位置或执行能力,例如某台机器上的 Claude Code / Codex CLI。 + +当前决策: + +- Host 不在第一阶段维护完整 runtime registry。 +- AgentRunner 插件可以通过 SDK remote layer 与 daemon 保持连接、心跳和执行通道。 +- 外部 harness / agent 不应直接访问 LangBot Host 或数据库。访问 LangBot 资源必须通过 daemon / AgentRunner plugin / SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge,并接受 run-scoped authorization 校验。 +- 如果后续多个插件都需要共享 runtime 状态,再把薄的 `RuntimeLease` / registry 下沉为 Host 通用能力。 + +## 4. Host 应新增的最小能力 + +第一阶段最重要的不是 daemon registry,而是让 Host 成为 run/result 的事实源。 + +### 4.1 AgentRun Store + +新增持久 `AgentRun`: + +```text +id / run_id +event_id +agent_id +binding_id +runner_id +conversation_id / thread_id +workspace_id / bot_id +status +status_reason +created_at / started_at / finished_at / updated_at +deadline_at +cancel_requested_at +usage_json +cost_json +metadata_json +``` + +建议 status 至少包含: + +```text +created +running +completed +failed +cancelled +timeout +``` + +如果后续加执行队列,再引入: + +```text +queued +claimed +dispatching +``` + +### 4.2 AgentRunEvent Store + +新增持久 `AgentRunEvent`: + +```text +id +run_id +sequence +type +data_json +usage_json +created_at +source +metadata_json +``` + +约束: + +- 同一 `run_id` 内 `sequence` 单调递增。 +- append 必须幂等,支持远程 daemon / plugin 重试。 +- 未知 result type 可保存但 Host 只对已知类型执行副作用。 +- 大 payload 仍应进入 sandbox/workspace,不直接塞入 result event。 +- `usage_json` 保存 `AgentRunResult.usage` 原样结构;缺失表示 unknown,不等于 0。 + +### 4.3 Run Control API + +Host 提供通用控制原语: + +```text +run.create +run.get +run.list +run.events.page +run.cancel +run.append_result +run.finalize +``` + +语义: + +- `run.create` 创建 Host-owned run 和授权快照。 +- `run.append_result` 只允许受信 SDK/runtime 路径调用,必须绑定 run 创建时固化的授权快照,写入 `AgentRunEvent` 并触发 transcript/state/delivery 副作用。 +- `run.finalize` 关闭 run,更新 terminal status。 +- `run.cancel` 设置取消意图;同步 runner 通过 context/deadline 感知,远程 runner 通过插件/daemon 通道感知。 + +第一阶段可以只暴露给插件 runtime action,不一定先做公开 HTTP API。 + +### 4.4 Result Persistence In Orchestrator + +当前 `AgentRunOrchestrator.run()` 已经处理: + +```text +event -> binding -> context -> runner invocation -> result normalization +``` + +需要补齐: + +- run 开始时创建 `AgentRun`。 +- 每个 `AgentRunResult` 进入 `AgentRunEvent`。 +- `run.completed` / 正常 generator 结束时标记 completed。 +- `run.failed` / exception / timeout 标记 failed 或 timeout。 +- terminal result 携带 usage 时,写入 `AgentRunEvent.usage_json` 并汇总到 `AgentRun.usage_json`。 +- `state.updated`、transcript 写入继续走现有 journal,但应与 `AgentRunEvent` 有可追踪关系。 + +### 4.5 Usage / Cost Accounting + +SDK 侧 `AgentRunResult` 已提供可选 `usage` 字段,用于把不同 runner / external harness / provider-native event 的 token usage 归一到同一个 run result envelope。 + +语义: + +- `run.completed.usage` SHOULD 表示本次 run 的最终聚合 token usage。 +- `run.failed.usage` MAY 表示失败前已知的部分 token usage。 +- 没有 usage 表示 upstream runtime 没有报告或 adapter 暂未接入;Host 不得按 0 计费或按 0 判断上下文消耗。 +- Host 应把 event-level usage 原样写入 `AgentRunEvent.usage_json`,并在 terminal event 或 finalize 阶段汇总到 `AgentRun.usage_json`。 +- cost 应由 Host 根据 usage、runner/model identity、发生时间和价格表计算,写入 `AgentRun.cost_json`;runner/provider 上报的 cost 只能作为非权威 telemetry 保留在 metadata 或 usage extra 中。 + +这层约束先解决协议位置和持久化位置;具体 ACP、remote daemon、local subprocess runner 如何从 native event 中抽取 usage,可在各插件后续适配。 + +### 4.6 Authorization Snapshot + +异步或远程执行时,run 创建时必须固化授权快照: + +- runner identity +- binding identity +- caller plugin identity +- resource policy +- allowed tools/models/files/knowledge bases/storage scopes +- state scopes +- conversation/thread/workspace scope + +后续 append result、state API、history API 和 sandbox/workspace 文件访问都以这个 snapshot 校验,不重新扩大权限。 + +## 5. SDK 侧应新增的最小能力 + +SDK 不需要马上定义完整 daemon registry,但需要让插件和 runner 使用 Host run/result 能力。 + +### 5.1 Entities + +新增或补齐: + +```text +AgentRun +AgentRunStatus +AgentRunEvent +RunEventPage +RunCreateRequest / RunCreateResult +RunAppendResultRequest +``` + +这些是 Host control primitives,不替代 `AgentRunContext` / `AgentRunResult`。 + +### 5.2 Proxy Methods + +在 SDK proxy 中提供: + +```python +create_run(...) +get_run(run_id) +list_runs(...) +page_run_events(run_id, cursor=None, limit=...) +cancel_run(run_id) +append_run_result(run_id, result, sequence=None) +finalize_run(run_id, status, error=None) +``` + +访问边界: + +- 普通 AgentRunner 在同步 `run(ctx)` 内不一定需要直接调用这些 API;Host orchestrator 可自动记录。 +- Platform 插件可以创建/查询/取消 run。 +- AgentRunner 插件或 daemon bridge 可以 append/finalize 自己负责的 run。 +- 外部 harness 仍不能直接调用 Host;必须经 SDK runtime / proxy / bridge。 + +### 5.3 Plugin-Daemon Heartbeat + +远程 daemon 的初始心跳可以是 SDK / AgentRunner plugin 私有能力: + +```text +daemon <-> AgentRunner plugin / SDK remote layer <-> LangBot plugin runtime <-> Host +``` + +Host 第一阶段只需要知道: + +- 相关插件是否在线。 +- run 是否有 progress/result。 +- run 是否超时或取消。 + +如果后续需要跨插件共享 daemon 可用性,再把 heartbeat/registry 下沉为 Host 能力。 + +## 6. Platform 插件应负责什么 + +Agent Platform 插件可以负责: + +- 管理哪些 agent 可用。 +- 维护产品层 agent profile、项目、任务板、workflow、team。 +- 订阅 EBA event,决定哪些 event 触发哪些 agent。 +- 维护业务 queue:优先级、重试策略、人工审批、分配规则。 +- 选择 runner / runtime / daemon。 +- 在 Run Control API 落地后,调用 Host run API 创建、取消、查询执行。 +- 展示 run status、result stream、文件引用、失败原因和审计。 + +Platform 插件不应负责: + +- 在 Host Run Ledger 落地后,私有保存通用 run/result 事实源。 +- 绕过 Host 直接写 transcript/state 或越权访问 sandbox/workspace 文件。 +- 让外部 harness 直接访问 LangBot DB 或 Host 内部资源。 +- 把某个业务队列语义强塞进 AgentRunner Protocol v1。 + +## 7. 与 EBA 的关系 + +EBA 做好后,事件流可以进入两种路径。 + +直接执行路径: + +```text +EventGateway + -> EventRouter resolves AgentBinding + -> AgentRunOrchestrator.run(event, binding) + -> Host records AgentRun / AgentRunEvent (after Run Ledger lands) + -> delivery +``` + +Platform 插件编排路径: + +```text +EventGateway + -> Platform plugin receives/subscribes event + -> plugin applies policy / business queue + -> plugin creates Host run (after Run Control API lands) + -> runner/plugin/daemon executes + -> Host records result and state + -> plugin displays / Host delivers +``` + +这两条路径最终应共享 Host run/result/state 事实源和 sandbox/workspace 文件边界。当前阶段可共享的是 event/transcript/state、sandbox 文件和同步执行链路;持久 run/result ledger 需要 Runtime Control Plane v2 Phase 1 补齐。区别在于是否有 Platform 插件参与产品化调度和业务队列。 + +## 8. 与 AgentRunner Protocol v1 的关系 + +本设计不改变 v1 的 runner 可见合同: + +```text +AgentRunContext -> AgentRunner.run(ctx) -> AgentRunResult stream +``` + +必须保持: + +- `AgentRunContext` 不塞入 daemon/worker/pod 细节。 +- `AgentRunResult` 仍是 runner 输出的统一事件流。 +- 普通 runner 不需要知道 task queue / runtime registry。 +- 远程 harness 可以自管 session、tool loop、MCP、上下文压缩,但访问 LangBot 资源必须通过 SDK proxy / bridge。 +- Runtime-managed execution 是 placement / transport 选择,不是普通 runner 协议的强制概念。 + +## 9. 分阶段实施建议 + +### Phase 1: Run Ledger(Foundation Implemented) + +目标:Host 成为执行状态和结果事实源。 + +范围: + +- `AgentRun` 表。 +- `AgentRunEvent` 表。 +- Orchestrator 自动创建/更新 run。 +- Journal 持久化每个 `AgentRunResult`。 +- Run 查询和事件分页 API。 +- SDK entities + proxy 方法。 + +复杂度:中等。 + +预计改动: + +```text +Host: 12-20 个文件 +SDK: 4-8 个文件 +Tests: 8-15 个文件 +``` + +### Phase 2: Platform Plugin Queue On Host Run Primitives(Control Primitives Partially Implemented; Product Queue Pending) + +目标:Platform 插件管理业务 queue,Host 提供 run/result/cancel 原语。 + +范围: + +- `run.create` +- `run.cancel` +- `run.append_result` +- `run.finalize` +- result append 的 sequence/idempotency。 +- 受权限保护的远程 append/finalize。 +- Platform 插件可基于 Host run 构建任务板和调度体验。 + +复杂度:中等偏高。 + +预计改动: + +```text +Host: 20-35 个文件 +SDK: 8-14 个文件 +Tests: 15-25 个文件 +``` + +### Phase 3: Optional Host Execution Queue / Claim Lease(Claim Lease Primitive Implemented; Full Queue Pending) + +目标:当多个插件重复实现 claim/cancel/retry/recovery 时,再下沉执行队列到 Host。 + +范围: + +- `queued/running/completed/failed/cancelled` 状态机扩展。 +- `claim_run` / `lease_until`。 +- dispatch timeout。 +- retry / orphan recovery。 +- cancel propagation。 +- 并发 claim 防重。 + +复杂度:高。 + +预计改动: + +```text +Host: 35-55 个文件 +SDK: 12-20 个文件 +Tests: 25-40 个文件 +``` + +### Phase 4: Optional Runtime Registry(Minimal Registry Implemented; Full Daemon Control Pending) + +目标:当 Host 需要统一管理多个 daemon / worker 时,再引入 runtime registry。 + +范围: + +- runtime register / heartbeat / deregister。 +- capability report:provider、version、login status、workspace access、slot。 +- runtime online/offline。 +- runtime scoped auth。 +- runtime audit。 +- runtime gone recovery。 +- task wakeup / long polling / websocket。 +- 多 Host 实例下的 relay / distributed lock。 + +复杂度:很高。 + +预计改动: + +```text +Host: 55-80+ 个文件 +SDK: 18-30 个文件 +Tests: 40+ 个文件 +``` + +不建议现在直接进入此阶段。 + +## 10. 设计原则 + +- 先把 run/result 事实源做进 Host,再谈完整 runtime control plane。 +- Agent Platform 产品做插件;Host 做基础设施。 +- Host 不写业务调度策略,但要保存通用状态、结果、权限和审计。 +- EBA event 不是 queue;queue 是执行生命周期问题。 +- 业务 queue 可以先在 Platform 插件里;执行 queue 只有在复用需求明确后再下沉 Host。 +- Daemon registry 不应污染 AgentRunner Protocol v1。 +- 外部 harness 不直接访问 LangBot Host 或 DB。 +- 所有 LangBot 资源访问必须走 SDK runtime / `AgentRunAPIProxy` / scoped MCP bridge。 +- Docker / remote / local subprocess 只是 runtime placement,不是 runner 协议差异。 + +## 11. 非目标 + +当前阶段不做: + +- 完整 Multica 式 runtime registry。 +- Host 内置项目管理、任务板、agent team、workflow 产品逻辑。 +- 把 daemon heartbeat / worker liveness 放进 `AgentRunContext`。 +- 把业务 queue 定义为 AgentRunner Protocol 字段。 +- 让 Platform 插件私有保存 run/result 事实源。 +- 让外部 agent/harness 直连 Host 内部资源。 + +## 12. 待定问题 + +- 已确认:Agent 与 Pipeline 都持久存在,并由 EBA 处理器 binding 指向其中之一;Pipeline 仅在 AI Stage 调用 runner 时投影一次性 `AgentBinding`,不会充当持久 Agent 的替代物。 +- Platform 插件创建 run 时,是否传完整 `AgentBinding` snapshot,还是引用 Host-owned binding id。 +- `AgentRunEvent` 与现有 `EventLog` / `Transcript` 的查询关系:直接 join,还是通过专门 view 聚合。 +- `run.append_result` 的认证粒度:runner plugin identity、run token、scoped capability token,或 SDK runtime 内部 channel。 +- 取消语义:同步 runner、external harness runtime/session 如何统一感知 cancel。 +- 何时把插件私有 daemon heartbeat 提升为 Host `RuntimeLease`。 +- 若未来 Host 做 claim lease,Platform 插件业务 queue 与 Host execution queue 如何避免双队列混乱。 diff --git a/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md b/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md new file mode 100644 index 000000000..88b9a0b55 --- /dev/null +++ b/docs/agent-runner-pluginization/RUN_STEERING_AND_CHECKPOINT.md @@ -0,0 +1,154 @@ +# Run Steering 与 Compaction Checkpoint(Design Note) + +本文档记录两项 Host/runner 协作能力:**运行中消息注入(steering / follow-up)**和 +**压缩摘要持久化(compaction checkpoint)**。两者来自官方 local-agent 对照 +Pi agent harness(`pi-mono/packages/agent`,下称 pi-agent-core)的差距分析: +local-agent 已移植 Pi 的事件生命周期、并行工具语义、hook 扩展点和压缩预算模型, +这两项需要 Host 协议、授权与 runner turn 边界协同才能闭环。 + +> 本文是设计备忘,不是 schema 事实源。涉及的数据结构最终落到 +> [PROTOCOL_V1.md](./PROTOCOL_V1.md);上下文边界语义以 +> [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md) 为准; +> run 持久化与控制原语以 [RUNTIME_CONTROL_PLANE_V2.md](./RUNTIME_CONTROL_PLANE_V2.md) 为准。 + +## 1. Run Steering / Follow-up(运行中消息注入) + +### 1.1 问题 + +IM 场景下用户在 agent 运行中追加消息非常常见(补充信息、纠正方向、"算了别查了")。 +EBA 先按事件选择一个 Pipeline 或 Agent 处理器;进入 AgentRunner 后,当前调用链是 `one AgentBinding -> one run_id -> one runner` +(PROTOCOL_V1 §13):同会话的新消息要么等待当前 run 结束后触发新 run, +要么并发触发独立 run。两种行为都无法把新消息送进**正在执行的 tool loop**, +用户体验是"agent 自顾自跑完过期任务,然后才看到新消息"。 + +cancel(PROTOCOL_V1 §10)不解决这个问题:cancel 丢弃已完成的工作; +steering 是在保留当前进度的前提下改变后续方向。 + +### 1.2 Pi 的参考语义 + +pi-agent-core 区分两个队列,注入时机都在 turn 边界,不打断进行中的模型流或工具执行: + +- **steering**:运行中插入。当前 assistant 消息的全部 tool call 完成后、 + 下一次模型调用前,注入排队的用户消息;模型在下一 turn 看到它们。 +- **follow-up**:排队后续工作。仅当没有 pending tool call 且没有 steering 消息、 + run 即将自然结束时检查;若有排队消息则注入并继续下一 turn,而不是结束 run。 + +两个队列各自支持 `one-at-a-time`(每次注入一条)和 `all`(一次注入全部)模式。 + +### 1.3 设计方向 + +职责划分遵循既有原则:Host 拥有事件路由和会话事实源,runner 拥有 turn 边界。 + +- **Host 侧**:BindingResolver / dispatch 层识别"同 conversation 存在 active run + 且 runner 声明支持 steering"的新消息事件,将其写入 run-scoped steering queue, + 并标记该事件已被在途 run 认领(不再触发新 run,避免破坏 §13 的基数约束)。 + 事件仍照常进 EventLog / Transcript(事实源不变,改变的只是触发行为)。 +- **Runner 侧**:在 turn 边界(tool batch 完成后、下一次模型调用前,以及 run + 即将自然结束前)通过 run-scoped pull API 拉取 pending steering 输入, + 注入 working context。local-agent 的 `AgentLoopHooks.prepare_next_turn` / + `should_stop_after_turn` 已预留了对应的注入点。 +- **能力协商**:runner manifest 声明 `steering` capability(参照 PROTOCOL_V1 §4.3); + 未声明的 runner 保持现状(新消息按现有规则另起 run)。 +- **回执**:被 steering 消费的事件通过 EventLog 审计。原始 `message.received` + 记录在 `metadata.steering` 标记 queued/absorbed 与 `claimed_by_run_id`; + runner 成功 pull 后,Host 追加 `steering.injected` 记录并引用源事件。 + run 结束时仍未被 pull 的已 claim 输入,Host 追加 `steering.dropped` 记录作为 + dispatch 终态;原始 Transcript 事实不删除。 + Transcript 继续只表示会话事实,不扩展 dispatch 行为字段。 + +已落地的协议面(最终定义归 PROTOCOL_V1): + +1. `ContextAccess.available_apis` 增加 steering pull 能力位。 +2. `AgentRunAPIProxy` 增加 steering 拉取 action:默认 `mode=all`,Host 保序返回全部 + pending 输入;`one-at-a-time` 仅作为 runner 主动节流选项。 +3. dispatch 层的"认领"规则:`message.received` 可被同 conversation 的 active run + 吸收,原事件写 EventLog / Transcript,dispatch 行为写入 EventLog metadata。 +4. Host 对单 run steering queue 设置内存上限,队列满时不再 claim 新消息,消息回到 + 正常 dispatch 路径,避免 active run 无限吞入同会话输入。 + +### 1.4 边界 + +- 不引入 Host 替 runner 做 prompt 拼接:Host 只递队列,注入位置和格式由 runner 决定。 +- 不与 observer / fan-out 混淆:steering 仍是单 run 内的输入补充,不产生第二个 runner。 +- 远程 / 外部 harness runner(claude-code、codex 等)若其底层 session 自带 + steering 能力,adapter 可以直接转发;协议面保持一致。 + +## 2. Compaction Checkpoint 持久化 + +### 2.1 问题 + +local-agent 当前是无状态 runner:每次 run 重新拉取 transcript 尾部 +(默认 50 条)、重新估算 token、重新生成压缩摘要。后果: + +- 长会话中每 run 重复压缩计算,摘要每次重新生成,不同 run 之间措辞漂移, + 对 provider KV cache 不友好(AGENT_CONTEXT_PROTOCOL §"Summary checkpoint 稳定" + 已写明期望:只有压缩发生时才产生新 checkpoint)。 +- 历史一旦超过 fetch limit,更早的内容永久不可见——没有 checkpoint 记录 + "已压缩到哪里、压缩出了什么"。 + +pi-agent-core 把 compaction 条目持久化进 session tree:摘要带 +`tokensBefore` 和覆盖范围,后续 turn 直接复用,只在再次越过阈值时增量压缩。 + +### 2.2 现状盘点 + +协议面和主消费路径已具备: + +- State / Storage API 已定义(PROTOCOL_V1 §8 "State / Storage"), + 且 AGENT_CONTEXT_PROTOCOL 已点名 `summary.checkpoint` 是 state 的预期用法。 +- Host 会根据 binding state policy 暴露 `ContextAccess.available_apis.state`。 +- local-agent 会在 state API 可用时读取/写入 `runner.compaction.checkpoint`; + 缺失、schema 不匹配、conversation 不匹配或游标失败时回退尾部历史拉取。 +- LLM 生成摘要**不依赖**本项 Host 能力——runner 用已授权的 `invoke_llm` + 即可生成;checkpoint 只解决"存下来、下次复用"。 + +### 2.3 设计方向 + +- **存放位置**:state,scope=`conversation`(小 JSON,符合 PROTOCOL_V1 §8 + 对 state/storage 的边界建议)。若未来摘要膨胀,超出部分放 storage 并在 + state 中留引用。 +- **key 约定**:`runner.compaction.checkpoint`(runner 命名空间内)。 +- **内容约定**(schema 落 PROTOCOL_V1 或 runner 文档,此处只列语义): + - `schema_version` + - `summary`:压缩摘要文本(LLM 生成或确定性生成) + - `covers_until`:已被摘要覆盖的 transcript 游标(seq / message id), + 是增量压缩和"从哪继续拉历史"的锚点 + - `tokens_before` / `created_at`:诊断与失效判断 +- **消费流程**:run 开始时读 checkpoint → 只拉取 `covers_until` 之后的 + transcript → 压缩触发时基于旧摘要增量生成新摘要、写回新 checkpoint。 + checkpoint 缺失或解析失败时回退到现行为(全量拉尾部),保证向后兼容。 +- **失效规则**:`covers_until` 在 Host transcript 中不存在(会话被清理 / 重置) + 即作废;runner 不得信任跨 conversation 的 checkpoint。 +- **授权**:Host 对声明需要 state 的 runner binding 开启 + `available_apis.state`;校验沿用现有 run-scoped state 校验 + (scope、key、value 大小、JSON 可序列化,见 PROTOCOL_V1 §7.2 对 + `state.updated` 的要求)。 + +### 2.4 相关但独立的工作 + +- **tokenizer / usage metadata 透传**:runner 目前用 chars/4 启发式估 token, + 对 CJK 偏低 3-4 倍,压缩触发系统性偏晚。Host 应在模型响应或 + `ctx.runtime.metadata` 透传 provider usage(prompt/completion tokens)与 + model context window(LiteLLM model-info 工作)。该项不阻塞 checkpoint + 落地,但决定压缩触发的准确性。 + +## 3. 实施拆分 + +| 项 | 归属 | 依赖 | +| --- | --- | --- | +| steering queue、事件认领、基础审计 | LangBot Host(dispatch / binding 层) | 已落地,含队列上限与未消费 dropped 终态 | +| steering pull API + capability 位 | PROTOCOL_V1 + SDK proxy | 已落地 | +| turn 边界拉取与注入 | langbot-local-agent | 已落地 | +| local-agent 对 state API 的 checkpoint 读写 | langbot-local-agent | 已落地 | +| checkpoint key / 内容 / 失效约定 | PROTOCOL_V1 + local-agent README | 已落地 | +| LLM 压缩摘要生成 | langbot-local-agent | 已落地(`invoke_llm`,失败回退确定性摘要) | +| usage / context-window metadata 透传 | LangBot Host(model 层) | LiteLLM model-info | + +剩余工作应优先补 usage / context-window metadata。streaming delivery 衔接依赖 +`ctx.delivery` 编辑/追加语义,不建议在协议能力缺失时硬编码。 + +## 4. 开放问题 + +- streaming delivery 下 steering 注入后,前序 turn 已流出的内容与新 turn + 输出在 IM 消息编辑面的衔接(涉及 `ctx.delivery` 能力,待 delivery 演进定)。 +- checkpoint 是否需要 Host 侧主动失效通知(如会话清空时删除对应 state key)。 + 当前实现靠 runner 读取时校验并回退,功能不阻塞。 diff --git a/docs/agent-runner-pluginization/SECURITY_HARDENING.md b/docs/agent-runner-pluginization/SECURITY_HARDENING.md new file mode 100644 index 000000000..3d577fe4a --- /dev/null +++ b/docs/agent-runner-pluginization/SECURITY_HARDENING.md @@ -0,0 +1,211 @@ +# Agent Runner Security Boundary + +本文档记录 agent-runner 插件化后的安全边界和最小护栏。 + +## 状态 + +**当前结论:不采用高强度监管模型。** + +LangBot 的目标不是托管一个强隔离、不可信 code runner 平台。AgentRunner 插件,尤其是 ACP / Claude Code / Codex / OpenCode / Kimi Code 这类外部 harness,默认视为 **operator-owned execution**:用户或部署者显式配置并承担其文件系统、进程、网络、workspace、provider 登录态和 native tool 风险。 + +LangBot 需要负责的是保护 **LangBot 自己持有的资源**,包括模型、知识库、LangBot tools、history、event、state、plugin/workspace storage、sandbox/workspace 文件访问等。只要这些资源访问是 run-scoped、permission-scoped、可校验、可诊断的,当前阶段即可接受。 + +这意味着: + +- 不要求 LangBot 在应用层实现完整 OS sandbox、VM、cgroup、seccomp、CPU / memory / network quota。 +- 不要求为 ACP runner 做复杂审批流;用户选择 ACP runner 即表示显式 opt-in。 +- 不要求在非 Docker 进程部署里做强监管;只要文档明确风险归属即可。 +- Docker / K8s 可以提供部署级隔离,但不是 LangBot agent-runner 协议发布的前置条件。 +- 不能宣传 LangBot 已经提供 managed sandbox;除非未来真的提供受管执行环境。 + +## 责任边界 + +### LangBot Host 负责 + +- **资源授权**:根据 runner manifest permissions、binding resource policy、run scope 生成本次 run 可访问的资源快照。 +- **运行期校验**:所有带 `run_id` 的 SDK / Host action 必须校验 active run session、caller plugin identity、resource id 和 operation。 +- **Scoped projection**:只把授权后的资源摘要、MCP server config、context、attachment/path ref、state snapshot 投影给 runner。 +- **LangBot 文件路径约束**:LangBot 自己 staged 和读取的文件必须限制在声明 root 内,防止 path escape。 +- **基础 secret 策略**:不要主动把 LangBot 持有的 API key / token / secret 投影给 runner;日志和错误里做常见 secret 字段脱敏。 +- **基础运行约束**:提供 timeout、取消传播、输出大小限制或错误映射的基础能力。 +- **audit-lite**:记录 event、run id、runner id、binding、资源授权摘要、关键失败、state/file/transcript 事实。 + +### Runner Plugin 负责 + +- 遵守 Host 下发的 `ctx.resources`、`ctx.context.available_apis`、runner config 和 state policy。 +- 把 LangBot 资源投影成目标平台可消费的形式,例如 MCP config、context prompt、HTTP header、run token。 +- 不绕过 SDK / Host action 直接访问 LangBot 内部资源。 +- 对自己启动的外部进程做合理封装,包括参数构造、timeout、取消、输出解析和错误映射。 +- 清楚记录自身 README 中的 provider 风险、部署假设和限制。 + +### 部署者 / 用户负责 + +- ACP / external harness 的 workspace 内容、文件系统访问、进程权限、网络访问、provider-native tool 权限。 +- Docker / K8s 的 image、volume、secret、network policy、resource limit、namespace、service account 配置。 +- 本机进程部署时的 OS 用户权限、PATH、HOME、CLI 登录态、全局配置和外部 MCP 配置。 +- 是否允许 runner 对某个目录执行真实写操作。 + +### 外部 Harness 负责 + +Claude Code、Codex、OpenCode、Kimi Code、Gemini CLI 等外部工具继续使用自己的权限模型、MCP 加载策略、session/resume、sandbox 或 approval 能力。LangBot 不承诺约束这些工具对其所在容器或宿主 OS 用户本来可访问资源的能力。 + +## 部署场景策略 + +| 场景 | LangBot 策略 | 不由 LangBot 承担 | +| --- | --- | --- | +| 普通进程部署 | 文档提示 operator-owned execution;Host 只保护 LangBot 资源。 | 阻止外部 CLI 读取同一 OS 用户可访问的文件、进程、HOME、全局 CLI 配置。 | +| Docker / K8s 部署 | 继续使用相同 Host 资源边界;容器隔离由部署环境提供。 | 应用层重复实现容器/VM/cgroup/seccomp/network quota。 | +| ACP runner | 用户显式选择 runner 和 workspace;LangBot 注入 scoped MCP / run token。 | ACP CLI native tools、workspace 写入、provider 登录态和外部 MCP 行为。 | +| 外部 SaaS runner,例如 Dify | LangBot 通过 run token / gateway 限制 LangBot 资产访问。 | SaaS 平台内部 agent 执行策略、模型工具消息格式、平台侧日志。 | +| 未来 managed runner | 只有当 LangBot 明确提供受管执行环境时,才需要单独定义强隔离 SLA。 | 当前协议闭环不承诺 managed sandbox。 | + +## 最小护栏 + +以下是当前阶段需要维持的最小要求。它们是保护 LangBot 资源边界的要求,不是完整监管外部进程的要求。 + +### Resource Permission Boundary + +每次 run 前必须冻结授权快照: + +- runner manifest permissions 是资源访问上限。 +- binding resource policy / runner config 决定本次实际授权。 +- runtime action 按 `run_id` + `caller_plugin_identity` + resource id + operation 校验。 +- manifest permissions 只约束 LangBot 持有资源,不约束 external harness native tools。 + +当前实现方向是正确的:`AgentRunSessionRegistry` 保存 run-scoped snapshot,`plugin/handler.py` 对模型、工具、知识库、history、state、storage 等 action 做运行期校验,sandbox/workspace 文件访问由 scoped tool 边界控制。 + +**Skill 读写门控(不可弱化)**:pipeline-visible 的 skill 一次性以 `rw` 挂进同一 sandbox,mount 层不区分「可见」与「已激活」;写类 native 操作(write/edit/exec)只放行 activated skill,读类放行 visible + activated——这层区分等同资产授权语义,必须保留。skill 全 tool 化后尤其注意:「都是 tool」不等于「只控资产授权即可」,native 层的 visible/activated 门控不能砍。可弱化的只是 realpath 越界字符串检查(有 chroot/namespace 兜底)。 + +### MCP / Asset Gateway Boundary + +LangBot MCP / asset gateway 只暴露当前 run 授权的工具面: + +- `langbot_list_assets` +- `langbot_get_current_event` +- `langbot_history_page` +- `langbot_retrieve_knowledge` +- `langbot_get_tool_detail` +- `langbot_call_tool` + +外部平台需要使用短期 `run_token` 或 Authorization bearer token。token 缺失、错误或过期时必须拒绝访问。 + +不要求当前阶段实现 admin 级 MCP allowlist、dangerous tool approval 或复杂审批流。是否注册外部 MCP provider 是部署者/用户行为。 + +### Workspace / Path Boundary + +LangBot 只需要约束自己管理的路径: + +- Host staged 文件必须校验 `realpath` 和 root containment。 +- Attachment/file metadata 不应暴露 Host-only storage key / host path。 +- Context 文件、sandbox/workspace 文件如由 LangBot 创建,应放在可清理的位置。 + +用户配置给 ACP runner 的 workspace 不属于 LangBot 的强监管范围。Docker/K8s 下依赖 volume 挂载边界;普通进程部署下依赖 OS 用户权限和用户自担风险。 + +### Secret Handling + +这里的 secret 指 API key、provider token、run token、MCP token、platform secret、数据库密码等。 + +当前阶段只要求基础策略: + +- LangBot 不主动把自己持有的 secret 投影给 runner,除非这是 runner config 明确需要的外部服务凭据。 +- run token 是短期、run-scoped 的,不应长期保存。 +- 日志、错误、transcript、attachment/file metadata 尽量避免打印常见 secret 字段。 +- 配置 UI / API 返回时继续沿用现有 secret masking 规则。 + +不要求当前阶段实现完整 DLP、全链路敏感数据追踪、secret lineage 或自动轮换体系。 + +### Process / Runtime Bounds + +LangBot 需要提供基本可控性: + +- Host run deadline / runner timeout。 +- runner 侧请求 timeout。 +- generator close / cancel 传播。 +- 输出和 inline payload size 上限。 +- 错误映射为受控 runner failure。 + +不要求 LangBot 为外部 harness 实现 CPU、内存、磁盘、网络、进程树强隔离。需要这些能力时由 Docker/K8s、systemd、容器平台或用户机器策略提供。 + +### UI / Admin Surface + +前端可以展示 runner 权限摘要,但它是信息披露,不是审批系统。 + +权限摘要指 runner manifest 声明的 LangBot 资源权限,例如: + +- `tools.detail` +- `tools.call` +- `knowledge_bases.retrieve` +- `history.page` +- `storage.plugin` + +当前阶段不要求强制弹窗、管理员审批、dangerous tool approval 或生产禁用开关。可以在 runner 配置区展示简短提示:此 runner 能访问哪些 LangBot 资源,外部 harness 执行风险由用户/部署者承担。 + +### Audit Lite + +需要记录足够排查问题的事实: + +- run id、runner id、binding、event。 +- 授权资源摘要。 +- state update、file write/read event、transcript message。 +- MCP / pull API 拒绝时的 warning。 +- steering queued / injected / dropped。 + +不要求当前阶段建立独立安全审计产品、审批记录系统或 SIEM 级事件模型。 + +## 降级后的检查表 + +| 项目 | 当前要求 | 状态判断 | +| --- | --- | --- | +| Path isolation | 只约束 LangBot 管理的 context/sandbox 文件路径;runner workspace 归用户/部署环境。 | Minimal required | +| Permission boundary | 必须保护 LangBot 资源;不约束外部 CLI native 能力。 | Required | +| Secret handling | 基础不投影、基础 masking、run token 短期化。 | Basic required | +| MCP policy | run-scoped token + scoped tool surface;无复杂审批。 | Required | +| Skill access policy | skill 通过 Host 授权 tool 暴露(发现 / activate / register / native exec 走统一 tool 授权);**native 层 visible(只读)vs activated(可写)门控不可弱化**——所有 pipeline-visible skill 以 `rw` 挂进同一 sandbox,读写区分全靠 native 层;harness-native skill 文件不作为 LangBot 安全边界。 | Required | +| Process isolation | 由 Docker/K8s/用户机器负责。 | Out of scope | +| State lifecycle | scope 隔离、JSON size limit、基础 cleanup primitive。 | Basic required | +| Audit | 记录运行事实和拒绝原因。 | Audit-lite | +| UI / Admin control | 权限摘要可展示;不要求审批流。 | Optional | +| Test matrix | 覆盖 run auth、MCP token、permission deny、timeout、sandbox path、state size。 | Focused tests | + +## 当前实现快照 + +截至 2026-06-15,已有实现覆盖: + +- SDK typed AgentRunner manifest、capabilities、permissions。 +- Host resource builder 按 manifest permissions 和 binding policy 生成 `ctx.resources`。 +- Active run session snapshot 和 `caller_plugin_identity` 校验。 +- History / event / state / tool / knowledge runtime action 的 run-scoped 校验。 +- Sandbox file path `realpath` + root containment。 +- Persistent state scope 隔离和 JSON size limit。 +- SDK-owned MCP bridge 和 long-lived asset gateway。 +- Dify / ACP runner 对 LangBot asset gateway 的接入。 +- Runner timeout、Dify HTTP timeout、ACP startup / initialize / request timeout。 + +仍可继续优化但不阻塞当前发布的事项: + +- 前端展示 runner LangBot 资源权限摘要。 +- 常见 secret 字段 redaction 收敛成统一 helper。 +- Context/sandbox file TTL cleanup 调度。 +- 更完整的 MCP 调用 audit。 +- 更好的文档提示:ACP runner 是 operator-owned execution。 + +## 非目标 + +以下不属于当前 agent-runner pluginization 的安全目标: + +- 防止 ACP / external harness 修改其 workspace。 +- 防止外部 CLI 读取同一容器或 OS 用户本来可读的文件。 +- 管控 external harness 的 provider-native tools、approval、MCP、browser、shell。 +- 在 LangBot 应用层实现 VM / container / cgroup / seccomp / network policy。 +- 为 Docker/K8s 部署替代平台自身的 secret、volume、network、resource limit 管理。 +- 实现企业级审批系统、SIEM、DLP 或安全运营面板。 + +## 发布口径 + +可以对外说明: + +> AgentRunner 插件通过 run-scoped authorization 和 scoped MCP gateway 保护 LangBot 持有资源。外部 code harness 的执行环境由用户或部署平台负责隔离;LangBot 当前不提供 managed sandbox。 + +不能对外说明: + +> LangBot 已经安全沙箱化 Claude Code / Codex / OpenCode 等外部 runner。 diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md new file mode 100644 index 000000000..04e83071a --- /dev/null +++ b/docs/agent-runner-pluginization/STATUS.md @@ -0,0 +1,60 @@ +# AgentRunner Pluginization Status + +本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。 + +状态快照日期:2026-07-12。 + +## 实现状态 + +| 领域 | 状态 | 说明 | +| --- | --- | --- | +| SDK manifest schema | Done | `AgentRunnerManifest` 包含 typed `capabilities` / `permissions`;未知 capability / permission key 禁止进入 typed model。 | +| Runner discovery | Done | Runtime 返回 typed manifest;Host registry 校验单个 runner,失败 warning + skip,不影响其它 runner。 | +| Host resource authorization | Done | `ctx.resources` 和 `ctx.context.available_apis` 由 manifest permissions 与 binding policy / run scope 求交后生成。 | +| Run authorization snapshot | Done | active run session 冻结 run-scoped resources 与 available APIs;runtime handler 按 snapshot 校验 pull API。 | +| Result payload validation | Done | Wire 保持 `{type, data}`;Host 对投递/副作用类 payload 严格校验,tool-call telemetry 宽松,未知 type 忽略并 warning。 | +| Old built-in runners | Done | 旧 `src/langbot/pkg/provider/runners/*` 与 `RequestRunner` 路径已从本分支删除。 | +| Official runner manifests | Done | `local-agent`、ACP / Claude Code / Codex 外部 harness runner、外部服务 runner 已重新声明真实生效的 LangBot resource permissions。 | +| Skill 链路 | Unit-pass; WebUI smoke pending | 已按 **skill 全 tool 化** 收敛:发现走 `list_skills` / `langbot_list_assets` 和 skill resources;`activate` / `register_skill` 走统一 tool 授权;`skill_authoring` capability 降级为便捷开关。`activate` 现在会 best-effort 写入 conversation-scope `host.activated_skills`,后续 run 通过当前 pipeline-visible skill cache 恢复,语义为 last-write-wins。 | +| Runtime Control Plane v2 foundation | Partial | Host-owned `AgentRun` / `AgentRunEvent` ledger、orchestrator 自动建账、result event persistence、run get/list/event page/cancel/append/finalize actions 已落地;`agent_run:admin` / `runtime:admin` 控制权限、最小 runtime register/heartbeat/list/reconcile 和 run claim/renew/release 原语已落地。完整 Agent Platform 产品形态、daemon supervisor、任务唤醒/长轮询/WebSocket、分布式 runtime 管控仍未完成。 | +| Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 | +| Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | +| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | +| EBA processor routing | Done; clean-instance catalog gate pending | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;全新实例 Runner Marketplace 用例仍需独立空白环境。 | + +## Spec 与实现已知差距 + +- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。 +- `action.requested` 权限模型完成前,DeliveryContext 的 adapter capability 投影只用于输出决策,不提供平台动作执行权限。 +- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 +- `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。 +- EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。 +- External harness 的 native shell / filesystem / CLI / MCP 权限不受 manifest permissions 约束;manifest permissions 只约束 LangBot 持有的资源访问。 +- LangBot 当前不承诺 managed sandbox;external harness 的 OS/process/network quota、workspace GC、provider-native tool 权限由用户或部署环境承担。 +- Runtime Control Plane v2 当前只落地 Host 事实源和控制原语;还没有内置 Agent Platform UI、业务队列、daemon 进程托管、runtime wakeup channel、跨 Host 分布式锁或 provider 登录态诊断。 + +## Runner 验收状态 + +| Runner | 状态 | 最近证据 | +| --- | --- | --- | +| `plugin:langbot-team/LocalAgent/default` | Unit-pass; UI smoke pending | 2026-06-10 本地 pytest / ruff 通过;WebUI smoke 由人工统一执行。 | +| `plugin:langbot-team/ACPAgentRunner/default` / `plugin:langbot-team/ClaudeCodeAgent/default` / `plugin:langbot-team/CodexAgent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 | +| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | + +## Host / SDK 验收状态 + +| 范围 | 状态 | 最近证据 | +| --- | --- | --- | +| LangBot Runtime Control Plane v2 foundation | Unit-pass; EBA product flow pass | 2026-07-12 事件路由与 Agent 协议针对性测试通过;WebUI 已验证 Quick Start 场景筛选、事件路由 dry-run / 合成派发、Runner 健康状态,以及真实 OneBot `group.member_joined` → Agent → `send_group_msg` 链路。clean-instance Runner Marketplace 用例因当前实例已有插件与 runner 未执行。 | +| SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-23 SDK `tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/test_pull_api_handlers.py`、`tests/runtime/io/handlers/test_plugin_handler.py`、EBA event entities 和 message tests 通过,覆盖 typed entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 | + +## 历史高价值记录 + +历史报告已合并为本状态页和 QA 指南,不再保留单独进度文档。后续若需要追溯,优先查看 `langbot-skills/reports/` 下的原始执行报告。 + +截至 2026-05-29,已有本地 smoke 证明: + +- `local-agent` 可以通过 Pipeline Debug Chat 走插件化 `AgentRunOrchestrator` 主链路。 +- 外部 harness runner 可以通过同一条 `run(event, binding)` 路径执行;当前官方实现已收敛到 ACP / Claude Code / Codex 等直接 runner 插件。 + +这些记录只证明本地协议闭环可用,不代表 LangBot 提供 managed sandbox 或 external harness OS 级隔离。 diff --git a/docs/agent-runner-pluginization/eba-productization-release.md b/docs/agent-runner-pluginization/eba-productization-release.md new file mode 100644 index 000000000..803625cee --- /dev/null +++ b/docs/agent-runner-pluginization/eba-productization-release.md @@ -0,0 +1,342 @@ +# EBA 产品化与发布计划 + +> 状态:规划草案,2026-07-01 +> +> 范围:将已经合并的 AgentRunner 插件化和 Event Based Agent 适配器工作产品化,使非技术用户也能快速上手。本文聚焦产品缺口、发布门禁和 SaaS 多命名空间租户能力。本文不引入新的协议 schema;协议事实仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准,Host 模型事实仍以 [HOST_SDK_INFRASTRUCTURE.md](./HOST_SDK_INFRASTRUCTURE.md) 为准。 + +## 1. 产品方向 + +当前技术方向是正确的:LangBot 应该把平台输入视为事件,为每个事件解析出一个有效路由,并通过 AgentRunner Host 边界调用一个处理资产。但这还不是一个非技术用户无需理解内部架构就能采用的产品。 + +产品层模型应当是: + +- **机器人(Bot)**:平台连接与事件路由入口。机器人拥有适配器凭据、平台权限、入站事件可见性,以及这些事件的路由表。 +- **处理器(Processor)**:可复用的事件处理资产。当前处理器类型包括 **Agent** 和 **Pipeline**。未来可以增加 **Workflow**。 +- **Pipeline**:一等的无代码消息处理器,通过完整 Stage 链提供预处理、AI、后处理、扩展和输出控制。Pipeline 只处理消息,应当只能绑定到 `message.*` 事件。 +- **Agent**:由 runner 驱动的事件优先处理器。Agent 可以根据自身声明的事件支持范围处理消息事件和非消息事件。 +- **Solution(方案包)**:未来的分发/导出单元,包含处理器、路由模板、依赖清单、变量和文档。Solution 不应包含具体机器人凭据、租户密钥或已安装资产的 UUID 绑定。 + +`EBA` 是内部工程术语。它可以出现在内部设计文档中,但不应出现在主要产品流程里。面向用户的语言应优先使用“频道”“事件路由”“处理器”“消息流水线”“自动化”“路由模板”等表达。 + +## 2. 当前基础 + +已合并分支已经具备内部冒烟测试所需的技术基础: + +- EBA 适配器可以把平台活动规范化为稳定的 Host 事件名。 +- 机器人可以持久化 `event_bindings`,并将事件路由到 Agent、Pipeline 或丢弃目标。 +- 旧消息输入可以投射到标准的 `message.received` 事件路径。 +- Pipeline 仍可作为只处理消息的无代码处理器使用。 +- AgentRunner 插件化提供了 Host 与 Runner 的契约、事件优先上下文、结果流和运行时集成边界。 +- 官方 local runner 和 external runner 插件可以验证 runner 行为已经不再硬编码在 LangBot Core 中。 +- WebUI 已具备 Agent/处理器管理页面,以及机器人侧事件路由页面。 +- 机器人事件路由已具备 dry-run 诊断、运行时状态展示,以及安全的合成测试事件派发;测试事件会走已保存的 runtime 路由,但抑制真实平台出站动作。 +- MCP 工具面已暴露机器人事件路由状态查询和合成测试事件派发,便于 QA agent 或外部调试工具复用。 + +这是一个技术收敛里程碑,还不是产品就绪版本。 + +## 3. 距离非技术产品的缺口 + +### 3.1 概念负担 + +当前用户仍需要理解过多内部概念:EBA、适配器事件名、runner 标识、插件运行时健康状态、事件模式、优先级和绑定目标。面向非技术用户的产品应当通过意图和结果来引导: + +- “收到一条消息时,用这个处理器回复。” +- “有新群成员加入时,发送欢迎语。” +- “收到好友请求时,让 Agent 判断是否接受。” + +`group.member_joined` 这类原始事件模式应继续保留在高级模式中,但默认 UI 应按友好名称和平台能力对事件进行分组。 + +### 3.2 上手路径 + +首次使用路径应当从用例开始,而不是从架构开始: + +1. 选择一个频道。 +2. 连接账号或 webhook。 +3. 选择该频道支持的事件预设。 +4. 选择或创建一个处理器。 +5. 发送测试事件。 +6. 如果失败,阅读简单的运行轨迹。 + +当前产品仍假设用户能诊断后端、插件运行时、Box 运行时、适配器和 runner 插件是否都已连接。对于开发者这是可接受的,但对非技术用户不可接受。 + +### 3.3 适配器就绪度 + +每个适配器都需要产品能力清单,而不仅是工程实现: + +- 支持的事件列表及友好标签; +- 支持的出站动作; +- 所需凭据和配置步骤; +- 本地部署、自托管、SaaS 可用性; +- 测试信号可用性; +- 废弃/遗留状态; +- 已知限制。 + +废弃适配器应在产品中明确标记为“已废弃”或“遗留”。新的事件型适配器应按频道名称和能力描述,而不是使用 EBA 缩写。 + +### 3.4 处理器体验 + +处理器页面应管理可复用的 Agent 与 Pipeline,而不是让用户一次性理解所有事件路由决策。 + +- 创建 Agent 时应提供有倾向性的 runner 模板。 +- 创建 Pipeline 时应继续保持无代码消息流水线路径。 +- 未来 Workflow 的执行语义稳定后,可以作为另一种处理器类型引入。 +- 支持的事件范围应作为能力信息和高级约束展示,而不是作为创建流程的主要概念。 +- 依赖健康状态应可见:runner 插件是否安装、运行时是否连接、所需模型是否配置、所需资源是否可访问。 + +### 3.5 机器人事件路由体验 + +机器人页面应成为平台特定事件路由的主要配置位置,因为平台事件在已连接频道的上下文中最容易被用户理解。 + +最低产品要求: + +- 基于适配器能力生成友好的事件选择器; +- 目标选择器按事件兼容性过滤; +- 对非消息事件隐藏 Pipeline; +- 对重叠路由给出冲突警告; +- 优先级先用视觉方式解释,而不是首先展示原始数字; +- 提供路由测试按钮,可以注入或重放样例事件; +- 每条路由展示状态,包括最近一次匹配的 run 和最近失败原因; +- 提供安全的兜底路由,包括显式丢弃。 + +### 3.6 可观测性 + +非技术用户需要的是简短运行轨迹,而不是原始日志: + +```text +收到事件 -> 命中路由 -> 启动处理器 -> 动作已投递 +``` + +当失败发生时,UI 应指出失败层级: + +- 频道未连接; +- 适配器不支持该事件; +- 没有路由命中; +- 处理器已禁用; +- runner 插件不可用; +- 模型/资源缺失; +- 投递权限被拒绝。 + +### 3.7 文档和模板 + +发布需要面向产品场景的文档和模板: + +- 客服机器人; +- 群欢迎和群管理; +- 好友请求审核; +- Dify 支持的外部 Agent; +- 使用 LangBot 模型和知识库的本地 Agent; +- 用于多阶段消息处理的 Pipeline。 + +文档应先描述产品模型,只在高级架构章节中暴露内部术语。 + +## 4. 推荐 UX 边界 + +之前“把所有事件编排都放进 Agent”的方向应当收窄。更好的边界是: + +- **机器人页面负责事件路由**,因为事件面是平台特定的,用户也自然会在机器人上配置频道行为。 +- **处理器页面负责处理器资产**,因为 Agent 与 Pipeline 都应能跨机器人复用,并且未来可以被打包进 Solution。 +- **Pipeline 保持为一种处理器类型**,而不是隐藏在 Agent 术语背后的历史对象。 + +这样可以降低心智负担: + +- 用户在机器人页面问:“当这个机器人遇到某件事时,应该做什么?” +- 用户在处理器页面问:“我想复用什么处理逻辑?” + +这也支持同一个机器人上的不同事件使用不同处理器类型:一个事件可以使用 Pipeline,另一个事件可以使用 Agent,未来另一个事件可以使用 Workflow。 + +## 5. 未来导出、分发和导入单元 + +导出/导入不在当前实现范围内,但产品边界不应阻塞它。 + +正确的未来分发单元是 **Solution**,不是机器人,也不是单独的 Agent。 + +Solution 应包含: + +- 处理器:Agent、Pipeline、未来 Workflow 定义; +- 路由模板:事件模式、友好名称、目标逻辑引用、默认优先级和可选条件; +- 依赖清单:所需 runner 插件、适配器能力要求、模型、工具和资源; +- 变量:用户提供的值,例如 API key、频道选择、模型选择和 prompt 参数; +- 文档:配置意图和预期行为。 + +Solution 不应包含: + +- 具体机器人凭据; +- 已安装运行时 token; +- 租户或命名空间 UUID; +- 密钥; +- 原始平台账号标识; +- 已解析的机器人事件绑定 UUID。 + +导入时,应在目标命名空间内解析路由模板。用户需要先选择机器人/频道,并授予所需权限。 + +## 6. SaaS 多命名空间架构 + +产品在公开 SaaS 发布前必须支持多命名空间 SaaS 架构。事件路由模型很敏感,因为适配器、凭据、处理器、运行时、状态和日志都会跨越信任边界。 + +### 6.1 命名空间模型 + +采用分层命名空间模型: + +| 范围 | 用途 | +| --- | --- | +| 租户(Tenant) | 计费、法律归属、顶层隔离。 | +| 工作空间(Workspace) | 租户内的协作和产品工作区。 | +| 命名空间(Namespace) | 机器人、处理器、运行时 token、资源和日志的可部署隔离边界。自托管部署可以只有一个默认命名空间。 | +| 机器人范围 | 平台适配器实例和事件路由表。 | +| 处理器范围 | Agent、Pipeline、Workflow 及相关配置。 | +| 运行时范围 | 插件运行时、runner 注册、lease 和执行权限。 | +| 资源范围 | 知识库、模型凭据、文件、状态和密钥。 | + +在 SaaS GA 前,核心持久化对象都应携带 `tenant_id`、`workspace_id` 和 `namespace_id`。自托管部署可以在迁移时种子化一个默认租户/工作空间/命名空间。 + +### 6.2 事件入口隔离 + +每个入站事件都必须先解析命名空间,再进行路由匹配: + +```text +adapter ingress -> tenant/workspace/namespace resolution -> event normalization -> event log append -> route match -> processor run +``` + +Webhook 和回调端点应编码或查找命名空间范围内的适配器安装。来自一个命名空间的平台事件,绝不能匹配另一个命名空间的路由,即使适配器名称、机器人名称或原始平台 ID 发生碰撞。 + +### 6.3 路由目标规则 + +运行时路由绑定可以使用已安装 UUID,但导出的路由模板必须使用逻辑引用。在 SaaS 中: + +- 默认情况下,机器人路由只能指向同一命名空间内的处理器; +- 跨命名空间目标默认禁止,除非由策略明确共享; +- Pipeline 目标仍然只允许处理消息; +- 路由冲突评估应限制在命名空间内; +- 路由审计事件必须包含租户、工作空间、命名空间、机器人、路由、目标和 run 标识。 + +### 6.4 运行时和插件隔离 + +插件运行时和 runner 注册表需要命名空间范围的授权: + +- 运行时注册 token 只作用于一个命名空间,或显式允许的一组命名空间; +- runner 发现结果按命名空间权限过滤; +- lease 和 heartbeat 按命名空间隔离; +- run-scoped API token 不能访问 run 所在命名空间之外的对象; +- 插件存储、状态和临时文件的 storage key 应包含命名空间; +- 早期 SaaS 可以接受共享运行时,但每个 API 调用都必须被 scoped 并审计; +- 企业或高风险租户应支持专用运行时。 + +### 6.5 密钥、资源和状态 + +密钥和资源不能全局寻址: + +- 适配器凭据存放在命名空间范围的密钥存储中; +- 模型提供商凭据可以根据策略按租户/工作空间/命名空间设定范围; +- 知识资源声明允许哪些命名空间使用; +- Agent 持久状态按租户/工作空间/命名空间/处理器分区,除非共享策略另有规定; +- 事件日志和会话记录按命名空间隔离,并受保留策略约束。 + +### 6.6 Marketplace 和已安装资产 + +Marketplace 包可见性不等于已安装资产可见性: + +- Marketplace 包可以是公开、租户私有或工作空间私有; +- 安装会创建命名空间本地资产,或命名空间本地引用; +- 已安装处理器和路由模板默认复制,避免意外跨租户变更; +- 更新必须显式且可审计。 + +### 6.7 SaaS 测试要求 + +SaaS beta 前必须验证: + +- 租户 A 的事件不能匹配租户 B 的路由; +- 命名空间 A 的运行时不能 claim 命名空间 B 的 run; +- 命名空间 A 的处理器不能读取命名空间 B 的资源; +- Solution 导入不能保留源租户 UUID 或密钥; +- 路由重放不能暴露另一个命名空间的原始事件 payload; +- 管理员可以查看审计轨迹,但不能访问密钥值。 + +## 7. 发布计划 + +### Phase 0:技术收敛 + +目标:证明合并分支可以基于事件绑定和外置 runner 运行。 + +必要门禁: + +- 机器人事件路由以 `event_bindings` 作为唯一路由来源。 +- 旧机器人 pipeline 路由字段已移除或迁移。 +- Pipeline 可作为只处理消息的处理器运行。 +- Agent runner 插件冒烟测试通过,覆盖 local runner 和 Dify runner。 +- 迁移命名和 downgrade 路径有效。 +- 主 UI 不再在面向用户的适配器名称中暴露 “EBA”。 + +### Phase 1:面向技术用户的私有 Beta + +目标:让贡献者和早期自托管用户可用。 + +必要门禁: + +- 文档和 UI 中存在适配器能力矩阵; +- 路由编辑器会过滤不兼容目标; +- runner/plugin 健康检查可见; +- local Agent 和 Dify Agent 有引导式配置路径; +- 每条路由有运行轨迹; +- 废弃适配器被一致标记; +- 失败信息能指出失败层级。 + +### Phase 2:面向非技术用户的产品 Beta + +目标:让用户无需阅读架构文档也能完成常见场景。 + +必要门禁: + +- 首次使用机器人向导从用例和频道开始; +- 事件预设默认隐藏原始事件模式; +- 存在路由模拟或测试事件能力; +- 存在常见处理器模板; +- 冲突警告和兜底行为清晰; +- 文档先使用产品语言,再介绍高级术语; +- SaaS 命名空间 schema 已实现,或已经具备迁移准备。 + +### Phase 3:SaaS Beta + +目标:安全地为多个租户运行产品。 + +必要门禁: + +- 所有路由、运行时、状态、日志和资源事实都具备租户/工作空间/命名空间字段; +- 命名空间范围的运行时注册和 run claim 已强制执行; +- 命名空间范围的密钥和适配器安装已强制执行; +- 路由匹配和审计限制在命名空间内; +- 配额、保留策略和管理员审计界面存在; +- 自托管默认命名空间迁移已有文档。 + +### Phase 4:GA + +目标:让产品具备广泛采用所需的可靠性。 + +必要门禁: + +- Solution 导出/导入已实现,并支持依赖和变量解析; +- Marketplace 分发支持命名空间本地安装; +- 跨命名空间共享策略是显式的; +- 安全评审覆盖适配器、运行时 token、runner API、密钥、日志和路由重放; +- 升级和回滚流程已有文档; +- 产品遥测可以衡量上手流失和路由失败类别。 + +## 8. 验收清单 + +只有满足以下条件,才可以认为发布版本达到产品就绪: + +- 非技术用户可以连接一个受支持频道,选择场景,绑定处理器,测试它,并在不编辑原始 JSON 的情况下理解结果; +- 产品 UI 在主要流程中避免使用 EBA 这类内部术语; +- 机器人页面负责平台事件路由,处理器页面负责可复用的 Agent 与 Pipeline; +- Pipeline 仍作为无代码消息处理器可见,并且不会出现在非消息事件目标中; +- 同一个机器人的不同事件可以路由到不同处理器类型; +- 路由失败能按层级解释; +- 命名空间隔离通过 schema、service check、运行时 token、storage key 和测试强制执行; +- 导出/导入实现后,使用带路由模板的 Solution 包,而不是具体机器人绑定。 + +## 9. 待决策问题 + +- 组合处理器入口统一使用 “Processor / 处理器”;Agent 与 Pipeline 是其中平级的类型。 +- Workflow 应作为独立持久化处理器类型,还是作为 Agent runner 类别。 +- SaaS 命名空间初期是否与工作空间一一映射,还是高级租户在首个 SaaS beta 就需要一个工作空间下多个命名空间。 +- 哪些适配器允许在 SaaS 共享运行时中运行,哪些需要专用运行时隔离。 +- 未来 Solution 导出/导入的确切包格式。 diff --git a/docs/event-based-agents/00-overview.md b/docs/event-based-agents/00-overview.md new file mode 100644 index 000000000..4f1f55c42 --- /dev/null +++ b/docs/event-based-agents/00-overview.md @@ -0,0 +1,196 @@ +# Event Based Agents 架构设计总览 + +## 1. 背景与动机 + +### 当前架构的局限性 + +LangBot 当前的平台适配器架构围绕**消息事件**单一场景设计: + +- **事件层面**:只监听 `FriendMessage`(私聊消息)和 `GroupMessage`(群消息)两种事件 +- **API 层面**:只暴露 `send_message` 和 `reply_message` 两个平台 API +- **处理层面**:所有消息统一进入 Pipeline 流水线处理,无法为不同事件类型配置不同处理逻辑 +- **适配器结构**:每个适配器是单个 Python 文件(200-800 行),随着功能增加难以维护 + +这导致以下问题: + +1. **无法处理非消息事件**:新成员入群、好友请求、消息撤回、消息编辑等大部分平台都支持的事件被完全忽略 +2. **平台能力未充分利用**:编辑消息、撤回消息、获取群成员列表、管理群组等 API 无法使用 +3. **插件能力受限**:插件只能监听消息事件、只能发送/回复消息,无法实现更丰富的交互 +4. **处理逻辑不灵活**:所有消息走同一条 Pipeline,无法为入群欢迎、好友自动通过等场景配置独立的处理流程 + +### 设计目标 + +Event Based Agents(EBA)架构旨在将 LangBot 从"消息处理平台"升级为"事件驱动的智能代理平台": + +- **丰富事件**:支持消息、群组、好友、Bot 状态等多种事件类型 +- **丰富 API**:支持消息编辑/撤回、群组管理、用户信息查询等通用 API,以及适配器特有 API 的透传调用 +- **灵活编排**:用户可在 WebUI 上为每个 Bot 的每种事件类型配置不同的处理器 +- **可扩展**:适配器可声明自己支持的事件和 API,平台特有能力通过标准机制暴露 +- **向后兼容**:现有插件无需修改即可在新架构下运行 + +## 2. 架构对比 + +### 现有架构 + +``` +消息平台 (Telegram/Discord/...) + │ + ▼ +平台适配器 (单文件, 只处理消息) + │ FriendMessage / GroupMessage + ▼ +RuntimeBot (注册 on_friend_message / on_group_message 回调) + │ + ▼ +MessageAggregator (消息聚合) + │ + ▼ +QueryPool → Controller → Pipeline (固定阶段链) + │ │ + │ ▼ + │ AgentRunner Host orchestrator + │ ▼ + │ plugin AgentRunner + │ + ▼ +adapter.reply_message() / adapter.send_message() +``` + +关键代码路径: +- 适配器基类:`langbot-plugin-sdk/.../abstract/platform/adapter.py` — `AbstractMessagePlatformAdapter` +- 事件定义:`langbot-plugin-sdk/.../builtin/platform/events.py` — 仅 `FriendMessage` / `GroupMessage` +- Bot 管理:`LangBot/src/langbot/pkg/platform/botmgr.py` — `RuntimeBot` 只注册两个消息回调 +- 流水线控制:`LangBot/src/langbot/pkg/pipeline/controller.py` — 从 QueryPool 消费并执行 Pipeline + +### 新架构(Event Based Agents) + +``` +消息平台 (Telegram/Discord/...) + │ + ▼ +平台适配器 (独立目录, 监听所有事件, 实现丰富 API) + │ MessageReceived / MemberJoined / FriendRequest / ... + ▼ +EventBus (统一事件总线) + │ + ├─→ Plugin EventListener observers(始终广播,不参与响应仲裁) + │ + ▼ +EventRouter (读取 Bot 的 event_bindings) + │ + ├─→ Pipeline target — 完整 Stage 链,仅消息事件 + ├─→ Agent target — 独立 Agent,经插件 AgentRunner 执行 + └─→ discard — 明确丢弃 + │ + ▼ +统一平台 API + send / reply / edit / delete / getGroupInfo / getUserInfo / callPlatformApi / ... +``` + +## 3. 核心概念 + +### 3.1 统一事件体系 + +所有平台事件统一为命名空间式的事件类型: + +| 命名空间 | 事件 | 说明 | +|----------|------|------| +| `message.*` | `message.received`, `message.edited`, `message.deleted`, `message.reaction` | 消息相关 | +| `feedback.*` | `feedback.received` | 用户对 Bot 回复的点赞、点踩、取消反馈等评价事件 | +| `group.*` | `group.member_joined`, `group.member_left`, `group.member_banned`, `group.info_updated` | 群组相关 | +| `friend.*` | `friend.request_received`, `friend.added`, `friend.removed` | 好友相关 | +| `bot.*` | `bot.invited_to_group`, `bot.removed_from_group`, `bot.muted`, `bot.unmuted` | Bot 状态 | +| `platform.*` | `platform.{adapter}.{action}` | 适配器特有事件 | + +详见 [01-event-system.md](./01-event-system.md)。 + +### 3.2 统一平台 API + +扩展适配器基类,提供通用 API + 透传机制: + +| 类别 | API | 必需/可选 | +|------|-----|----------| +| 消息 | `send_message`, `reply_message`, `edit_message`, `delete_message`, `forward_message` | send/reply 必需,其余可选 | +| 群组 | `get_group_info`, `get_group_member_list`, `get_group_member_info`, `mute_member`, `kick_member` | 全部可选 | +| 用户 | `get_user_info`, `get_friend_list` | 全部可选 | +| 媒体 | `upload_file`, `get_file_url` | 全部可选 | +| 透传 | `call_platform_api(action, params)` | 可选 | + +详见 [02-platform-api.md](./02-platform-api.md)。 + +### 3.3 适配器新结构 + +每个适配器从单文件迁移到独立目录: + +``` +pkg/platform/adapters/ +├── _base/ # 基类和通用定义 +│ ├── adapter.py +│ ├── events.py +│ ├── entities.py +│ └── api.py +├── telegram/ +│ ├── __init__.py +│ ├── adapter.py # 主适配器类 +│ ├── event_converter.py # 事件转换(多种事件类型) +│ ├── message_converter.py # 消息链转换 +│ ├── api_impl.py # 通用 API 实现 +│ ├── platform_api.py # 平台特有 API +│ ├── types.py # 平台特有类型 +│ └── manifest.yaml +├── discord/ +│ └── ... +``` + +详见 [03-adapter-structure.md](./03-adapter-structure.md)。 + +### 3.4 事件响应目标与观察者 + +Pipeline 与 Agent 是长期并存、场景不同的同级处理器。Pipeline 保留完整 Stage 链,面向消息处理;Agent 是独立配置对象,选择一个已安装的插件 AgentRunner,并可声明消息或非消息事件能力。Bot 的 `event_bindings` 只负责把事件绑定到既有 Pipeline、独立 Agent 或 `discard`。 + +插件 EventListener 是观察者:事件先广播给有权限的监听器,随后路由器再选择一个响应目标。Webhook、Dify、n8n 等外部执行方式若需要作为响应者,应由对应 AgentRunner 插件表达,而不是增加另一套 Host Handler 主链。 + +现有 Pipeline 不会被转换为 Agent,Pipeline 内的 runner 配置也不会复制到独立 Agent。用户需要 Agent 时自行创建并绑定。 + +详见 [04-event-routing.md](./04-event-routing.md)。 + +### 3.5 插件 SDK 改造 + +- 新事件类型全部暴露给插件 +- 新 API 全部通过 `LangBotAPIProxy` 暴露 +- 兼容层保证现有插件零修改运行 + +详见 [05-plugin-sdk.md](./05-plugin-sdk.md)。 + +## 4. 关键设计决策 + +| # | 决策点 | 选择 | 理由 | +|---|--------|------|------| +| 1 | 事件处理器配置粒度 | 每个 Bot 独立配置 | Bot 是用户操作的核心单元,不同 Bot 可能对接不同业务场景 | +| 2 | 适配器特有 API | 统一抽象 + `call_platform_api` 透传 | 通用 API 覆盖大部分场景,透传机制保证灵活性,避免每个适配器导出独立的类型化 API 包 | +| 3 | 向后兼容策略 | 兼容层适配 | 保留旧事件类型和 API 作为新系统的 alias/wrapper,现有插件无需修改 | +| 4 | 处理器配置存储 | Bot 表使用 `event_bindings`,目标引用原始 Pipeline 或独立 Agent UUID | 路由关系不复制处理器配置,Pipeline/Agent 各自保持事实源 | +| 5 | Agent 处理器定位 | 独立 Agent + 插件 AgentRunner | Host 不再内置具体 runner;不同 AgentRunner 通过统一协议接入 | +| 6 | 事件命名方式 | 命名空间式(`message.received`) | 清晰的分类层级,便于通配匹配(`message.*`),与 WebUI 配置天然对应 | + +## 5. 文档索引 + +| 文档 | 内容 | +|------|------| +| [01-event-system.md](./01-event-system.md) | 统一事件体系:事件分类、定义、生命周期 | +| [02-platform-api.md](./02-platform-api.md) | 统一平台 API:通用 API、透传 API、实体定义 | +| [03-adapter-structure.md](./03-adapter-structure.md) | 适配器新结构:目录布局、基类、注册机制 | +| [04-event-routing.md](./04-event-routing.md) | 事件路由与编排:路由引擎、处理器类型、WebUI 数据模型 | +| [05-plugin-sdk.md](./05-plugin-sdk.md) | 插件 SDK 改造:新事件/API、兼容层 | +| [06-migration-plan.md](./06-migration-plan.md) | 分阶段迁移计划 | +| [07-agent-orchestration.md](./07-agent-orchestration.md) | **产品最终形态(2026-06 修订)**:Pipeline / Agent 同级处理器编排、SDK Agent 组件契约、发布火车 | + +## 6. 涉及的代码仓库 + +| 仓库 | 改动范围 | +|------|----------| +| **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 | +| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot/Agent 实体、AgentRunner Host 编排 | +| **LangBot**(前端) | Bot 事件处理器编排面板 | +| **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 | +| **langbot-plugin-demo** | 示例更新(使用新事件和 API) | diff --git a/docs/event-based-agents/01-event-system.md b/docs/event-based-agents/01-event-system.md new file mode 100644 index 000000000..660c59d0d --- /dev/null +++ b/docs/event-based-agents/01-event-system.md @@ -0,0 +1,562 @@ +# 统一事件体系 + +## 1. 设计原则 + +- **命名空间分类**:事件类型采用 `{namespace}.{action}` 格式,如 `message.received` +- **通用优先**:大部分平台都支持的事件抽象为通用事件,定义统一的字段格式 +- **平台特有事件标准化**:各适配器的独有事件通过 `PlatformSpecificEvent` 承载,保留原始数据 +- **向后兼容**:现有 `FriendMessage` / `GroupMessage` 通过兼容层映射到新的 `message.received` 事件 + +## 2. 事件基类层次 + +``` +Event (事件基类) +├── MessageEvent (消息相关事件) +│ ├── MessageReceivedEvent # message.received +│ ├── MessageEditedEvent # message.edited +│ ├── MessageDeletedEvent # message.deleted +│ └── MessageReactionEvent # message.reaction +├── FeedbackEvent (用户反馈事件) +│ └── FeedbackReceivedEvent # feedback.received +├── GroupEvent (群组相关事件) +│ ├── MemberJoinedEvent # group.member_joined +│ ├── MemberLeftEvent # group.member_left +│ ├── MemberBannedEvent # group.member_banned +│ ├── MemberUnbannedEvent # group.member_unbanned +│ └── GroupInfoUpdatedEvent # group.info_updated +├── FriendEvent (好友相关事件) +│ ├── FriendRequestReceivedEvent # friend.request_received +│ ├── FriendAddedEvent # friend.added +│ └── FriendRemovedEvent # friend.removed +├── BotEvent (Bot 状态事件) +│ ├── BotInvitedToGroupEvent # bot.invited_to_group +│ ├── BotRemovedFromGroupEvent # bot.removed_from_group +│ ├── BotMutedEvent # bot.muted +│ └── BotUnmutedEvent # bot.unmuted +└── PlatformSpecificEvent # platform.{adapter}.{action} +``` + +## 3. 通用事件定义 + +### 3.1 事件基类 + +```python +class Event(pydantic.BaseModel): + """事件基类""" + + type: str + """事件类型标识,如 'message.received'""" + + timestamp: float + """事件发生的时间戳""" + + bot_uuid: str + """接收到此事件的 Bot UUID""" + + adapter_name: str + """产生此事件的适配器名称""" + + source_platform_object: typing.Optional[typing.Any] = None + """原始平台事件对象,供适配器内部使用""" +``` + +### 3.2 消息事件 + +#### MessageReceivedEvent (`message.received`) + +收到新消息。这是最核心的事件,替代现有的 `FriendMessage` / `GroupMessage`。 + +```python +class MessageReceivedEvent(Event): + """收到新消息""" + + type: str = "message.received" + + message_id: typing.Union[int, str] + """消息 ID""" + + message_chain: MessageChain + """消息内容""" + + sender: User + """发送者""" + + chat_type: ChatType # "private" | "group" + """会话类型""" + + chat_id: typing.Union[int, str] + """会话 ID(私聊为对方用户 ID,群聊为群 ID)""" + + group: typing.Optional[Group] = None + """群信息(仅群聊时存在)""" +``` + +与现有类型的映射关系: +- `chat_type == "private"` → 等价于现有 `FriendMessage` +- `chat_type == "group"` → 等价于现有 `GroupMessage` + +`ChatType` 枚举: + +```python +class ChatType(str, Enum): + PRIVATE = "private" + GROUP = "group" +``` + +#### MessageEditedEvent (`message.edited`) + +消息被编辑。 + +```python +class MessageEditedEvent(Event): + """消息被编辑""" + + type: str = "message.edited" + + message_id: typing.Union[int, str] + """被编辑的消息 ID""" + + new_content: MessageChain + """编辑后的新内容""" + + editor: User + """编辑者""" + + chat_type: ChatType + chat_id: typing.Union[int, str] + group: typing.Optional[Group] = None +``` + +#### MessageDeletedEvent (`message.deleted`) + +消息被删除/撤回。 + +```python +class MessageDeletedEvent(Event): + """消息被删除/撤回""" + + type: str = "message.deleted" + + message_id: typing.Union[int, str] + """被删除的消息 ID""" + + operator: typing.Optional[User] = None + """操作者(可能是发送者自己撤回,也可能是管理员删除)""" + + chat_type: ChatType + chat_id: typing.Union[int, str] + group: typing.Optional[Group] = None +``` + +#### MessageReactionEvent (`message.reaction`) + +消息收到表情回应。 + +```python +class MessageReactionEvent(Event): + """消息收到表情回应""" + + type: str = "message.reaction" + + message_id: typing.Union[int, str] + """被回应的消息 ID""" + + user: User + """回应者""" + + reaction: str + """回应的表情标识(emoji 或平台特定表情 ID)""" + + is_add: bool + """True 为添加回应,False 为移除回应""" + + chat_type: ChatType + chat_id: typing.Union[int, str] + group: typing.Optional[Group] = None +``` + +### 3.3 用户反馈事件 + +#### FeedbackReceivedEvent (`feedback.received`) + +用户对 Bot 回复提交反馈。该事件用于承载平台提供的点赞、点踩、取消反馈以及点踩原因等评价信息;典型来源包括企业微信 AI Bot 的 `feedback_event`、飞书卡片按钮回调、Web Embed 的反馈入口等。 + +```python +class FeedbackReceivedEvent(Event): + """收到用户反馈""" + + type: str = "feedback.received" + + feedback_id: str + """平台侧反馈 ID,用于幂等记录或取消反馈""" + + feedback_type: int + """1 = like, 2 = dislike, 3 = cancel/remove feedback""" + + feedback_content: typing.Optional[str] = None + """用户填写的自由文本反馈""" + + inaccurate_reasons: typing.Optional[list[str]] = None + """点踩时平台提供的预设不准确原因""" + + user_id: typing.Optional[str] = None + """提交反馈的用户 ID""" + + session_id: typing.Optional[str] = None + """会话 ID,例如 person_xxx 或 group_xxx""" + + message_id: typing.Optional[str] = None + """被评价的 Bot 回复消息 ID""" + + stream_id: typing.Optional[str] = None + """流式回复 ID,用于关联 streaming response""" +``` + +设计约定: + +- `feedback_id` 是幂等键;同一个 `feedback_id` 的后续事件应更新已有记录。 +- `feedback_type == 3` 表示用户取消/移除反馈,处理器可删除对应记录或标记为取消。 +- 如果平台只能给出原始回调 payload,差异字段保留在 `source_platform_object` 或 `PlatformSpecificEvent.data` 中;通用字段仍优先映射到 `FeedbackReceivedEvent`。 +- 该事件保留向后兼容映射:EBA 事件可转换为旧的 `FeedbackEvent`,字段语义保持一致。 + +### 3.4 群组事件 + +#### MemberJoinedEvent (`group.member_joined`) + +新成员加入群组。 + +```python +class MemberJoinedEvent(Event): + """新成员加入群组""" + + type: str = "group.member_joined" + + group: Group + """群组""" + + member: User + """加入的成员""" + + inviter: typing.Optional[User] = None + """邀请者(如有)""" + + join_type: typing.Optional[str] = None + """加入方式:'invite' / 'request' / 'direct' / None""" +``` + +#### MemberLeftEvent (`group.member_left`) + +成员离开群组。 + +```python +class MemberLeftEvent(Event): + """成员离开群组""" + + type: str = "group.member_left" + + group: Group + member: User + + is_kicked: bool = False + """是否被踢出""" + + operator: typing.Optional[User] = None + """操作者(踢出时为管理员)""" +``` + +#### MemberBannedEvent (`group.member_banned`) + +成员被禁言。 + +```python +class MemberBannedEvent(Event): + """成员被禁言""" + + type: str = "group.member_banned" + + group: Group + member: User + operator: typing.Optional[User] = None + duration: typing.Optional[int] = None + """禁言时长(秒),None 表示永久""" +``` + +#### MemberUnbannedEvent (`group.member_unbanned`) + +成员被解除禁言。 + +```python +class MemberUnbannedEvent(Event): + """成员被解除禁言""" + + type: str = "group.member_unbanned" + + group: Group + member: User + operator: typing.Optional[User] = None +``` + +#### GroupInfoUpdatedEvent (`group.info_updated`) + +群组信息被修改。 + +```python +class GroupInfoUpdatedEvent(Event): + """群组信息被修改""" + + type: str = "group.info_updated" + + group: Group + """更新后的群组信息""" + + operator: typing.Optional[User] = None + """操作者""" + + changed_fields: list[str] = [] + """发生变更的字段名列表,如 ['name', 'description']""" +``` + +### 3.5 好友事件 + +#### FriendRequestReceivedEvent (`friend.request_received`) + +收到好友请求。 + +```python +class FriendRequestReceivedEvent(Event): + """收到好友请求""" + + type: str = "friend.request_received" + + request_id: typing.Union[int, str] + """请求 ID,用于后续 approve/reject 操作""" + + user: User + """请求者""" + + message: typing.Optional[str] = None + """验证消息""" +``` + +#### FriendAddedEvent (`friend.added`) + +成功添加好友。 + +```python +class FriendAddedEvent(Event): + """成功添加好友""" + + type: str = "friend.added" + + user: User + """新好友""" +``` + +#### FriendRemovedEvent (`friend.removed`) + +好友被移除。 + +```python +class FriendRemovedEvent(Event): + """好友被移除""" + + type: str = "friend.removed" + + user: User + """被移除的好友""" +``` + +### 3.6 Bot 状态事件 + +#### BotInvitedToGroupEvent (`bot.invited_to_group`) + +Bot 被邀请加入群组。 + +```python +class BotInvitedToGroupEvent(Event): + """Bot 被邀请加入群组""" + + type: str = "bot.invited_to_group" + + group: Group + inviter: typing.Optional[User] = None + + request_id: typing.Optional[typing.Union[int, str]] = None + """邀请请求 ID,某些平台需要 Bot 确认才加入""" +``` + +#### BotRemovedFromGroupEvent (`bot.removed_from_group`) + +Bot 被移出群组。 + +```python +class BotRemovedFromGroupEvent(Event): + """Bot 被移出群组""" + + type: str = "bot.removed_from_group" + + group: Group + operator: typing.Optional[User] = None +``` + +#### BotMutedEvent / BotUnmutedEvent (`bot.muted` / `bot.unmuted`) + +Bot 被禁言/解除禁言。 + +```python +class BotMutedEvent(Event): + """Bot 被禁言""" + + type: str = "bot.muted" + + group: Group + operator: typing.Optional[User] = None + duration: typing.Optional[int] = None + + +class BotUnmutedEvent(Event): + """Bot 被解除禁言""" + + type: str = "bot.unmuted" + + group: Group + operator: typing.Optional[User] = None +``` + +### 3.7 平台特有事件 + +对于无法抽象为通用事件的平台特有事件,使用统一的 `PlatformSpecificEvent` 承载: + +```python +class PlatformSpecificEvent(Event): + """平台特有事件 + + 适配器无法映射到通用事件类型时,使用此类型承载。 + 插件可以通过 adapter_name + action 来识别和处理。 + """ + + type: str = "platform.specific" + + action: str + """平台特有的事件动作标识,如 'channel_created', 'pin_message'""" + + data: dict = {} + """事件数据,结构由具体适配器定义""" +``` + +事件类型字符串格式为 `platform.{adapter_name}.{action}`,例如: +- `platform.telegram.chat_member_updated` — Telegram 的群成员信息更新 +- `platform.discord.channel_created` — Discord 的频道创建 +- `platform.discord.voice_state_update` — Discord 的语音状态变更 +- `platform.slack.app_home_opened` — Slack 的 App Home 打开 + +## 4. 各平台事件支持矩阵 + +下表标注各通用事件在主要平台上的支持情况: + +| 事件 | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK | +|------|----------|---------|-----------|------|------|-------|------|------|------| +| `message.received` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `message.edited` | Y | Y | N | Y | N | Y | N | N | Y | +| `message.deleted` | Y | Y | Y | Y | N | Y | Y | N | Y | +| `message.reaction` | Y | Y | Y | Y | Y | Y | N | N | Y | +| `feedback.received` | N | N | N | Y | N | N | Y | N | N | +| `group.member_joined` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `group.member_left` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `group.member_banned` | Y | Y | Y | N | N | N | N | N | N | +| `group.info_updated` | Y | Y | Y | Y | Y | Y | N | N | Y | +| `friend.request_received` | N | Y | Y | N | N | N | Y | Y | Y | +| `friend.added` | N | Y | Y | N | N | N | Y | Y | N | +| `bot.invited_to_group` | Y | Y | Y | Y | Y | Y | Y | N | Y | +| `bot.removed_from_group` | Y | Y | Y | Y | N | N | Y | N | Y | +| `bot.muted` | Y | N | Y | N | N | N | N | N | N | +| `bot.unmuted` | Y | N | Y | N | N | N | N | N | N | +| `platform.specific` | Y | Y | Y | Y | Y | Y | Y | Y | Y | + +> 注:此表为初步评估,具体以各平台 SDK/API 文档为准,实施时逐个确认。 + +## 5. 事件生命周期 + +``` +1. 平台 SDK 回调触发 + │ +2. 适配器 EventConverter.target2yiri(raw_event) + │ 将平台原生事件转换为统一 Event 对象 + │ 无法映射的事件 → PlatformSpecificEvent + │ +3. 适配器回调注册的 listener(event, adapter) + │ +4. RuntimeBot 接收事件 + │ +5. EventBus 分发 + │ +6. EventBus 向有权限的 Plugin EventListener 广播观察者事件 + │ observer 不占用响应目标,也不参与 priority 仲裁 + │ +7. EventRouter 查询 Bot 的 event_bindings + │ 精确/通配匹配 event_pattern,应用 filters 与 priority + │ 选择一个 Pipeline、Agent 或 discard 目标 + │ +8. 目标处理事件 + │ Pipeline → 进入完整 Pipeline 流水线(仅消息事件) + │ Agent → Host 编排已安装的插件 AgentRunner + │ discard → 不产生响应 + │ +9. 处理器执行完毕,可能通过 Host 授权 API 执行响应动作 + (发消息、编辑消息、踢人、同意好友请求等) +``` + +## 6. 与现有事件类型的兼容映射 + +为保证现有插件不受影响,建立以下映射关系: + +| 新事件 | 条件 | 旧事件 | +|--------|------|--------| +| `MessageReceivedEvent` (chat_type=private) | — | `FriendMessage` | +| `MessageReceivedEvent` (chat_type=group) | — | `GroupMessage` | + +在插件 SDK 层面: + +| 新事件 | 旧插件事件 | +|--------|-----------| +| `MessageReceivedEvent` (chat_type=private, 非命令) | `PersonNormalMessageReceived` | +| `MessageReceivedEvent` (chat_type=group, 非命令) | `GroupNormalMessageReceived` | +| `MessageReceivedEvent` (chat_type=private, 命令) | `PersonCommandSent` | +| `MessageReceivedEvent` (chat_type=group, 命令) | `GroupCommandSent` | +| `MessageReceivedEvent` (处理完毕后) | `NormalMessageResponded` | + +兼容层在事件分发给插件 EventListener 时自动生成旧格式事件,确保监听旧事件类型的插件仍能正常工作。 + +## 7. 事件类型注册表 + +适配器在 manifest.yaml 中声明自己支持的事件类型: + +```yaml +kind: MessagePlatformAdapter +metadata: + name: telegram +spec: + supported_events: + - message.received + - message.edited + - message.deleted + - message.reaction + - feedback.received + - group.member_joined + - group.member_left + - group.member_banned + - group.info_updated + - bot.invited_to_group + - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific + platform_specific_events: + - chat_member_updated + - chat_join_request +``` + +这份声明用于: +1. WebUI 在配置事件处理器时,只显示当前 Bot 的适配器支持的事件类型 +2. EventRouter 在路由时校验事件类型有效性 +3. 文档自动生成 diff --git a/docs/event-based-agents/02-platform-api.md b/docs/event-based-agents/02-platform-api.md new file mode 100644 index 000000000..f67d11f4a --- /dev/null +++ b/docs/event-based-agents/02-platform-api.md @@ -0,0 +1,546 @@ +# 统一平台 API 与实体定义 + +## 1. 设计原则 + +- **通用 API 抽象**:大部分平台都支持的操作(发消息、获取群信息等)定义为通用 API 方法 +- **required / optional 标记**:每个 API 标记为必需或可选,适配器未实现可选 API 时抛出 `NotSupportedError` +- **透传机制**:适配器特有的操作通过 `call_platform_api(action, params)` 统一入口透传调用 +- **能力声明**:适配器在 manifest 中声明自己支持的 API 列表,供 WebUI 和插件查询 +- **实体统一**:通用实体(User、Group 等)在 SDK 层面统一定义,适配器负责转换 + +## 2. 通用实体定义 + +### 2.1 现有实体回顾 + +当前 SDK 已有以下实体(`langbot_plugin/api/entities/builtin/platform/entities.py`): + +```python +Entity(id) +├── Friend(id, nickname, remark) +├── Group(id, name, permission) +└── GroupMember(id, member_name, permission, group, special_title) +``` + +### 2.2 新实体设计 + +扩展实体体系,保持向后兼容: + +```python +class User(pydantic.BaseModel): + """用户实体(统一表示)""" + + id: typing.Union[int, str] + """用户 ID""" + + nickname: str = "" + """昵称""" + + avatar_url: typing.Optional[str] = None + """头像 URL""" + + is_bot: bool = False + """是否为 Bot""" + + # 以下为可选的扩展信息,不同平台可能部分为空 + username: typing.Optional[str] = None + """用户名(如 Telegram 的 @username)""" + + remark: typing.Optional[str] = None + """备注名""" + + +class Group(pydantic.BaseModel): + """群组实体""" + + id: typing.Union[int, str] + """群组 ID""" + + name: str = "" + """群组名称""" + + description: typing.Optional[str] = None + """群组描述""" + + member_count: typing.Optional[int] = None + """成员数量""" + + avatar_url: typing.Optional[str] = None + """群组头像 URL""" + + owner_id: typing.Optional[typing.Union[int, str]] = None + """群主 ID""" + + +class GroupMember(pydantic.BaseModel): + """群成员实体""" + + user: User + """用户信息""" + + group_id: typing.Union[int, str] + """所属群组 ID""" + + role: MemberRole + """群内角色""" + + display_name: typing.Optional[str] = None + """群内显示名""" + + joined_at: typing.Optional[float] = None + """加入群组的时间戳""" + + title: typing.Optional[str] = None + """群头衔/特殊称号""" + + +class MemberRole(str, Enum): + """群成员角色""" + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" +``` + +### 2.3 与现有实体的兼容映射 + +| 新实体 | 旧实体 | 映射方式 | +|--------|--------|----------| +| `User` | `Friend` | `User(id=friend.id, nickname=friend.nickname, remark=friend.remark)` | +| `Group` | `Group`(旧) | `Group(id=old.id, name=old.name)` + `permission` 字段弃用 | +| `GroupMember` | `GroupMember`(旧) | `GroupMember(user=User(...), role=..., display_name=old.member_name)` | +| `MemberRole` | `Permission` | `OWNER↔Owner`, `ADMIN↔Administrator`, `MEMBER↔Member` | + +旧实体类保留,标记为 `@deprecated`,内部通过转换方法桥接到新实体。 + +## 3. 通用 API 定义 + +### 3.1 API 方法一览 + +#### 消息 API + +| 方法 | 必需/可选 | 说明 | +|------|----------|------| +| `send_message(target_type, target_id, message)` | **必需** | 主动发送消息 | +| `reply_message(event, message, quote_origin)` | **必需** | 回复一个消息事件 | +| `edit_message(chat_type, chat_id, message_id, new_content)` | 可选 | 编辑已发送的消息 | +| `delete_message(chat_type, chat_id, message_id)` | 可选 | 删除/撤回消息 | +| `forward_message(from_chat, message_id, to_chat_type, to_chat_id)` | 可选 | 转发消息到另一个会话 | +| `get_message(chat_type, chat_id, message_id)` | 可选 | 获取指定消息的内容 | + +#### 群组 API + +| 方法 | 必需/可选 | 说明 | +|------|----------|------| +| `get_group_info(group_id)` | 可选 | 获取群组信息 | +| `get_group_list()` | 可选 | 获取 Bot 加入的群组列表 | +| `get_group_member_list(group_id)` | 可选 | 获取群成员列表 | +| `get_group_member_info(group_id, user_id)` | 可选 | 获取指定群成员信息 | +| `set_group_name(group_id, name)` | 可选 | 修改群名称 | +| `mute_member(group_id, user_id, duration)` | 可选 | 禁言群成员 | +| `unmute_member(group_id, user_id)` | 可选 | 解除禁言 | +| `kick_member(group_id, user_id)` | 可选 | 踢出群成员 | +| `leave_group(group_id)` | 可选 | Bot 退出群组 | + +#### 用户 API + +| 方法 | 必需/可选 | 说明 | +|------|----------|------| +| `get_user_info(user_id)` | 可选 | 获取用户信息 | +| `get_friend_list()` | 可选 | 获取好友列表 | +| `approve_friend_request(request_id, approve, remark)` | 可选 | 处理好友请求 | +| `approve_group_invite(request_id, approve)` | 可选 | 处理入群邀请 | + +#### 媒体 API + +| 方法 | 必需/可选 | 说明 | +|------|----------|------| +| `upload_file(file_data, filename)` | 可选 | 上传文件,返回可引用的文件 ID 或 URL | +| `get_file_url(file_id)` | 可选 | 获取文件下载 URL | + +#### 透传 API + +| 方法 | 必需/可选 | 说明 | +|------|----------|------| +| `call_platform_api(action, params)` | 可选 | 调用适配器特有 API | + +### 3.2 API 方法签名详解 + +```python +class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta): + """平台适配器基类(新版)""" + + # ======== 必需方法 ======== + + @abc.abstractmethod + async def send_message( + self, + target_type: str, # "private" | "group" + target_id: typing.Union[int, str], + message: MessageChain, + ) -> MessageResult: + """主动发送消息 + + Returns: + MessageResult: 包含 message_id 等发送结果 + """ + ... + + @abc.abstractmethod + async def reply_message( + self, + event: MessageReceivedEvent, + message: MessageChain, + quote_origin: bool = False, + ) -> MessageResult: + """回复一个消息事件""" + ... + + # ======== 可选消息方法 ======== + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: MessageChain, + ) -> None: + """编辑已发送的消息""" + raise NotSupportedError("edit_message") + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + """删除/撤回消息""" + raise NotSupportedError("delete_message") + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> MessageResult: + """转发消息""" + raise NotSupportedError("forward_message") + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> MessageReceivedEvent: + """获取指定消息""" + raise NotSupportedError("get_message") + + # ======== 可选群组方法 ======== + + async def get_group_info( + self, + group_id: typing.Union[int, str], + ) -> Group: + """获取群组信息""" + raise NotSupportedError("get_group_info") + + async def get_group_list(self) -> list[Group]: + """获取 Bot 加入的群组列表""" + raise NotSupportedError("get_group_list") + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[GroupMember]: + """获取群成员列表""" + raise NotSupportedError("get_group_member_list") + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> GroupMember: + """获取指定群成员信息""" + raise NotSupportedError("get_group_member_info") + + async def set_group_name( + self, + group_id: typing.Union[int, str], + name: str, + ) -> None: + """修改群名称""" + raise NotSupportedError("set_group_name") + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + """禁言群成员,duration 为秒数,0 表示永久""" + raise NotSupportedError("mute_member") + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """解除禁言""" + raise NotSupportedError("unmute_member") + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """踢出群成员""" + raise NotSupportedError("kick_member") + + async def leave_group( + self, + group_id: typing.Union[int, str], + ) -> None: + """Bot 退出群组""" + raise NotSupportedError("leave_group") + + # ======== 可选用户方法 ======== + + async def get_user_info( + self, + user_id: typing.Union[int, str], + ) -> User: + """获取用户信息""" + raise NotSupportedError("get_user_info") + + async def get_friend_list(self) -> list[User]: + """获取好友列表""" + raise NotSupportedError("get_friend_list") + + async def approve_friend_request( + self, + request_id: typing.Union[int, str], + approve: bool = True, + remark: typing.Optional[str] = None, + ) -> None: + """处理好友请求""" + raise NotSupportedError("approve_friend_request") + + async def approve_group_invite( + self, + request_id: typing.Union[int, str], + approve: bool = True, + ) -> None: + """处理入群邀请""" + raise NotSupportedError("approve_group_invite") + + # ======== 可选媒体方法 ======== + + async def upload_file( + self, + file_data: bytes, + filename: str, + ) -> str: + """上传文件,返回文件 ID 或 URL""" + raise NotSupportedError("upload_file") + + async def get_file_url( + self, + file_id: str, + ) -> str: + """获取文件下载 URL""" + raise NotSupportedError("get_file_url") + + # ======== 透传 API ======== + + async def call_platform_api( + self, + action: str, + params: dict = {}, + ) -> dict: + """调用适配器特有 API + + Args: + action: 平台特有的 API 动作标识 + params: 参数字典 + + Returns: + dict: 返回结果 + + Examples: + # Telegram: pin 消息 + await adapter.call_platform_api("pin_message", { + "chat_id": 123456, + "message_id": 789 + }) + + # Discord: 创建频道 + await adapter.call_platform_api("create_channel", { + "guild_id": "...", + "name": "new-channel", + "type": "text" + }) + """ + raise NotSupportedError("call_platform_api") + + # ======== 流式输出(保留现有机制) ======== + + async def reply_message_chunk( + self, + event: MessageReceivedEvent, + bot_message: dict, + message: MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + """流式回复消息""" + raise NotSupportedError("reply_message_chunk") + + async def is_stream_output_supported(self) -> bool: + """是否支持流式输出""" + return False + + # ======== 生命周期方法(保留现有) ======== + + @abc.abstractmethod + async def run_async(self): + """启动适配器""" + ... + + @abc.abstractmethod + async def kill(self) -> bool: + """停止适配器""" + ... + + @abc.abstractmethod + def register_listener(self, event_type, callback): + """注册事件监听器""" + ... + + @abc.abstractmethod + def unregister_listener(self, event_type, callback): + """注销事件监听器""" + ... +``` + +### 3.3 返回值类型 + +```python +class MessageResult(pydantic.BaseModel): + """消息发送结果""" + + message_id: typing.Optional[typing.Union[int, str]] = None + """发送成功后的消息 ID""" + + raw: typing.Optional[dict] = None + """平台原始返回数据""" + + +class NotSupportedError(Exception): + """适配器未实现此 API""" + + def __init__(self, api_name: str): + self.api_name = api_name + super().__init__(f"API not supported by this adapter: {api_name}") +``` + +## 4. API 能力声明 + +适配器在 manifest.yaml 中声明支持的 API: + +```yaml +kind: MessagePlatformAdapter +metadata: + name: telegram +spec: + supported_apis: + required: + - send_message + - reply_message + optional: + - edit_message + - delete_message + - get_group_info + - get_group_member_list + - get_user_info + - upload_file + - get_file_url + - call_platform_api + platform_specific_apis: + - action: pin_message + description: "Pin a message in a chat" + params_schema: + chat_id: { type: "string", required: true } + message_id: { type: "string", required: true } + - action: unpin_message + description: "Unpin a message" + params_schema: + chat_id: { type: "string", required: true } + message_id: { type: "string", required: true } +``` + +用途: +1. **WebUI**:在配置界面展示当前 Bot 可用的 API 能力 +2. **插件**:插件可查询某个 Bot 是否支持特定 API,据此决定行为 +3. **文档**:自动生成各适配器的 API 支持矩阵 + +## 5. 各平台 API 支持矩阵 + +| API | Telegram | Discord | OneBot(QQ) | 飞书 | 钉钉 | Slack | 微信 | LINE | KOOK | +|-----|----------|---------|-----------|------|------|-------|------|------|------| +| `send_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `reply_message` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `edit_message` | Y | Y | N | Y | N | Y | N | N | Y | +| `delete_message` | Y | Y | Y | Y | N | Y | Y | N | Y | +| `forward_message` | Y | N | Y | Y | N | N | Y | N | N | +| `get_group_info` | Y | Y | Y | Y | Y | Y | N | Y | Y | +| `get_group_member_list` | Y | Y | Y | Y | Y | Y | N | Y | Y | +| `get_user_info` | Y | Y | Y | Y | Y | Y | N | Y | Y | +| `get_friend_list` | N | Y | Y | N | N | N | Y | N | N | +| `mute_member` | Y | Y | Y | N | N | N | N | N | N | +| `kick_member` | Y | Y | Y | N | N | N | N | N | Y | +| `upload_file` | Y | Y | Y | Y | Y | Y | Y | Y | Y | +| `call_platform_api` | Y | Y | Y | Y | Y | Y | Y | Y | Y | + +> 注:此表为初步评估,具体以各平台 SDK/API 文档为准。 + +## 6. MessageChain 扩展 + +### 6.1 保留的通用组件 + +以下 MessageComponent 类型保持不变,继续作为通用消息元素: + +- `Source` — 消息元信息 +- `Plain` — 纯文本 +- `Quote` — 引用回复 +- `At` / `AtAll` — @提及 +- `Image` — 图片 +- `Voice` — 语音 +- `File` — 文件 +- `Forward` — 合并转发 +- `Face` — 表情 +- `Unknown` — 未知类型 + +### 6.2 平台特有组件处理 + +当前 MessageChain 中存在大量微信特有的组件类型(`WeChatMiniPrograms`, `WeChatEmoji`, `WeChatLink` 等)。在新架构下: + +- 这些类型**继续保留**在 SDK 中以保持兼容 +- 新增的平台特有消息组件统一使用 `PlatformComponent` 基类: + +```python +class PlatformComponent(MessageComponent): + """平台特有的消息组件""" + + type: str = "Platform" + + platform: str + """平台标识""" + + component_type: str + """组件类型""" + + data: dict = {} + """组件数据""" +``` + +适配器在转换消息链时,对于无法映射到通用组件的平台特有内容,使用 `PlatformComponent` 承载。 diff --git a/docs/event-based-agents/03-adapter-structure.md b/docs/event-based-agents/03-adapter-structure.md new file mode 100644 index 000000000..4943b8ad0 --- /dev/null +++ b/docs/event-based-agents/03-adapter-structure.md @@ -0,0 +1,483 @@ +# 适配器新目录结构 + +## 1. 设计目标 + +- **模块化**:每个适配器从单文件拆分到独立目录,各模块职责清晰 +- **可维护**:随着事件和 API 的增加,代码量会显著增长,目录结构有助于管理复杂度 +- **一致性**:所有适配器遵循相同的目录布局和文件命名约定 +- **兼容现有发现机制**:保持 YAML manifest + ComponentDiscoveryEngine 的注册体系 + +## 2. 新目录布局 + +### 2.1 整体结构 + +``` +pkg/platform/ +├── __init__.py +├── botmgr.py # PlatformManager + RuntimeBot(重构) +├── event_bus.py # EventBus(新增) +├── event_router.py # EventRouter(新增) +├── logger.py # EventLogger(保留) +├── webhook_pusher.py # WebhookPusher(重构为 WebhookHandler) +│ +├── adapters/ # 适配器(新目录) +│ ├── __init__.py +│ │ +│ ├── telegram/ +│ │ ├── __init__.py +│ │ ├── adapter.py # TelegramAdapter 主类 +│ │ ├── event_converter.py # 平台事件 → 统一事件 +│ │ ├── message_converter.py # MessageChain 互转 +│ │ ├── api_impl.py # 通用 API 实现 +│ │ ├── platform_api.py # call_platform_api 的动作映射 +│ │ ├── types.py # 平台特有类型定义 +│ │ └── manifest.yaml # 适配器清单 +│ │ +│ ├── discord/ +│ │ ├── __init__.py +│ │ ├── adapter.py +│ │ ├── event_converter.py +│ │ ├── message_converter.py +│ │ ├── api_impl.py +│ │ ├── platform_api.py +│ │ ├── types.py +│ │ ├── voice.py # Discord 语音连接管理(特有) +│ │ └── manifest.yaml +│ │ +│ ├── aiocqhttp/ # OneBot v11 (QQ) +│ │ └── ... +│ ├── qqofficial/ +│ │ └── ... +│ ├── lark/ # 飞书 +│ │ └── ... +│ ├── dingtalk/ +│ │ └── ... +│ ├── slack/ +│ │ └── ... +│ ├── wechatpad/ +│ │ └── ... +│ ├── officialaccount/ # 微信公众号 +│ │ └── ... +│ ├── wecom/ # 企业微信 +│ │ └── ... +│ ├── wecombot/ +│ │ └── ... +│ ├── wecomcs/ +│ │ └── ... +│ ├── kook/ +│ │ └── ... +│ ├── line/ +│ │ └── ... +│ ├── satori/ +│ │ └── ... +│ ├── websocket/ # 内置 WebSocket 适配器 +│ │ ├── __init__.py +│ │ ├── adapter.py +│ │ ├── manager.py # WebSocket 连接管理 +│ │ └── manifest.yaml +│ │ +│ └── legacy/ # 旧版适配器(保留一段时间后移除) +│ ├── gewechat/ +│ ├── nakuru/ +│ └── qqbotpy/ +│ +└── handlers/ # 事件处理器实现(新增) + ├── __init__.py + ├── base.py # AbstractEventHandler 基类 + ├── pipeline_handler.py # PipelineHandler + ├── agent_handler.py # AgentHandler + ├── webhook_handler.py # WebhookHandler + └── plugin_handler.py # PluginHandler +``` + +### 2.2 适配器目录内各文件职责 + +以 Telegram 为例: + +| 文件 | 职责 | 关键类/函数 | +|------|------|------------| +| `adapter.py` | 主入口,继承 `AbstractPlatformAdapter`,组装其他模块 | `TelegramAdapter` | +| `event_converter.py` | 将 Telegram 原生事件转换为统一事件类型 | `TelegramEventConverter` — 支持 Message/Edit/Delete/Reaction/MemberJoin 等所有事件 | +| `message_converter.py` | `MessageChain` 与 Telegram 消息格式互转 | `TelegramMessageConverter.yiri2target()` / `target2yiri()` | +| `api_impl.py` | 实现通用 API 方法(edit_message, delete_message, get_group_info 等) | 各 API 方法的 Telegram 实现 | +| `platform_api.py` | 实现 `call_platform_api` 的动作分发表 | `PLATFORM_API_MAP = {"pin_message": ..., "unpin_message": ...}` | +| `types.py` | 平台特有的类型定义 | Telegram 特有的枚举、配置结构等 | +| `manifest.yaml` | 适配器清单:名称、配置 schema、支持的事件和 API 列表 | — | + +## 3. 新基类设计 + +### 3.1 AbstractPlatformAdapter + +新基类继承自现有 `AbstractMessagePlatformAdapter` 并扩展,位于 `langbot-plugin-sdk` 中: + +```python +# langbot_plugin/api/definition/abstract/platform/adapter.py + +class AbstractPlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta): + """平台适配器基类(EBA 版本) + + 相比旧版 AbstractMessagePlatformAdapter: + - 新增通用 API 方法(edit_message, delete_message, get_group_info 等) + - 新增透传 API(call_platform_api) + - 新增能力声明(get_supported_events, get_supported_apis) + - 事件监听器支持所有事件类型,不仅限于消息事件 + """ + + bot_account_id: str = "" + config: dict + logger: AbstractEventLogger = pydantic.Field(exclude=True) + + class Config: + arbitrary_types_allowed = True + + # ---- 能力声明 ---- + + def get_supported_events(self) -> list[str]: + """返回此适配器支持的事件类型列表 + + 默认实现从 manifest.yaml 读取。 + 适配器也可以 override 此方法动态声明。 + """ + return ["message.received"] + + def get_supported_apis(self) -> list[str]: + """返回此适配器支持的 API 列表 + + 默认实现从 manifest.yaml 读取。 + """ + return ["send_message", "reply_message"] + + # ---- 必需方法(抽象) ---- + + @abc.abstractmethod + async def send_message(self, target_type, target_id, message) -> MessageResult: + ... + + @abc.abstractmethod + async def reply_message(self, event, message, quote_origin=False) -> MessageResult: + ... + + @abc.abstractmethod + async def run_async(self): + ... + + @abc.abstractmethod + async def kill(self) -> bool: + ... + + @abc.abstractmethod + def register_listener(self, event_type, callback): + ... + + @abc.abstractmethod + def unregister_listener(self, event_type, callback): + ... + + # ---- 可选方法(默认抛 NotSupportedError) ---- + # edit_message, delete_message, forward_message, + # get_group_info, get_group_member_list, ... + # call_platform_api, ... + # (完整签名见 02-platform-api.md) + + # ---- 流式输出(保留) ---- + + async def reply_message_chunk(self, event, bot_message, message, + quote_origin=False, is_final=False): + raise NotSupportedError("reply_message_chunk") + + async def is_stream_output_supported(self) -> bool: + return False + + # ---- 消息卡片(保留) ---- + + async def create_message_card(self, message_id, event) -> bool: + return False + + async def is_muted(self, group_id) -> bool: + return False +``` + +### 3.2 AbstractMessagePlatformAdapter 兼容 + +旧的 `AbstractMessagePlatformAdapter` 保留为 `AbstractPlatformAdapter` 的类型别名: + +```python +# 向后兼容 +AbstractMessagePlatformAdapter = AbstractPlatformAdapter +``` + +现有适配器代码中的 `AbstractMessagePlatformAdapter` 引用不需要立即修改。 + +### 3.3 EventConverter 新设计 + +现有 `AbstractEventConverter` 只有 `target2yiri` 和 `yiri2target` 两个静态方法,且只处理消息事件。 + +新设计支持多种事件类型: + +```python +class AbstractEventConverter: + """事件转换器基类(EBA 版本) + + 适配器需要实现此转换器,将平台原生事件转换为统一事件。 + """ + + @staticmethod + def target2yiri(raw_event: typing.Any) -> typing.Optional[Event]: + """将平台原生事件转换为统一事件 + + Args: + raw_event: 平台 SDK 回调传入的原始事件对象 + + Returns: + 统一 Event 对象,如果无法转换或不需要处理则返回 None + """ + raise NotImplementedError + + @staticmethod + def yiri2target(event: Event) -> typing.Any: + """将统一事件转换为平台原生事件(一般不需要)""" + raise NotImplementedError +``` + +具体适配器的 EventConverter 实现会是一个分发式的结构: + +```python +class TelegramEventConverter(AbstractEventConverter): + """Telegram 事件转换器""" + + @staticmethod + def target2yiri(update: telegram.Update) -> typing.Optional[Event]: + # 消息事件 + if update.message: + return TelegramEventConverter._convert_message(update) + # 消息编辑 + if update.edited_message: + return TelegramEventConverter._convert_edited_message(update) + # 成员变动 + if update.chat_member: + return TelegramEventConverter._convert_chat_member(update) + # 回调查询(按钮点击等) + if update.callback_query: + return TelegramEventConverter._convert_callback_query(update) + # 其他 → PlatformSpecificEvent + return TelegramEventConverter._convert_platform_specific(update) + + @staticmethod + def _convert_message(update) -> MessageReceivedEvent: + ... + + @staticmethod + def _convert_edited_message(update) -> MessageEditedEvent: + ... + + @staticmethod + def _convert_chat_member(update) -> typing.Union[ + MemberJoinedEvent, MemberLeftEvent, ... + ]: + ... + + @staticmethod + def _convert_platform_specific(update) -> PlatformSpecificEvent: + ... +``` + +## 4. Manifest 文件格式扩展 + +现有 manifest.yaml 只声明 `kind`, `metadata`, `spec.config`, `execution`。 + +新增 `spec.supported_events` 和 `spec.supported_apis`: + +```yaml +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: telegram + label: + en_US: Telegram + zh_Hans: Telegram + icon: telegram.svg + description: + en_US: Telegram Bot adapter + zh_Hans: Telegram Bot 适配器 + +spec: + config: + # 现有配置 schema(保持不变) + - key: token + label: { en_US: "Bot Token", zh_Hans: "Bot Token" } + type: string + required: true + sensitive: true + # ... + + supported_events: + - message.received + - message.edited + - message.deleted + - message.reaction + - feedback.received + - group.member_joined + - group.member_left + - group.member_banned + - group.info_updated + - bot.invited_to_group + - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - edit_message + - delete_message + - get_group_info + - get_group_member_list + - get_group_member_info + - get_user_info + - upload_file + - get_file_url + - call_platform_api + + platform_specific_apis: + - action: pin_message + description: { en_US: "Pin a message", zh_Hans: "置顶消息" } + - action: unpin_message + description: { en_US: "Unpin a message", zh_Hans: "取消置顶" } + - action: get_chat_administrators + description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" } + +execution: + python: + path: pkg/platform/adapters/telegram/adapter.py + attr: TelegramAdapter +``` + +## 5. 适配器注册与发现 + +### 5.1 Blueprint 更新 + +`templates/components.yaml` 中更新扫描路径: + +```yaml +kind: Blueprint +spec: + components: + MessagePlatformAdapter: + fromDirs: + - path: pkg/platform/adapters/ # 新路径 +``` + +`ComponentDiscoveryEngine` 的递归扫描逻辑不变——它会扫描所有子目录中的 `.yaml` 文件。因此每个适配器目录下的 `manifest.yaml` 会被自动发现。 + +### 5.2 PlatformManager 适配 + +`PlatformManager.initialize()` 的核心逻辑基本不变: + +```python +async def initialize(self): + # 1. 发现适配器组件(自动扫描新目录结构) + self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter') + + # 2. 动态导入适配器类 + for component in self.adapter_components: + self.adapter_dict[component.metadata.name] = component.get_python_component_class() + + # 3. 从数据库加载 Bot 并实例化适配器(不变) + await self.load_bots_from_db() +``` + +变更点: +- `execution.python.path` 从 `pkg/platform/sources/telegram.py` 变为 `pkg/platform/adapters/telegram/adapter.py` +- `get_python_component_class()` 正常工作,因为它按路径动态导入 + +## 6. RuntimeBot 重构 + +### 6.1 现有问题 + +当前 `RuntimeBot.initialize()` 硬编码注册了两个回调: + +```python +# 现有代码 +self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) +self.adapter.register_listener(platform_events.GroupMessage, on_group_message) +``` + +### 6.2 新设计 + +`RuntimeBot` 改为注册一个通用的事件回调: + +```python +class RuntimeBot: + async def initialize(self): + # 注册通用事件回调,接收所有事件类型 + self.adapter.register_listener(Event, self._on_event) + + async def _on_event( + self, + event: Event, + adapter: AbstractPlatformAdapter, + ): + """统一事件入口""" + + # 1. 设置事件的 bot_uuid 和 adapter_name + event.bot_uuid = self.bot_entity.uuid + event.adapter_name = self.bot_entity.adapter + + # 2. 日志记录 + await self._log_event(event) + + # 3. 提交给 EventBus + await self.ap.event_bus.emit(event, adapter) +``` + +适配器侧的 `register_listener` 实现也需调整: +- 当 `event_type` 为 `Event`(基类)时,注册为"接收所有事件"的通配回调 +- 适配器在收到平台原生事件时,通过 `EventConverter.target2yiri()` 转换后,调用所有匹配的回调 + +## 7. 从现有单文件适配器迁移 + +### 7.1 迁移模式 + +以 Telegram 为例,从 `sources/telegram.py`(445 行)拆分: + +| 原代码位置 | → 新文件 | +|-----------|----------| +| `TelegramMessageConverter` 类 | `telegram/message_converter.py` | +| `TelegramEventConverter` 类 | `telegram/event_converter.py`(扩展,支持更多事件) | +| `TelegramAdapter.__init__` / `run_async` / `kill` / `register_listener` | `telegram/adapter.py` | +| `TelegramAdapter.send_message` / `reply_message` / `reply_message_chunk` | `telegram/adapter.py`(消息方法保留在主类)+ `telegram/api_impl.py`(新增 API) | +| 新增代码 | `telegram/api_impl.py`(edit_message, delete_message, get_group_info 等) | +| 新增代码 | `telegram/platform_api.py`(pin_message, unpin_message 等的映射) | +| `telegram.yaml` | `telegram/manifest.yaml`(扩展 supported_events/apis) | + +### 7.2 迁移顺序建议 + +1. **Telegram** — 功能最完整的适配器之一,适合作为模板 +2. **Discord** — 第二个迁移,验证模式的通用性 +3. **AioCQHTTP (OneBot)** — 国内最常用,确保兼容 +4. **其他适配器** — 按使用频率排序 + +### 7.3 渐进式迁移 + +不需要一次性迁移所有适配器。可以采用渐进策略: + +1. 先在 `adapters/` 下建立新适配器 +2. `Blueprint` 同时扫描 `sources/` 和 `adapters/` 两个目录 +3. 旧适配器在 `sources/` 中继续工作 +4. 逐个迁移到新结构 +5. 全部迁移完成后移除 `sources/` 目录 + +```yaml +# 过渡期的 Blueprint +kind: Blueprint +spec: + components: + MessagePlatformAdapter: + fromDirs: + - path: pkg/platform/sources/ # 旧路径(尚未迁移的适配器) + - path: pkg/platform/adapters/ # 新路径(已迁移的适配器) +``` diff --git a/docs/event-based-agents/04-event-routing.md b/docs/event-based-agents/04-event-routing.md new file mode 100644 index 000000000..cb2003b1c --- /dev/null +++ b/docs/event-based-agents/04-event-routing.md @@ -0,0 +1,174 @@ +# 事件路由与编排 + +> 状态:当前实施模型(2026-07-12)。本文以 Pipeline / Agent 平级并存为准,不再保留早期 `pipeline / agent / webhook / plugin` 四种 Handler 草案。 + +## 1. 路由边界 + +EBA 将事件处理拆成两个互不替代的阶段: + +1. **观察者广播**:EventBus 把事件广播给有权限的插件 EventListener。观察者可记录、同步或执行受控副作用,但不占用响应目标。 +2. **响应者仲裁**:EventRouter 按 Bot 的 `event_bindings` 选择一个 Pipeline、独立 Agent 或 `discard`。 + +Pipeline 与 Agent 是平级处理器: + +| 处理器 | 配置事实源 | 执行路径 | 事件范围 | +| --- | --- | --- | --- | +| Pipeline | Pipeline 表与完整 Stage 配置 | MessageAggregator -> QueryPool -> RuntimePipeline | 消息事件,首版为 `message.received` | +| Agent | Agent 表中的 runner 与 runner config | AgentRunner Host orchestrator -> plugin AgentRunner | Agent/Runner 声明支持的消息或非消息事件 | +| discard | 无处理器配置 | 明确结束路由 | 任意事件 | + +插件 EventListener 不是第三种响应目标。Webhook、Dify、n8n、Coze 等外部系统需要响应事件时,由对应 AgentRunner 插件承接。 + +## 2. 数据模型 + +### 2.1 独立 Agent + +Agent 保存自己的 Runner 选择与配置,不嵌入 Pipeline,也不复制 Pipeline 的 AI stage: + +```python +class Agent(Base): + uuid: str + name: str + description: str + emoji: str + kind: str # 固定为 "agent" + component_ref: str # AgentRunner id + config: dict # runner + runner_config + enabled: bool + supported_event_patterns: list[str] +``` + +当前配置形状: + +```json +{ + "runner": { + "id": "plugin:langbot-team/LocalAgent/default" + }, + "runner_config": { + "plugin:langbot-team/LocalAgent/default": { + "model": { + "primary": "model-uuid", + "fallbacks": [] + } + } + } +} +``` + +Runner id 来自已安装插件的 AgentRunner manifest。Host 不维护 LocalAgent、Dify 或其他具体实现的内置分支。 + +### 2.2 EventBinding + +Bot 维护事件到处理器的引用: + +```python +class EventBinding(BaseModel): + id: str + event_pattern: str # 精确、namespace.* 或 * + target_type: str # agent | pipeline | discard + target_uuid: str | None # Agent/Pipeline 原始 UUID + filters: list[dict] + priority: int + enabled: bool + description: str +``` + +示例: + +```json +[ + { + "id": "binding-message", + "event_pattern": "message.received", + "target_type": "pipeline", + "target_uuid": "pipeline-uuid", + "filters": [], + "priority": 100, + "enabled": true + }, + { + "id": "binding-member-joined", + "event_pattern": "group.member_joined", + "target_type": "agent", + "target_uuid": "agent-uuid", + "filters": [], + "priority": 50, + "enabled": true + } +] +``` + +Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置。 + +## 3. 匹配与仲裁 + +事件模式支持: + +- 精确匹配:`group.member_joined` +- 命名空间通配:`group.*` +- 全局通配:`*` + +路由按以下顺序处理: + +1. 忽略 `enabled = false` 的 binding。 +2. 检查 `event_pattern` 与结构化 filters。 +3. 校验目标存在、启用且声明支持该事件。 +4. 按 `priority` 从高到低选择;同优先级按稳定列表顺序。 +5. 只执行一个响应目标。 + +Pipeline 目标只能匹配消息事件。非消息事件不得伪装成用户文本塞进 Pipeline。 + +## 4. 执行流程 + +```text +Platform adapter + -> normalized event + -> EventBus + -> authorized Plugin EventListener observers + -> EventRouter + -> Pipeline target -> full Pipeline stage chain + -> Agent target -> AgentRunner Host orchestrator + -> discard -> stop + -> Host delivery/platform API +``` + +### 4.1 Pipeline target + +消息事件按原有方式构造 Query,经 MessageAggregator、QueryPool 和完整 Pipeline Stage 链执行。Pipeline 可以继续使用 AgentRunner 作为 AI stage 的实现,但 Pipeline 本身不会因此变成 Agent。 + +### 4.2 Agent target + +Host 读取独立 Agent 的 Runner id/config,构造 event-first context、run-scoped resources 与 delivery policy,再调用插件 AgentRunner。Runner 输出由 Host 统一归一化、记录和投递。 + +AgentRunner 可通过 SDK/Python `AgentRunAPIProxy.call_tool` 或 SDK-owned scoped MCP bridge 回调 Host 能力。两条路径都映射到 `PluginToRuntimeAction.CALL_TOOL`,使用相同的 run authorization、Host execution Query、ToolManager 和 Box session 规则。Box session 是 Host canonical scope 的固定长度安全哈希;同一平台会话稳定、不同 scope 隔离、缺少 identity 时 fail closed,Runner 不配置 sandbox scope。 + +### 4.3 Observer side effects + +观察者与响应者可以同时工作。为避免编辑、reaction 等合成事件重复触发不可逆操作,Host 应按事件能力和授权过滤观察者可用 API,并记录副作用结果。Observer 广播不作为 fallback 响应。 + +## 5. Pipeline 与 Agent 的并存规则 + +1. Pipeline 与 Agent 保留各自的持久化、编辑和执行语义。 +2. 处理器聚合页面可以统一展示二者,但不会创建第三份处理器记录。 +3. 旧 Pipeline 仍是 Pipeline;其 runner config 不迁移、不复制为独立 Agent。 +4. 需要 Agent 的用户新建 Agent、选择已安装 AgentRunner,再建立 event binding。 +5. 一个 Bot 可按不同事件同时绑定 Pipeline 与 Agent。 + +## 6. WebUI 约束 + +处理器入口展示带类型标识的 Agent 与 Pipeline。Bot 事件编排器应: + +- 按 adapter manifest 展示可用事件; +- 非消息事件不提供 Pipeline 目标; +- Agent 目标按 `supported_event_patterns` 过滤; +- 展示 priority、filters、enabled 与 discard; +- 保存前校验目标 UUID 与 `target_type` 一致。 + +Pipeline 的配置、Debug Chat 和 Monitoring 继续使用 Pipeline 页面;Agent 使用独立表单配置 Runner 与事件能力。 + +## 7. 版本与迁移边界 + +此功能按当前 4.x schema 直接实现,不提供 LangBot 3.x 数据库或配置升级路径,也不读取旧 Runner 字段作为 fallback。旧 Pipeline 中的 runner 配置不会生成独立 Agent;用户按新产品模型添加 Agent 即可。 + +详见 [07-agent-orchestration.md](./07-agent-orchestration.md) 与 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md)。 diff --git a/docs/event-based-agents/05-plugin-sdk.md b/docs/event-based-agents/05-plugin-sdk.md new file mode 100644 index 000000000..eabed1055 --- /dev/null +++ b/docs/event-based-agents/05-plugin-sdk.md @@ -0,0 +1,738 @@ +# 插件 SDK 改造 + +## 1. 概述 + +插件 SDK 需要配合 EBA 架构进行以下改造: + +1. **新事件类型**:将所有通用事件暴露给插件 +2. **新 API**:将新增的平台 API 通过 `LangBotAPIProxy` 暴露给插件 +3. **兼容层**:保证现有插件零修改运行 +4. **通信协议扩展**:新增 action 枚举支持新 API + +## 2. 新事件类型暴露 + +### 2.1 插件事件模型扩展 + +当前插件 SDK 的事件模型(`api/entities/events.py`)只有消息相关事件。需要新增所有通用事件的插件级包装: + +```python +# api/entities/events.py — 新增事件 + +# ---- 消息事件(扩展) ---- + +class MessageEditedReceived(BaseEventModel): + """消息被编辑事件""" + launcher_type: str + launcher_id: typing.Union[int, str] + message_id: typing.Union[int, str] + editor_id: typing.Union[int, str] + new_content: MessageChain + chat_type: str # "private" | "group" + +class MessageDeletedReceived(BaseEventModel): + """消息被删除/撤回事件""" + launcher_type: str + launcher_id: typing.Union[int, str] + message_id: typing.Union[int, str] + operator_id: typing.Optional[typing.Union[int, str]] = None + chat_type: str + +class MessageReactionReceived(BaseEventModel): + """消息表情回应事件""" + launcher_type: str + launcher_id: typing.Union[int, str] + message_id: typing.Union[int, str] + user_id: typing.Union[int, str] + reaction: str + is_add: bool + +# ---- 用户反馈事件 ---- + +class FeedbackReceived(BaseEventModel): + """用户对 Bot 回复提交反馈""" + feedback_id: str + feedback_type: int # 1=like, 2=dislike, 3=cancel/remove feedback + feedback_content: typing.Optional[str] = None + inaccurate_reasons: typing.Optional[list[str]] = None + user_id: typing.Optional[str] = None + session_id: typing.Optional[str] = None + message_id: typing.Optional[str] = None + stream_id: typing.Optional[str] = None + +# ---- 群组事件 ---- + +class GroupMemberJoined(BaseEventModel): + """新成员加入群组""" + group_id: typing.Union[int, str] + group_name: str + member_id: typing.Union[int, str] + member_name: str + inviter_id: typing.Optional[typing.Union[int, str]] = None + join_type: typing.Optional[str] = None + +class GroupMemberLeft(BaseEventModel): + """成员离开群组""" + group_id: typing.Union[int, str] + group_name: str + member_id: typing.Union[int, str] + member_name: str + is_kicked: bool = False + operator_id: typing.Optional[typing.Union[int, str]] = None + +class GroupMemberBanned(BaseEventModel): + """成员被禁言""" + group_id: typing.Union[int, str] + member_id: typing.Union[int, str] + operator_id: typing.Optional[typing.Union[int, str]] = None + duration: typing.Optional[int] = None + +class GroupMemberUnbanned(BaseEventModel): + """成员被解除禁言""" + group_id: typing.Union[int, str] + member_id: typing.Union[int, str] + operator_id: typing.Optional[typing.Union[int, str]] = None + +class GroupInfoUpdated(BaseEventModel): + """群组信息被修改""" + group_id: typing.Union[int, str] + group_name: str + operator_id: typing.Optional[typing.Union[int, str]] = None + changed_fields: list[str] = [] + +# ---- 好友事件 ---- + +class FriendRequestReceived(BaseEventModel): + """收到好友请求""" + request_id: typing.Union[int, str] + user_id: typing.Union[int, str] + user_name: str + message: typing.Optional[str] = None + +class FriendAdded(BaseEventModel): + """成功添加好友""" + user_id: typing.Union[int, str] + user_name: str + +class FriendRemoved(BaseEventModel): + """好友被移除""" + user_id: typing.Union[int, str] + user_name: str + +# ---- Bot 状态事件 ---- + +class BotInvitedToGroup(BaseEventModel): + """Bot 被邀请加入群组""" + group_id: typing.Union[int, str] + group_name: str + inviter_id: typing.Optional[typing.Union[int, str]] = None + request_id: typing.Optional[typing.Union[int, str]] = None + +class BotRemovedFromGroup(BaseEventModel): + """Bot 被移出群组""" + group_id: typing.Union[int, str] + group_name: str + operator_id: typing.Optional[typing.Union[int, str]] = None + +class BotMuted(BaseEventModel): + """Bot 被禁言""" + group_id: typing.Union[int, str] + operator_id: typing.Optional[typing.Union[int, str]] = None + duration: typing.Optional[int] = None + +class BotUnmuted(BaseEventModel): + """Bot 被解除禁言""" + group_id: typing.Union[int, str] + operator_id: typing.Optional[typing.Union[int, str]] = None + +# ---- 平台特有事件 ---- + +class PlatformSpecificEventReceived(BaseEventModel): + """平台特有事件""" + adapter_name: str + action: str + data: dict = {} +``` + +### 2.2 EventListener 注册方式 + +插件的 EventListener 继续使用 `@self.handler(EventType)` 装饰器注册,只是可以注册的事件类型大幅增加: + +```python +class MyEventListener(EventListener): + def __init__(self, host): + super().__init__(host) + + # 现有方式(继续工作) + @self.handler(PersonNormalMessageReceived) + async def on_person_message(ctx: EventContext): + ... + + # 新事件类型 + @self.handler(GroupMemberJoined) + async def on_member_joined(ctx: EventContext): + group_name = ctx.event.group_name + member_name = ctx.event.member_name + await ctx.reply(MessageChain([ + Plain(f"欢迎 {member_name} 加入 {group_name}!") + ])) + + @self.handler(FriendRequestReceived) + async def on_friend_request(ctx: EventContext): + # 自动通过好友请求 + await ctx.approve_friend_request( + ctx.event.request_id, approve=True + ) + + @self.handler(FeedbackReceived) + async def on_feedback(ctx: EventContext): + if ctx.event.feedback_type == 2: + await self.log_warning( + f"用户点踩了回复: {ctx.event.feedback_content or ''}" + ) + + @self.handler(PlatformSpecificEventReceived) + async def on_platform_event(ctx: EventContext): + if ctx.event.adapter_name == "telegram" and ctx.event.action == "chat_join_request": + ... +``` + +## 3. 新 API 暴露 + +### 3.1 LangBotAPIProxy 扩展 + +在 `LangBotAPIProxy` 中新增以下方法,插件通过 `self.xxx()` 调用(在 BasePlugin 中继承): + +```python +class LangBotAPIProxy: + # ---- 现有方法(保留) ---- + # get_langbot_version, get_bots, get_bot_info, + # send_message, invoke_llm, get/set/delete_plugin_storage, ... + + # ---- 新增消息 API ---- + + async def edit_message( + self, + bot_uuid: str, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: MessageChain, + ) -> None: + """编辑已发送的消息""" + ... + + async def delete_message( + self, + bot_uuid: str, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + """删除/撤回消息""" + ... + + async def forward_message( + self, + bot_uuid: str, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> dict: + """转发消息""" + ... + + async def get_message( + self, + bot_uuid: str, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> dict: + """获取指定消息""" + ... + + # ---- 新增群组 API ---- + + async def get_group_info( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + ) -> dict: + """获取群组信息""" + ... + + async def get_group_list( + self, + bot_uuid: str, + ) -> list[dict]: + """获取 Bot 加入的群组列表""" + ... + + async def get_group_member_list( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + ) -> list[dict]: + """获取群成员列表""" + ... + + async def get_group_member_info( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> dict: + """获取指定群成员信息""" + ... + + async def mute_member( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + """禁言群成员""" + ... + + async def unmute_member( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """解除禁言""" + ... + + async def kick_member( + self, + bot_uuid: str, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """踢出群成员""" + ... + + # ---- 新增用户 API ---- + + async def get_user_info( + self, + bot_uuid: str, + user_id: typing.Union[int, str], + ) -> dict: + """获取用户信息""" + ... + + async def get_friend_list( + self, + bot_uuid: str, + ) -> list[dict]: + """获取好友列表""" + ... + + async def approve_friend_request( + self, + bot_uuid: str, + request_id: typing.Union[int, str], + approve: bool = True, + remark: typing.Optional[str] = None, + ) -> None: + """处理好友请求""" + ... + + async def approve_group_invite( + self, + bot_uuid: str, + request_id: typing.Union[int, str], + approve: bool = True, + ) -> None: + """处理入群邀请""" + ... + + # ---- 新增透传 API ---- + + async def call_platform_api( + self, + bot_uuid: str, + action: str, + params: dict = {}, + ) -> dict: + """调用适配器特有 API + + Examples: + # Telegram: pin 消息 + result = await self.call_platform_api( + bot_uuid, "pin_message", + {"chat_id": 123456, "message_id": 789} + ) + + # Discord: 创建频道 + result = await self.call_platform_api( + bot_uuid, "create_channel", + {"guild_id": "...", "name": "new-channel"} + ) + """ + ... + + # ---- 新增能力查询 API ---- + + async def get_supported_events( + self, + bot_uuid: str, + ) -> list[str]: + """获取指定 Bot 的适配器支持的事件类型""" + ... + + async def get_supported_apis( + self, + bot_uuid: str, + ) -> list[str]: + """获取指定 Bot 的适配器支持的 API""" + ... +``` + +### 3.2 QueryBasedAPIProxy 扩展 + +在事件处理上下文中(EventContext),通过 `QueryBasedAPIProxy` 新增便捷方法: + +```python +class QueryBasedAPIProxy: + # ---- 现有方法(保留) ---- + # reply, get_bot_uuid, set_query_var, get_query_var, + # create_new_conversation, ... + + # ---- 新增便捷方法 ---- + + async def edit_message( + self, + message_id: typing.Union[int, str], + new_content: MessageChain, + ) -> None: + """在当前会话中编辑消息(自动使用当前 bot_uuid 和 chat 信息)""" + ... + + async def delete_message( + self, + message_id: typing.Union[int, str], + ) -> None: + """在当前会话中删除消息""" + ... + + async def approve_friend_request( + self, + request_id: typing.Union[int, str], + approve: bool = True, + remark: typing.Optional[str] = None, + ) -> None: + """处理好友请求(上下文中自动获取 bot_uuid)""" + ... + + async def approve_group_invite( + self, + request_id: typing.Union[int, str], + approve: bool = True, + ) -> None: + """处理入群邀请""" + ... + + async def get_group_info(self) -> dict: + """获取当前群组信息(仅群聊事件中可用)""" + ... + + async def get_group_member_list(self) -> list[dict]: + """获取当前群组成员列表(仅群聊事件中可用)""" + ... + + async def call_platform_api( + self, + action: str, + params: dict = {}, + ) -> dict: + """调用平台特有 API(自动使用当前 bot_uuid)""" + ... +``` + +## 4. 兼容层设计 + +### 4.1 事件兼容层 + +当 PluginHandler 将新的 `MessageReceivedEvent` 分发给插件时,需要同时生成旧格式事件: + +```python +class PluginEventCompatLayer: + """插件事件兼容层 + + 将新的统一事件转换为旧的插件事件格式, + 确保监听旧事件类型的插件仍能正常工作。 + """ + + @staticmethod + def convert_to_legacy_events( + event: Event, + ) -> list[BaseEventModel]: + """将统一事件转换为旧插件事件列表 + + 一个统一事件可能生成多个旧插件事件。 + 例如 MessageReceivedEvent 会同时生成: + - PersonMessageReceived / GroupMessageReceived(总是生成) + - PersonNormalMessageReceived / GroupNormalMessageReceived(非命令时) + - PersonCommandSent / GroupCommandSent(命令时) + """ + legacy_events = [] + + if isinstance(event, MessageReceivedEvent): + if event.chat_type == ChatType.PRIVATE: + legacy_events.append( + PersonMessageReceived( + launcher_type="person", + launcher_id=event.chat_id, + sender_id=event.sender.id, + message_event=event.to_legacy_friend_message(), + message_chain=event.message_chain, + ) + ) + # 命令检测后还会生成 PersonNormalMessageReceived + # 或 PersonCommandSent,在 Pipeline 阶段处理 + elif event.chat_type == ChatType.GROUP: + legacy_events.append( + GroupMessageReceived( + launcher_type="group", + launcher_id=event.chat_id, + sender_id=event.sender.id, + message_event=event.to_legacy_group_message(), + message_chain=event.message_chain, + ) + ) + + # 新事件类型没有旧的对应物,不生成兼容事件 + # 只有监听了新事件类型的插件才会收到 + + return legacy_events +``` + +### 4.2 分发流程 + +``` +统一事件 (MessageReceivedEvent) + │ + ├─→ 转换为旧格式 (PersonMessageReceived / GroupMessageReceived) + │ └─→ 分发给监听旧事件类型的插件 EventListener + │ + └─→ 直接分发为新格式 (MessageReceivedEvent → 对应的插件事件) + └─→ 分发给监听新事件类型的插件 EventListener +``` + +插件 Runtime 在分发事件时检查每个 EventListener 注册的事件类型: +- 如果注册的是旧类型(`PersonMessageReceived` 等),发送兼容层生成的旧格式事件 +- 如果注册的是新类型(`GroupMemberJoined` 等),发送新格式事件 +- 两者可以共存,同一个插件可以同时监听新旧类型 + +### 4.3 API 兼容层 + +现有插件使用的 API 不受影响: + +| 现有 API | 新架构行为 | +|---------|----------| +| `self.send_message(bot_uuid, target_type, target_id, message_chain)` | 不变,直接调用适配器的 `send_message` | +| `ctx.reply(message_chain, quote_origin)` | 不变,在 MessageReceivedEvent 上下文中调用适配器的 `reply_message` | +| `self.get_bots()` | 不变 | +| `self.get_bot_info(bot_uuid)` | 不变 | + +新 API 只是额外新增的方法,不影响现有方法。 + +## 5. 通信协议扩展 + +### 5.1 新增 Action 枚举 + +在 `entities/io/actions/enums.py` 中新增 action: + +```python +class PluginToRuntimeAction(str, Enum): + # ---- 现有 actions(保留) ---- + REGISTER_PLUGIN = "register_plugin" + REPLY = "reply" + SEND_MESSAGE = "send_message" + # ... + + # ---- 新增消息 API ---- + EDIT_MESSAGE = "edit_message" + DELETE_MESSAGE = "delete_message" + FORWARD_MESSAGE = "forward_message" + GET_MESSAGE = "get_message" + + # ---- 新增群组 API ---- + GET_GROUP_INFO = "get_group_info" + GET_GROUP_LIST = "get_group_list" + GET_GROUP_MEMBER_LIST = "get_group_member_list" + GET_GROUP_MEMBER_INFO = "get_group_member_info" + MUTE_MEMBER = "mute_member" + UNMUTE_MEMBER = "unmute_member" + KICK_MEMBER = "kick_member" + + # ---- 新增用户 API ---- + GET_USER_INFO = "get_user_info" + GET_FRIEND_LIST = "get_friend_list" + APPROVE_FRIEND_REQUEST = "approve_friend_request" + APPROVE_GROUP_INVITE = "approve_group_invite" + + # ---- 新增透传 API ---- + CALL_PLATFORM_API = "call_platform_api" + + # ---- 新增能力查询 ---- + GET_SUPPORTED_EVENTS = "get_supported_events" + GET_SUPPORTED_APIS = "get_supported_apis" + + +class RuntimeToPluginAction(str, Enum): + # ---- 现有 actions(保留) ---- + EMIT_EVENT = "emit_event" + # ... + # EMIT_EVENT 的 data 结构扩展以支持新事件类型 +``` + +### 5.2 新增 Action 的请求/响应格式 + +以 `EDIT_MESSAGE` 为例: + +```json +// 请求 (Plugin → Runtime) +{ + "action": "edit_message", + "seq_id": 12345, + "data": { + "bot_uuid": "...", + "chat_type": "group", + "chat_id": "123456", + "message_id": "789", + "new_content": [ + { "type": "Plain", "text": "edited message" } + ] + } +} + +// 响应 (Runtime → Plugin) +{ + "seq_id": 12345, + "code": 0, + "message": "ok", + "data": {} +} +``` + +以 `GET_GROUP_MEMBER_LIST` 为例: + +```json +// 请求 +{ + "action": "get_group_member_list", + "seq_id": 12346, + "data": { + "bot_uuid": "...", + "group_id": "123456" + } +} + +// 响应 +{ + "seq_id": 12346, + "code": 0, + "message": "ok", + "data": { + "members": [ + { + "user": { "id": "111", "nickname": "Alice" }, + "group_id": "123456", + "role": "admin", + "display_name": "管理员Alice" + }, + ... + ] + } +} +``` + +以 `CALL_PLATFORM_API` 为例: + +```json +// 请求 +{ + "action": "call_platform_api", + "seq_id": 12347, + "data": { + "bot_uuid": "...", + "action": "pin_message", + "params": { + "chat_id": "123456", + "message_id": "789" + } + } +} + +// 响应 +{ + "seq_id": 12347, + "code": 0, + "message": "ok", + "data": { + "result": { ... } + } +} +``` + +### 5.3 LangBot 侧 Handler 实现 + +在 `ControlConnectionHandler`(LangBot → Runtime 侧)和 `PluginConnectionHandler`(Runtime → Plugin 侧)中新增对应的 action 处理逻辑: + +```python +# PluginConnectionHandler 中新增 +async def _handle_edit_message(self, data): + bot_uuid = data["bot_uuid"] + bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + await bot.adapter.edit_message( + chat_type=data["chat_type"], + chat_id=data["chat_id"], + message_id=data["message_id"], + new_content=MessageChain.model_validate(data["new_content"]), + ) + return {} + +async def _handle_call_platform_api(self, data): + bot_uuid = data["bot_uuid"] + bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + result = await bot.adapter.call_platform_api( + action=data["action"], + params=data.get("params", {}), + ) + return {"result": result} +``` + +## 6. 插件开发者迁移指南 + +### 6.1 无需迁移(零修改运行) + +以下场景的现有插件**不需要任何修改**: + +- 使用 `PersonNormalMessageReceived` / `GroupNormalMessageReceived` 监听消息 +- 使用 `PersonCommandSent` / `GroupCommandSent` 处理命令 +- 使用 `ctx.reply()` 回复消息 +- 使用 `self.send_message()` 主动发消息 +- 使用 LLM / 存储 / RAG 等现有 API + +### 6.2 推荐迁移(获得新能力) + +如果插件希望利用新功能,可以: + +1. **监听新事件类型**:在 EventListener 中注册新事件类型的 handler +2. **使用新 API**:调用 `self.edit_message()`, `self.get_group_info()` 等 +3. **使用透传 API**:调用 `self.call_platform_api()` 使用平台特有功能 + +### 6.3 SDK 版本号 + +新功能通过提升 SDK minor 版本发布: + +- 现有版本:`langbot-plugin-sdk >= x.y.z` +- 新版本:`langbot-plugin-sdk >= x.(y+1).0` + +插件的 `manifest.yaml` 中的 `min_sdk_version` 决定是否能使用新 API。使用旧 SDK 版本的插件在新 LangBot 上正常运行(兼容层保证),只是无法调用新 API。 diff --git a/docs/event-based-agents/06-migration-plan.md b/docs/event-based-agents/06-migration-plan.md new file mode 100644 index 000000000..a39d69add --- /dev/null +++ b/docs/event-based-agents/06-migration-plan.md @@ -0,0 +1,145 @@ +# EBA 分阶段实施计划 + +> 更新:2026-07-12。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。 + +## 1. 发布边界 + +EBA 跨越 SDK、平台适配器、LangBot Host、WebUI 与插件生态,按可验证阶段落地。当前发布遵守以下硬边界: + +- LangBot 4.x 不支持从 3.x 数据库或配置升级;不保留 legacy migration chain、旧 JSON 模板或旧 Runner 字段读取。 +- Pipeline 与 Agent 平级且长期并存,分别保留持久化模型与执行链。 +- 现有 Pipeline 不迁移为 Agent,Pipeline 内的 runner config 不复制到 Agent。 +- 用户需要 Agent 时新建独立 Agent并选择已安装的 AgentRunner。 +- Host 不按 LocalAgent id 做运行时、Box 或 WebUI 特判。 +- AgentRunner 的 SDK/Python 与 scoped MCP bridge 回调共享 Host 授权与事件 session 规则。 + +## 2. 阶段总览 + +| 阶段 | 目标 | 主要仓库 | 完成条件 | +| --- | --- | --- | --- | +| P0 | SDK 事件、能力与 AgentRunner 协议 | `langbot-plugin-sdk` | typed entities、manifest、proxy、runtime action 通过测试 | +| P1 | 平台适配器 EBA 化 | LangBot + SDK | 事件转换、能力声明、通用/透传 API 通过 adapter checklist | +| P2 | Host 观察者与响应者路由 | LangBot backend | observer 广播 + Pipeline/Agent/discard 单目标仲裁可运行 | +| P3 | 独立 Agent 与 Runner 注册 | LangBot backend + plugins | Agent CRUD、registry、run authorization、delivery 可运行 | +| P4 | WebUI 处理器与事件编排 | LangBot web | Agent/Pipeline 聚合入口和 Bot binding 编辑器可用 | +| P5 | 发布门禁与文档 | LangBot skills/docs + runner plugins | 单测、UI E2E、真实 adapter smoke 和插件预检通过 | + +## 3. P0:SDK 契约 + +### 工作项 + +- 定义规范化平台事件、actor/subject/conversation/delivery context。 +- 定义 adapter `supported_events`、`supported_apis` 与平台透传 API。 +- 定义 AgentRunner manifest、run context/result、resource handles 和 pull/callback API。 +- 提供 `AgentRunAPIProxy` 与 SDK-owned scoped MCP bridge。 +- 保持协议传输与权限校验可测试,不把 Host 私有 Query 对象暴露给插件。 + +### 验收 + +- stdio/WebSocket runtime 对 typed action 的序列化一致。 +- 无 `run_id` 或越权资源调用被拒绝。 +- Python proxy 与 MCP bridge 对同一 Host tool 呈现一致结果/错误形状。 + +## 4. P1:平台适配器 + +每个适配器按自己的能力增量接入,而不是要求所有平台一次性支持全部事件。 + +### 单适配器步骤 + +1. 将原生 SDK callback 转换为规范化事件。 +2. 声明实际支持的事件与 API,不把缺失能力伪装为成功。 +3. 实现 send/reply/edit/delete/group/user 等适用 API 与平台透传。 +4. 对消息链、媒体、reply target 和错误语义写单元测试。 +5. 按 `adapters/acceptance-checklist.md` 记录真实平台 probe。 + +### 验收 + +- `message.received` 保持正常收发。 +- 新事件不会重复转换或产生循环合成事件。 +- `supported_apis` 与实际调用能力一致。 + +## 5. P2:Host 路由 + +### 执行顺序 + +```text +adapter event + -> EventBus observer broadcast + -> EventRouter match event_bindings + -> one target: Pipeline | Agent | discard + -> Host delivery +``` + +### 约束 + +- Plugin EventListener 是 observer,不作为 priority fallback。 +- Pipeline 只处理消息事件并复用完整 Stage 链。 +- Agent 使用独立 Agent 配置和 AgentRunner Host orchestrator。 +- edit/reaction 等事件的 observer 副作用能力按事件和 adapter 能力过滤。 +- dry-run 与合成派发必须使用同一匹配器,避免 UI 预览与真实路由漂移。 + +### 验收 + +- 精确、namespace wildcard、全局 wildcard、filters 与 priority 有单元覆盖。 +- 同一事件最多一个响应目标,但 observer 仍能收到事件。 +- Pipeline 与 Agent 可以在同一个 Bot 的不同 binding 中同时生效。 + +## 6. P3:独立 Agent 与 AgentRunner + +### 工作项 + +- `agents` 只保存 Agent;Pipeline 继续使用自己的表和 API。 +- Agent config 使用 `runner.id` 与 `runner_config[runner_id]`。 +- registry 只展示已安装、有效的插件 AgentRunner。 +- Host 构造 run-scoped resources、state、delivery 与 event log/transcript。 +- SDK/Python `call_tool` 和 scoped MCP bridge 都回到同一个 Host ToolManager。 +- Box session 由 Host 将 instance/workspace/bot/adapter/target/thread scope 规范化并哈希为固定长度 `lb-box-`;同 scope 稳定、不同 scope 隔离、缺少 identity 时 fail closed。 + +### 验收 + +- 安装/卸载 Runner 后 metadata 与表单选项同步。 +- Runner 无法调用未授权 model/tool/knowledge/state。 +- 两种 Host callback transport 不能覆盖 sandbox session id。 +- LocalAgent、ACP/ClaudeCode/Codex 与外部服务 Runner 不需要 Host id 特判。 + +## 7. P4:WebUI + +### 工作项 + +- `/home/agents` 聚合显示 Agent 与 Pipeline,并明确类型。 +- 创建时选择 Agent 或 Pipeline,编辑时进入各自表单。 +- Agent 表单读取动态 Runner metadata,保存当前 `runner` / `runner_config` 形状。 +- Bot 事件编排器编辑 `event_pattern`、target、filters、priority 与 enabled。 +- 非消息事件过滤 Pipeline;Agent 按声明事件能力过滤。 + +### 验收 + +- 页面不出现 LocalAgent 专属 banner、变量隐藏或 Box/Pipeline 注入逻辑。 +- 空 Runner 市场状态给出可安装 AgentRunner 的正常路径。 +- Pipeline Debug Chat/Monitoring 与 Agent 运行日志分别可用。 + +## 8. P5:发布门禁 + +### 自动化 + +- LangBot backend unit/integration tests 与 Ruff。 +- SDK AgentRunner/proxy/MCP bridge tests。 +- Web lint/build 与关键 Playwright cases。 +- `skills/bin/lbs validate`、`skills/bin/lbs index --check`。 +- LocalAgent 与其他官方 Runner plugin package/test gate。 + +### 真实环境 + +- 至少一个消息事件走 Pipeline。 +- 至少一个非消息事件走独立 Agent 并执行平台动作。 +- SDK/Python 和 MCP bridge 各完成一次受权工具调用,并证明二者都进入同一个 `PluginToRuntimeAction.CALL_TOOL` Host handler。 +- Box 可用/不可用的降级路径可观察且无 Runner 特判。 + +## 9. 非目标 + +- 不把 Pipeline 改名或包装成 Agent。 +- 不自动把 Pipeline 内 runner 配置迁移成 Agent。 +- 不为 3.x 保留数据库迁移、旧模板或配置 fallback。 +- 不要求所有 Runner 经 MCP;本地 Python Runner 可以直接使用 SDK。 +- 不允许 Runner 自定义 Box session scope。 +- 不在首版实现多 Agent 串并联;多步骤编排留给后续 workflow。 diff --git a/docs/event-based-agents/07-agent-orchestration.md b/docs/event-based-agents/07-agent-orchestration.md new file mode 100644 index 000000000..fecd3c453 --- /dev/null +++ b/docs/event-based-agents/07-agent-orchestration.md @@ -0,0 +1,185 @@ +# Agent 与 Pipeline 统一编排(产品最终形态) + +> **状态**:方向修订稿(2026-06-12),供「适配器改造 / Agent 插件化 / 工作流引擎」三条工作线评审。 +> +> 本文档修订 [00-overview.md](./00-overview.md) §3.4 与 [04-event-routing.md](./04-event-routing.md) 中"四种 Handler"的编排模型:**所有编排目标统一进入处理器选择与事件绑定界面,但独立 Agent 与现有 Pipeline 保持不同类型**。事件路由的匹配机制、数据迁移策略、WebUI 交互骨架等内容仍以 04 为准,仅 handler 分类法被本文档取代。 + +## 1. 产品最终形态 + +**适配器接收各种事件 → 用户编排处理逻辑 → 处理器统一选择表面**,实现从 0 代码到低代码再到全代码的全层面支持: + +``` +消息平台 (Telegram / Discord / 企微 / ...) + │ 各类平台事件 + ▼ +平台适配器(EBA 新结构,已迁移 12 个) + │ EBAEvent (message.* / group.* / friend.* / bot.* / feedback.* / platform.*) + ▼ +EventRouter(事件 → 处理器绑定) + ├─→ 选中的处理器(响应者,单一仲裁) + │ ├─ Pipeline:保留现有实体和执行链,仅处理消息事件 + │ └─ Agent:用户新建并选择 AgentRunner 插件,可接本地、低代码或外部 runtime + │ + └─→ 插件 EventListener(观察者,N 个广播,可 prevent_default) +``` + +| 编写方式 | 处理器形态 | 代码化程度 | +|----------|-----------|-----------| +| WebUI 配置模型 + 提示词 + 工具 | 独立 Agent + LocalAgent 插件 | 0 代码 | +| 可视化消息编排 | Pipeline(`kind=pipeline`,完整多 Stage 链) | 0 代码 | +| 市场安装 | Agent 插件(市场分发) | 0 代码(使用者视角) | +| 可视化工作流 | 工作流引擎定义的 Agent | 低代码 | +| 对接外部平台 | dify / n8n / coze / webhook 外部 Agent | 集成 | +| SDK 编写 | Agent 插件组件 | 全代码 | + +### 1.1 三条并行工作线与汇合点 + +| 工作线 | 范围 | 在本架构中的位置 | +|--------|------|------------------| +| 适配器改造(refactor/eba,本分支) | 事件体系、适配器结构、平台 API、EventRouter | 事件的**生产侧** + 路由层 | +| Agent 插件化 | Agent 抽象、Agent 组件类型、市场分发 | 事件的**消费侧**统一抽象 | +| 工作流引擎 | 内部低代码工作流 | Agent 的一种**编写方式** | + +**汇合点是 SDK 的 Agent 组件契约(§4)与 event→处理器绑定模型(§3)**。这两个接口冻结后,三条线可彼此 mock 独立推进。契约由本分支(EBA)牵头起草,三线评审后在 langbot-plugin-sdk 落地(发布通道:0.5.0aX pre-release 已打通)。 + +## 2. 从四种 Handler 到统一处理器表面 + +### 2.1 演进理由 + +04 文档中的 pipeline / agent / webhook / plugin 四种 handler_type,本质上都是"对事件作出响应的逻辑",差别只在编写和部署方式。产品层统一展示和绑定这些处理器,但不会把既有 Pipeline 持久化为 Agent: + +- **产品**:用户只需理解"给 Bot 的事件绑定处理器",处理器可以是 Pipeline 或 Agent; +- **工程**:路由层按 `target_type` 分发到 Pipeline 或 Agent,Agent 的扩展集中到 AgentRunner 抽象; +- **生态**:Agent 成为市场上可分发、可复用的一等公民。 + +### 2.2 收编映射 + +| 原 handler_type(04 文档) | 收编后 | +|---------------------------|--------| +| `pipeline` | 保留 Pipeline 实体;binding 使用 `target_type=pipeline` 和原 `pipeline_uuid`,进程内直接复用 MessageAggregator → QueryPool → Pipeline 机制 | +| `agent`(RequestRunner) | 用户新建独立 Agent,并选择对应 AgentRunner 插件;不读取或复制旧 Pipeline 内嵌 runner 配置 | +| `webhook` | 外部 Agent 的一种:事件 POST 出去、响应解析为动作(保留 04 §5.4 的请求/响应格式) | +| `plugin`(EventListener 分发) | **不收编**——角色不同,见 §2.3 | + +### 2.3 响应者与观察者的角色切分 + +事件的消费方有两种角色,不应混为一谈: + +- **响应者(Pipeline 或 Agent)**:路由选中**一个**,负责对事件作出回应(回复消息、执行动作)。多条绑定匹配同一事件时按 priority 仲裁,只取最高者。 +- **观察者(插件 EventListener)**:**广播**给所有注册插件,做旁路逻辑(日志、审计、风控、统计)。沿用现有机制不变,包括 `prevent_default()`——观察者可拦截本次事件,使处理器不被调用(与现有"插件拦截流水线"行为完全兼容)。 + +执行顺序:事件到达 → 先广播观察者(按插件优先级)→ 若未被 prevent_default → 分发给选中的处理器。 + +## 3. 数据模型:event → 处理器绑定 + +### 3.1 独立 Agent 与现有 Pipeline + +Agent 与 Pipeline 都是一等处理器。用户创建 Agent、选择已安装的 AgentRunner,再把适合的事件绑定到 Agent;Pipeline 继续保存在 Pipeline 表中,以完整 Stage 链处理消息事件。两者可在同一处理器列表中以不同 `kind` 展示和选择;这种聚合展示不会创建额外记录,也不会在两种模型之间复制配置。 + +```python +class Agent(Base): + """Agent 实例:一个具体配置过的、可被事件绑定的响应者""" + uuid: str # 主键 + name: str + kind: str # 固定为 "agent";Pipeline 使用自己的持久模型 + component_ref: str # AgentRunner id,例如 plugin:// + config: dict # JSON — runner id、runner config 与资源/状态/投递策略 + # 多租户预留:归属主体字段(tenant/workspace),首版可空 +``` + +Bot 上的绑定配置(替代 04 §2.2 的 EventHandlerConfig,沿用其匹配语义): + +```python +class EventBinding(pydantic.BaseModel): + event_pattern: str # 精确 / "message.*" / "*",匹配规则同 04 §4 + target_type: str # "agent" | "pipeline" | "discard" + target_uuid: str # Agent 或 Pipeline 的原始 UUID;discard 时为空 + enabled: bool = True + priority: int = 0 # 多条匹配时取最高者(单一仲裁) + description: str = '' +``` + +`use_pipeline_uuid` 只迁移 Bot 的路由结构:写入 `{"event_pattern": "message.received", "target_type": "pipeline", "target_uuid": <原 pipeline uuid>}`,继续引用原 Pipeline。迁移不会创建独立 Agent,也不会复制 Pipeline 内嵌的 runner 配置;需要 Agent 的用户自行新增 Agent 并修改 binding。观察者广播不需要配置(始终发生),04 中"兜底 plugin 规则"不再需要。 + +## 4. SDK Agent 组件契约(草案) + +Agent 成为插件系统的第七种组件(现有:Command / Tool / EventListener / KnowledgeEngine / Parser / Page)。 + +### 4.1 Manifest + +```yaml +apiVersion: v1 +kind: Agent +metadata: + name: group-assistant + label: { en_US: Group Assistant, zh_Hans: 群助理 } +spec: + handled_events: # 声明可处理的事件类型;绑定 UI 据此过滤 + - message.received + - group.member_joined + config: # 实例化配置 schema,复用现有组件配置体系 + - name: model + type: llm-model-selector + - name: persona + type: prompt-editor +execution: + python: { path: agent.py, attr: GroupAssistant } +``` + +### 4.2 运行时接口 + +```python +class Agent(BaseComponent): + async def handle(self, ctx: AgentContext) -> typing.AsyncGenerator[AgentChunk, None]: + """处理一次事件,流式产出回复与动作。每次事件调用一次。""" + ... + +class AgentContext: + event: EBAEvent # 触发事件(统一事件体系) + bot: BotHandle # 来源 Bot 信息 + session: SessionHandle # 会话句柄:历史消息、会话变量(LangBot 侧管理,Agent 保持无状态) + config: dict # 该 Agent 实例的配置 + + # 能力面(经 runtime RPC 回 LangBot 执行): + async def reply(self, chain: MessageChain, quote: bool = False): ... + async def send_message(self, target_type: str, target_id: str, chain: MessageChain): ... + async def call_platform_api(self, action: str, params: dict) -> dict: ... + async def invoke_llm(self, model_uuid: str, messages: list, funcs: list = None) -> dict: ... + # + 工具调用 / KB 检索 / 插件存储(沿用 LangBotAPIProxy 既有方法) + +class AgentChunk: + delta_message: MessageChain | None = None # 增量回复(流式) + actions: list[dict] | None = None # 平台动作(同 webhook response_actions 格式) + final: bool = False +``` + +**流式**:复用 SDK 通信协议既有的 `chunk_status: continue/end` 机制,`handle()` 的每次 yield 对应一个 chunk。 +**Pipeline 与 Agent 分流**:Pipeline target 继续走 LangBot 进程内的 Pipeline 执行链;独立 Agent 经 AgentRunner 插件 runtime 分发。路由层通过 binding 的 `target_type` 明确区分二者。 + +### 4.3 执行语义与可靠性 + +| 关注点 | 约定 | +|--------|------| +| 仲裁 | 单响应者:priority 最高的匹配绑定生效,其余忽略 | +| 性能 | Pipeline 继续走进程内链路;插件 Agent 每事件过一次 RPC 边界,消息场景需设延迟预算(评审项:目标 P95 附加延迟) | +| 会话状态 | 归 LangBot 侧(SessionHandle),插件 Agent 原则上无状态,崩溃重启不丢会话 | +| 降级 | Agent 调用失败/超时:可配置 fallback(回错误提示,或指定备用处理器);不会隐式回退到旧 Pipeline 配置 | +| 多租户预留 | AgentContext / SessionHandle / 存储接口显式携带归属主体标识,禁止新增全局单例状态——为后续轻量 SaaS 多租户铺路 | + +## 5. 发布火车 + +| 版本 | 内容 | 备注 | +|------|------|------| +| 4.11(可选) | 现状成果:12 个 EBA 适配器、插件全事件订阅、`call_platform_api` | 对用户不可见的管道工程 + 插件新能力,不动产品概念 | +| **5.0** | 产品形态首发:EventRouter + event→处理器绑定 + WebUI 编排 + 旧 Bot 路由迁移 + 独立 Agent / AgentRunner 插件 + SDK Agent 组件契约(可标 experimental) | `use_pipeline_uuid` 仅改写为指向原 Pipeline 的 binding,不生成 Agent;配 SDK 0.5.0 正式版;走 beta 周期 | +| 5.x | 工作流 Agent(工作流引擎线挂入)、Agent 市场生态、剩余适配器(satori 等)、Agent 插件化收尾 | 验证开放注册机制 | +| 多租户 | 独立评估:仅数据隔离 → 5.x 部署选项;伴随权限/计费/产品定位变化 → 6.0 | 前置条件是 §4.3 的归属主体预留已落实 | + +## 6. 开放问题(评审清单) + +1. **webhook 的最终定位**:作为外部 Agent(响应者,现方案)之外,是否还需要"纯通知观察者"形态(现 WebhookPusher 的角色)? +2. **多 Agent 协作**:单一仲裁之外,是否需要"串联/并联多个 Agent"的场景?(建议 5.0 不做,留给工作流引擎表达) +3. **工作流引擎的宿主**:核心内置,还是自身也作为一个插件交付(解释工作流定义的 Agent 插件)? +4. **插件 Agent 的延迟预算**:消息主链路过 RPC 的 P95 目标值与压测方案。 +5. **Workflow 的处理器边界**:未来 Workflow 应作为与 Pipeline、Agent 平级的处理器,还是作为其中一种处理器的内部编排实现。 +6. **SDK 1.0 时机**:Agent 契约稳定后是否随 LangBot 5.x 给插件生态一个 API 稳定承诺。 diff --git a/docs/event-based-agents/08-agent-page-and-event-orchestration.md b/docs/event-based-agents/08-agent-page-and-event-orchestration.md new file mode 100644 index 000000000..c3b9eb2b6 --- /dev/null +++ b/docs/event-based-agents/08-agent-page-and-event-orchestration.md @@ -0,0 +1,181 @@ +# 处理器页面与事件编排产品设计 + +> 状态:实施稿(2026-06-23) +> +> 本文档修订 [07-agent-orchestration.md](./07-agent-orchestration.md) 中“Agent 替代 Pipeline”的表述。当前产品形态保留两种长期并存的同级处理器:**Agent** 与 **Pipeline**。处理器页面只是共享入口,不改变二者各自的持久化模型和执行语义。 + +## 1. 产品边界 + +LangBot 的处理逻辑分成两种同级形态: + +| 形态 | 定位 | 可处理事件 | 典型用户 | +| --- | --- | --- | --- | +| Agent | runner 驱动的事件优先处理器,承载 AgentRunner / 外部 runner | `message.*`、`group.*`、`friend.*`、`bot.*`、`feedback.*`、`platform.*` 等声明范围 | 需要直接处理多类平台事件或接入外部 agent runtime 的用户 | +| Pipeline | 可视化、可控、可组合的消息处理流水线,执行完整 Stage 链 | 仅 `message.*`,首版等价于 `message.received` | 需要预处理、AI、后处理、扩展和输出控制的消息场景 | + +处理器页面负责统一管理这两种处理单元: + +- 创建时选择 **Agent** 或 **Pipeline**; +- 列表中清晰标注类型与事件能力; +- Pipeline 的编辑、调试、监控继续复用现有能力; +- Agent 保存 runner 配置与事件能力,并通过 Bot 事件绑定进入运行时执行。 + +## 2. 信息架构 + +### 2.1 处理器页面 + +路径:`/home/agents` + +职责: + +1. 展示所有可被事件绑定的处理单元,包括 Agent 与 Pipeline。 +2. 创建时先选择类型: + - Agent:创建一条独立 Agent 配置对象,默认支持其 runner 声明的事件范围; + - Pipeline:创建一条独立 Pipeline,执行完整消息 Stage 链。 +3. 编辑时按类型进入不同表单: + - Pipeline:沿用原 Pipeline 配置页,包括 AI、触发、安全、输出、扩展、Debug、Monitoring; + - Agent:配置基础信息、runner、runner config 和事件能力。 + +`/home/pipelines` 继续提供 Pipeline 直接编辑路径;共享处理器入口当前使用 `/home/agents`。URL 是实现路径,不代表 Agent 包含 Pipeline。 + +### 2.2 Bot 的事件编排 + +Bot 上维护“事件 -> 处理单元”的绑定规则: + +```text +Bot + └─ EventBinding[] + ├─ event_pattern: message.received / group.member_joined / group.* / * + ├─ target_type: agent / pipeline / discard + ├─ target_uuid: Agent UUID 或 Pipeline UUID + ├─ filters: 事件字段过滤条件 + ├─ priority: 数字越大越优先 + └─ enabled +``` + +Pipeline 只能被绑定到 `message.*`。如果用户选择非消息事件,目标选择器不展示 Pipeline。 + +## 3. 持久化模型 + +### 3.1 Agent 实例与 Pipeline 实例 + +`agents` 表只保存 Agent;Pipeline 继续保存在 Pipeline 表中。当前物理表名 `legacy_pipelines` 是既有存储名称,不代表 Pipeline 在产品架构中是遗留或过渡形态。 + +```python +class Agent(Base): + uuid: str + name: str + description: str + emoji: str + kind: str # 首版固定为 "agent" + component_ref: str # runner id / workflow id / future external ref + config: dict # runner 与 runner_config + enabled: bool + supported_event_patterns: list[str] + created_at: datetime + updated_at: datetime +``` + +处理器聚合服务把 `agents` 与 Pipeline 表投影成同一个前端列表: + +```json +{ + "uuid": "...", + "name": "...", + "kind": "agent | pipeline", + "capability": { + "supported_event_patterns": ["*"], + "message_only": false + } +} +``` + +Pipeline 投影时固定: + +```json +{ + "kind": "pipeline", + "capability": { + "supported_event_patterns": ["message.*"], + "message_only": true + } +} +``` + +### 3.2 Bot 事件绑定 + +Bot 新增 `event_bindings` JSON 字段,首版作为轻量配置面。后续当 EventRouter 查询、审计和多作用域规则稳定后,再拆成独立表。 + +```json +[ + { + "id": "uuid", + "event_pattern": "group.member_joined", + "target_type": "agent", + "target_uuid": "...", + "filters": [], + "priority": 100, + "enabled": true, + "description": "Welcome new group members" + } +] +``` + +## 4. 匹配规则 + +事件模式支持三层: + +1. 精确匹配:`group.member_joined` +2. 命名空间通配:`group.*` +3. 全局通配:`*` + +优先级: + +1. `enabled = true` +2. event pattern 命中 +3. filters 全部命中 +4. `priority` 数值高者优先 +5. 同优先级按列表顺序 + +## 5. 并存策略 + +1. Pipeline 与 Agent 长期并存,各自保存配置并执行自己的运行链路。 +2. 现有 Bot 的 `use_pipeline_uuid` 转换为仍指向原 Pipeline 的消息事件绑定。 +3. 现有 `pipeline_routing_rules` 仍只作用于消息事件。 +4. `event_bindings` 允许 `target_type=pipeline|agent|discard`;Pipeline 目标只限 `message.*`。 +5. Pipeline 与 Agent 保留各自的持久化和编辑语义;处理器聚合入口只负责统一展示和选择。 + +## 6. 分阶段落地 + +### P0:处理器入口统一 + +- 新增 `/home/agents`。 +- 侧边栏显示“处理器”,列表包含平级的 Agent 与 Pipeline。 +- 创建时选择 Agent 或 Pipeline。 +- `/home/pipelines` 继续作为 Pipeline 直接编辑路径。 + +### P1:配置模型落地 + +- 新增 `agents` 表与 `/api/v1/agents`。 +- Agent 可保存 runner 与 runner_config。 +- Pipeline 继续使用原 Pipeline 表单与 API。 + +### P2:事件编排配置面 + +- Bot 表单新增事件编排编辑器。 +- 读取 adapter manifest 的 `supported_events` 生成事件选项。 +- 根据事件类型过滤可选目标:Pipeline 仅在 `message.*` 可选。 + +### P3:EventRouter 执行接入 + +- EBA 事件先广播插件 observer。 +- 然后按 `event_bindings` 的事件模式、filters、priority 和顺序选择一个处理器。 +- Pipeline 目标通过 MessageAggregator 进入完整 Pipeline Stage 链;Agent 目标直接进入 AgentRunner 链路。 +- 非消息事件只选择声明支持该事件的 Agent,不调用 Pipeline;AgentRunner 输出有平台 reply target 时会投递回平台。 + +## 7. 不做的事 + +- 不把 Pipeline 改名成 Agent,也不删除 Pipeline 的配置模型。 +- 不把 Pipeline 降级为兼容层、Agent 子类型或临时运行入口。 +- 不把非消息事件伪装成用户文本塞入 Pipeline。 +- 不在首版做多 Agent 串并联;需要多步骤处理时留给后续 workflow。 diff --git a/docs/event-based-agents/adapters/00-index.md b/docs/event-based-agents/adapters/00-index.md new file mode 100644 index 000000000..f6cd8b0b7 --- /dev/null +++ b/docs/event-based-agents/adapters/00-index.md @@ -0,0 +1,41 @@ +# EBA Adapter Migration Records + +This directory records adapter-level migration details for the Event-Based Agents architecture. Each adapter document should be kept close to the implementation and must answer four questions: + +1. What changed in the adapter structure. +2. Which configuration fields are required. +3. Which events and APIs are supported. +4. What has been verified end to end. + +## Adapter Documents + +General acceptance checklist: [EBA Adapter Acceptance Checklist](./acceptance-checklist.md) + +Current acceptance report: [EBA Adapter Acceptance Report](./acceptance-report.md) + +| Adapter | Status | Document | +|---------|--------|----------| +| Telegram | Migrated; partial plugin E2E, real UI inbound image/file verified | [Telegram](./telegram.md) | +| Discord | Migrated; partial plugin E2E, media-inbound gaps remain | [Discord](./discord.md) | +| OneBot v11 / aiocqhttp | Migrated; Matcha UI plus protocol-level multi-component coverage | [OneBot v11 / aiocqhttp](./aiocqhttp.md) | +| DingTalk | Migrated; partial plugin E2E, real UI inbound image/file verified; group gap remains | [DingTalk](./dingtalk.md) | +| Lark / Feishu | Migrated; partial live text E2E, media-inbound gap remains | [Lark / Feishu](./lark.md) | +| WeCom | Migrated; private text plugin E2E verified, media/group gaps remain | [WeCom](./wecom.md) | +| WeComBot | Migrated; private text and outbound/API plugin E2E verified, feedback/group gaps remain | [WeComBot](./wecombot.md) | +| Official Account | Migrated; private text plugin E2E verified, proactive outbound not supported | [Official Account](./officialaccount.md) | +| QQ Official API | Migrated; WebSocket inbound reached LangBot, model config blocked reply | [QQ Official API](./qqofficial.md) | +| Slack | Migrated; private text and outbound/API plugin E2E verified | [Slack](./slack.md) | +| WeCom Customer Service | Migrated; customer-side UI text plugin E2E verified, inbound media and platform-API live coverage pending | [WeCom Customer Service](./wecomcs.md) | +| Kook | Migrated; unit/mocked converter and API coverage only, live acceptance pending | [Kook](./kook.md) | + +## Documentation Checklist + +When migrating a new adapter, add one document here with: + +- Configuration table matching the adapter manifest. +- Supported event list. +- Supported common API list. +- Supported `call_platform_api` action list. +- Known unsupported APIs and the reason. +- Live test notes, including platform, channel type, destructive operations, and residual risks. +- A clear distinction between real UI inbound media, protocol-level injected inbound media, and bot outbound media. diff --git a/docs/event-based-agents/adapters/acceptance-checklist.md b/docs/event-based-agents/adapters/acceptance-checklist.md new file mode 100644 index 000000000..14eb9f4fa --- /dev/null +++ b/docs/event-based-agents/adapters/acceptance-checklist.md @@ -0,0 +1,208 @@ +# EBA Adapter Acceptance Checklist + +This checklist is the architecture-level acceptance standard for every Event-Based Agents platform adapter. It is not platform-specific. Adapter migration is not complete until the adapter has a written result against this checklist. + +## Evidence Levels + +Use these evidence levels consistently in adapter records: + +| Level | Meaning | Can Mark Complete | +|-------|---------|-------------------| +| `plugin-e2e-ui` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real platform/simulator UI action. | Yes | +| `plugin-e2e-protocol` | Real SDK plugin running through standalone runtime, LangBot core, and the migrated adapter from a protocol-boundary event injection, such as a OneBot reverse WebSocket event. | Partial; must not be claimed as UI coverage | +| `plugin-e2e-outbound` | Real SDK plugin calls an API and the bot output is visible in the real platform/simulator UI. | Yes for send/API coverage only | +| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only | +| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only | +| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported | +| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No | + +The primary acceptance path must be `plugin-e2e-ui` for inbound UI-triggered behavior and `plugin-e2e-outbound` for bot send/API behavior. `adapter-live`, `plugin-e2e-protocol`, and `unit` tests are useful, but they must be labelled precisely. + +## Required Architecture Path + +Every adapter must prove this full path: + +```text +Real platform / simulator UI + -> platform SDK native event + -> adapter event converter + -> unified EBA event/entity/message types + -> LangBot core event dispatch + -> standalone SDK runtime + -> real test plugin listener + -> plugin calls platform APIs through SDK + -> LangBot core API dispatch + -> adapter API implementation + -> real platform / simulator UI +``` + +The test plugin must record JSONL evidence containing: + +- event class and `event.type` +- `bot_uuid` and `adapter_name` as received by the plugin +- adapter name +- chat type and chat ID +- sender/user/group IDs with secrets redacted +- message component list for received messages +- API action name, input summary, result or error +- raw unsupported/blocked reason when an item is skipped + +## Required Message Receive Tests + +For every adapter, inbound message conversion must be tested through `plugin-e2e-ui` for each component the platform can receive. If a protocol-level injection is used, label it `plugin-e2e-protocol`; it proves the adapter/core/plugin path, but it does not prove that the user-facing platform UI can send that component. If the platform UI/simulator cannot create a component, record it as `blocked` with the endpoint limitation. + +| Component | Required Receive Assertion | +|-----------|----------------------------| +| `Source` | Message ID and timestamp are present and stable enough for reply/get/delete APIs. | +| `Plain` | Text is preserved exactly, including spaces and multi-line content. | +| `At` | Mentioned user ID is converted to common `At.target`. | +| `AtAll` | Broadcast mention is converted to common `AtAll`, if platform supports it. | +| `Image` | Image ID, URL, path, or base64 is represented without leaking platform-native segment shape. | +| `Voice` | Voice/audio component is represented as `Voice` when the platform exposes it. | +| `File` | File name, ID/URL, and size are represented as `File` when available. | +| `Quote` | Reply/quote source ID and origin content are represented when the platform exposes it. | +| `Face` | Native emoji/sticker/dice/rps-like components are represented as `Face` or documented as platform-specific. | +| `Forward` | Merged/forwarded messages are represented as `Forward` when the platform exposes structured content. | +| `Unknown` | Unsupported native segments become `Unknown` or `PlatformSpecificEvent` data, not crashes. | +| Mixed chain | A message containing multiple component types preserves order. | + +The plugin must subscribe to `MessageReceivedEvent` and assert that `message_chain` contains common `langbot_plugin.api.entities.builtin.platform.message` components, not platform-native SDK objects. + +## Required Message Send Tests + +For every adapter, outbound message conversion must be tested through `plugin-e2e-outbound` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message. + +| Component | Required Send Assertion | +|-----------|-------------------------| +| `Plain` | Text appears exactly on the platform. | +| `At` | User mention renders as a mention or platform equivalent. | +| `AtAll` | Broadcast mention renders or is explicitly unsupported. | +| `Image` | URL, path, or base64 image sends and renders/downloads correctly. | +| `Voice` | Voice/audio sends when supported. | +| `File` | File sends with name and content/link when supported. | +| `Quote` | Quoted reply points to the original message when supported. | +| `Face` | Native emoji/sticker/dice/rps sends or is explicitly unsupported. | +| `Forward` | Forward/merged-forward sends when supported; otherwise fallback behavior is documented. | +| Mixed chain | A mixed chain preserves component order as closely as the platform allows. | + +If a platform supports a component only in one direction, the adapter record must say so explicitly. + +## Required Event Tests + +The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e-ui`, `plugin-e2e-protocol`, `not-supported`, or `blocked`. + +| Event | Required Assertion | +|-------|--------------------| +| `message.received` | Real message reaches plugin as `MessageReceivedEvent`. | +| `message.edited` | Edited message reaches plugin with message ID and new content, if declared. | +| `message.deleted` | Deleted/recalled message reaches plugin with message ID and operator when available, if declared. | +| `message.reaction` | Reaction add/remove reaches plugin with message ID, user, reaction, and direction, if declared. | +| `feedback.received` | Feedback payload reaches plugin with feedback type and message/session IDs, if declared. | +| `group.member_joined` | Join event reaches plugin with group and member. | +| `group.member_left` | Leave/kick event reaches plugin with group, member, and kick flag. | +| `group.member_banned` | Mute/ban event reaches plugin with group, member, operator, and duration. | +| `group.info_updated` | Group metadata update reaches plugin with changed fields, if declared. | +| `friend.request_received` | Friend request reaches plugin with request ID and message. | +| `friend.added` | Friend-added event reaches plugin. | +| `friend.removed` | Friend-removed event reaches plugin, if declared. | +| `bot.invited_to_group` | Bot invite/join request reaches plugin with group and inviter/request ID. | +| `bot.removed_from_group` | Bot removal reaches plugin with group and operator when available. | +| `bot.muted` | Bot mute reaches plugin with duration. | +| `bot.unmuted` | Bot unmute reaches plugin. | +| `platform.specific` | At least one unmapped native event is delivered as structured platform-specific data, if declared. | + +Do not declare an event in the manifest unless there is an implementation path and an acceptance entry. + +## Required Common API Tests + +The plugin must call every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. Each call must be recorded with input summary and result. + +| API | Required Assertion | +|-----|--------------------| +| `send_message` | Plugin sends to private and group/channel targets where supported. | +| `reply_message` | Plugin replies to the triggering message, with quoted mode tested when supported. | +| `edit_message` | Plugin edits a bot-sent message, if declared. | +| `delete_message` | Plugin deletes/recalls a bot-sent message, if declared and permissions allow. | +| `forward_message` | Plugin forwards or emulates forwarding a real message, if declared. | +| `get_message` | Plugin retrieves a real message and receives common `MessageReceivedEvent` shape. | +| `get_group_info` | Plugin receives `UserGroup` with ID/name/count where available. | +| `get_group_list` | Plugin receives joined groups/channels list where supported. | +| `get_group_member_list` | Plugin receives list of `UserGroupMember` where supported. | +| `get_group_member_info` | Plugin receives one member with role/display name where available. | +| `set_group_name` | Plugin changes and restores a disposable group name, if declared. | +| `mute_member` | Plugin mutes a disposable target, if declared. | +| `unmute_member` | Plugin unmutes the same target, if declared. | +| `kick_member` | Plugin kicks a disposable target only in destructive test mode, if declared. | +| `leave_group` | Plugin leaves only in destructive test mode and only at the end, if declared. | +| `get_user_info` | Plugin receives common `User` shape. | +| `get_friend_list` | Plugin receives friend/contact list where supported. | +| `approve_friend_request` | Plugin accepts/rejects a disposable friend request, if declared. | +| `approve_group_invite` | Plugin accepts/rejects a disposable group invite, if declared. | +| `upload_file` | Plugin uploads a real small file, if declared. | +| `get_file_url` | Plugin resolves a real file ID to a URL, if declared. | +| `call_platform_api` | Plugin calls every declared platform-specific action with safe parameters. | + +Destructive APIs must be opt-in and documented with the exact target used. + +The SDK must expose a plugin-side platform API escape hatch for adapter-specific actions. The acceptance plugin should call it from the same EBA event handler that received the real platform event, so the evidence proves both directions of the path: + +```text +plugin -> SDK call_platform_api -> LangBot core -> adapter call_platform_api -> platform SDK/API +``` + +The result must be serialized into JSON-safe values before it is returned to the plugin runtime. + +## Platform-Specific API Tests + +Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry: + +- `plugin-e2e-ui` or `plugin-e2e-outbound`: called by the plugin against the live/simulator endpoint. +- `plugin-e2e-protocol`: called by the plugin after a protocol-boundary injected event; useful for endpoint-specific simulators but must be labelled. +- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not. +- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable. + +Do not leave a platform-specific API in the manifest without a corresponding test record. + +## Required Compatibility Tests + +Each migrated adapter must also prove: + +- Manifest supported events match `adapter.get_supported_events()`. +- Manifest supported APIs match `adapter.get_supported_apis()`. +- Manifest platform-specific actions match `PLATFORM_API_MAP`. +- Legacy `FriendMessage` / `GroupMessage` listeners still work when the core registers them. +- EBA listener dispatch prefers the most specific event class, then `EBAEvent`, then base `Event`. +- Self-message filtering prevents bot echo loops without dropping edit/delete/moderation events needed for API tests. +- `source_platform_object` is present for reply/debug but not required by plugins for common behavior. + +## Required Documentation Per Adapter + +Each adapter document must include: + +- adapter directory and manifest name +- config table +- supported event table with evidence level per event +- supported common API table with evidence level per API +- platform-specific API table with evidence level per action +- receive component table with evidence level per component +- send component table with evidence level per component +- exact test date +- exact platform endpoint or simulator used +- standalone runtime command +- plugin path/name used for testing +- evidence JSONL path +- destructive operations performed or explicitly skipped +- blocked items and reasons + +## Acceptance Rule + +An adapter can be marked migrated only when: + +1. All declared events have `plugin-e2e-ui`, justified `plugin-e2e-protocol`, or `not-supported` evidence. +2. All declared APIs have `plugin-e2e-outbound` or `not-supported` evidence. +3. All platform-supported receive components have `plugin-e2e-ui` evidence; protocol-only receive coverage keeps the status partial. +4. All platform-supported send components have `plugin-e2e-outbound` evidence. +5. Unit tests cover conversion and API-shape boundaries. +6. The adapter document lists every blocked or skipped item honestly. + +If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial. diff --git a/docs/event-based-agents/adapters/acceptance-report.md b/docs/event-based-agents/adapters/acceptance-report.md new file mode 100644 index 000000000..eaa7ffe25 --- /dev/null +++ b/docs/event-based-agents/adapters/acceptance-report.md @@ -0,0 +1,171 @@ +# EBA Adapter Acceptance Report + +Date: May 10, 2026 + +Scope: + +- `telegram-eba` +- `discord-eba` +- `aiocqhttp-eba` +- `dingtalk-eba` +- `lark-eba` +- `wecom-eba` +- `wecombot-eba` +- `wecomcs-eba` +- `officialaccount-eba` +- `qqofficial-eba` +- `slack-eba` + +This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict: + +- `plugin-e2e-ui`: real platform or simulator UI event reached LangBot, standalone runtime, and `EBAEventProbe`. +- `plugin-e2e-protocol`: real adapter endpoint event reached LangBot, standalone runtime, and `EBAEventProbe`, but the event was injected at the platform protocol boundary rather than sent through the UI. +- `plugin-e2e-outbound`: the plugin called SDK APIs and the resulting bot message was visible on the platform. +- `unit`: mocked converter/API coverage only. +- `blocked`: not completed, either because the platform/simulator/client could not trigger it or because a safe disposable fixture was unavailable. +- `not-supported`: the platform has no equivalent capability. + +## Summary + +| Adapter | Status | Honest acceptance summary | +|---------|--------|---------------------------| +| Telegram | Partial EBA acceptance | Real Telegram UI covered private text, group mention text, bot invite, inbound private image/file, outbound component sweep, safe SDK APIs, and safe Telegram platform APIs. Real UI inbound voice/quote was not completed in the latest plugin run. | +| Discord | Partial EBA acceptance | Real Discord UI covered group text, outbound image/file/quote/mention components, safe SDK APIs, and safe Discord platform APIs. Real UI inbound attachment/image/file/reply/mention was not completed. A later UI retry was blocked because the Discord client kept the send button disabled. | +| OneBot v11 / aiocqhttp | Partial EBA acceptance | Matcha UI covered real group text and outbound supported components/APIs. Multi-component inbound `Source/Plain/At/Face/Image/Voice/File/Quote` was verified through the real OneBot reverse WebSocket adapter endpoint, but not through Matcha UI upload/send. Matcha blocks file-send and merged-forward APIs. | +| DingTalk | Partial EBA acceptance | Real DingTalk UI covered private text, emoji-as-text inbound, private inbound image/file, outbound image/file/quote/mention fallback components, safe SDK APIs, and safe DingTalk platform APIs. Real UI inbound voice/quote and group trigger were not completed. | +| Lark / Feishu | Partial EBA acceptance | EBA adapter structure, self-built/store app config, WebSocket/Webhook mode handling, converters, common APIs, platform APIs, and unit tests are in place. One real LangBot organization WebSocket private text event reached `EBAEventProbe`; outbound component sweep was visible in Feishu. Latest real UI image/file sends did not reach local plugin evidence, so media receive remains blocked. | +| WeCom | Partial EBA acceptance | Regular WeCom application-message adapter is split into the EBA directory with manifest, converters, API mixin, platform API map, and unit tests. Private text reached `EBAEventProbe` through standalone runtime and the real WeCom client; safe plugin APIs passed. Real inbound media and broader event coverage remain pending. | +| WeComBot | Partial EBA acceptance | WeCom AI Bot is split into the EBA directory with WebSocket long connection mode and optional webhook mode, EBA message/feedback/platform-specific conversion, cache-backed common APIs, platform API map, unit tests, and a direct live probe. Private text, outbound component sweep, safe common APIs, and all declared WeComBot platform APIs reached `EBAEventProbe`; group, real inbound media, and feedback callback evidence remain pending. | +| WeCom Customer Service | Partial EBA acceptance | WeCom Customer Service is split into the EBA directory with manifest, converters, API mixin, platform API map, unit tests, docs, and a direct live probe scaffold. Real WeChat customer-side UI text reached `EBAEventProbe`; plugin outbound text/image and safe cache-backed common APIs passed. Inbound media and platform-specific API live coverage remain pending; later fallback text sends were blocked by WeCom `95001 send msg count limit`. | +| Official Account | Partial EBA acceptance | WeChat Official Account is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, and a direct live probe scaffold. Real WeChat Official Account UI private text reached `EBAEventProbe`; safe cache-backed common APIs and declared platform APIs passed. Proactive outbound `send_message` is not supported because replies must be tied to inbound webhook windows; inbound image/voice live UI evidence remains pending. | +| QQ Official API | Partial EBA acceptance | QQ Official API is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; reply/outbound evidence is blocked by the test model provider returning `model_not_found` for `deepseek-v3`. | +| Slack | Partial EBA acceptance | Slack is split into the EBA directory with manifest, converters, cache-backed safe APIs, platform API map, unit tests, docs, and a direct live probe scaffold. Real Slack private text reached `EBAEventProbe`; safe common APIs, outbound component fallback sweep, and declared Slack platform APIs passed. Channel mention and real inbound media evidence remain pending. | + +Telegram and DingTalk now have real user-side UI image/file upload evidence in plugin JSONL. Discord and aiocqhttp do not yet have real UI inbound image/file evidence. + +## Evidence Files + +| Adapter | Endpoint | Evidence | +|---------|----------|----------| +| Telegram private | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-rerun.jsonl` | +| Telegram private media | Telegram Lite, `@rockchinq_bot` private chat | `data/temp/telegram-plugin-e2e-media-ui.jsonl` | +| Telegram group | Telegram Lite, `Rock'sBotGroup` | `data/temp/telegram-plugin-e2e-group.jsonl` | +| Discord | Discord client, LangBot server, `#debugging` | `data/temp/discord-plugin-e2e-20260510-final.jsonl` | +| aiocqhttp UI | local Matcha, group `test group` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` | +| aiocqhttp protocol | OneBot reverse WebSocket endpoint `127.0.0.1:2280/ws` | `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` | +| DingTalk | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` | +| DingTalk private media | DingTalk Mac, `LangBot Team` org private chat | `data/temp/dingtalk-plugin-e2e-media-ui.jsonl` | +| Lark / Feishu unit | local mocked Feishu SDK/client paths | `tests/unit_tests/platform/test_lark_eba_adapter.py` | +| Lark / Feishu partial live | Feishu Mac, LangBot organization `LangBotDev` private chat | `data/temp/lark-plugin-e2e-ws.jsonl` | +| WeCom Customer Service | WeChat customer-side UI, `客服消息 -> 浪波智能客服` on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` | +| Official Account | WeChat desktop client, subscribed Official Account on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` | +| QQ Official API unit | local mocked QQ Official client paths | `tests/unit_tests/platform/test_qqofficial_eba_adapter.py` | +| Slack unit | local mocked Slack client paths | `tests/unit_tests/platform/test_slack_eba_adapter.py` | +| Slack private | Slack workspace private DM on `dev.rockchin.top` | `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl` | + +All plugin runs used SDK standalone runtime ports `5400/5401`, LangBot `--standalone-runtime`, and the real plugin at `langbot-plugin-demo/EBAEventProbe`. + +## Unified Shape Verification + +All four adapters deliver common SDK entities to plugins before LangBot core/plugin logic handles the event. + +| Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu | +|-------------|----------|---------|-----------|----------|---------------| +| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending | +| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-eba` in current unit/code; older live text evidence recorded `lark` before the naming fix | +| common `MessageChain` delivered | `Plain`, group `At + Plain`, private `Image`, private `File` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain`, private `Source + Image`, private `Source + File` | live private `Source + Plain`; unit `Source + Plain + At/Image/File`; latest live image/file blocked | +| common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed | live private user; unit private/group | +| raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | + +## Message Receive Components + +| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu | +|-----------|----------|---------|-----------|----------|---------------| +| `Source` | design gap: event has message id but chain omits `Source` | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text | +| `Plain` | plugin-e2e-ui private/group | plugin-e2e-ui | plugin-e2e-ui/protocol | plugin-e2e-ui | plugin-e2e-ui private text | +| `At` | plugin-e2e-ui group mention | unit; real UI mention not completed in latest run | plugin-e2e-protocol; unit | unit; group trigger not completed | unit; group trigger not completed | +| `AtAll` | not-supported | unit only | unit only | unit/send fallback only | unit only | +| `Image` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI image sent but not observed in plugin evidence | +| `Voice` | converter/unit; real UI inbound not completed | not-supported as native voice; audio is attachment/file | plugin-e2e-protocol, not Matcha UI | converter/unit; real UI inbound not completed | unit; real UI inbound not completed | +| `File` | plugin-e2e-ui private | converter/unit; real UI attachment not completed | plugin-e2e-protocol, not Matcha UI | plugin-e2e-ui private | unit; real UI file sent but not observed in plugin evidence | +| `Quote` | converter/unit; real UI reply not completed | unit; real UI reply not completed | plugin-e2e-protocol | converter/unit; real UI quote not completed | unit/API-backed quote lookup; real UI quote not completed | +| `Face` | not-supported as common `Face` | not-supported as common `Face` | plugin-e2e-protocol | UI emoji becomes `Plain` (`[smile]` text), not `Face` | not-supported as common `Face` | +| `Forward` | not-supported inbound | not-supported inbound | unit; Matcha forward UI/action blocked | not-supported inbound | not-supported inbound | +| Mixed chain | group `At + Plain`; media tested as separate messages | not completed inbound | plugin-e2e-protocol | media tested as separate messages; mixed inbound not completed | unit only | + +## Message Send Components + +| Component | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu | +|-----------|----------|---------|-----------|----------|---------------| +| `Plain` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | +| `At` | plugin-e2e-outbound equivalent | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback/equivalent | plugin-e2e-outbound | +| `AtAll` | plugin-e2e-outbound fallback | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | unit; group live not completed | +| `Image` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | +| `Voice` | not-supported in current send converter | not-supported as native voice | converter path; not completed against Matcha UI | fallback as file/text depending DingTalk media support | converter path; live not completed | +| `File` | plugin-e2e-outbound | plugin-e2e-outbound | blocked by Matcha endpoint error | plugin-e2e-outbound | plugin-e2e-outbound | +| `Quote` | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound fallback | plugin-e2e-outbound fallback | +| `Face` | not-supported | not-supported | plugin-e2e-outbound attempted in mixed chain | fallback text | not-supported | +| `Forward` | flattened fallback | flattened fallback | blocked by Matcha unsupported action | flattened fallback | plugin-e2e-outbound flattened fallback | +| Mixed chain | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound except blocked file/forward | plugin-e2e-outbound | plugin-e2e-outbound | + +## Event Acceptance + +| Event category | Telegram | Discord | aiocqhttp | DingTalk | +|----------------|----------|---------|-----------|----------| +| `message.received` | plugin-e2e-ui | plugin-e2e-ui | plugin-e2e-ui and plugin-e2e-protocol | plugin-e2e-ui private | +| `message.edited` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared | +| `message.deleted` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | unit | not declared | +| `message.reaction` | implemented/unit, not plugin-e2e-ui | historical/direct only, not latest plugin-e2e | not-supported in standard OneBot message path | not declared | +| member join/left/ban | implemented/unit or blocked without disposable users | blocked without disposable users | unit; Matcha fixture unavailable | not declared | +| bot invited/removed | invite plugin-e2e-ui for Telegram; removal blocked | invite historical/plugin-series; removal blocked | unit; Matcha fixture unavailable | not declared | +| requests/friend events | not applicable | not applicable | unit; Matcha fixture unavailable | not declared | +| `platform.specific` | implemented; not latest plugin-e2e | not latest plugin-e2e | adapter lifecycle observed; plugin focus was message path | declared for fallback; not reproduced in UI run | + +## Common API Acceptance + +| API area | Telegram | Discord | aiocqhttp | DingTalk | +|----------|----------|---------|-----------|----------| +| send/reply | plugin-e2e-outbound | plugin-e2e-outbound | plugin-e2e-outbound, with Matcha file/forward gaps | plugin-e2e-outbound | +| edit/delete | historical/direct or unit; destructive/current UI not repeated | historical/direct; destructive/current UI not repeated | unit/destructive blocked | not declared or blocked | +| message lookup | not-supported | not-supported | plugin-e2e | inbound cache-backed where available; limited live coverage | +| group info/member info | plugin-e2e safe subset | plugin-e2e safe subset | plugin-e2e safe subset | private path only; group not completed | +| user/friend info | plugin-e2e where platform allows | plugin-e2e where platform allows | plugin-e2e | plugin-e2e private user | +| moderation/leave | blocked without disposable safe targets | blocked without disposable safe targets | blocked without disposable safe targets | blocked/not declared | +| `get_file_url` | implemented; latest inbound `File` carried downloadable file data in plugin evidence | URL passthrough for attachments; inbound attachment not completed | not portable/endpoint-dependent | implemented through DingTalk media API; latest inbound `File` carried a platform file URL | +| `call_platform_api` | plugin-e2e safe actions | plugin-e2e safe actions | plugin-e2e safe actions, Matcha gaps documented | plugin-e2e safe `check_access_token` | + +## Platform-Specific API Acceptance + +| Adapter | plugin-e2e verified | Blocked or not reproduced | +|---------|---------------------|---------------------------| +| Telegram | safe chat/admin/member count/chat-action actions | mutating actions and callback-only actions were not repeated | +| Discord | safe channel/guild/role/typing actions | mutating pin/reaction/invite actions were not repeated in the latest plugin run; inbound attachment paths not completed | +| aiocqhttp | safe OneBot actions such as status/version/can-send checks | `get_group_honor_info` unsupported by Matcha; admin/card/title/ban/record/file/forward require better endpoint fixtures | +| DingTalk | `check_access_token`; real inbound file produced a file URL in the common `File` component | separate media-download replay APIs and group actions need a working follow-up fixture | + +## SDK API Acceptance + +`EBAEventProbe` exercised the standalone runtime path for: + +- bot discovery and bot info lookup +- send message +- component sweep where enabled +- platform API sweep where enabled +- plugin storage +- workspace storage +- plugin/command/tool/knowledge-base list APIs + +The probe logs set `ok=true` when the sweep completed with only expected unsupported/blocked items. Individual call details are stored in the JSONL evidence files. + +## Residual Risks And Required Follow-Up + +- Discord still requires real UI inbound image/file upload evidence before it can be called media-complete. +- aiocqhttp has rich inbound component evidence only at the OneBot reverse WebSocket boundary; Matcha UI did not provide image/file upload coverage. +- DingTalk group trigger remains unclosed; current evidence is private chat only. +- Lark / Feishu requires a clean follow-up live pass: the latest LangBot organization WebSocket run connected, but UI-sent text/image/file after the loop-scheduling fix did not append plugin events. +- Discord UI retry on May 10, 2026 was blocked by the client keeping the send button disabled even after text was entered. +- Destructive moderation and leave APIs are intentionally blocked until disposable users/groups are available. + +## Conclusion + +The EBA conversion path is implemented and partially proven for the migrated adapters. Telegram and DingTalk now have real UI private-chat image/file inbound evidence. Discord, aiocqhttp, and Lark / Feishu still have explicit UI-level media gaps, so the overall adapter set remains partial acceptance rather than production-complete media acceptance. diff --git a/docs/event-based-agents/adapters/aiocqhttp.md b/docs/event-based-agents/adapters/aiocqhttp.md new file mode 100644 index 000000000..270ceb2ab --- /dev/null +++ b/docs/event-based-agents/adapters/aiocqhttp.md @@ -0,0 +1,162 @@ +# OneBot v11 / aiocqhttp EBA Adapter + +## Status + +OneBot v11 has been migrated to the EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/aiocqhttp/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +├── types.py +└── onebot.svg +``` + +The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `host` | Yes | `0.0.0.0` | Host for the reverse WebSocket server that the OneBot endpoint connects to. | +| `port` | Yes | `2280` | Reverse WebSocket listen port. | +| `access-token` | No | `""` | OneBot access token, if the endpoint is configured to use one. | + +## Events + +The adapter declares these EBA events: + +- `message.received` +- `message.deleted` +- `group.member_joined` +- `group.member_left` +- `group.member_banned` +- `friend.request_received` +- `friend.added` +- `bot.invited_to_group` +- `bot.removed_from_group` +- `bot.muted` +- `bot.unmuted` +- `platform.specific` + +`platform.specific` is used for OneBot notice/request/meta events that do not yet have a common EBA event type, such as group admin changes, group file uploads, pokes, honor changes, and group join requests from non-bot users. + +## Common APIs + +| API | Status | Notes | +|-----|--------|-------| +| `send_message` | Supported | Supports private and group text, mentions, images, voice, files, faces, and flattened forwards. Group merged forwards are sent through OneBot forward APIs when possible. | +| `reply_message` | Supported | Uses the original OneBot event and can prepend a reply segment. | +| `edit_message` | Not supported | OneBot v11 has no standard message edit action. | +| `delete_message` | Supported | Uses `delete_msg`; permission depends on endpoint and group role. | +| `forward_message` | Supported | Emulates forward by fetching the source message with `get_msg` and sending its content to the target chat. | +| `get_message` | Supported | Uses `get_msg` and converts the response into `MessageReceivedEvent`. | +| `get_group_info` | Supported | Uses `get_group_info`. | +| `get_group_list` | Supported | Uses `get_group_list`. | +| `get_group_member_list` | Supported | Uses `get_group_member_list`. | +| `get_group_member_info` | Supported | Uses `get_group_member_info`. | +| `set_group_name` | Supported | Uses `set_group_name`; may be unsupported by mock endpoints. | +| `get_user_info` | Supported | Uses `get_stranger_info`. | +| `get_friend_list` | Supported | Uses `get_friend_list`. | +| `approve_friend_request` | Supported | Uses `set_friend_add_request`. | +| `approve_group_invite` | Supported | Uses `set_group_add_request` with `sub_type=invite`. | +| `upload_file` | Not supported | OneBot v11 has endpoint-specific file upload extensions but no portable standalone upload action. | +| `get_file_url` | Not supported | OneBot v11 file URL resolution is endpoint-specific. Use `call_platform_api("get_image")`, `get_record`, or endpoint extensions when available. | +| `mute_member` | Supported | Uses `set_group_ban`. | +| `unmute_member` | Supported | Uses `set_group_ban` with duration `0`. | +| `kick_member` | Supported | Destructive; test only with disposable members. | +| `leave_group` | Supported | Destructive; should run last in live tests. | +| `call_platform_api` | Supported | See below. | + +## Platform-Specific APIs + +`call_platform_api(action, params)` supports: + +- `get_login_info` +- `get_status` +- `get_version_info` +- `get_group_honor_info` +- `set_group_card` +- `set_group_special_title` +- `set_group_admin` +- `set_group_whole_ban` +- `send_group_forward_msg` +- `get_forward_msg` +- `get_record` +- `get_image` +- `can_send_image` +- `can_send_record` + +## Message Conversion Notes + +Incoming OneBot segments are converted into common `MessageChain` components before LangBot core/plugin dispatch: + +- `text` -> `Plain` +- `at` -> `At` / `AtAll` +- `image` -> `Image` or `Face` for OneBot emoji-package images +- `record` -> `Voice` +- `file` -> `File` +- `reply` -> `Quote` +- `face`, `rps`, `dice` -> `Face` +- unsupported segments -> `Unknown` + +Outgoing `MessageChain` components are converted back into `aiocqhttp.Message` segments. Base64 media strings are normalized to OneBot `base64://...` format. + +## Live Test Record + +The direct live probe is: + +```bash +PYTHONPATH=/Users/qinjunyan/code/projects/langbot/langbot-plugin-sdk/src \ +uv run python tests/e2e/live_aiocqhttp_eba_probe.py --host 127.0.0.1 --port 2280 +``` + +It starts the reverse WebSocket adapter directly, records observed EBA events to `data/temp/aiocqhttp_eba_live_probe.jsonl`, waits for a real Matcha or OneBot message, then tries reply/send/get/delete/group/user/platform API calls as far as the endpoint supports them. + +Verified on May 10, 2026 with local Matcha connected to `ws://127.0.0.1:2280/ws`: + +- Real inbound group message converted to `MessageReceivedEvent`. +- Real lifecycle connection converted to `PlatformSpecificEvent`. +- Real reply API succeeded and rendered a quoted bot reply in Matcha. +- Real proactive send API succeeded and rendered a bot group message in Matcha. +- Real outgoing component sweep succeeded for text, `At`, `AtAll`, `Face`, and base64 `Image`. +- Real `get_message`, `get_group_info`, `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record` calls succeeded against Matcha. +- Unit conversion and API-shape tests passed for `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `rps`, `dice`, `Forward`, `Unknown`, private/group message events, delete notices, group join/leave/ban notices, bot mute notices, friend requests, group invites, friend added notices, dispatch specificity, send, reply, delete, forward, get message, group APIs, user APIs, request approval APIs, moderation APIs, leave group, unsupported file APIs, and all declared `call_platform_api` actions. + +Skipped or residual live-test items: + +- `edit_message`: not implemented because OneBot v11 has no standard edit action. +- `upload_file` and `get_file_url`: not implemented as common APIs because portable OneBot v11 file upload/download URL semantics are endpoint-specific. +- `kick_member` and `leave_group`: destructive; run only with explicit `--destructive` and disposable Matcha/OneBot state. +- `group.info_updated`, message reactions, and message edits are not declared because OneBot v11 does not provide standard equivalents for them. +- Matcha returned `ActionFailed` for outgoing `File` segment rendering and did not support merged-forward actions in this run. The adapter keeps the conversion/API implementations because they are valid OneBot/NapCat-style capabilities, but the Matcha live probe records them as skipped. +- Matcha returned an empty `get_group_member_list` for the test group, so `get_group_member_info`, mute/unmute, kick, and leave were covered by unit/API-shape tests only in this run. + +## Standalone Runtime Plugin E2E Record + +Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot `--standalone-runtime`, local Matcha, and group `测试群`. + +Evidence: + +- Plugin JSONL: `data/temp/aiocqhttp-plugin-e2e-20260510-multiformat.jsonl` + +Observed and verified: + +- A real Matcha group message reached the plugin as `MessageReceived` with `bot_uuid=eba-aiocqhttp-matcha`, `adapter_name=aiocqhttp`, common `Source`/`Plain` message components, common sender, and common group identifiers. +- A protocol-level OneBot reverse WebSocket event reached the plugin as `MessageReceived` with a mixed common chain: `Source`, `Plain`, `At`, `Face`, `Image`, `Voice`, `File`, `Quote`, and trailing `Plain`. This proves the real adapter + LangBot + standalone runtime + plugin path for mixed inbound OneBot payloads, but it was not sent through Matcha UI. +- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`. +- Outbound component sweep succeeded for plain text plus `At`/`Face`, `AtAll`, base64 `Image`, and quoted reply. +- Common APIs succeeded through the plugin path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, `get_group_member_list`, and `get_group_member_info`. +- Safe OneBot platform APIs succeeded through `call_platform_api`: `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record`. + +Documented Matcha limits in this E2E run: + +- Matcha UI did not provide a completed image/file upload/send path for inbound media. The rich inbound media evidence is `plugin-e2e-protocol`, not UI-level media upload evidence. +- Outbound `File` failed in Matcha even after the adapter emitted an official `file` segment shape. +- Outbound `Forward` failed because Matcha returned unsupported action for merged-forward. +- `get_group_honor_info` failed because Matcha returned unsupported action. +- Destructive/admin APIs such as mute, unmute, kick, leave, group rename, card/title/admin/whole-ban changes, and request approvals were not run without disposable fixtures. diff --git a/docs/event-based-agents/adapters/dingtalk.md b/docs/event-based-agents/adapters/dingtalk.md new file mode 100644 index 000000000..5576c783b --- /dev/null +++ b/docs/event-based-agents/adapters/dingtalk.md @@ -0,0 +1,115 @@ +# DingTalk EBA Adapter Migration Record + +Status: migrated with partial plugin E2E evidence. + +Adapter directory: `src/langbot/pkg/platform/adapters/dingtalk/` + +## What Changed + +The DingTalk adapter now has an Event-Based Agents adapter package with: + +- `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs. +- `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch. +- `event_converter.py` for native DingTalk events and card callbacks to common EBA events. +- `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components. +- `api_impl.py` for common EBA API implementations. +- `platform_api.py` for DingTalk-specific `call_platform_api` actions. + +The legacy DingTalk HTTP client now returns successful JSON response bodies from proactive send methods and raises with response details on non-200 responses. + +## Configuration + +| Field | Required | Notes | +|-------|----------|-------| +| `client-id` | yes | DingTalk robot/client identifier. | +| `client-secret` | yes | DingTalk client secret. | +| `robot-code` | yes | Robot code used for send APIs. | +| `robot-name` | no | Used for bot mention/self filtering and display. | +| `encrypt-key` | no | DingTalk callback encryption key when configured. | +| `verification-token` | no | DingTalk callback verification token when configured. | + +## Supported Events + +| Event | Support | Evidence | +|-------|---------|----------| +| `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. | +| `feedback.received` | unit covered | DingTalk card callback actions with feedback-like values (`like`, `dislike`, `cancel`, or `1`/`2`/`3`) map to `FeedbackReceivedEvent`. Other card actions remain `platform.specific`. | +| `platform.specific` | implemented | Non-feedback card callbacks and unmapped callback/message shapes are emitted as structured platform-specific events. | + +## Receive Components + +| Component | Support | Evidence | +|-----------|---------|----------| +| `Source` | supported | `plugin-e2e-ui` private message. | +| `Plain` | supported | `plugin-e2e-ui` private text. DingTalk emoji currently arrives as plain text such as `[smile]`. | +| `At` | converter path | Group trigger was not completed in the latest run. | +| `AtAll` | fallback/send-side only | Not completed inbound. | +| `Image` | supported | Real DingTalk Mac private-chat image upload reached the plugin as common `Image`. | +| `Voice` | converter path | Real UI inbound voice was not completed. | +| `File` | supported | Real DingTalk Mac private-chat file upload reached the plugin as common `File`. | +| `Quote` | converter path | Real UI inbound quote was not completed. | +| `Face` | not native common mapping | DingTalk emoji was observed as `Plain`, not `Face`. | +| `Forward` | not-supported inbound | DingTalk does not expose a portable structured forward event in this adapter. | + +## Send Components + +| Component | Support | Evidence | +|-----------|---------|----------| +| `Plain` | supported | `plugin-e2e-outbound`. | +| `At` | supported or text fallback | `plugin-e2e-outbound`. | +| `AtAll` | fallback | `plugin-e2e-outbound`. | +| `Image` | supported | `plugin-e2e-outbound`. | +| `File` | supported | `plugin-e2e-outbound`. | +| `Quote` | fallback | `plugin-e2e-outbound`. | +| `Face` | fallback | `plugin-e2e-outbound` as text fallback. | +| `Forward` | flattened fallback | `plugin-e2e-outbound`. | +| `Voice` | fallback/endpoint-dependent | Not separately verified as a native DingTalk voice send. | + +## Common APIs + +| API | Support | Notes | +|-----|---------|-------| +| `send_message` | supported | Verified through `EBAEventProbe`. | +| `reply_message` | supported | Verified through quoted/fallback send path. | +| `get_message` | cache-backed | Requires the message to have been observed by this adapter process. | +| `get_group_info` | cache-backed/API-backed where available | Group path not completed in latest UI run. | +| `get_group_list` | supported where DingTalk API allows | Limited live coverage. | +| `get_group_member_info` | supported where DingTalk API allows | Limited live coverage. | +| `get_user_info` | supported | Private sender path verified. | +| `get_friend_list` | limited | DingTalk does not expose a portable friend-list equivalent. | +| `get_file_url` | supported with media/file identifiers | Real inbound file yielded a platform file URL in the converted `File` component. | +| `call_platform_api` | supported | Safe action `check_access_token` verified. | + +## Platform-Specific APIs + +| Action | Support | Evidence | +|--------|---------|----------| +| `check_access_token` | supported | `plugin-e2e`. | +| `refresh_access_token` | supported | Implemented; not separately reproduced in the latest plugin run. | +| `get_file_url` | supported | Real inbound file yielded a platform file URL in the converted `File` component. | +| `get_audio_base64` | supported | Needs real inbound audio/media ID. | +| `download_image_base64` | supported | Real inbound image reached the plugin as `Image`; separate image-download API replay was not completed. | + +## End-to-End Evidence + +Evidence files: + +- Text/API/component JSONL: `data/temp/dingtalk-plugin-e2e-20260510-rerun.jsonl` +- Real UI inbound media JSONL: `data/temp/dingtalk-plugin-e2e-media-ui.jsonl` + +Verified: + +- DingTalk Mac private chat in the `LangBot Team` organization produced `MessageReceived` through LangBot standalone runtime and `EBAEventProbe`. +- The common chain was `Source + Plain` for normal text. +- DingTalk emoji was received as `Source + Plain`, not common `Face`. +- Real DingTalk Mac private-chat image upload was received as `Source + Image`. +- Real DingTalk Mac private-chat file upload was received as `Source + File`. +- The plugin sent outbound text, mention/fallback, image, quote/fallback, file, and forward/fallback messages visible in DingTalk. +- The plugin called safe SDK and DingTalk platform APIs. + +Not completed: + +- Real UI inbound voice. +- Real UI inbound quote. +- Group trigger with a real robot mention. +- Destructive or organization-mutating APIs. diff --git a/docs/event-based-agents/adapters/discord.md b/docs/event-based-agents/adapters/discord.md new file mode 100644 index 000000000..a2ea4143c --- /dev/null +++ b/docs/event-based-agents/adapters/discord.md @@ -0,0 +1,146 @@ +# Discord EBA Adapter + +## Status + +Discord has been migrated from the legacy source adapter: + +```text +src/langbot/pkg/platform/sources/discord.py +src/langbot/pkg/platform/sources/discord.yaml +``` + +EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/discord/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +├── types.py +└── voice.py +``` + +The adapter is registered as `discord-eba`. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `client_id` | Yes | `""` | Discord application client ID. | +| `token` | Yes | `""` | Discord bot token. | + +The bot needs gateway permissions and intents for the target test server. Message Content intent is required for message bodies, Server Members intent is required for member APIs/events, and reaction events require the Reactions intent and channel permissions. + +## Events + +Discord declares these EBA events: + +- `message.received` +- `message.edited` +- `message.deleted` +- `message.reaction` +- `group.member_joined` +- `group.member_left` +- `bot.invited_to_group` +- `bot.removed_from_group` +- `platform.specific` + +Discord-specific events that do not map cleanly to common events should be surfaced as `platform.specific`. + +## Common APIs + +| API | Status | Notes | +|-----|-----------------|-------| +| `send_message` | Supported | Supports text, image, file, and mixed message chains through Discord messages and attachments. | +| `reply_message` | Supported | Uses Discord message references when replying to a received EBA message event. | +| `edit_message` | Supported | Bot can edit its own messages. File edits are implemented by clearing old attachments and sending replacement files when needed. | +| `delete_message` | Supported | Requires message management permissions for non-bot messages. | +| `forward_message` | Emulated | Discord has no native forward API; the adapter copies content and attachments. | +| `get_group_info` | Supported | Maps Discord guild metadata to EBA group info. | +| `get_group_member_list` | Supported | Requires member cache or the Server Members intent/fetch permission. | +| `get_group_member_info` | Supported | Maps Discord roles/permissions into EBA member roles. | +| `get_user_info` | Supported | Uses Discord user fetch/cache. | +| `upload_file` | Not supported | Discord uploads files as message attachments; standalone upload raises `NotSupportedError`. | +| `get_file_url` | Supported | Discord attachment URLs are already downloadable URLs, so the adapter returns the input URL. | +| `mute_member` | Supported where possible | Uses Discord timeout API and requires guild moderation permission. | +| `unmute_member` | Supported where possible | Clears timeout and requires guild moderation permission. | +| `kick_member` | Supported | Destructive; test only with a disposable account/bot. | +| `leave_group` | Supported | Bot leaves a guild; destructive and should run last. | +| `call_platform_api` | Supported | Discord-specific actions live here. | + +## Platform-Specific APIs + +`call_platform_api(action, params)` supports: + +- `get_channel` +- `get_guild` +- `get_guild_channels` +- `get_guild_roles` +- `create_invite` +- `pin_message` +- `unpin_message` +- `add_reaction` +- `remove_reaction` +- `typing` + +Voice helpers are intentionally kept Discord-specific: + +- `join_voice_channel` +- `leave_voice_channel` +- `get_voice_connection_status` +- `list_active_voice_connections` +- `get_voice_channel_info` + +## Live Test Record + +The live probe is: + +```bash +uv run python tests/e2e/live_discord_eba_probe.py --help +``` + +Verified on May 7, 2026 with a newly created Discord application/bot named `LangBot EBA Test 0507`, the LangBot Discord server, and the `#🐞-debugging` channel: + +- SDK standalone runtime started with WebSocket control/debug ports, and the `EBAEventProbe` plugin connected through `lbp run`. +- Plugin runtime received real Discord events through LangBot: `BotInvitedToGroup`, `MessageReceived`, `MessageReactionReceived` add/remove, `MessageEdited`, and `MessageDeleted`. +- Plugin runtime API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage APIs, workspace storage APIs, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`. +- Direct live adapter probe observed `message.received`, `message.edited`, `message.deleted`, and `bot.removed_from_group`. +- Message APIs verified: send, reply, edit, delete, forward, text/image/file mixed message chains. +- User and guild APIs verified: `get_user_info`, `get_group_info`, `get_group_member_list`, `get_group_member_info`. +- Platform-specific APIs verified: `get_channel`, `get_guild`, `get_guild_channels`, `get_guild_roles`, `create_invite`, `typing`, `pin_message`, `unpin_message`, `add_reaction`, `remove_reaction`. +- Unsupported API behavior verified: `upload_file` raises `NotSupportedError`. +- Destructive API verified at the end: `leave_group`, which emitted `bot.removed_from_group`. + +Not verified in the shared LangBot server live run: `mute_member`, `unmute_member`, and `kick_member`, because the run did not use a disposable target member. They are implemented through Discord timeout/kick APIs and should only be exercised against a disposable account or bot. + +The test fixed one real test-fixture issue: `EBAEventProbe` previously assumed `get_bots()` returned UUID strings. The current standalone runtime returns bot dictionaries, so the probe now selects an enabled bot dictionary and passes its `uuid` to `get_bot_info` and `send_message`. The probe also now subscribes to `MessageDeleted`. + +## Standalone Runtime Plugin E2E Record + +Verified again on May 10, 2026 with SDK standalone runtime, LangBot `--standalone-runtime`, Discord web client, the LangBot server, and `#🐞-debugging`. + +Evidence: + +- Main plugin JSONL: `data/temp/discord-plugin-e2e-20260510-final.jsonl` +- LangBot runtime log: `data/temp/discord-langbot-e2e-20260510-rerun.log` + +Observed and verified: + +- A newly invited Discord bot connected to the LangBot server and received a real web-client message in `#🐞-debugging`. +- `MessageReceived` reached the plugin with `bot_uuid=eba-discord-live`, `adapter_name=discord`, common `Source`/`Plain` message components, common `User`, and common `UserGroup` for the guild. +- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`. +- Outbound component sweep succeeded: plain text plus user mention, `AtAll`/`@everyone`, base64 image, quoted reply, file attachment, and flattened forward fallback. +- Common APIs succeeded: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`. +- Discord platform APIs succeeded through `call_platform_api`: `get_channel`, `typing`, `get_guild`, `get_guild_channels`, and `get_guild_roles`. + +Documented limits in this E2E run: + +- Real Discord UI inbound attachment/image/file, reply/quote, and fresh mention-chain messages were not completed in the plugin E2E evidence. Outbound image/file attachments from the bot do not prove inbound attachment conversion. +- A later May 10 UI retry could write text into the Discord message box, but the client kept the send button disabled and did not send the message, so it produced no new plugin evidence. +- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Discord adapter. +- Destructive moderation and guild-leave APIs were not repeated against the shared LangBot server. +- Native Discord voice is not represented as common `Voice`; audio-like payloads are treated as file attachments. +- `create_invite`, pin/unpin, and reaction mutation were covered by prior direct live probes but were not repeated by the final plugin run to avoid extra shared-server side effects. diff --git a/docs/event-based-agents/adapters/kook.md b/docs/event-based-agents/adapters/kook.md new file mode 100644 index 000000000..25a5e4aef --- /dev/null +++ b/docs/event-based-agents/adapters/kook.md @@ -0,0 +1,108 @@ +# KOOK EBA Adapter + +## Status + +KOOK has been migrated to the EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/kook/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +└── types.py +``` + +The adapter is registered as `kook-eba`. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `token` | Yes | `""` | KOOK bot token. | +| `enable-stream-reply` | Yes | `false` | Reserved for shared platform configuration compatibility. | + +## Events + +| Event | Evidence | Notes | +|-------|----------|-------| +| `message.received` | `plugin-e2e-ui` | Real KOOK UI channel message reached `EBAEventProbe` as `MessageReceivedEvent`. | +| `platform.specific` | `plugin-e2e-ui` | KOOK gateway event without a common EBA mapping reached `EBAEventProbe` as `PlatformSpecificEventReceived`. | + +## Common APIs + +| API | Evidence | Notes | +|-----|----------|-------| +| `send_message` | `plugin-e2e-outbound` | Probe plugin sent channel messages through SDK `send_message`; KOOK returned message IDs. | +| `reply_message` | `unit` | Supports `reply_msg_id` and optional quoted replies when the source message ID is available. | +| `get_message` | `plugin-e2e-outbound` | Probe plugin fetched the cached triggering message. | +| `get_group_info` | `plugin-e2e-outbound` | Probe plugin received cached KOOK channel info. | +| `get_group_list` | `plugin-e2e-outbound` | Probe plugin received cached channel/group entities observed by the adapter. | +| `get_group_member_info` | `plugin-e2e-outbound` | Probe plugin received cached sender info as a group member. | +| `get_user_info` | `plugin-e2e-outbound` | Probe plugin received cached sender user info. | +| `get_friend_list` | `plugin-e2e-outbound` | Probe plugin received cached users. | +| `upload_file` | `unit` | Uses KOOK `asset/create` and returns URL/ID. | +| `get_file_url` | `unit` | KOOK media IDs are URL-like in the adapter path; returns the ID unchanged. | +| `delete_message` | `unit` | Calls KOOK delete endpoints. Live permission verification is still required. | +| `forward_message` | `plugin-e2e-outbound` | Probe plugin sent flattened forward content through SDK `send_message`. | +| `call_platform_api` | `plugin-e2e-outbound` | Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`. | + +## Platform-Specific APIs + +| Action | Evidence | Notes | +|--------|----------|-------| +| `get_current_user` | `plugin-e2e-outbound` | Probe plugin called `user/me`. | +| `get_user` | `plugin-e2e-outbound` | Probe plugin called `user/view` for the triggering sender. | +| `get_channel` | `plugin-e2e-outbound` | Probe plugin called `channel/view` for the triggering channel. | +| `get_guild` | `plugin-e2e-outbound` | Probe plugin called `guild/view`; gateway URLs redact token query values. | +| `get_gateway` | `plugin-e2e-outbound` | Probe plugin called `gateway/index`; returned token query values are redacted. | +| `send_direct_message` | `unit` | Calls `direct-message/create`. | + +## Components + +| Component | Receive Evidence | Send Evidence | Notes | +|-----------|------------------|---------------|-------| +| `Source` | `plugin-e2e-ui` | N/A | KOOK message ID and timestamp are preserved. | +| `Plain` | `plugin-e2e-ui` | `plugin-e2e-outbound` | Text and KMarkdown are represented as plain common text. | +| `At` | `plugin-e2e-ui` | `plugin-e2e-outbound` | KOOK `(met)(met)` mentions map to common `At`. | +| `AtAll` | `unit` | `plugin-e2e-outbound` | KOOK `(met)all(met)` maps to common `AtAll`; real inbound UI AtAll was not tested. | +| `Image` | `unit` | `unit` | URL/image ID based path only; live rendering still needs verification. | +| `Voice` | `unit` | `unit` | URL based path only; live rendering still needs verification. | +| `File` | `unit` | `unit` | URL based path only; upload API is exposed separately. | +| `Forward` | `unit` | `unit` | Outbound forwards are flattened; inbound structured forwards are not exposed by current legacy implementation. | +| `Unknown` | `unit` | N/A | Unsupported KOOK message types become `Unknown` or `PlatformSpecificEvent`. | + +## Acceptance Record + +Test date: June 4, 2026. + +Plugin E2E verified on June 4, 2026 with `EBAEventProbe`, SDK standalone runtime, KOOK WebSocket adapter, and a real KOOK channel UI message. + +Evidence: + +- JSONL: `data/temp/kook_eba_plugin_probe.jsonl` +- Plugin log: `data/logs/eba-probe-kook.log` + +Observed and verified: + +- A real KOOK UI channel message reached the plugin as `MessageReceived` with `bot_uuid=7ab5b065-6e4e-4def-95f0-3c265366e26f`, `adapter_name=kook`, common sender/group/chat fields, and common `MessageChain` components. +- KOOK gateway-specific event reached the plugin as `PlatformSpecificEventReceived`. +- Probe plugin called SDK `send_message`; KOOK returned message IDs for text, At, AtAll, image URL/base64 fallback path, quote fallback, file fallback, and flattened forward cases. +- Probe plugin called common API methods through the SDK path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, and `get_group_member_info`. +- Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`: `get_current_user`, `get_user`, `get_channel`, `get_gateway`, and `get_guild`. + +Run: + +```bash +uv run pytest tests/unit_tests/platform/test_kook_eba_adapter.py +git diff --check +``` + +Blocked or partial items: + +- `plugin-e2e-ui` inbound coverage for image, file, voice, AtAll, quote, and forward. +- `plugin-e2e-outbound` visual verification in KOOK UI for image/file/voice rendering. KOOK returned message IDs, but UI inspection was not performed in this run. +- `reply_message` and `delete_message` live permission verification. +- Destructive or permission-sensitive APIs were not declared beyond delete; KOOK mute/kick/leave remain explicit `NotSupportedError` paths until a safe fixture is available. diff --git a/docs/event-based-agents/adapters/lark.md b/docs/event-based-agents/adapters/lark.md new file mode 100644 index 000000000..c162284bb --- /dev/null +++ b/docs/event-based-agents/adapters/lark.md @@ -0,0 +1,135 @@ +# Lark / Feishu EBA Adapter Migration Record + +Status: migrated with unit coverage and partial live plugin E2E. WebSocket text reached the standalone runtime once in the LangBot organization test app, but the latest real UI image/file inbound attempts did not reach the local adapter log, so media receive is not release-complete yet. + +Adapter directory: `src/langbot/pkg/platform/adapters/lark/` + +## What Changed + +The Lark/Feishu adapter now has an Event-Based Agents adapter package with: + +- `manifest.yaml` for adapter metadata, configuration, events, common APIs, platform-specific APIs, app type, and communication mode. +- `adapter.py` for self-built/store app token handling, WebSocket long connection startup, Webhook callback handling, card feedback, streaming-card replies, and EBA dispatch. +- `event_converter.py` for native Feishu events to common EBA events. +- `message_converter.py` for Feishu text/post/image/file/audio payloads to/from common `MessageChain` components. +- `api_impl.py` for common EBA API implementations. +- `platform_api.py` for Feishu-specific `call_platform_api` actions. + +The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-eba`. + +## Configuration + +| Field | Required | Notes | +|-------|----------|-------| +| `app_id` | yes | Feishu/Lark application App ID. | +| `app_secret` | yes | Feishu/Lark application App Secret. | +| `bot_name` | yes | Must match the bot name so group mentions can be recognized. | +| `enable-webhook` | yes | `false` uses WebSocket long connection; `true` uses Request URL/Webhook callbacks. | +| `webhook_url` | no | Generated callback URL for Webhook mode. | +| `encrypt-key` | no | Webhook decrypt key when event encryption is enabled. | +| `enable-stream-reply` | yes | Enables streaming replies through an updating Feishu card. | +| `app_type` | no | `self` for self-built apps; `isv` for store apps. | +| `bot_added_welcome` | no | Optional group welcome message sent after bot-added events. | + +## Application And Communication Modes + +| Mode | Support | Implementation | +|------|---------|----------------| +| Self-built application | implemented | Uses standard app credentials and tenant token behavior from the Feishu SDK client. | +| Store application | implemented | Builds an ISV client, requests app tickets, and resolves app/tenant access tokens with per-tenant caching. | +| WebSocket long connection | implemented | Registers `im.message.receive_v1` and card-action callbacks through `lark_oapi.ws.Client`. | +| Webhook Request URL | implemented | Handles URL verification, encrypted payloads, message events, app-ticket events, bot-added events, and card-action feedback. | + +## Supported Events + +| Event | Support | Evidence | +|-------|---------|----------| +| `message.received` | implemented | Unit coverage for private and group native events to common EBA events. | +| `bot.invited_to_group` | implemented | Webhook bot-added event maps to common bot invite event and optional welcome send. | +| `platform.specific` | implemented | Unknown callback events are preserved as `platform.specific`. | +| `FeedbackEvent` | compatibility event | Card button feedback is still dispatched through the existing SDK `FeedbackEvent` type. | + +## Receive Components + +| Component | Support | Evidence | +|-----------|---------|----------| +| `Source` | supported | Unit coverage; live private text evidence. | +| `Plain` | supported | Text and post payloads convert to common text; live private text evidence. | +| `At` | supported | Feishu mentions map to common `At` with user ID and display name. | +| `AtAll` | supported | `user_id=all` maps to common `AtAll`. | +| `Image` | supported | Image payloads download through message resource API and map to common `Image`; real UI image send attempted, but not observed in local plugin evidence yet. | +| `Voice` | supported | Audio payloads download through message resource API and map to common `Voice`. | +| `File` | supported | File payloads download through message resource API and map to common `File`; real UI file send attempted, but not observed in local plugin evidence yet. | +| `Quote` | supported | Parent/thread reply lookup maps quoted content into common `Quote`. | +| `Face` | not native common mapping | Feishu emoji/stickers are not exposed as a portable common `Face` component here. | +| `Forward` | not-supported inbound | Feishu does not expose a portable structured forward event in this adapter. | + +## Send Components + +| Component | Support | Evidence | +|-----------|---------|----------| +| `Plain` | supported | Unit coverage; sends Feishu `text`. | +| `At` | supported | Unit coverage; sends Feishu `post` at element. | +| `AtAll` | supported | Unit coverage; sends Feishu `post` at-all element. | +| `Image` | supported | Uploads image resource and sends Feishu `image`. | +| `Voice` | supported | Uploads OPUS/audio resource and sends Feishu `audio`. | +| `File` | supported | Uploads file resource and sends Feishu `file`. | +| `Quote` | supported/fallback | Sends quote marker plus origin content. | +| `Face` | not-supported | No portable send mapping. | +| `Forward` | flattened fallback | Flattens forward nodes into text/media messages. | + +## Common APIs + +| API | Support | Notes | +|-----|---------|-------| +| `send_message` | supported | Supports private/open_id and group/chat_id targets; live plugin outbound component sweep produced visible Feishu messages. | +| `reply_message` | supported | Replies to the source Feishu message; fixed to recover the native Feishu message ID from legacy-wrapped source events. | +| `get_message` | cache-backed/API-backed | Returns cached inbound event where possible and converts uncached Feishu message API items into common `MessageReceivedEvent`. | +| `get_group_info` | supported | Uses cached group or Feishu chat metadata. | +| `get_group_member_info` | limited | Uses cached user data when available. | +| `get_user_info` | limited | Uses cached user data when available. | +| `get_file_url` | limited | Returns `file://` paths from downloaded inbound resources; remote Feishu resource download uses platform-specific API params. | +| `call_platform_api` | supported | See below. | + +## Platform-Specific APIs + +| Action | Support | Evidence | +|--------|---------|----------| +| `check_tenant_access_token` | supported | Unit coverage. | +| `refresh_app_access_token` | supported | Store-app token path implemented. | +| `refresh_tenant_access_token` | supported | Store-app tenant token path implemented. | +| `get_chat` | supported | Feishu chat metadata API wrapper. | +| `get_message` | supported | Feishu message API wrapper with JSON-safe return values for plugin calls. | +| `get_message_resource` | supported | Feishu message resource download wrapper. | + +## End-to-End Evidence + +Current code-level evidence: + +- `tests/unit_tests/platform/test_lark_eba_adapter.py` +- `PYTHONPATH=../langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_lark_eba_adapter.py -q` + +Live evidence collected on May 11, 2026: + +- Standalone runtime: `uv run lbp rt --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check` +- LangBot: `uv run main.py --standalone-runtime --debug` +- Plugin: `LangBot__EBAEventProbe` +- Feishu org/app: LangBot organization, `LangBotDev` private chat. +- Observed plugin JSONL: one private `MessageReceived` event with `Source + Plain`; plugin API probe then exercised bot discovery, bot info, `send_message`, outbound component sweep, storage/list APIs, and safe platform API calls. +- Real UI sends attempted after the fixes: private text, local file, and image/video image upload. These appeared in the Feishu client but did not append new `EBAEventProbe` records in the local JSONL during this run. +- Fixes from live testing: reply path now extracts the native Feishu `message_id` from legacy-wrapped source events; WebSocket callbacks are scheduled onto the adapter event loop instead of assuming the SDK callback has a running asyncio loop; platform API results are converted to JSON-safe values. + +Live E2E items still required before marking release-complete: + +- WebSocket self-built app in LangBot organization: repeat private text after callback-loop fix, plus private image/file/audio and group mention message received by `EBAEventProbe`. +- Webhook self-built app in LangBot organization: URL verification plus text/image/file message received by `EBAEventProbe`. +- Store app token path: at least token acquisition/tenant-token safe API through `call_platform_api`; full message E2E if a LangBot organization store-app fixture is available. +- Outbound component sweep: text, mention, at-all, image, file, voice where Feishu accepts the fixture, quote/fallback, and forward/fallback. +- Safe platform API sweep: token check, chat metadata, message lookup, and message resource download using real inbound IDs. + +## Known Limits + +- Store-app live E2E requires a real ISV app ticket/tenant installation fixture. +- Current LangBot organization WebSocket run connected successfully but did not deliver the latest UI-sent image/file attempts to local plugin evidence; this blocks release-complete media acceptance. +- Feishu native emoji/sticker semantics are not represented as common `Face`. +- Destructive org or chat mutations are not declared in this adapter. diff --git a/docs/event-based-agents/adapters/officialaccount.md b/docs/event-based-agents/adapters/officialaccount.md new file mode 100644 index 000000000..92f744f12 --- /dev/null +++ b/docs/event-based-agents/adapters/officialaccount.md @@ -0,0 +1,101 @@ +# OfficialAccount EBA Adapter + +Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/` + +Manifest name: `officialaccount-eba` + +Status: partial migration. Unit/API-shape coverage is present, and private text `plugin-e2e-ui` plus safe API evidence has been verified against the `dev.rockchin.top` Official Account fixture. Proactive outbound `send_message` remains not supported by this adapter because WeChat Official Account replies must be tied to inbound webhook windows. + +## Config + +| Field | Required | Notes | +| --- | --- | --- | +| `webhook_url` | no | Generated by LangBot and copied into the Official Account callback settings. | +| `token` | yes | WeChat callback token. | +| `EncodingAESKey` | yes | WeChat message encryption key. | +| `AppID` | yes | Official Account app ID. | +| `AppSecret` | yes | Official Account app secret. | +| `Mode` | yes | `drop` waits for an in-callback reply; `passive` returns the loading text first and queues the answer for the user's next message. | +| `LoadingMessage` | no | Only used by `passive` mode. | +| `api_base_url` | no | Optional API base URL for proxy deployments. | + +## Events + +| Event | Evidence | Notes | +| --- | --- | --- | +| `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. | +| `platform.specific` | unit | Subscribe/unsubscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. | + +## Common APIs + +| API | Evidence | Notes | +| --- | --- | --- | +| `reply_message` | unit | Queues/passively returns text through the inbound webhook source event. | +| `get_message` | plugin-e2e-ui, unit | Cached inbound message retrieved by `EBAEventProbe` platform API sweep. | +| `get_user_info` | plugin-e2e-ui, unit | Cached inbound sender retrieved by `EBAEventProbe` platform API sweep. | +| `get_friend_list` | plugin-e2e-ui, unit | Cached inbound sender list retrieved by `EBAEventProbe` platform API sweep. | +| `call_platform_api` | plugin-e2e-ui, unit | Safe diagnostic actions verified through `get_mode` and `get_cached_response_status`. | +| `send_message` | not-supported | Official Account customer-service proactive messaging is not implemented by the existing SDK adapter; only webhook reply is supported here. | + +## Platform APIs + +| Action | Evidence | Notes | +| --- | --- | --- | +| `get_mode` | plugin-e2e-ui, unit | Returned `{"mode": "drop", "longer_response": false}` in live probe. | +| `get_cached_response_status` | plugin-e2e-ui, unit | Returned `{"pending": false}` in live probe. | + +## Components + +| Receive Component | Evidence | Notes | +| --- | --- | --- | +| `Source` | plugin-e2e-ui, unit | Uses `MsgId` and `CreateTime`; live UI text message included `Source`. | +| `Plain` | plugin-e2e-ui, unit | Live UI text message mapped to `Plain`. | +| `Image` | unit | `PicUrl` and `MediaId` map to common `Image`. | +| `Voice` | unit | `MediaId` maps to common `Voice`. | +| `Unknown` | unit | Unsupported message/event types do not crash. | +| `At`, `AtAll`, `File`, `Quote`, `Face`, `Forward`, mixed chain | not-supported | WeChat Official Account inbound webhook payloads used by the current SDK do not expose these as common structured components. | + +| Send Component | Evidence | Notes | +| --- | --- | --- | +| `Plain` | unit | Sent as webhook reply text. | +| `Image`, `Voice`, `File`, `Quote`, `At`, `AtAll`, `Face`, `Forward`, mixed chain | not-supported | Existing SDK reply path is text XML only; non-text components degrade to readable placeholders in tests and are not declared as supported outbound components. | + +## Verification Record + +Test date: 2026-05-28 + +Endpoint/simulator: `dev.rockchin.top` with WeChat desktop client and a real subscribed Official Account conversation. The running EBA test stack used SDK standalone runtime ports `5400/5401`, LangBot from `/home/wgc/LangBotxg/LangBotEbaTest`, and `EBAEventProbe`. + +Verified UI message: `EBA officialaccount single probe 2026-05-28 16:53` + +Observed event/API evidence: + +- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-eba`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`. +- Common safe APIs through probe platform sweep: `get_message`, `get_user_info`, `get_friend_list`. +- Platform APIs through `call_platform_api`: `get_mode`, `get_cached_response_status`. +- `send_message` and outbound component sweep returned explicit `NotSupportedError: send_message:official_account_requires_inbound_webhook_reply`, as expected for this adapter. + +Standalone runtime command: + +```bash +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check +``` + +Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available. + +Adapter live probe: + +```bash +uv run python -m py_compile tests/e2e/live_officialaccount_eba_probe.py +OFFICIALACCOUNT_TOKEN=... OFFICIALACCOUNT_ENCODING_AES_KEY=... OFFICIALACCOUNT_APP_SECRET=... OFFICIALACCOUNT_APP_ID=... uv run python tests/e2e/live_officialaccount_eba_probe.py +``` + +Evidence JSONL path: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/officialaccount_eba_plugin_probe.jsonl` for plugin E2E, or `data/temp/officialaccount_eba_probe.jsonl` for direct adapter live probe. + +Destructive operations: none. + +Blocked items: + +- `plugin-e2e-outbound`: proactive `send_message` is not supported for this adapter; Official Account responses must be produced through the inbound webhook reply window. +- Inbound image and voice live UI evidence remains pending; webhook conversion is covered by unit tests. diff --git a/docs/event-based-agents/adapters/qqofficial.md b/docs/event-based-agents/adapters/qqofficial.md new file mode 100644 index 000000000..66b1c0059 --- /dev/null +++ b/docs/event-based-agents/adapters/qqofficial.md @@ -0,0 +1,119 @@ +# QQOfficial EBA Adapter + +Adapter directory: `src/langbot/pkg/platform/adapters/qqofficial/` + +Manifest name: `qqofficial-eba` + +Status: partial migration. The EBA adapter structure, manifest, converters, cache-backed safe APIs, platform API map, unit tests, and direct live probe scaffold are in place. A real QQ Official WebSocket bot on `dev.rockchin.top` received an inbound user message and drove LangBot into the normal pipeline path; the response path was blocked by the test environment model service returning `model_not_found` for `deepseek-v3`. + +## Config + +| Field | Required | Notes | +| --- | --- | --- | +| `appid` | yes | QQ Official app ID. | +| `secret` | yes | QQ Official app secret. | +| `token` | yes | QQ Official callback token. | +| `enable-webhook` | yes | Uses LangBot unified webhook when true; otherwise uses the QQ WebSocket gateway. | +| `enable-stream-reply` | yes | Enables C2C streaming replies when supported by the QQ Official endpoint. | +| `webhook_url` | no | Generated by LangBot and copied into the QQ Official callback settings in webhook mode. | + +## Events + +| Event | Evidence | Notes | +| --- | --- | --- | +| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. | +| `message.reaction` | unit | `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE` map to common `MessageReactionEvent`. Live gateway evidence is pending. | +| `group.member_joined` | unit | `GUILD_MEMBER_ADD` and `GROUP_MEMBER_ADD` map to common `MemberJoinedEvent` when the gateway payload carries a group/guild/channel ID and member openid. | +| `group.member_left` | unit | `GUILD_MEMBER_REMOVE` and `GROUP_MEMBER_REMOVE` map to common `MemberLeftEvent`. Live gateway evidence is pending. | +| `bot.invited_to_group` | unit | `GUILD_CREATE` and `GROUP_ADD_ROBOT` map to common `BotInvitedToGroupEvent`. | +| `bot.removed_from_group` | unit | `GUILD_DELETE` and `GROUP_DEL_ROBOT` map to common `BotRemovedFromGroupEvent`. | +| `platform.specific` | unit | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. | + +## Common APIs + +| API | Evidence | Notes | +| --- | --- | --- | +| `send_message` | unit, blocked | Sends private C2C, group, and text-only channel messages through the existing QQ Official client. Live outbound UI verification is pending because the test pipeline failed before producing a bot response. | +| `reply_message` | unit, blocked | Replies using the source `QQOfficialEvent` message ID when available. Live reply was blocked by the test environment model service returning `model_not_found`. | +| `get_message` | unit | Returns cached inbound `MessageReceivedEvent`. | +| `get_user_info` | unit | Returns cached inbound sender. | +| `get_friend_list` | unit | Returns cached private senders. | +| `get_group_info` | unit | Returns cached group/channel metadata from inbound events. | +| `get_group_member_info` | unit | Returns cached group sender as a common member. | +| `get_group_member_list` | unit | Returns cached group members observed by the adapter. | +| `call_platform_api` | unit, blocked | Safe diagnostic actions are implemented; live calls are pending credentials. | + +## Platform APIs + +| Action | Evidence | Notes | +| --- | --- | --- | +| `check_access_token` | unit, blocked | Calls the existing client token check. | +| `refresh_access_token` | unit, blocked | Forces token refresh. | +| `get_gateway_url` | unit, blocked | Fetches the WebSocket gateway URL. | +| `get_mode` | unit | Returns webhook and stream-reply mode. | + +## Components + +| Receive Component | Evidence | Notes | +| --- | --- | --- | +| `Source` | unit | Uses QQ message/event IDs and timestamp. | +| `Plain` | unit | Preserves text content. | +| `At` | unit | Group and channel mention events insert an adapter bot mention marker. | +| `Image` | unit | QQ image attachment URL is converted to common `Image`; falls back to URL if download fails. | +| `Unknown` | unit | Unsupported/empty native payloads become `Unknown`. | +| `Voice`, `File`, `Quote`, `Face`, `Forward`, mixed chain | blocked | Current native parser only exposes text and image attachments; live endpoint behavior still needs verification. | + +| Send Component | Evidence | Notes | +| --- | --- | --- | +| `Plain` | unit, blocked | Sends through private, group, or channel text APIs. | +| `At`, `AtAll` | unit, blocked | Converted to readable mention text. | +| `Image` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. | +| `Voice` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. | +| `File` | unit, blocked | Sends through the QQ Official rich media upload/send path for C2C and group targets. | +| `Quote`, `Forward`, mixed chain | unit, blocked | Flattened to ordered send payloads where possible. | +| `Face` | not-supported | No common QQ Official face mapping is implemented. | + +## Verification Record + +Test date: 2026-06-02 + +Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-eba`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`. + +Observed evidence: + +- The QQ Official WebSocket bot was enabled with `enable-webhook=false`. +- A real user message reached LangBot and entered the standard pipeline path. +- The response path stopped at the model layer with `model_not_found` for `deepseek-v3`; this is a model/provider configuration issue, not an adapter conversion failure. +- `qq-webhook.langbot.dev` was temporarily routed through Caddy to `127.0.0.1:5301` for webhook checks, but the observed EBA bot used WebSocket mode. + +Standalone runtime command: + +```bash +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check +``` + +Probe plugin: `data/plugins/LangBot__EBAEventProbe` when live credentials are available. + +Adapter live probe: + +```bash +uv run python -m py_compile tests/e2e/live_qqofficial_eba_probe.py +QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py +``` + +Webhook-mode probe: + +```bash +QQOFFICIAL_APPID=... QQOFFICIAL_SECRET=... QQOFFICIAL_TOKEN=... uv run python tests/e2e/live_qqofficial_eba_probe.py --webhook --host 0.0.0.0 --port 5312 +``` + +Evidence JSONL path: `data/temp/qqofficial_eba_probe.jsonl` for direct adapter live probe; plugin E2E evidence should use `data/temp/qqofficial_eba_plugin_probe.jsonl`. + +Destructive operations: none implemented. + +Blocked items: + +- `plugin-e2e-ui`: standalone probe plugin JSONL evidence is still pending; the observed live run reached LangBot core/pipeline but was not recorded by the EBA probe plugin. +- `plugin-e2e-outbound`: waiting for visible QQ client verification of plugin `send_message`/`reply_message` output after a working model/provider is configured. +- Inbound non-text media and platform lifecycle events require endpoint evidence before they can be marked complete. diff --git a/docs/event-based-agents/adapters/slack.md b/docs/event-based-agents/adapters/slack.md new file mode 100644 index 000000000..514d2dfa7 --- /dev/null +++ b/docs/event-based-agents/adapters/slack.md @@ -0,0 +1,84 @@ +# Slack EBA Adapter + +## Structure + +Slack is migrated into `src/langbot/pkg/platform/adapters/slack/` with the standard EBA adapter layout: + +- `adapter.py` owns lifecycle, listener dispatch, unified webhook handling, outbound send/reply, and event caches. +- `event_converter.py` maps Slack `im` and `app_mention` channel events to `message.received`. +- `message_converter.py` maps common `MessageChain` components to Slack text fallback and maps inbound Slack text/image payloads back to EBA components. +- `api_impl.py` provides cache-backed common read APIs. +- `platform_api.py` declares safe Slack-specific API actions. +- `manifest.yaml` declares `slack-eba`. + +The legacy `src/langbot/pkg/platform/sources/slack.py` adapter is kept unchanged. + +## Configuration + +| Field | Required | Notes | +|-------|----------|-------| +| `webhook_url` | No | Generated by LangBot. Paste it into Slack Event Subscriptions. | +| `bot_token` | Yes | Slack bot token, usually `xoxb-...`. | +| `signing_secret` | Yes | Slack app signing secret. | + +## Events + +| Event | Notes | +|-------|-------| +| `message.received` | Emitted for private `im` messages and channel `app_mention` events. Channel messages are mapped to group chats. | +| `platform.specific` | Reserved for Slack event types that are not converted into common message events. | + +## Common APIs + +Required: + +- `send_message` +- `reply_message` + +Optional: + +- `get_message` +- `get_user_info` +- `get_friend_list` +- `get_group_info` +- `get_group_list` +- `get_group_member_list` +- `get_group_member_info` +- `call_platform_api` + +Cache-backed APIs are only available after the relevant inbound event has been observed. + +## Platform APIs + +| Action | Notes | +|--------|-------| +| `get_mode` | Returns webhook mode and configured bot account id. | +| `auth_test` | Calls Slack `auth.test` with the configured bot token. | + +## Known Limits + +- Slack file/image outbound is currently represented as text fallback because the existing Slack SDK wrapper only exposes `chat_postMessage`. +- Inbound channel coverage follows the legacy adapter behavior: only `app_mention` events are treated as group messages. +- Real live testing requires a public callback URL configured in Slack Event Subscriptions. + +## Verification + +Local mocked unit coverage validates manifest parity, event conversion, legacy listener compatibility, cache-backed APIs, send/reply routing, and declared platform APIs. + +Plugin E2E evidence was captured on June 2, 2026 against `dev.rockchin.top` with Slack private DM input and `EBAEventProbe` through the standalone runtime. + +Evidence file: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_probe.jsonl`. + +Observed: + +- Real Slack private text produced `MessageReceived` with `adapter_name=slack-eba`, `Source + Plain`, private chat type, and filled `bot_uuid`. +- Safe common APIs passed: `get_message`, `get_user_info`, `get_friend_list`. +- Outbound component fallback sweep passed through `send_message`: plain/at/face, image, quote, file, and forward. +- Declared Slack platform APIs passed: `get_mode`, `auth_test`. + +Still pending: + +- Channel `app_mention` plugin E2E. +- Real inbound Slack file/image UI evidence. + +Live probe scaffold: `tests/e2e/live_slack_eba_probe.py`. diff --git a/docs/event-based-agents/adapters/telegram.md b/docs/event-based-agents/adapters/telegram.md new file mode 100644 index 000000000..336508425 --- /dev/null +++ b/docs/event-based-agents/adapters/telegram.md @@ -0,0 +1,139 @@ +# Telegram EBA Adapter + +## Status + +Telegram has been migrated to the EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/telegram/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +└── types.py +``` + +The adapter is registered as `telegram-eba`. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `token` | Yes | `""` | Telegram Bot API token from BotFather. | +| `markdown_card` | No | `true` | Whether to render Markdown card style replies. | +| `enable-stream-reply` | Yes | `false` | Whether to use Telegram streaming reply mode. | + +## Events + +Telegram declares these EBA events: + +- `message.received` +- `message.edited` +- `message.reaction` +- `group.member_joined` +- `group.member_left` +- `group.member_banned` +- `bot.invited_to_group` +- `bot.removed_from_group` +- `bot.muted` +- `bot.unmuted` +- `platform.specific` + +`platform.specific` is currently used for Telegram-only callback and chat-member update payloads that do not yet have a more specific common event type. + +## Common APIs + +| API | Status | Notes | +|-----|--------|-------| +| `send_message` | Supported | Supports text, image, file, and mixed message chains. | +| `reply_message` | Supported | Supports quoted replies through the original message event. | +| `edit_message` | Supported | Uses Telegram message editing APIs. | +| `delete_message` | Supported | Deletes messages where bot permissions allow it. | +| `forward_message` | Supported | Forwards a message between Telegram chats. | +| `get_group_info` | Supported | Uses Telegram chat metadata. | +| `get_group_member_list` | Supported | Telegram only exposes administrators through the Bot API; this returns the available member set. | +| `get_group_member_info` | Supported | Maps Telegram member status to EBA member roles. | +| `get_user_info` | Supported | Uses Telegram `get_chat` for user chat metadata. | +| `upload_file` | Not supported | Telegram has no standalone upload endpoint; files are uploaded as part of messages. The adapter raises `NotSupportedError`. | +| `get_file_url` | Supported | Returns the Bot API file URL. Test output redacts the bot token. | +| `mute_member` | Supported | Requires a supergroup and bot moderation permission. | +| `unmute_member` | Supported | Uses current `telegram.ChatPermissions` fields. | +| `kick_member` | Supported | Destructive; should only be run against disposable users/bots in tests. | +| `leave_group` | Supported | Destructive; should run at the end of a live test. | +| `call_platform_api` | Supported | See below. | + +## Platform-Specific APIs + +`call_platform_api(action, params)` supports: + +- `pin_message` +- `unpin_message` +- `unpin_all_messages` +- `get_chat_administrators` +- `set_chat_title` +- `set_chat_description` +- `get_chat_member_count` +- `send_chat_action` +- `create_chat_invite_link` +- `answer_callback_query` + +## Live Test Record + +The live probe is: + +```bash +uv run python tests/e2e/live_telegram_eba_probe.py --help +``` + +It supports private chat tests, group/supergroup tests, moderation tests, destructive tests, and a callback-only mode. + +Verified on May 7, 2026: + +- Private chat message APIs: send, reply, edit, delete, forward. +- Private chat media APIs: image/file sending and `get_file_url`. +- User API: `get_user_info`. +- Supergroup APIs: group info, member list, member info, administrators, member count, invite link. +- Supergroup mutation APIs: pin, unpin, unpin all, set title, restore title, set description, restore description. +- Moderation APIs: mute and unmute against a non-owner target bot. +- Destructive APIs: kick a disposable target bot, then make the test bot leave the test group. +- Event conversion observed for `message.received`, `group.member_banned`, `group.member_left`, `bot.removed_from_group`, and Telegram-specific chat-member updates. + +The test fixed one real compatibility issue: `unmute_member` previously used Telegram's removed `can_send_media_messages` permission field. It now uses the split media permission fields required by current `python-telegram-bot`. + +## Standalone Runtime Plugin E2E Record + +Verified on May 10, 2026 with `EBAEventProbe`, SDK standalone runtime, Telegram Lite, `@rockchinq_bot`, and `Rock'sBotGroup`. + +Evidence: + +- Private chat JSONL: `data/temp/telegram-plugin-e2e-rerun.jsonl` +- Group chat JSONL: `data/temp/telegram-plugin-e2e-group.jsonl` +- Private media JSONL: `data/temp/telegram-plugin-e2e-media-ui.jsonl` + +Observed and verified: + +- `MessageReceived` reached the plugin with `bot_uuid=eba-telegram-live`, `adapter_name=telegram`, common sender/chat fields, and common `MessageChain` content. +- `BotInvitedToGroup` reached the plugin after adding the bot to `Rock'sBotGroup`. +- SDK API calls succeeded: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin storage, workspace storage, `list_plugins_manifest`, `list_commands`, `list_tools`, and `list_knowledge_bases`. +- Outbound component sweep succeeded in private and group chats: plain text, mention text/equivalent, base64 image, quoted reply, file/document, and flattened forward fallback. Group mode also covered `AtAll` fallback behavior. +- Real Telegram Lite private-chat inbound media was verified through the plugin path: a sent document arrived as common `File`, and a sent photo arrived as common `Image`. +- Telegram platform API sweep succeeded for safe group actions: `get_chat_administrators`, `get_chat_member_count`, and `send_chat_action`. +- Common group/user APIs succeeded in group mode: `get_user_info`, `get_group_info`, `get_group_member_list`, and `get_group_member_info`. + +Documented limits in this E2E run: + +- Real Telegram UI inbound voice, sticker/emoji-as-common-component, and reply/quote messages were not completed in the plugin E2E evidence. +- `get_message`, `get_friend_list`, and `get_group_list` are not supported by this Telegram adapter. +- Mutating/destructive Telegram-specific actions such as pin/unpin, title/description changes, invite-link creation, moderation, kick, and leave were not repeated in the plugin run. They remain opt-in live-probe cases. +- Telegram does not expose a portable common `Face` component for native sticker/emoji semantics in the current adapter. + +## Notes for Future Adapters + +Telegram is the reference implementation for: + +- Keeping platform-specific actions behind `call_platform_api`. +- Treating unsupported common APIs as explicit `NotSupportedError`. +- Marking destructive live test operations behind CLI flags. +- Redacting access tokens from live probe output. diff --git a/docs/event-based-agents/adapters/wecom.md b/docs/event-based-agents/adapters/wecom.md new file mode 100644 index 000000000..c217cbd31 --- /dev/null +++ b/docs/event-based-agents/adapters/wecom.md @@ -0,0 +1,130 @@ +# WeCom EBA Adapter + +## Status + +WeCom application messages now have an EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/wecom/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +└── types.py +``` + +The adapter is registered as `wecom-eba`. + +This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-eba`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom application callback settings. | +| `corpid` | Yes | `""` | WeCom corporate ID. | +| `secret` | Yes | `""` | WeCom application secret. | +| `token` | Yes | `""` | WeCom callback token. | +| `EncodingAESKey` | Yes | `""` | WeCom callback encryption key. | +| `contacts_secret` | No | `""` | Contacts secret for contact-list based helper APIs. | +| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. | + +## Events + +WeCom declares these EBA events: + +- `message.received` +- `platform.specific` + +`message.received` currently covers text and image application callbacks. Other WeCom callback types are surfaced as `platform.specific` so plugins can inspect the raw structured payload without crashing the common message path. + +## Common APIs + +| API | Status | Notes | +|-----|--------|-------| +| `send_message` | Supported | Private/person target only. `target_id` must be `user_id|agent_id`. Supports text, image, voice, file, flattened forward, and quote fallback. | +| `reply_message` | Supported | Replies to the original WeCom sender and application agent from `source_platform_object`. | +| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. | +| `get_user_info` | Supported | Uses cached event users first, then WeCom `user/get`. | +| `get_friend_list` | Partial | Returns users seen by this adapter instance. Full contacts listing is not declared as common coverage. | +| `call_platform_api` | Supported | See below. | +| `edit_message` | Not supported | WeCom application messages do not expose a general edit endpoint for sent messages. | +| `delete_message` | Not supported | WeCom application messages do not expose a general delete endpoint for sent messages. | +| `get_group_info` / member APIs | Not supported | Regular WeCom application callbacks handled here are private user messages, not group-chat bot messages. | +| `upload_file` / `get_file_url` | Not supported as common APIs | WeCom media upload is used internally while sending image/voice/file components; no portable standalone common file URL is exposed. | + +## Platform-Specific APIs + +`call_platform_api(action, params)` supports: + +- `check_access_token` +- `refresh_access_token` +- `get_user_info` +- `send_to_all` + +`send_to_all` requires a configured `contacts_secret` with suitable contact visibility and should be treated as a broad-send operation in live testing. + +## Unit Verification + +Covered by: + +```bash +uv run pytest tests/unit_tests/platform/test_wecom_eba_adapter.py +``` + +The unit tests cover: + +- Manifest events/APIs/platform actions match adapter declarations. +- Outbound component conversion for text, image, voice, file, quote fallback, and byte-safe text splitting. +- Text callback conversion to `MessageReceivedEvent`. +- Legacy `FriendMessage` compatibility. +- EBA listener dispatch and inbound message/user cache. +- `send_message`, `reply_message`, and safe platform API dispatch against a mocked WeCom client. + +## Standalone Runtime Plugin E2E Record + +Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and a real WeCom desktop client against the server test environment. + +```bash +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check + +cd LangBot +uv run main.py --standalone-runtime + +cd data/plugins/LangBot__EBAEventProbe +EBA_PROBE_API=1 EBA_PROBE_COMPONENT_SWEEP=1 EBA_PROBE_PLATFORM_API=1 \ +uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run +``` + +Evidence: + +- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl` +- Bot: `wecom-eba` +- Client: real WeCom desktop client +- Environment: `dev.rockchin.top` test server + +Observed and verified: + +- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-eba`, common sender/chat fields, and `Source + Plain`. +- SDK API calls succeeded through the standalone runtime, including `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, and manifest/list APIs. +- Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions. + +Still required for stricter acceptance: + +- Send a private image and confirm common `Image` reaches the plugin. +- Have the plugin call `send_message` and `reply_message` for text and one media component, then verify the WeCom client receives the bot output. +- Exercise `send_to_all` only with a disposable visible-contact scope. +- Trigger one non-text/image callback, if available, and confirm it becomes `PlatformSpecificEventReceived`. + +## Current Acceptance + +Current status is **partial EBA acceptance**. + +Blocked items: + +- Real inbound image/voice/file evidence was not completed in this run. +- Inbound voice/file callback parsing is not present in the legacy `WecomClient.get_message()` path, so the EBA adapter does not claim those receive components yet. +- Group/member/moderation APIs do not apply to this regular WeCom application-message adapter. diff --git a/docs/event-based-agents/adapters/wecombot.md b/docs/event-based-agents/adapters/wecombot.md new file mode 100644 index 000000000..5eee651ff --- /dev/null +++ b/docs/event-based-agents/adapters/wecombot.md @@ -0,0 +1,148 @@ +# WeComBot EBA Adapter + +## Status + +WeCom AI Bot now has an EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/wecombot/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +└── types.py +``` + +The adapter is registered as `wecombot-eba`. + +This is separate from regular WeCom internal applications (`wecom-eba`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`. + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `BotId` | Yes for WebSocket mode | `""` | WeCom AI Bot ID. | +| `robot_name` | Yes | `""` | Bot display name used to strip bot mentions from incoming group text. | +| `enable-webhook` | Yes | `false` | `false` uses WebSocket long connection mode; `true` uses webhook callback mode. | +| `webhook_url` | No | `""` | Unified webhook URL, only needed when webhook mode is enabled. | +| `Secret` | Yes for WebSocket mode | `""` | WeCom AI Bot secret for long connection mode. | +| `Corpid` | Yes for webhook mode | `""` | WeCom corporate ID for webhook callback mode. | +| `Token` | Yes for webhook mode | `""` | WeCom callback token. | +| `EncodingAESKey` | Yes for webhook mode; optional for WebSocket media decrypt | `""` | Message encryption/decryption key. | +| `enable-stream-reply` | No | `true` | Enables WeComBot streaming replies. | + +## Events + +WeComBot declares these EBA events: + +- `message.received` +- `feedback.received` +- `platform.specific` + +`message.received` covers private and group messages from the WeComBot SDK. `feedback.received` covers WeComBot like/dislike feedback callbacks. Native SDK events without a common EBA equivalent are emitted as `platform.specific`. + +## Common APIs + +| API | Status | Notes | +|-----|--------|-------| +| `send_message` | Supported in WebSocket mode | Sends proactive markdown/text to a person or group chat ID. Webhook mode raises `NotSupportedError` because the platform callback flow has no proactive send path here. | +| `reply_message` | Supported | Replies through native `req_id` in WebSocket mode or stream finalization/cache in webhook mode. | +| `get_message` | Supported from cache | Returns cached inbound `MessageReceivedEvent` by message ID. | +| `get_user_info` | Supported from cache | WeComBot events carry user info; no full user lookup endpoint is declared. | +| `get_friend_list` | Partial | Returns users observed by this adapter instance. | +| `get_group_info` | Supported from cache | Returns groups observed from inbound group messages. | +| `get_group_member_info` | Supported from cache | Returns observed sender/group-member pairs. | +| `get_group_member_list` | Partial | Returns observed members for the cached group only. | +| `call_platform_api` | Supported | See below. | +| `edit_message` / `delete_message` / `forward_message` | Not supported | WeComBot does not expose portable common APIs for these operations in the current SDK wrapper. | +| `upload_file` / `get_file_url` | Not supported as common APIs | Media is represented inside messages; no portable standalone file upload/URL API is declared. | +| moderation / leave APIs | Not supported | WeComBot does not expose equivalent common moderation operations through this adapter. | + +## Platform-Specific APIs + +`call_platform_api(action, params)` supports: + +- `is_websocket_mode` +- `get_stream_session_status` +- `send_markdown` + +`send_markdown` is only available in WebSocket mode. + +## Unit Verification + +Covered by: + +```bash +PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecombot_eba_adapter.py +``` + +The unit tests cover: + +- Manifest events/APIs/platform actions match adapter declarations. +- Outbound common components flatten to WeComBot markdown/text. +- Private and group native events become `MessageReceivedEvent`. +- Inbound image, file, voice, and quote components map to common `MessageChain`. +- Legacy `FriendMessage`/`GroupMessage` compatibility. +- EBA listener dispatch, message/user/group/member cache, reply, send, streaming chunk, feedback, and platform API calls. + +## Live Probe + +The direct adapter probe is: + +```bash +PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run python tests/e2e/live_wecombot_eba_probe.py --help +``` + +Default mode is WebSocket long connection and requires: + +- `WECOMBOT_BOT_ID` +- `WECOMBOT_SECRET` +- `WECOMBOT_ROBOT_NAME` +- optional `WECOMBOT_ENCODING_AES_KEY` + +Webhook mode uses `--webhook` and requires: + +- `WECOMBOT_TOKEN` +- `WECOMBOT_ENCODING_AES_KEY` +- `WECOMBOT_CORPID` + +The probe writes JSONL evidence to `data/temp/wecombot_eba_live_probe.jsonl`, waits for a real WeComBot message, records common EBA event fields and message components, then runs safe cached/common/platform API checks. + +## Standalone Runtime Plugin E2E Record + +Verified on May 27, 2026 with `EBAEventProbe`, SDK standalone runtime, LangBot core, and the real WeCom desktop client in a WeCom AI Bot private chat. + +Evidence: + +- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl` +- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111` +- Adapter: `wecombot-eba` +- Client: real WeCom desktop client, private `LangBot` BOT chat +- Mode: WebSocket long connection (`enable-webhook=false`) + +Observed and verified: + +- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-eba`, common sender/chat fields, and `Source + Plain`. +- SDK API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, manifest/list APIs, and safe cached common platform APIs. +- Outbound component sweep was visible in the WeCom client and returned `errcode=0`: plain/mention/face fallback, base64 image marker, quote fallback, file marker, and flattened forward fallback. +- Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`. +- The `send_markdown` platform API produced visible bot output in the WeCom client. + +Not completed: + +- Clicking the visible WeCom AI feedback button did not produce a `FeedbackReceived` JSONL entry in this run, so `feedback.received` remains unverified at plugin E2E level. +- Group chat inbound and group cache/member coverage still need a real group-side trigger. +- Real inbound image/file/voice from the WeCom client was not exercised. + +## Current Acceptance + +Current status is **partial EBA acceptance**. + +Blocked or limited items: + +- `feedback.received` is implemented and unit-covered, but real plugin E2E feedback evidence was not observed from the desktop client click. +- Outbound image/voice/file are flattened as textual markers because the WeComBot SDK reply/proactive path used here is markdown/text oriented. +- Group member APIs are cache-backed and only know members observed in received messages. +- Destructive or moderation APIs are not declared because the current WeComBot protocol surface does not provide safe common equivalents. diff --git a/docs/event-based-agents/adapters/wecomcs.md b/docs/event-based-agents/adapters/wecomcs.md new file mode 100644 index 000000000..b5549396f --- /dev/null +++ b/docs/event-based-agents/adapters/wecomcs.md @@ -0,0 +1,161 @@ +# WeCom Customer Service EBA Adapter + +## Status + +WeCom Customer Service now has an EBA adapter directory: + +```text +src/langbot/pkg/platform/adapters/wecomcs/ +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +└── types.py +``` + +The adapter is registered as `wecomcs-eba`. It is separate from regular WeCom application messages (`wecom-eba`) and WeCom AI Bot (`wecombot-eba`). + +## Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `webhook_url` | No | `""` | Unified webhook URL copied into the WeCom Customer Service callback settings. | +| `corpid` | Yes | `""` | WeCom corporate ID. | +| `secret` | Yes | `""` | Customer Service secret used for access tokens. | +| `token` | Yes | `""` | Customer Service callback token. | +| `EncodingAESKey` | Yes | `""` | Customer Service callback encryption key. | +| `api_base_url` | No | `https://qyapi.weixin.qq.com/cgi-bin` | WeCom API base URL, overrideable for proxy/private-network deployments. | + +## Events + +| Event | Status | Notes | +|-------|--------|-------| +| `message.received` | Plugin E2E UI covered for text | Text, image, file, and voice payloads convert to common EBA message components in unit tests. Real WeChat customer-side UI text reached `EBAEventProbe` on May 27, 2026. | +| `platform.specific` | Unit covered | Non-message or unknown Customer Service payloads become structured `PlatformSpecificEvent` records. | + +## Common APIs + +| API | Status | Notes | +|-----|--------|-------| +| `send_message` | Plugin E2E outbound covered | Private/person target only. `target_id` must be `external_userid|open_kfid`. Text and image are implemented; voice/file are explicitly unsupported. | +| `reply_message` | Plugin E2E partial | Replies through Customer Service `kf/send_msg` using the original `source_platform_object`. The pipeline reply path reached the send API, but the dev account later hit WeCom `95001 send msg count limit`. | +| `get_message` | Plugin E2E covered from cache | Returns cached inbound `MessageReceivedEvent` by message ID. | +| `get_user_info` | Plugin E2E covered | Uses cached event users first, then Customer Service `customer/batchget`. | +| `get_friend_list` | Plugin E2E covered, partial | Returns customer users seen by this adapter instance. | +| `call_platform_api` | Unit covered | See platform-specific APIs below. | +| `edit_message` / `delete_message` | Not supported | WeCom Customer Service does not expose a general edit/delete endpoint for bot-sent messages in this adapter. | +| Group/member/moderation APIs | Not supported | Customer Service conversations handled here are private customer sessions, not group chats. | +| `upload_file` / `get_file_url` | Not supported | Media upload is used internally for outbound image; no portable file URL common API is exposed. | + +## Platform-Specific APIs + +| Action | Status | Notes | +|--------|--------|-------| +| `check_access_token` | Unit covered | Checks whether the current access token is present. | +| `refresh_access_token` | Unit covered | Refreshes the Customer Service access token. | +| `get_customer_info` | Unit covered | Calls Customer Service customer lookup by `external_userid`. | + +## Message Components + +Receive: + +| Component | Status | Notes | +|-----------|--------|-------| +| `Source` | Unit covered | Uses Customer Service `msgid` and `send_time`. | +| `Plain` | Unit covered | Text payload content is preserved. | +| `Image` | Unit covered | Uses the base64 data URL produced by the existing SDK image download path. | +| `Voice` | Unit covered | Maps exposed voice media ID to common `Voice.voice_id`; live UI evidence pending. | +| `File` | Unit covered | Maps exposed file media ID/name/size to common `File`; live UI evidence pending. | +| `Quote`, `At`, `AtAll`, `Face`, `Forward` | Not supported inbound | The current Customer Service SDK event model does not expose these as structured inbound fields. | +| `Unknown` | Unit covered | Unsupported message types become `Unknown` in message conversion or `platform.specific` at event level. | + +Send: + +| Component | Status | Notes | +|-----------|--------|-------| +| `Plain` | Plugin E2E outbound covered | Sends through `kf/send_msg` text. | +| `Image` | Plugin E2E outbound covered | Uploads media as WeCom image media and sends through `kf/send_msg` image. | +| `Quote`, `At`, `AtAll`, `Forward` | Unit covered fallback, live partially blocked | Flattened to text where possible. In the May 27 sweep, later text sends hit WeCom `95001 send msg count limit` after the successful text/image sends. | +| `Voice`, `File`, `Face` | Not supported | The adapter raises `NotSupportedError`; no tested Customer Service send path is implemented. | + +## Unit Verification + +Covered by: + +```bash +PYTHONPATH=/Users/wangqiang/code/python/langbot-plugin-sdk/src uv run pytest tests/unit_tests/platform/test_wecomcs_eba_adapter.py +``` + +Result on May 27, 2026: `10 passed`. + +The local `PYTHONPATH` is required in this workspace because the installed SDK package in the LangBot venv does not contain the newer `langbot_plugin.api.entities.builtin.platform.errors` module; the existing EBA adapter tests need the same SDK override. + +## Live Probe + +Auxiliary direct adapter probe: + +```bash +PYTHONPATH=/path/to/langbot-plugin-sdk/src uv run python -m py_compile tests/e2e/live_wecomcs_eba_probe.py + +WECOMCS_CORPID=... \ +WECOMCS_SECRET=... \ +WECOMCS_TOKEN=... \ +WECOMCS_ENCODING_AES_KEY=... \ +PYTHONPATH=/path/to/langbot-plugin-sdk/src \ +uv run python tests/e2e/live_wecomcs_eba_probe.py \ + --path /wecomcs/callback \ + --log data/temp/wecomcs_eba_live_probe.jsonl +``` + +This probe is diagnostic only. Final EBA acceptance still requires the standalone SDK runtime plus `EBAEventProbe` plugin path. + +## Standalone Runtime Plugin E2E Record + +Completed partial plugin E2E on May 27, 2026 against `dev.rockchin.top` and the WeChat customer-side UI entry `微信 -> 客服消息 -> 浪波智能客服`. + +Evidence: + +- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` +- Trigger text: `EBA wecomcs dedupe probe 2026-05-27` +- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4` +- `adapter_name`: `wecomcs-eba` +- Observed common event: `MessageReceived`, `event.type=message.received` +- Observed message chain: `Source + Plain` +- Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid` +- Observed sender: customer `User` with nickname/avatar from Customer Service lookup +- Plugin API probe: `send_message`, `get_message`, `get_user_info`, `get_friend_list`, plugin/workspace storage, and manifest/list APIs succeeded +- Component sweep: outbound `Plain` and `Image` succeeded; `Face` and `File` returned explicit `NotSupportedError`; later quote/forward fallback sends were blocked by WeCom `95001 send msg count limit` + +Command shape used: + +```bash +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt --debug-only --ws-control-port 5400 --ws-debug-port 5401 --skip-deps-check + +cd LangBot +PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src uv run main.py --standalone-runtime + +cd data/plugins/LangBot__EBAEventProbe +DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws \ +EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/wecomcs_eba_plugin_probe.jsonl \ +EBA_PROBE_API=1 \ +EBA_PROBE_COMPONENT_SWEEP=1 \ +EBA_PROBE_PLATFORM_API=1 \ +uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run +``` + +Required real UI trigger: send a Customer Service message from the WeCom/WeChat customer-side UI to the configured `dev.rockchin.top` Customer Service account. + +## Current Acceptance + +Current status is **partial EBA acceptance**. + +Blocked or pending items: + +- Inbound UI media (`Image`, `Voice`, `File`) was not sent from the real WeChat customer UI during this run, so receive-side media remains unit-covered only. +- Pipeline auto-reply reached `kf/send_msg`, but the test account hit WeCom `95001 send msg count limit` after successful plugin outbound text/image sends. This is recorded as an account/platform rate-limit block, not a conversion or API-shape failure. +- The current `EBAEventProbe` run did not call the adapter-specific `call_platform_api` actions (`check_access_token`, `refresh_access_token`, `get_customer_info`); the platform API map remains unit-covered. +- Inbound voice/file depends on whether the real Customer Service callback plus `sync_msg` endpoint returns those fields in the shape the local SDK models. +- Group, member, edit, delete, moderation, and standalone file URL APIs are intentionally not declared because this Customer Service protocol path does not provide tested common equivalents. diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md index 2a5e06e65..904e77715 100644 --- a/docs/review/box-architecture.md +++ b/docs/review/box-architecture.md @@ -1,6 +1,6 @@ # Box 系统架构深度分析 -> 更新日期: 2026-06-02 +> 更新日期: 2026-07-12 > 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) @@ -13,7 +13,9 @@ ┌──────────────────────────────────────────────────────────────────┐ │ LangBot 主进程 │ │ │ -│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │ +│ AgentRunner ──> SDK call_tool / scoped MCP bridge │ +│ │ │ │ +│ └────────────────> ToolManager ──> NativeToolLoader │ │ │ │ │ │ │ │ │ exec / read / write / edit │ │ │ │ glob / grep │ @@ -32,7 +34,7 @@ │ ├─ Host mount 校验 (allowed_mount_roots) │ │ ├─ Workspace quota 检查 │ │ ├─ 输出截断 (head+tail) │ -│ ├─ Session ID 模板解析 (resolve_box_session_id) │ +│ ├─ Host scope 哈希 (resolve_box_session_id) │ │ ├─ 技能挂载组装 (build_skill_extra_mounts) │ │ ├─ 重连循环 (_reconnect_loop, 指数退避) │ │ └─ BoxRuntimeConnector │ @@ -85,7 +87,8 @@ **核心设计原则**: - Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller) - 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process -- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md)) +- AgentRunner 无权指定 session scope。SDK/Python `call_tool` 与 scoped MCP bridge 都发出同一个 `PluginToRuntimeAction.CALL_TOOL`,最终由 Host 的 ToolManager 执行,并使用当前 run 保存的同一个 execution Query +- Box 内托管的 stdio MCP server 使用独立的长期 `mcp-shared` session;它不是 AgentRunner 本次事件的 sandbox session(详见 [box-session-scope.md](./box-session-scope.md)) --- @@ -93,7 +96,7 @@ ### 2.1 BoxService (`pkg/box/service.py`, 722 行) -应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板: +应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Host scope 哈希: 主要公开方法(按定义顺序): @@ -104,7 +107,7 @@ BoxService ├─ _reconnect_loop(connector) 指数退避重连 ├─ available (property) 连接状态 │ - ├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id + ├─ resolve_box_session_id(query) 哈希 Host 私有 scope,生成固定长度 session_id ├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 │ ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 @@ -137,6 +140,8 @@ BoxService **输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。 +**Session 所有权**: `resolve_box_session_id(query)` 只接受 Host 已确定的私有 scope 或 Query launcher/session identity,并输出 `lb-box-` + 64 位小写 SHA-256 十六进制摘要(固定 71 个 ASCII 字符)。哈希输入是 canonical JSON,包含 instance、workspace、bot、platform adapter、target type/id 与 thread;原始用户、群组、conversation 或 event id 不会出现在 Box session id 中。相同 Host scope 稳定复用,不同 target/thread/workspace/bot/adapter/instance 相互隔离;缺少可用 identity 时 fail closed。Pipeline、Agent 或 AgentRunner 配置都不能覆盖该规则。 + **Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 ### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) @@ -414,7 +419,9 @@ ToolManager.initialize() 1. 验证 skill 已激活 2. 单次 exec 只能引用一个 skill 包 3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`) -4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** +4. 调用 `box_service.execute_tool()` → 走 Host 从当前事件生成的 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** + +AgentRunner 可以直接通过 SDK/Python `AgentRunAPIProxy.call_tool` 调用这些工具,也可以让外部 harness 通过 SDK-owned scoped MCP bridge 回调。两条入口都发送 `PluginToRuntimeAction.CALL_TOOL`,共享同一个 run authorization、Host session 中保存的 execution Query、ToolManager 与 `resolve_box_session_id(query)` 规则;Runner 不能提交自定义 Box session id。Pipeline run 保存原 Query;纯 EBA run 由 Host 构造 `pipeline_config=None`、`pipeline_uuid=None` 的最小 Query。 ### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行) @@ -422,7 +429,7 @@ ToolManager.initialize() ``` initialize() - 1. 复用/创建共享 session (session_id = _build_box_session_id()) + 1. 复用/创建共享 session (`session_id = mcp-shared`) - persistent=True,长期保持 2. workspace.execute_raw(install_cmd) 安装依赖 (可选) 3. 将每个 MCP server 文件 stage 到 /workspace/.mcp// @@ -435,6 +442,8 @@ initialize() 每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。 +这里的 `mcp-shared` 只承载 LangBot 管理的 stdio MCP server 进程。AgentRunner 的 scoped MCP bridge 是回调 Host 工具的协议入口,不会把事件运行的 exec/read/write 改到 `mcp-shared`。 + --- ## 5. 启动与生命周期 @@ -566,22 +575,14 @@ volumes: | http/sse MCP server | 正常 | 正常(不依赖 Box) | | Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback | | Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) | -| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 | | Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | | 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | > 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。 -### Pipeline 配置 (templates/metadata/pipeline/ai.yaml) - -`local-agent.config.box-session-id-template` 控制 session 作用域,预设: - -- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认) -- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户 -- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文 -- `{query_id}` — 每条消息(完全隔离) +### Session scope -详见 [box-session-scope.md](./box-session-scope.md)。 +Pipeline 与 AgentRunner 配置不再暴露 sandbox session 模板。Host 将当前平台会话/事件 scope 规范化后哈希成固定长度的 `lb-box-`;相同 scope 稳定复用,不同 scope 隔离,缺少 identity 时拒绝执行。SDK/Python 与 scoped MCP bridge 的工具调用遵守同一规则。详见 [box-session-scope.md](./box-session-scope.md)。 ### REST API diff --git a/docs/review/box-session-scope.md b/docs/review/box-session-scope.md index bb92265de..b75d2518a 100644 --- a/docs/review/box-session-scope.md +++ b/docs/review/box-session-scope.md @@ -1,402 +1,181 @@ -# Box Session Scope Design +# Box Session Scope -> Date: 2026-04-18 (last reviewed 2026-06-02) -> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md). -> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> Last reviewed: 2026-07-12 +> Status: implemented Host-owned, hashed execution scope; Runner/Pipeline session templates are removed. > Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md) ---- +## 1. Decision -## 0. Implementation Status (2026-05-19) +The LangBot Host owns the Box session used by an event run. A Pipeline, Agent, +or AgentRunner cannot choose a global, per-user, per-conversation, or per-query +sandbox mode. -This document was authored as a design proposal. The current `feat/sandbox` branch -has shipped the design largely as written: +`BoxService.resolve_box_session_id(query)` always returns this shape: -| Item | Status | Notes | -|------|--------|-------| -| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` | -| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) | -| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` | -| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` | -| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` | -| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch | -| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` | -| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` | -| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) | - -The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on -the same line. Pipeline-scoped (not user-scoped) MCP container is the realized -behavior: each pipeline's MCP servers share one `mcp-` session, and -user exec sessions use the template-derived id. - -The remaining open work is multi-tenant overlays (tenant_id in session_id, -quota counters keyed by tenant), tracked in the toB analysis doc rather than here. - ---- - -## 1. Problems - -### 1.1 Default exec: per-message containers - -Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an -auto-incrementing integer per incoming message. Every user message creates a new sandbox -container. Dependencies installed and in-container state are lost between messages. - -### 1.2 Three isolated container pools - -Default exec, skills, and MCP servers each manage their own containers with -independent session IDs: - -| Path | Session ID | Container | -|--------------|-----------------------------------------------|-------------| -| Default exec | `str(query_id)` (per message) | Ephemeral | -| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill | -| MCP stdio | `mcp-{server_uuid}` | Per server | - -This means a single logical user interaction can spawn 3+ containers that cannot -share state, see each other's files, or reuse installed dependencies. - -### 1.3 Single bind mount limitation - -`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount. -This prevents mounting both a default workspace and skill directories into the -same container. - ---- - -## 2. Concept Model - -``` -Platform Message - → Query (query_id: int, auto-increment, per message) - → Session (launcher_type + launcher_id, per chat window) - → Conversation (uuid, per dialogue context within a Session) -``` - -| Concept | Key | Example | Scope | -|---------------|-------------------------------------|----------------------------|------------------------------| -| Query | `query_id` | `42` | Single message | -| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) | -| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session | -| Sender | `sender_id` | `789` | Individual user | - -Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The -individual sender is tracked as `sender_id` but does not affect Session/Conversation routing. - ---- - -## 3. Target Scenarios - -| # | Scenario | Box Granularity | Desired `session_id` | -|----|--------------------------------|------------------------------------------|---------------------------------------------------------| -| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` | -| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` | -| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` | -| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` | -| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` | -| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` | -| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` | -| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` | - -No single fixed granularity covers all scenarios. A template-based approach is needed. - ---- - -## 4. Design Overview - -Two key changes: - -1. **Unified container**: exec, skills, and MCP all share the same container per - session scope. No more separate container pools. -2. **Configurable session scope**: `session_id` is generated from a template with - pipeline variables, configurable per pipeline. - -### 4.1 Unified Container with Multiple Mounts - -A single container per session scope is created on first use. It has: - -- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`) -- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at - `/workspace/.skills/{skill_name}/` -- **MCP servers**: run as managed processes inside the same container - -``` -Container (session_id = "group_123456") - /workspace/ ← default workspace (bind mount, rw) - /workspace/.skills/web-search/ ← skill package (bind mount, rw) - /workspace/.skills/data-analysis/ ← skill package (bind mount, rw) - [managed process: mcp-server-a] ← MCP server running inside - [managed process: mcp-server-b] ← MCP server running inside -``` - -This requires extending `BoxSpec` to support multiple mounts (see §5). - -### 4.2 Session ID Template - -A new field `box-session-id-template` in the `local-agent` pipeline runner config -controls the session scope: - -```yaml -# templates/metadata/pipeline/ai.yaml (under local-agent.config) -- name: box-session-id-template - label: - en_US: Sandbox Scope - zh_Hans: 沙箱作用域 - description: - en_US: >- - Determines how sandbox environments are shared. Use variables to - control isolation granularity. - zh_Hans: >- - 决定沙箱环境的共享方式。使用变量控制隔离粒度。 - type: select - required: false - default: "{launcher_type}_{launcher_id}" - options: - - value: "{launcher_type}_{launcher_id}" - label: - en_US: Per chat (Recommended) - zh_Hans: 每个会话(推荐) - - value: "{launcher_type}_{launcher_id}_{sender_id}" - label: - en_US: Per user in chat - zh_Hans: 会话中每个用户 - - value: "{launcher_type}_{launcher_id}_{conversation_id}" - label: - en_US: Per conversation context - zh_Hans: 每个对话上下文 - - value: "{query_id}" - label: - en_US: Per message (isolated) - zh_Hans: 每条消息(完全隔离) +```text +lb-box-<64 lowercase SHA-256 hex characters> ``` -Available template variables (populated by PreProcessor in `query.variables`): - -| Variable | Source | Example | -|---------------------|---------------------------------|----------------------| -| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` | -| `{launcher_id}` | `query.session.launcher_id` | `123456` | -| `{sender_id}` | `query.sender_id` | `789` | -| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` | -| `{query_id}` | `query.query_id` | `42` | +The result is exactly 71 ASCII characters. Raw platform, user, group, +conversation, thread, and event identifiers never appear in the Box session +id. This avoids unsafe path characters, unbounded identifier length, and +identity leakage through runtime/container metadata. -Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box. +This rule replaces all former concepts of: ---- +- Pipeline or Runner `box-session-id-template` fields; +- a global forced session template; +- API fields that let a caller supply sandbox scope; +- LocalAgent-specific Host injection of Box availability, scope, or Pipeline id. -## 5. SDK Changes: Multi-Mount BoxSpec +## 2. Canonical Host scope -### 5.1 Model Extension +Before hashing, the Host creates a canonical, sorted JSON scope with these +dimensions: -```python -# box/models.py +| Dimension | Purpose | +| --- | --- | +| `instance_id` | Isolate separate LangBot installations | +| `workspace_id` | Preserve workspace/tenant boundary when available | +| `bot_id` | Prevent two bots from sharing a sandbox accidentally | +| `platform_adapter` | Separate identical target ids from different adapters | +| `target_type` / `target_id` | Identify the platform session or event target | +| `thread_id` | Isolate threads within a target when available | -class BoxMountSpec(pydantic.BaseModel): - """A single bind mount specification.""" - host_path: str - mount_path: str - mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE +The canonical JSON is domain-separated and hashed by the Host. Runner input, +runner config, and tool parameters are not trusted sources for this scope. -class BoxSpec(pydantic.BaseModel): - # ... existing fields ... - host_path: str | None = None # Primary mount (backward compat) - host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE - mount_path: str = DEFAULT_BOX_MOUNT_PATH - extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts -``` +### 2.1 Target identity priority -`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains -the primary mount for backward compatibility. +The Host resolves `target_type` / `target_id` in this order: -### 5.2 Backend: Apply Extra Mounts +1. For a Pipeline-backed run, use the exact Query launcher tuple. +2. For a pure EBA run, use `delivery.reply_target.target_type/target_id` + (`launcher_type/launcher_id` aliases are accepted). +3. If there is no delivery target, use `conversation_id`. +4. For a non-message event without a conversation, use `event_id`, producing + an event-scoped sandbox. -```python -# box/backend.py — CLISandboxBackend.start_session() +The adapter class or declared adapter capability supplies platform adapter +identity. The Host includes the active LangBot instance, workspace, bot, and +thread dimensions when they exist. -# Primary mount (unchanged) -if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE: - args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}']) +### 2.2 Stability and isolation -# Extra mounts (NEW) -for mount in spec.extra_mounts: - if mount.mode != BoxHostMountMode.NONE: - args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}']) -``` +The same normalized scope always produces the same hash, so repeated runs in +the same platform conversation reuse the same Box workspace. A rotating +transcript/conversation id does not change the scope when an explicit platform +reply target remains the same. -Same pattern for nsjail backend. +A different target, thread, workspace, bot, platform adapter, or LangBot +instance changes the hash. If delivery target is unavailable and +`conversation_id` is the fallback, different conversations also produce +different hashes. Event-scoped fallback isolates unrelated non-message events. ---- +### 2.3 Fail closed -## 6. LangBot Changes - -### 6.1 Session ID Resolution - -In `BoxService.execute_tool()`: - -```python -# Before: -spec_payload.setdefault('session_id', str(query.query_id)) - -# After: -template = (query.pipeline_config or {}).get('ai', {}) \ - .get('local-agent', {}).get('box-session-id-template', - '{launcher_type}_{launcher_id}') -variables = query.variables or {} -session_id = template.format_map(collections.defaultdict( - lambda: 'unknown', variables -)) -spec_payload.setdefault('session_id', session_id) -``` - -### 6.2 Skill Exec: Use Same Container - -Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per -skill with `host_path=package_root`. Instead: - -1. Use the **same session_id** as default exec (from the template). -2. Pass the skill's `package_root` as an **extra mount** at - `/workspace/.skills/{skill_name}/` instead of replacing `/workspace`. -3. The container already has the default workspace at `/workspace`. - -```python -# native.py — _invoke_exec, skill branch (REVISED) - -# Same session_id as default exec -session_id = resolve_box_session_id(query) - -spec_payload = { - 'cmd': rewritten_command, - 'workdir': rewritten_workdir, - 'session_id': session_id, - 'extra_mounts': [{ - 'host_path': package_root, - 'mount_path': f'/workspace/.skills/{selected_skill_name}', - 'mode': 'rw', - }], -} -result = await self.ap.box_service.execute_spec_payload(spec_payload, query) -``` +If the private Host scope marker is present but empty or malformed, Box rejects +execution with `BoxValidationError`. A direct Query without either a valid +Host scope or launcher/session identity is also rejected. There is no +`unknown`, raw query id, global, or caller-selected fallback. -The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the -command level — it maps directly to the bind mount path inside the container. +## 3. Host execution Query -### 6.3 MCP: Use Same Container +AgentRunner callbacks need a Host-owned Query view because model/tool loaders +already consume that type. The Query is internal and is never exposed as a +Runner-controlled object. -MCP servers should run inside the same container as exec and skills. Changes: +- A Pipeline run stores the exact current Query in `AgentRunSession`. +- A pure EBA run builds a minimal Query with a valid Session and + `pipeline_config=None`, `pipeline_uuid=None`. +- The Host attaches canonical `_host_box_scope` and the authorized skill names + in `_pipeline_bound_skills`. +- `PluginToRuntimeAction.CALL_TOOL` restores this Query from the active + `run_id` before dispatching to `ToolManager`. -1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of - `mcp-{server_uuid}`. -2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`). -3. MCP server's dependencies are mounted or installed into that subdirectory. -4. The MCP server runs as a managed process inside the shared container. +This gives Pipeline and pure EBA execution the same Host tool path without +inventing a fake Pipeline for an independent Agent. -Since MCP servers start at LangBot boot (not per-query), the session must be -created eagerly. The container will be kept alive by the managed process -exemption in TTL reaping (`runtime.py:259`). +## 4. AgentRunner callback paths -**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id -should be a **fixed identifier per pipeline** rather than the user-facing template. -This means one shared MCP container per pipeline, with user exec sessions separate. +AgentRunner implementations may use either callback transport: -Alternatively, in a future iteration, MCP managed processes could be launched -lazily into the user's container on first MCP tool call. This is more complex -but maximizes sharing. For V1, keeping MCP containers at pipeline scope is -simpler and more predictable. +1. SDK/Python runners call `AgentRunAPIProxy.call_tool`. +2. External harnesses call the SDK-owned scoped MCP bridge. ---- +Both transports emit the same `PluginToRuntimeAction.CALL_TOOL`. The Host then +validates the same run authorization, restores the same execution Query, and +dispatches to the same ToolManager and BoxService. -## 7. Mount Layout Summary - -### Default exec (no skills activated) - -``` -Container (session_id from template) - /workspace/ ← default_host_workspace (rw) -``` - -### Exec with activated skills - -``` -Container (same session_id) - /workspace/ ← default_host_workspace (rw) - /workspace/.skills/web-search/ ← skill package_root (rw) - /workspace/.skills/data-analysis/ ← skill package_root (rw) +```text +AgentRunner + +-- AgentRunAPIProxy.call_tool --------+ + | | + +-- SDK-owned scoped MCP bridge -------+--> PluginToRuntimeAction.CALL_TOOL + --> run authorization + --> execution Query + --> ToolManager + --> BoxService + --> lb-box- ``` -Extra mounts are **additive** — they are added when the container is first -created (or on the first exec that references a skill). Since Docker bind -mounts are specified at container creation time, skills must be known at -creation time. - -**Resolution**: When creating a container, inject `extra_mounts` for **all -pipeline-bound skills** (from `extensions_preferences`), not just the -currently activated one. This way any skill can be activated later without -recreating the container. - -### MCP servers (V1: pipeline-scoped) - -``` -Container (session_id = "mcp-pipeline-{pipeline_uuid}") - /workspace/ ← MCP shared workspace - /workspace/.mcp/server-a/ ← MCP server A files - /workspace/.mcp/server-b/ ← MCP server B files - [managed process: server-a] - [managed process: server-b] -``` - ---- - -## 8. Data Migration +An AgentRunner is not required to use MCP. Local Python runners can use the SDK +directly; code-agent harnesses can use the bridge. The transports do not define +different authorization or sandbox semantics. -Existing pipelines do not have `box-session-id-template`. The backend uses -`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`. -This changes behavior from per-message to per-launcher for existing pipelines. +## 5. Skills and mounts -Recommendation: **accept the behavior change** — per-launcher is the more -intuitive default, and the old per-message behavior was rarely desired. +Native exec and skill-backed exec for one Host scope use the same hashed +session. `BoxService.build_skill_extra_mounts(query)` adds visible, authorized +skill packages under `/workspace/.skills/` when the session is created. ---- +Skill activation controls which skill-backed tools and paths are available. It +does not create a different session and does not grant the Runner authority to +change the session id. -## 9. Cloud Quota Implications +## 6. `mcp-shared` is a different session -| Scope | Typical concurrent containers | -|-----------------------------------------------|-------------------------------| -| `{query_id}` (per message) | Many, short-lived | -| `{launcher_type}_{launcher_id}` (per chat) | = active chat count | -| `{sender_id}` (per user) | = active user count | -| `{conversation_id}` (per conversation) | Between per-chat and per-msg | +LangBot can host configured stdio MCP servers as managed processes inside Box. +Those long-lived infrastructure processes share the dedicated `mcp-shared` +session and are isolated from one another by `process_id`. -With the unified container model, each scope value maps to exactly **one** -container (instead of potentially 3+ per-message). This significantly reduces -resource usage. +This is separate from the scoped MCP bridge above: -Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK. +| Path | Purpose | Session rule | +| --- | --- | --- | +| AgentRunner scoped MCP bridge | Call authorized Host tools for one active run | Host-owned `lb-box-` from the run execution Query | +| MCP-in-Box stdio server | Keep configured MCP server processes running | Dedicated persistent `mcp-shared` session | ---- +Calling a sandbox tool through the AgentRunner bridge never redirects the run +workspace into `mcp-shared`. Conversely, an MCP server's managed-process +lifecycle does not inherit the current event scope. -## 10. Implementation Phases +## 7. Configuration and compatibility -### Phase 1: Session scope + skill unification (this PR) +There is no Box session scope field in Pipeline metadata, AgentRunner config, +or the public Pipeline/Runner API. Operators configure the Box subsystem itself +(`box.enabled`, backend/runtime settings, profiles, mount allowlists, quotas, +and workspace roots), not per-Runner session templates. -1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`. -2. **SDK**: Update Docker/nsjail backends to apply extra mounts. -3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata - and default pipeline config JSON. -4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation. -5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same - session_id + extra mounts instead of separate `BoxWorkspaceSession`. -6. **LangBot**: On container creation, inject extra mounts for all - pipeline-bound skills. -7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields. -8. **Tests**: Unit tests for template interpolation and multi-mount specs. +Old configuration containing `box-session-id-template` is unsupported in the +4.x contract. LangBot 4.x does not migrate LangBot 3.x configuration or +databases, so the removed field is not read as a compatibility fallback. -### Phase 2: MCP unification (future) +## 8. Regression coverage -1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container. -2. MCP servers become managed processes in the shared container. -3. Support multiple concurrent managed processes per container. +Release tests should prove: -MCP unification is deferred because it requires changes to the managed process -model (currently 1 managed process per session) and has startup ordering -concerns (MCP servers start at boot, before any user query determines -a session_id). +- every event-run session id matches `lb-box-[0-9a-f]{64}` and contains no raw + identity; +- the same canonical Host scope is stable while different targets, + conversations, threads, bots, adapters, workspaces, or instances are + isolated; +- Pipeline and pure EBA runs representing the same platform session produce + the same canonical scope; +- missing Host/Query identity fails closed; +- SDK/Python `call_tool` and the scoped MCP bridge both enter + `PluginToRuntimeAction.CALL_TOOL` and restore the run execution Query; +- Runner payload/config cannot override the session id; +- stdio MCP processes remain in `mcp-shared` and are isolated by process id; +- authorized skills are mounted into the hashed run session without creating + per-skill sessions. diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md index 995e6970b..cbe2b9786 100644 --- a/docs/review/box-test-coverage.md +++ b/docs/review/box-test-coverage.md @@ -1,6 +1,6 @@ # Box 系统测试覆盖分析 -> 更新日期: 2026-06-02 +> 更新日期: 2026-07-12 > 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) @@ -15,13 +15,14 @@ | `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 | | `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) | | `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 | +| `tests/unit_tests/agent/test_execution_context.py` | 144 | 是 | Pipeline/纯 EBA execution Query、canonical Host scope、adapter/instance 隔离 | +| `tests/unit_tests/plugin/test_handler_actions.py` | 1071 | 是 | AgentRun CALL_TOOL 恢复 execution Query、纯 EBA native exec | | `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info | -| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) | | `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 | | `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD | | `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 | | `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 | -| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 | +| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 的模型、历史与 bound skill 解析 | | `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 | | `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 | | `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 | @@ -35,7 +36,7 @@ | `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 | | `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD | -**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。 +**说明**: 本表按当前主链列出 Box 相关测试;其中 2 个真实容器集成测试默认不在 CI 中运行。 > 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。 @@ -51,7 +52,7 @@ | BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | | BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | | BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | -| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 | +| BoxService Host-owned session | 优秀 | 覆盖 `lb-box-` 固定格式、原始 identity 不泄露、同 scope 稳定、不同 conversation/scope 隔离、缺 identity fail closed | | RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | | BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | | BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | @@ -61,7 +62,7 @@ | Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect | | MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process | | Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 | -| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 | +| AgentRunner 工具入口 | 良好 | SDK proxy 与 MCP bridge 都映射到 `PluginToRuntimeAction.CALL_TOOL`;Host action 测试覆盖 run-scoped execution Query 与纯 EBA native exec | | Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service | --- diff --git a/pyproject.toml b/pyproject.toml index 3204bd35d..4f9c75db7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.5" +version = "4.11.0" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -70,7 +70,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin==0.4.13", + "langbot-plugin==0.5.0a2", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", @@ -120,7 +120,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] } +package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } [dependency-groups] dev = [ diff --git a/skills/scripts/e2e/agent-runner-health-visibility.mjs b/skills/scripts/e2e/agent-runner-health-visibility.mjs new file mode 100644 index 000000000..4801db302 --- /dev/null +++ b/skills/scripts/e2e/agent-runner-health-visibility.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +import { + apiJson, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "agent-runner-health-visibility"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); +const readyScreenshot = paths.screenshot.replace(/\.png$/, "-ready.png"); +const unavailableScreenshot = paths.screenshot.replace( + /\.png$/, + "-unavailable.png", +); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +const missingRunnerId = "plugin:qa/missing-runner/default"; + +let browser; +let token = ""; +let agentId = ""; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + visible_signals: [], + api: {}, + diagnostics: null, + cleanup: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + ready_screenshot: readyScreenshot, + unavailable_screenshot: unavailableScreenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +function schemaDefaults(items = []) { + return Object.fromEntries( + items + .filter((item) => item.name && Object.hasOwn(item, "default")) + .map((item) => [item.name, item.default]), + ); +} + +async function openRunnerSettings(page, agentUrl) { + await page.goto(agentUrl, { waitUntil: "domcontentloaded" }); + await page + .getByRole("button", { name: /Runner|运行器|ランナー/ }) + .first() + .waitFor({ timeout: 15_000 }); + await page + .getByRole("button", { name: /Runner|运行器|ランナー/ }) + .first() + .click(); +} + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser has no reusable local token."); + } + + const pluginStatus = await apiJson( + backendUrl, + "/api/v1/system/status/plugin-system", + { token }, + ); + const pluginData = pluginStatus.json.data || {}; + result.api.plugin_status = { + http_status: pluginStatus.status, + code: pluginStatus.json.code ?? null, + is_enable: pluginData.is_enable ?? null, + is_connected: pluginData.is_connected ?? null, + }; + if (pluginStatus.status >= 400 || pluginStatus.json.code !== 0) { + result.status = "env_issue"; + throw new Error(pluginStatus.json.msg || "Plugin status request failed."); + } + if (!pluginData.is_enable || !pluginData.is_connected) { + result.status = "env_issue"; + throw new Error("The plugin runtime is not enabled and connected."); + } + + const metadata = await apiJson(backendUrl, "/api/v1/agents/_/metadata", { + token, + }); + const runnerTab = metadata.json.data?.runner_config; + const runnerStage = runnerTab?.stages?.find( + (stage) => stage.name === "runner", + ); + const runnerOptions = + runnerStage?.config?.find((item) => item.name === "id")?.options || []; + const runner = runnerOptions[0]; + result.api.agent_metadata = { + http_status: metadata.status, + code: metadata.json.code ?? null, + runner_count: runnerOptions.length, + selected_runner: runner?.name || null, + }; + if (metadata.status >= 400 || metadata.json.code !== 0) { + throw new Error(metadata.json.msg || "Agent metadata request failed."); + } + if (!runner?.name) { + result.status = "blocked"; + throw new Error("No registered AgentRunner is available for the UI check."); + } + + const runnerConfigStage = runnerTab.stages.find( + (stage) => stage.name === runner.name, + ); + const create = await apiJson(backendUrl, "/api/v1/agents", { + method: "POST", + token, + body: { + kind: "agent", + name: `Runner Health ${paths.runId.slice(-40)}`, + description: "Temporary AgentRunner health visibility fixture", + emoji: "H", + component_ref: runner.name, + config: { + runner: { id: runner.name, "expire-time": 0 }, + runner_config: { + [runner.name]: schemaDefaults(runnerConfigStage?.config), + }, + }, + enabled: true, + supported_event_patterns: ["message.*"], + }, + }); + agentId = create.json.data?.uuid || ""; + result.api.create_agent = { + http_status: create.status, + code: create.json.code ?? null, + }; + if (create.status >= 400 || create.json.code !== 0 || !agentId) { + throw new Error(create.json.msg || "Failed to create the temporary Agent."); + } + + const agentUrl = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(agentId)}`; + result.url = agentUrl; + await openRunnerSettings(page, agentUrl); + await page + .getByText(/Runner ready|运行器已就绪|Runner の準備完了/, { exact: true }) + .waitFor({ timeout: 15_000 }); + result.visible_signals.push("registered-runner-ready"); + await safeScreenshot(page, readyScreenshot); + + const staleUpdate = await apiJson( + backendUrl, + `/api/v1/agents/${encodeURIComponent(agentId)}`, + { + method: "PUT", + token, + body: { + component_ref: missingRunnerId, + config: { + runner: { id: missingRunnerId, "expire-time": 0 }, + runner_config: { [missingRunnerId]: {} }, + }, + }, + }, + ); + result.api.set_stale_runner = { + http_status: staleUpdate.status, + code: staleUpdate.json.code ?? null, + }; + if (staleUpdate.status >= 400 || staleUpdate.json.code !== 0) { + throw new Error( + staleUpdate.json.msg || "Failed to set the stale runner fixture.", + ); + } + + await openRunnerSettings(page, agentUrl); + await page + .getByText( + /Selected runner is unavailable|所选运行器不可用|選択した Runner は利用できません/, + { exact: true }, + ) + .waitFor({ timeout: 15_000 }); + await page.getByRole("link", { name: /Extensions|扩展|拡張機能/ }).waitFor(); + result.visible_signals.push("stale-runner-unavailable", "recovery-action"); + await safeScreenshot(page, unavailableScreenshot); + await safeScreenshot(page, paths.screenshot); + + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Agent Runner settings visibly distinguished a registered runner from a stale binding."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + const cleanup = {}; + if (agentId && token && backendUrl) { + const deletedAgent = await apiJson( + backendUrl, + `/api/v1/agents/${encodeURIComponent(agentId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + cleanup.agent_deleted = + deletedAgent.status < 400 && deletedAgent.json.code === 0; + cleanup.agent_http_status = deletedAgent.status; + } + result.cleanup = cleanup; + if (agentId && !cleanup.agent_deleted && result.status === "pass") { + result.status = "fail"; + result.reason = "The temporary runner health Agent was not deleted."; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/agent-runner-release-preflight.mjs b/skills/scripts/e2e/agent-runner-release-preflight.mjs index 6ac69f2f1..e383ca197 100755 --- a/skills/scripts/e2e/agent-runner-release-preflight.mjs +++ b/skills/scripts/e2e/agent-runner-release-preflight.mjs @@ -70,7 +70,7 @@ const startedAt = new Date(); const targets = [ { id: "local-agent", - expected_runner_id: "plugin:langbot/local-agent/default", + expected_runner_id: "plugin:langbot-team/LocalAgent/default", pipeline_url: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_URL"), pipeline_name: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_NAME"), require_func_call_model: true, @@ -79,7 +79,7 @@ const targets = [ }, { id: "acp-agent-runner", - expected_runner_id: "plugin:langbot/acp-agent-runner/default", + expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default", pipeline_url: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_AGENT_RUNNER_PIPELINE_URL"), pipeline_name: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", "LANGBOT_AGENT_RUNNER_PIPELINE_NAME"), require_func_call_model: false, @@ -219,7 +219,7 @@ async function run() { return metadata.author && metadata.name ? `${metadata.author}/${metadata.name}` : ""; }) .filter(Boolean); - const requiredPlugins = ["langbot/local-agent", "langbot/acp-agent-runner", "qa/plugin-smoke"]; + const requiredPlugins = ["langbot-team/LocalAgent", "langbot-team/ACPAgentRunner", "qa/plugin-smoke"]; const pluginPresence = Object.fromEntries(requiredPlugins.map((id) => [id, installedPluginIds.includes(id)])); for (const [id, present] of Object.entries(pluginPresence)) { addCheck(`plugin:${id}`, present ? "pass" : "blocked", { plugin_id: id, reason: present ? "" : "Required plugin is not listed by /api/v1/plugins." }); @@ -309,7 +309,7 @@ async function run() { const config = pipeline.config || {}; const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; - const runnerId = runner.id || runner.runner || ""; + const runnerId = runner.id || ""; const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" ? aiConfig.runner_config : {}; const runnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" ? runnerConfigs[runnerId] : {}; const pipelineSummary = { diff --git a/skills/scripts/e2e/bot-event-routing-product-flow.mjs b/skills/scripts/e2e/bot-event-routing-product-flow.mjs new file mode 100644 index 000000000..566b89f03 --- /dev/null +++ b/skills/scripts/e2e/bot-event-routing-product-flow.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node + +import { + apiJson, + bodyText, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "bot-event-routing-product-flow"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); +const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); +const scenarioMenuScreenshot = paths.screenshot.replace( + /\.png$/, + "-scenario-menu.png", +); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +const fixtureName = `EBA Product Flow ${paths.runId}`; + +let browser; +let token = ""; +let botId = ""; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + bot_id: "", + visible_signals: [], + api: {}, + diagnostics: null, + cleanup: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + mobile_screenshot: mobileScreenshot, + scenario_menu_screenshot: scenarioMenuScreenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser has no reusable local token."); + } + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/bots?id=new`, { + waitUntil: "domcontentloaded", + }); + await page + .getByText(/Create Bot|创建机器人|ボットを作成/) + .first() + .waitFor({ timeout: 15_000 }); + await page.getByRole("combobox").first().click(); + await page + .getByRole("option", { name: /HTTP Bot|HTTP 通用接入|HTTP ボット/ }) + .click(); + await page + .getByText(/Event Routing|事件路由|イベントルーティング/) + .first() + .waitFor(); + const addBehavior = page.getByRole("button", { + name: /Add behavior|添加行为|動作を追加/, + }); + await addBehavior.waitFor(); + await addBehavior.click(); + const messageBehavior = page.getByRole("menuitem", { + name: /Reply to messages|回复收到的消息|受信メッセージに返信/, + }); + await messageBehavior.waitFor(); + await page.waitForTimeout(250); + await safeScreenshot(page, scenarioMenuScreenshot); + await messageBehavior.click(); + await page + .getByText(/Message received|收到消息|メッセージを受信/) + .first() + .waitFor(); + result.visible_signals.push("create-mode-routing", "scenario-route-added"); + + const create = await apiJson(backendUrl, "/api/v1/platform/bots", { + method: "POST", + token, + body: { + name: fixtureName, + description: "Temporary EBA product-flow fixture", + adapter: "http_bot", + adapter_config: { + inbound_secret: "eba-product-flow-local-only", + callback_url: "http://127.0.0.1:9/langbot-e2e-unused", + outbound_secret: "", + default_session_type: "person", + signature_required: false, + callback_timeout: 1, + callback_max_retries: 0, + }, + enable: true, + event_bindings: [ + { + id: "qa-message-discard", + event_pattern: "message.received", + target_type: "discard", + target_uuid: "", + filters: [], + priority: 0, + enabled: true, + description: "Discard the deterministic QA event", + order: 0, + }, + { + id: "qa-message-discard-shadowed", + event_pattern: "message.received", + target_type: "discard", + target_uuid: "", + filters: [], + priority: 0, + enabled: true, + description: "Verify visible route conflict guidance", + order: 1, + }, + ], + }, + }); + botId = create.json.data?.uuid || ""; + result.api.create_bot = { + http_status: create.status, + code: create.json.code ?? null, + }; + result.bot_id = botId; + if (create.status >= 400 || create.json.code !== 0 || !botId) { + throw new Error(create.json.msg || "Failed to create the temporary Bot."); + } + + const botUrl = `${frontendUrl.replace(/\/$/, "")}/home/bots?id=${encodeURIComponent(botId)}`; + await page.goto(botUrl, { waitUntil: "domcontentloaded" }); + await page + .waitForLoadState("networkidle", { timeout: 10_000 }) + .catch(() => {}); + result.url = page.url(); + + await page + .getByText(/Event Routing|事件路由|イベントルーティング/) + .first() + .waitFor({ timeout: 15_000 }); + await page + .getByText( + /Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/, + ) + .waitFor(); + await page + .getByText(/Message received|收到消息|メッセージを受信/) + .first() + .waitFor(); + await page + .getByText( + /Some routes overlap|部分路由存在覆盖冲突|一部のルートが重複しています/, + ) + .waitFor(); + await page + .getByText( + /Events that match no route are ignored|未命中任何路由的事件会被忽略|どのルートにも一致しないイベントは無視されます/, + ) + .waitFor(); + result.visible_signals.push( + "event-routing", + "adapter-capabilities", + "friendly-event-name", + "route-conflict-guidance", + "fallback-guidance", + ); + + await page + .getByRole("button", { name: /Test route|测试路由|ルートをテスト/ }) + .click(); + await page.getByRole("dialog").waitFor(); + await page + .getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ }) + .click(); + await page + .getByText(/Route matched|已命中路由|ルートに一致しました/) + .waitFor({ timeout: 15_000 }); + await page + .getByText(/Discard|丢弃|破棄/) + .first() + .waitFor(); + result.visible_signals.push("dry-run-matched", "discard-target"); + + await page + .getByRole("button", { + name: /Run saved route|运行已保存路由|保存済みルートを実行/, + }) + .click(); + await page + .getByText( + /saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/, + ) + .waitFor({ timeout: 20_000 }); + result.visible_signals.push("test-event-dispatched"); + + await page + .getByRole("button", { name: /Close|关闭|閉じる/ }) + .first() + .click(); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page + .getByText(/Discarded|已丢弃|破棄済み/) + .first() + .waitFor({ timeout: 10_000 }); + result.visible_signals.push("route-status-discarded"); + + const text = await bodyText(page); + if (/\bEBA event\b/.test(text)) { + throw new Error( + "The primary route surface exposes an internal EBA runtime message.", + ); + } + + const status = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}/event-routes/status`, + { token }, + ); + const latest = status.json.data?.routes?.find( + (route) => route.binding_id === "qa-message-discard", + ); + result.api.route_status = { + http_status: status.status, + code: status.json.code ?? null, + last_status: latest?.last_status || null, + }; + if ( + status.status >= 400 || + status.json.code !== 0 || + latest?.last_status !== "discarded" + ) { + throw new Error( + "Route status API did not confirm the discarded synthetic event.", + ); + } + + await safeScreenshot(page, paths.screenshot); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + const horizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + if (horizontalOverflow > 1) { + throw new Error( + `The mobile route editor overflows horizontally by ${horizontalOverflow}px.`, + ); + } + await page + .getByText( + /Some routes overlap|部分路由存在覆盖冲突|一部のルートが重複しています/, + ) + .waitFor(); + await safeScreenshot(page, mobileScreenshot); + result.visible_signals.push("mobile-layout"); + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + if (botId && token && backendUrl) { + const cleanup = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + result.cleanup = { + http_status: cleanup.status, + code: cleanup.json.code ?? null, + deleted: cleanup.status < 400 && cleanup.json.code === 0, + }; + if (!result.cleanup.deleted && result.status === "pass") { + result.status = "fail"; + result.reason = + cleanup.json.msg || "The temporary Bot could not be deleted."; + } + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs index 1278fcd1b..86b157d2c 100755 --- a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs +++ b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs @@ -12,7 +12,7 @@ import { writeResult, } from "./lib/langbot-e2e.mjs"; -const RUNNER_ID = "plugin:langbot/acp-agent-runner/default"; +const RUNNER_ID = "plugin:langbot-team/ACPAgentRunner/default"; const DEFAULT_PIPELINE_NAME = "Agent QA ACP Claude Debug Chat"; const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; const caseId = "ensure-acp-agent-runner-pipeline"; @@ -102,7 +102,7 @@ try { }); Object.assign(result, prepared); if (result.pipeline_id) { - result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`; } if (writeEnv && result.pipeline_id) { diff --git a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs index 73f2465fd..bf8c1835b 100755 --- a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs +++ b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs @@ -14,7 +14,7 @@ import { writeResult, } from "./lib/langbot-e2e.mjs"; -const RUNNER_ID = "local-agent"; +const RUNNER_ID = "plugin:langbot-team/LocalAgent/default"; const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; const DEFAULT_PIPELINE_NAME = "LangBot QA Fake Provider Debug Chat"; const DEFAULT_PROVIDER_NAME = "LangBot QA Fake OpenAI Provider"; @@ -511,15 +511,17 @@ async function ensurePipeline({ backendUrl, token, name, modelUuid }) { const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; - const existingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" - ? ai["local-agent"] + const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" + ? ai.runner_config + : {}; + const existingLocalAgentConfig = runnerConfigs[RUNNER_ID] && typeof runnerConfigs[RUNNER_ID] === "object" + ? runnerConfigs[RUNNER_ID] : {}; const localAgentConfig = { timeout: 60, prompt: [{ role: "system", content: "You are a deterministic QA assistant. Reply exactly as instructed." }], "remove-think": false, "knowledge-bases": [], - "box-session-id-template": "{launcher_type}_{launcher_id}", "retrieval-top-k": 5, "rerank-model": "", "rerank-top-k": 5, @@ -546,10 +548,12 @@ async function ensurePipeline({ backendUrl, token, name, modelUuid }) { runner: { ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), id: RUNNER_ID, - runner: RUNNER_ID, "expire-time": 0, }, - "local-agent": localAgentConfig, + runner_config: { + ...runnerConfigs, + [RUNNER_ID]: localAgentConfig, + }, }, }; diff --git a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs index b2cf9e7b2..f7c757a6d 100755 --- a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs +++ b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs @@ -21,7 +21,7 @@ await ensureEvidence(paths); const backendUrl = env.LANGBOT_BACKEND_URL || ""; const user = env.LANGBOT_E2E_LOGIN_USER || ""; const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; -const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "azalea-cobalt-7421"; +const expectedText = env.LANGBOT_E2E_RAG_EXPECTED_TEXT || "azalea-cobalt-7421"; const query = env.LANGBOT_E2E_RETRIEVE_QUERY || "What is the local agent runner retrieval sentinel?"; const writeEnv = process.argv.includes("--write-env"); const checkOnly = process.argv.includes("--check-only"); diff --git a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs index da4336211..2562c8952 100755 --- a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs +++ b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs @@ -18,12 +18,14 @@ import { writeResult, } from "./lib/langbot-e2e.mjs"; -const RUNNER_ID = "local-agent"; +const RUNNER_ID = "plugin:langbot-team/LocalAgent/default"; const SPACE_PROVIDER_UUID = "00000000-0000-0000-0000-000000000000"; const DEFAULT_PIPELINE_NAME = "Agent QA Local Agent Debug Chat"; const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; const DEFAULT_MODEL_TEST_LIMIT = 8; const DEFAULT_MODEL_FALLBACK_COUNT = 3; +const DEFAULT_FAKE_MODEL_UUID = "langbot-e2e-fake-local-agent-model"; +const DEFAULT_FAKE_PROVIDER_NAME = "LangBot E2E Fake OpenAI Provider"; const caseId = "ensure-local-agent-pipeline"; await loadEnvFiles(); @@ -31,7 +33,10 @@ const paths = evidencePaths(caseId); await ensureEvidence(paths); const writeEnv = process.argv.includes("--write-env"); -const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const pipelineName = + env.LANGBOT_E2E_CREATE_PIPELINE_NAME || + env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || + DEFAULT_PIPELINE_NAME; const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; const backendUrl = env.LANGBOT_BACKEND_URL || ""; const envLocalPath = resolve("skills/.env.local"); @@ -51,6 +56,7 @@ const result = { selected_model_id: "", selected_model_name: "", fallback_model_ids: [], + fake_provider: null, model_count: 0, space_model_count: 0, scanned_space_model_count: 0, @@ -83,7 +89,9 @@ try { const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; if (!user) { result.status = "env_issue"; - throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + throw new Error( + "LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API.", + ); } const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); @@ -97,7 +105,9 @@ try { result.wizard = wizard; if (wizard.status !== "pass") { result.status = "fail"; - throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); + throw new Error( + wizard.reason || "Failed to mark the local QA wizard as skipped.", + ); } const prepared = await ensureLocalAgentPipeline({ @@ -108,7 +118,7 @@ try { }); Object.assign(result, prepared); if (result.pipeline_id) { - result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`; } if (writeEnv && result.pipeline_id) { @@ -118,10 +128,12 @@ try { LANGBOT_PIPELINE_NAME: result.pipeline_name || pipelineName, LANGBOT_LOCAL_AGENT_PIPELINE_URL: result.pipeline_url, LANGBOT_LOCAL_AGENT_PIPELINE_NAME: result.pipeline_name || pipelineName, - ...(result.selected_model_id ? { - LANGBOT_LOCAL_AGENT_MODEL_UUID: result.selected_model_id, - LANGBOT_E2E_MODEL_UUID: result.selected_model_id, - } : {}), + ...(result.selected_model_id + ? { + LANGBOT_LOCAL_AGENT_MODEL_UUID: result.selected_model_id, + LANGBOT_E2E_MODEL_UUID: result.selected_model_id, + } + : {}), }); result.wrote_env = true; } @@ -132,12 +144,21 @@ try { const browserCheck = await verifyBrowserToken(page, backendUrl); result.browser_token_check = browserCheck; if (!browserCheck.authenticated) { - throw new Error(browserCheck.reason || "Browser token check failed after setup."); + throw new Error( + browserCheck.reason || "Browser token check failed after setup.", + ); } - await page.goto(result.pipeline_url || frontendUrl, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + await page.goto(result.pipeline_url || frontendUrl, { + waitUntil: "domcontentloaded", + }); + await page + .waitForLoadState("networkidle", { timeout: 10_000 }) + .catch(() => {}); const text = await bodyText(page); - result.page_signal = ["Pipelines", "流水线", pipelineName].find((signal) => text.includes(signal)) || ""; + result.page_signal = + ["Pipelines", "流水线", pipelineName].find((signal) => + text.includes(signal), + ) || ""; } catch (error) { result.status = result.status === "env_issue" ? "env_issue" : "fail"; result.reason = result.reason || error.message; @@ -148,24 +169,51 @@ try { console.log(JSON.stringify(result, null, 2)); } -process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +process.exit( + result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1, +); async function skipWizard({ backendUrl, token }) { - const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { - method: "POST", - token, - body: { status: "skipped" }, - }); + const response = await apiJson( + backendUrl, + "/api/v1/system/wizard/completed", + { + method: "POST", + token, + body: { status: "skipped" }, + }, + ); const ok = response.status < 400 && response.json.code === 0; return { status: ok ? "pass" : "fail", http_status: response.status, code: response.json.code ?? null, - reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", + reason: ok + ? "Wizard marked skipped for local QA." + : response.json.msg || "Wizard status update failed.", }; } -async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runnerId }) { +async function ensureLocalAgentPipeline({ + backendUrl, + token, + pipelineName, + runnerId, +}) { + const fakeProviderBaseUrl = + env.LANGBOT_E2E_FAKE_PROVIDER_BASE_URL || + env.LANGBOT_FAKE_PROVIDER_BASE_URL || + ""; + let fakeModel = null; + if (fakeProviderBaseUrl) { + fakeModel = await ensureFakeProviderModel({ + backendUrl, + token, + baseUrl: fakeProviderBaseUrl, + }); + if (fakeModel.status !== "pass") return fakeModel; + } + const [pipelineList, modelList] = await Promise.all([ apiJson(backendUrl, "/api/v1/pipelines", { token }), apiJson(backendUrl, "/api/v1/provider/models/llm", { token }), @@ -199,7 +247,9 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne .map((item) => item.trim()) .filter(Boolean), ); - const spaceModels = models.filter((model) => isSpaceModel(model) && !skippedModelIds.has(model.uuid)); + const spaceModels = models.filter( + (model) => isSpaceModel(model) && !skippedModelIds.has(model.uuid), + ); const pipelines = pipelineList.json.data?.pipelines || []; let pipeline = pipelines.find((item) => item.name === pipelineName) || null; let created = false; @@ -210,7 +260,8 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne token, body: { name: pipelineName, - description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + description: + "Local QA pipeline for AgentRunner Debug Chat smoke tests.", emoji: "QA", }, }); @@ -224,7 +275,11 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne }; } const pipelineId = createdResponse.json.data?.uuid || ""; - const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + const loaded = await apiJson( + backendUrl, + `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, + { token }, + ); pipeline = loaded.json.data?.pipeline || null; created = true; } @@ -238,7 +293,11 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne }; } - const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + const loaded = await apiJson( + backendUrl, + `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, + { token }, + ); if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { return { status: "fail", @@ -251,32 +310,53 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne } pipeline = loaded.json.data.pipeline; - const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const config = + pipeline.config && typeof pipeline.config === "object" + ? pipeline.config + : {}; const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; - const rawExistingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" - ? ai["local-agent"] - : {}; + const runnerConfigs = + ai.runner_config && typeof ai.runner_config === "object" + ? ai.runner_config + : {}; + const rawExistingLocalAgentConfig = + runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" + ? runnerConfigs[runnerId] + : {}; const existingLocalAgentConfig = rawExistingLocalAgentConfig; - const existingModel = existingLocalAgentConfig.model && typeof existingLocalAgentConfig.model === "object" - ? existingLocalAgentConfig.model - : {}; - const requestedModelId = env.LANGBOT_LOCAL_AGENT_MODEL_UUID || env.LANGBOT_E2E_MODEL_UUID || ""; - const selected = await selectWorkingSpaceModel({ - backendUrl, - token, - models, - skippedModelIds, - skippedModelNames, - requestedModelId, - existingModelId: existingModel.primary || "", - }); + const existingModel = + existingLocalAgentConfig.model && + typeof existingLocalAgentConfig.model === "object" + ? existingLocalAgentConfig.model + : {}; + const requestedModelId = + env.LANGBOT_LOCAL_AGENT_MODEL_UUID || env.LANGBOT_E2E_MODEL_UUID || ""; + const selected = fakeModel + ? { + status: "pass", + reason: "", + selected_model_id: fakeModel.model_uuid, + selected_model_name: fakeModel.model_name, + fallback_model_ids: [], + scanned_space_model_count: 0, + tested_model_count: 0, + model_tests: [], + } + : await selectWorkingSpaceModel({ + backendUrl, + token, + models, + skippedModelIds, + skippedModelNames, + requestedModelId, + existingModelId: existingModel.primary || "", + }); const selectedModelId = selected.selected_model_id || ""; const localAgentConfig = { timeout: 300, prompt: [{ role: "system", content: "You are a helpful assistant." }], "remove-think": false, "knowledge-bases": [], - "box-session-id-template": "{launcher_type}_{launcher_id}", "retrieval-top-k": 5, "rerank-model": "", "rerank-top-k": 5, @@ -288,7 +368,6 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne "context-reserve-tokens": 16384, "context-keep-recent-tokens": 20000, "context-summary-tokens": 8000, - ...existingLocalAgentConfig, // Current backend truncation still reads this field directly. "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), model: { @@ -303,23 +382,30 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne runner: { ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), id: runnerId, - runner: runnerId, "expire-time": 0, }, - "local-agent": localAgentConfig, + runner_config: { + ...runnerConfigs, + [runnerId]: localAgentConfig, + }, }, }; - const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { - method: "PUT", - token, - body: { - name: pipelineName, - description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", - emoji: "QA", - config: updatedConfig, + const updateResponse = await apiJson( + backendUrl, + `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, + { + method: "PUT", + token, + body: { + name: pipelineName, + description: + "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, }, - }); + ); if (isApiFailure(updateResponse)) { return { status: "fail", @@ -341,7 +427,8 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne status: selectedModelId ? "pass" : "env_issue", reason: selectedModelId ? `Local-agent pipeline is configured for Debug Chat with Space model ${selected.selected_model_name || selectedModelId} and ${selected.fallback_model_ids.length} fallback(s).` - : selected.reason || "No working Space LLM model is configured in this LangBot instance.", + : selected.reason || + "No working Space LLM model is configured in this LangBot instance.", pipeline_id: pipeline.uuid, pipeline_name: pipelineName, model_count: models.length, @@ -352,21 +439,214 @@ async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runne selected_model_id: selectedModelId, selected_model_name: selected.selected_model_name, fallback_model_ids: selected.fallback_model_ids, + fake_provider: fakeModel, created, updated: true, }; } +async function ensureFakeProviderModel({ backendUrl, token, baseUrl }) { + const modelUuid = env.LANGBOT_E2E_FAKE_MODEL_UUID || DEFAULT_FAKE_MODEL_UUID; + const modelName = + env.LANGBOT_E2E_FAKE_MODEL_NAME || + env.LANGBOT_FAKE_PROVIDER_MODEL || + env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || + "langbot-e2e-fake-model"; + const providerName = + env.LANGBOT_E2E_FAKE_PROVIDER_NAME || DEFAULT_FAKE_PROVIDER_NAME; + const providerRequester = + env.LANGBOT_E2E_FAKE_PROVIDER_REQUESTER || "openai-chat-completions"; + const apiKey = + env.LANGBOT_E2E_FAKE_PROVIDER_API_KEY || + env.LANGBOT_FAKE_PROVIDER_API_KEY || + "fake-key"; + + const providersResponse = await apiJson( + backendUrl, + "/api/v1/provider/providers", + { token }, + ); + if (isApiFailure(providersResponse)) { + return { + status: "fail", + reason: + providersResponse.json.msg || + "Failed to list providers before creating fake provider.", + provider_status: providersResponse.status, + }; + } + + const normalizedBaseUrl = String(baseUrl || "").replace(/\/$/, ""); + const providers = providersResponse.json.data?.providers || []; + let provider = providers.find( + (item) => + item.name === providerName || + (item.requester === providerRequester && + String(item.base_url || "").replace(/\/$/, "") === normalizedBaseUrl), + ); + const providerBody = { + name: providerName, + requester: providerRequester, + base_url: normalizedBaseUrl, + api_keys: [apiKey], + }; + + if (provider?.uuid) { + const updateResponse = await apiJson( + backendUrl, + `/api/v1/provider/providers/${encodeURIComponent(provider.uuid)}`, + { + method: "PUT", + token, + body: providerBody, + }, + ); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update fake provider.", + provider_status: updateResponse.status, + }; + } + } else { + const createProviderResponse = await apiJson( + backendUrl, + "/api/v1/provider/providers", + { + method: "POST", + token, + body: providerBody, + }, + ); + if (isApiFailure(createProviderResponse)) { + return { + status: "fail", + reason: + createProviderResponse.json.msg || "Failed to create fake provider.", + provider_status: createProviderResponse.status, + }; + } + provider = { uuid: createProviderResponse.json.data?.uuid || "" }; + } + + if (!provider?.uuid) { + return { + status: "fail", + reason: "Fake provider did not return a provider uuid.", + }; + } + + let resolvedModelUuid = modelUuid; + let modelResponse = await apiJson( + backendUrl, + `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, + { + token, + }, + ); + if (modelResponse.status === 404) { + const providerModelsResponse = await apiJson( + backendUrl, + `/api/v1/provider/models/llm?provider_uuid=${encodeURIComponent(provider.uuid)}`, + { token }, + ); + if (!isApiFailure(providerModelsResponse)) { + const existingModel = ( + providerModelsResponse.json.data?.models || [] + ).find((item) => item.name === modelName); + if (existingModel?.uuid) { + resolvedModelUuid = existingModel.uuid; + modelResponse = await apiJson( + backendUrl, + `/api/v1/provider/models/llm/${encodeURIComponent(resolvedModelUuid)}`, + { + token, + }, + ); + } + } + } + + const modelBody = { + name: modelName, + provider_uuid: provider.uuid, + abilities: ["func_call", "vision"], + context_length: 8192, + extra_args: {}, + prefered_ranking: 0, + }; + if (modelResponse.status === 404) { + const createModelResponse = await apiJson( + backendUrl, + "/api/v1/provider/models/llm", + { + method: "POST", + token, + body: { + uuid: modelUuid, + ...modelBody, + }, + }, + ); + if (isApiFailure(createModelResponse)) { + return { + status: "fail", + reason: createModelResponse.json.msg || "Failed to create fake model.", + model_status: createModelResponse.status, + }; + } + resolvedModelUuid = createModelResponse.json.data?.uuid || modelUuid; + } else if (isApiFailure(modelResponse)) { + return { + status: "fail", + reason: modelResponse.json.msg || "Failed to load fake model.", + model_status: modelResponse.status, + }; + } else { + const updateModelResponse = await apiJson( + backendUrl, + `/api/v1/provider/models/llm/${encodeURIComponent(resolvedModelUuid)}`, + { + method: "PUT", + token, + body: modelBody, + }, + ); + if (isApiFailure(updateModelResponse)) { + return { + status: "fail", + reason: updateModelResponse.json.msg || "Failed to update fake model.", + model_status: updateModelResponse.status, + }; + } + } + + return { + status: "pass", + provider_uuid: provider.uuid, + provider_requester: providerRequester, + base_url: normalizedBaseUrl, + model_uuid: resolvedModelUuid, + model_name: modelName, + }; +} + function isApiFailure(response) { - return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); + return ( + response.status >= 400 || + (response.json.code !== undefined && response.json.code !== 0) + ); } function isSpaceModel(model) { - const provider = model?.provider && typeof model.provider === "object" ? model.provider : {}; - return model?.provider_uuid === SPACE_PROVIDER_UUID - || provider.uuid === SPACE_PROVIDER_UUID - || provider.requester === "space-chat-completions" - || provider.name === "LangBot Models"; + const provider = + model?.provider && typeof model.provider === "object" ? model.provider : {}; + return ( + model?.provider_uuid === SPACE_PROVIDER_UUID || + provider.uuid === SPACE_PROVIDER_UUID || + provider.requester === "space-chat-completions" || + provider.name === "LangBot Models" + ); } async function selectWorkingSpaceModel({ @@ -379,15 +659,24 @@ async function selectWorkingSpaceModel({ existingModelId, }) { const modelTests = []; - const testLimit = positiveInteger(env.LANGBOT_E2E_MODEL_TEST_LIMIT, DEFAULT_MODEL_TEST_LIMIT); - const fallbackCount = positiveInteger(env.LANGBOT_E2E_MODEL_FALLBACK_COUNT, DEFAULT_MODEL_FALLBACK_COUNT); + const testLimit = positiveInteger( + env.LANGBOT_E2E_MODEL_TEST_LIMIT, + DEFAULT_MODEL_TEST_LIMIT, + ); + const fallbackCount = positiveInteger( + env.LANGBOT_E2E_MODEL_FALLBACK_COUNT, + DEFAULT_MODEL_FALLBACK_COUNT, + ); const workingModels = []; - const spaceModels = rankModels(models.filter((model) => ( - model.uuid - && isSpaceModel(model) - && !skippedModelIds.has(model.uuid) - && !skippedModelNames.has(model.name) - ))); + const spaceModels = rankModels( + models.filter( + (model) => + model.uuid && + isSpaceModel(model) && + !skippedModelIds.has(model.uuid) && + !skippedModelNames.has(model.name), + ), + ); const requestedModel = requestedModelId ? spaceModels.find((model) => model.uuid === requestedModelId) || null : null; @@ -396,7 +685,9 @@ async function selectWorkingSpaceModel({ : null; const candidates = uniqueCandidates([ ...(requestedModel ? [existingCandidate(requestedModel, "requested")] : []), - ...(existingModel ? [existingCandidate(existingModel, "existing-pipeline")] : []), + ...(existingModel + ? [existingCandidate(existingModel, "existing-pipeline")] + : []), ...spaceModels.map((model) => existingCandidate(model, "configured-space")), ]); @@ -405,9 +696,16 @@ async function selectWorkingSpaceModel({ scanResult = await scanSpaceModels({ backendUrl, token }); if (scanResult.status === "pass") { const knownNames = new Set(spaceModels.map((model) => model.name)); - candidates.push(...scanResult.models - .filter((model) => model.name && !knownNames.has(model.name) && !skippedModelNames.has(model.name)) - .map((model) => scannedCandidate(model))); + candidates.push( + ...scanResult.models + .filter( + (model) => + model.name && + !knownNames.has(model.name) && + !skippedModelNames.has(model.name), + ) + .map((model) => scannedCandidate(model)), + ); } } @@ -435,14 +733,16 @@ async function selectWorkingSpaceModel({ }; } - const baseReason = unique.length === 0 - ? scanResult.reason || "No Space LLM model candidates are available." - : `No working Space LLM model found after testing ${modelTests.length} candidate(s).`; + const baseReason = + unique.length === 0 + ? scanResult.reason || "No Space LLM model candidates are available." + : `No working Space LLM model found after testing ${modelTests.length} candidate(s).`; return { status: "env_issue", - reason: requestedModelId && !requestedModel - ? `Requested Space LLM model ${requestedModelId} is missing or skipped; ${baseReason}` - : baseReason, + reason: + requestedModelId && !requestedModel + ? `Requested Space LLM model ${requestedModelId} is missing or skipped; ${baseReason}` + : baseReason, selected_model_id: "", selected_model_name: "", fallback_model_ids: [], @@ -462,7 +762,11 @@ async function scanSpaceModels({ backendUrl, token }) { return { status: "env_issue", models: [], - reason: safeReason(response.json.msg || response.json.message || "Failed to scan Space LLM models."), + reason: safeReason( + response.json.msg || + response.json.message || + "Failed to scan Space LLM models.", + ), }; } return { @@ -492,28 +796,42 @@ async function ensureAndTestModel({ backendUrl, token, candidate }) { if (isApiFailure(create) || !modelUuid) { return modelTestResult(candidate, { status: "fail", - reason: safeReason(create.json.msg || "Failed to create scanned Space model."), + reason: safeReason( + create.json.msg || "Failed to create scanned Space model.", + ), http_status: create.status, }); } created = true; } - const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { - method: "POST", - token, - body: { extra_args: {} }, - }); + const test = await apiJson( + backendUrl, + `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, + { + method: "POST", + token, + body: { extra_args: {} }, + }, + ); const passed = !isApiFailure(test); if (!passed && created) { - await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { - method: "DELETE", - token, - }).catch(() => {}); + await apiJson( + backendUrl, + `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, + { + method: "DELETE", + token, + }, + ).catch(() => {}); } return modelTestResult(candidate, { status: passed ? "pass" : "fail", - reason: passed ? "" : safeReason(test.json.msg || test.json.message || "Space model test failed."), + reason: passed + ? "" + : safeReason( + test.json.msg || test.json.message || "Space model test failed.", + ), http_status: test.status, model_uuid: modelUuid, created, @@ -558,7 +876,9 @@ function uniqueCandidates(candidates) { const seen = new Set(); const result = []; for (const candidate of candidates) { - const key = candidate.uuid ? `uuid:${candidate.uuid}` : `name:${candidate.name}`; + const key = candidate.uuid + ? `uuid:${candidate.uuid}` + : `name:${candidate.name}`; if (!candidate.name || seen.has(key)) continue; seen.add(key); result.push(candidate); @@ -568,8 +888,12 @@ function uniqueCandidates(candidates) { function rankModels(models) { return [...models].sort((left, right) => { - const leftRank = Number.isFinite(Number(left.prefered_ranking)) ? Number(left.prefered_ranking) : 9999; - const rightRank = Number.isFinite(Number(right.prefered_ranking)) ? Number(right.prefered_ranking) : 9999; + const leftRank = Number.isFinite(Number(left.prefered_ranking)) + ? Number(left.prefered_ranking) + : 9999; + const rightRank = Number.isFinite(Number(right.prefered_ranking)) + ? Number(right.prefered_ranking) + : 9999; if (leftRank !== rightRank) return leftRank - rightRank; return String(left.name || "").localeCompare(String(right.name || "")); }); @@ -605,5 +929,9 @@ async function upsertEnvLocal(path, updates) { for (const [key, value] of Object.entries(updates)) { if (!seen.has(key)) next.push(`${key}=${value}`); } - await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); + await writeFile( + path, + `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, + "utf8", + ); } diff --git a/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs index abc43241a..7346eff29 100755 --- a/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs +++ b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs @@ -74,7 +74,7 @@ try { }); Object.assign(result, prepared); if (result.pipeline_id) { - result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`; } if (writeEnv && result.pipeline_id) { diff --git a/skills/scripts/e2e/fake-openai-provider.mjs b/skills/scripts/e2e/fake-openai-provider.mjs index 1cca9c46b..da0d88c9c 100755 --- a/skills/scripts/e2e/fake-openai-provider.mjs +++ b/skills/scripts/e2e/fake-openai-provider.mjs @@ -9,7 +9,43 @@ const args = parseArgs(process.argv.slice(2)); const host = args.host || env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; const port = integer(args.port ?? env.LANGBOT_FAKE_PROVIDER_PORT, 0); const stateFile = args["state-file"] || env.LANGBOT_FAKE_PROVIDER_STATE_FILE || ""; -const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || "gpt-4o-mini"; +const modelName = args.model || env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || env.LANGBOT_FAKE_PROVIDER_MODEL || "gpt-4o-mini"; +const COMPACTION_SENTINEL = "qa_compaction_sentinel_7391"; +const COMBO_SENTINEL = "qa_combo_compaction_sentinel_2406"; +const RAG_SENTINEL = "azalea-cobalt-7421"; +const COMBO_TOOL_INPUT = "combo-tool-ok-local-agent"; +const COMBO_TOOL_RESULT = `qa-plugin-smoke:${COMBO_TOOL_INPUT}`; +const COMBO_FINAL = `COMBO_FINAL ${COMBO_SENTINEL} ${RAG_SENTINEL} ${COMBO_TOOL_RESULT}`; +const MULTITOOL_SENTINEL = "qa_multitool_compaction_sentinel_6718"; +const MULTITOOL_TOOL_A_INPUT = "multi-tool-a-local-agent"; +const MULTITOOL_TOOL_B_INPUT = "multi-tool-b-local-agent"; +const MULTITOOL_TOOL_A_RESULT = `qa-plugin-smoke:${MULTITOOL_TOOL_A_INPUT}`; +const MULTITOOL_TOOL_B_RESULT = `qa-plugin-smoke:${MULTITOOL_TOOL_B_INPUT}`; +const MULTITOOL_FINAL = [ + "MULTITOOL_COMBO_FINAL", + MULTITOOL_SENTINEL, + RAG_SENTINEL, + MULTITOOL_TOOL_A_RESULT, + MULTITOOL_TOOL_B_RESULT, +].join(" "); +const PARALLEL_SENTINEL = "qa_parallel_compaction_sentinel_8142"; +const PARALLEL_TOOL_A_INPUT = "parallel-tool-a-local-agent"; +const PARALLEL_TOOL_B_INPUT = "parallel-tool-b-local-agent"; +const PARALLEL_TOOL_A_RESULT = `qa-plugin-smoke:${PARALLEL_TOOL_A_INPUT}`; +const PARALLEL_TOOL_B_RESULT = `qa-plugin-smoke:${PARALLEL_TOOL_B_INPUT}`; +const PARALLEL_FINAL = [ + "PARALLEL_COMBO_FINAL", + PARALLEL_SENTINEL, + RAG_SENTINEL, + PARALLEL_TOOL_A_RESULT, + PARALLEL_TOOL_B_RESULT, +].join(" "); +const LOOP_LIMIT_INPUT = "loop-limit-repeat-local-agent"; +const TOOL_ERROR_INPUT = "tool-error-recovery-local-agent"; +const TOOL_ERROR_FINAL = "TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed"; +const STEERING_FOLLOWUP_SENTINEL = "qa_steering_sentinel_6194"; +const STEERING_SLEEP_INPUT = "steering-e2e-anchor"; +const STEERING_SLEEP_RESULT = `qa-plugin-smoke:sleep:8:${STEERING_SLEEP_INPUT}`; const config = { response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", first_token_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), @@ -88,6 +124,7 @@ const server = createServer(async (request, response) => { created: 1, owned_by: "langbot-qa", type: "llm", + context_length: 8192, }, ], }); @@ -100,7 +137,8 @@ const server = createServer(async (request, response) => { const requestId = `chatcmpl-langbot-fake-${requestCount}`; const shouldFail = requestCount <= config.fail_first_n || (config.fail_every_n > 0 && requestCount % config.fail_every_n === 0); - const replyText = responseTextForBody(body); + const replyMessage = buildResponse(body); + const replyText = replyMessage.content || ""; requestRecord = recordRequest({ id: requestId, request_number: requestCount, @@ -140,7 +178,7 @@ const server = createServer(async (request, response) => { await streamCompletion(response, { requestId, model: body.model || modelName, - content: replyText, + message: replyMessage, failAfterFirstChunk: config.fail_after_first_chunk, requestRecord, startedPerf, @@ -150,7 +188,7 @@ const server = createServer(async (request, response) => { sendJson(response, 200, completionPayload({ requestId, model: body.model || modelName, - content: replyText, + message: replyMessage, })); markRequestTiming(requestRecord, "first_chunk", startedPerf); markRequestTiming(requestRecord, "first_content_chunk", startedPerf); @@ -270,8 +308,8 @@ function sendJson(response, status, payload) { response.end(text); } -function completionPayload({ requestId, model, content }) { - const completionTokens = tokenEstimate(content); +function completionPayload({ requestId, model, message }) { + const completionTokens = tokenEstimate(message.content || JSON.stringify(message.tool_calls || [])); return { id: requestId, object: "chat.completion", @@ -280,11 +318,8 @@ function completionPayload({ requestId, model, content }) { choices: [ { index: 0, - message: { - role: "assistant", - content, - }, - finish_reason: "stop", + message, + finish_reason: message.tool_calls?.length ? "tool_calls" : "stop", }, ], usage: { @@ -298,7 +333,7 @@ function completionPayload({ requestId, model, content }) { async function streamCompletion(response, { requestId, model, - content, + message, failAfterFirstChunk: failMidStream, requestRecord, startedPerf, @@ -319,7 +354,56 @@ async function streamCompletion(response, { choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], }); - const chunks = splitContent(content); + const chunks = message.tool_calls?.length ? [] : splitContent(message.content || ""); + if (message.tool_calls?.length) { + await sleep(config.chunk_delay_ms); + markRequestTiming(requestRecord, "first_content_chunk", startedPerf); + requestRecord.content_chunk_count = (requestRecord.content_chunk_count || 0) + 1; + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + tool_calls: message.tool_calls.map((call, index) => ({ + index, + id: call.id, + type: call.type, + function: { + name: call.function.name, + arguments: call.function.arguments, + }, + })), + }, + finish_reason: null, + }, + ], + }); + await sleep(config.chunk_delay_ms); + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { + prompt_tokens: 8, + completion_tokens: tokenEstimate(JSON.stringify(message.tool_calls)), + total_tokens: 8 + tokenEstimate(JSON.stringify(message.tool_calls)), + }, + }); + response.write("data: [DONE]\n\n"); + response.end(); + finishRequestRecord(requestRecord, startedPerf, { + status: "ok", + http_status: 200, + }); + return; + } + for (let index = 0; index < chunks.length; index += 1) { await sleep(config.chunk_delay_ms); if (index === 0) markRequestTiming(requestRecord, "first_content_chunk", startedPerf); @@ -342,7 +426,7 @@ async function streamCompletion(response, { } await sleep(config.chunk_delay_ms); - const completionTokens = tokenEstimate(content); + const completionTokens = tokenEstimate(message.content || ""); writeSse(response, { id: requestId, object: "chat.completion.chunk", @@ -399,6 +483,258 @@ function responseTextForBody(body) { return config.response_text; } +function messageText(messages = []) { + return messages + .map((message) => { + const parts = [contentText(message?.content)]; + if (Array.isArray(message?.tool_calls)) { + parts.push( + message.tool_calls + .map((call) => `${call?.function?.name || call?.name || ""} ${call?.function?.arguments || ""}`) + .join("\n"), + ); + } + return parts.filter(Boolean).join("\n"); + }) + .join("\n"); +} + +function contentText(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((part) => contentText(part)).join(""); + if (content && typeof content === "object") { + for (const key of ["text", "content", "message", "error", "value"]) { + if (content[key] !== undefined && content[key] !== null) { + return contentText(content[key]); + } + } + return JSON.stringify(content); + } + return ""; +} + +function latestUserText(messages = []) { + const latest = [...messages].reverse().find((message) => message?.role === "user"); + return contentText(latest?.content); +} + +function toolNames(tools = []) { + return tools + .map((tool) => tool?.function?.name || tool?.name || "") + .filter(Boolean); +} + +function firstToolName(tools = [], candidates = []) { + const names = toolNames(tools); + return candidates.find((candidate) => names.includes(candidate)) || ""; +} + +function isSummarizationRequest(messages = []) { + const text = messageText(messages); + return /context summarization assistant|conversation to summarize|/i.test(text); +} + +function buildSummaryResponse(text) { + const references = []; + if (text.includes(COMPACTION_SENTINEL)) references.push(COMPACTION_SENTINEL); + if (text.includes(COMBO_SENTINEL)) references.push(COMBO_SENTINEL); + if (text.includes(MULTITOOL_SENTINEL)) references.push(MULTITOOL_SENTINEL); + if (text.includes(PARALLEL_SENTINEL)) references.push(PARALLEL_SENTINEL); + if (/RAG_TOOL_COMBO_GOAL/i.test(text)) references.push("RAG_TOOL_COMBO_GOAL"); + if (/MULTITOOL_RAG_GOAL/i.test(text)) references.push("MULTITOOL_RAG_GOAL"); + if (/PARALLEL_RAG_GOAL/i.test(text)) references.push("PARALLEL_RAG_GOAL"); + const critical = references.length + ? references.map((reference) => `- ${reference}`).join("\n") + : "- (none)"; + return { + role: "assistant", + content: [ + "## Goal", + "Preserve deterministic LangBot E2E context across compaction.", + "", + "## Constraints & Preferences", + "- Keep exact sentinel values verbatim.", + "", + "## Progress", + "### Done", + "- [x] Earlier conversation was compacted by the fake provider.", + "", + "### In Progress", + "- [ ] Continue the current Debug Chat run.", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- **Deterministic QA**: Return only known sentinel values from compacted input.", + "", + "## Next Steps", + "1. Answer the current user request using preserved context.", + "", + "## Critical Context", + critical, + ].join("\n"), + }; +} + +function buildResponse(payload) { + const text = messageText(payload.messages || []); + const current = latestUserText(payload.messages || []); + const tools = payload.tools || []; + if (isSummarizationRequest(payload.messages || [])) { + return buildSummaryResponse(text); + } + + const pluginTool = firstToolName(tools, ["qa_plugin_echo"]); + const pluginFailTool = firstToolName(tools, ["qa_plugin_fail"]); + const pluginSleepTool = firstToolName(tools, ["qa_plugin_sleep"]); + const mcpTool = firstToolName(tools, ["qa_mcp_echo"]); + + if (/STEERING_NO_FOLLOWUP|qa_plugin_sleep|steering-e2e-anchor|qa_steering_sentinel_6194/i.test(current || text)) { + if (text.includes(STEERING_FOLLOWUP_SENTINEL) && text.includes(STEERING_SLEEP_RESULT)) { + return { role: "assistant", content: STEERING_FOLLOWUP_SENTINEL }; + } + if (text.includes(STEERING_SLEEP_RESULT) && !text.includes(STEERING_FOLLOWUP_SENTINEL)) { + return { role: "assistant", content: "STEERING_NO_FOLLOWUP" }; + } + if (pluginSleepTool && !text.includes(STEERING_SLEEP_RESULT)) { + return toolCall("call_qa_plugin_sleep_steering", pluginSleepTool, { seconds: 8, text: STEERING_SLEEP_INPUT }); + } + } + + if (/测试暗号是什么|original sentinel|first.*sentinel/i.test(current)) { + return { + role: "assistant", + content: text.includes(COMPACTION_SENTINEL) ? COMPACTION_SENTINEL : "COMPACTION_SENTINEL_MISSING", + }; + } + if ((current.includes(COMPACTION_SENTINEL) + || current.includes(COMBO_SENTINEL) + || current.includes(MULTITOOL_SENTINEL) + || current.includes(PARALLEL_SENTINEL)) + && /请只回复 MEMORY_SET|only reply MEMORY_SET/i.test(current)) { + return { role: "assistant", content: "MEMORY_SET" }; + } + if (/PARALLEL_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "PARALLEL_CONTEXT_PRESSURE_READY" }; + if (/MULTITOOL_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "MULTITOOL_CONTEXT_PRESSURE_READY" }; + if (/COMBO_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "COMBO_CONTEXT_PRESSURE_READY" }; + if (/CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "CONTEXT_PRESSURE_READY" }; + + if (/qa_mcp_echo:mcp-ok-local-agent/.test(text)) return { role: "assistant", content: "qa_mcp_echo:mcp-ok-local-agent" }; + if (/qa_mcp_echo|mcp-ok-local-agent/i.test(current || text) && mcpTool && !/qa_mcp_echo:mcp-ok-local-agent/.test(text)) { + return toolCall("call_qa_mcp_echo", mcpTool, { text: "mcp-ok-local-agent" }); + } + + if (/LOOP_LIMIT|loop-limit-repeat-local-agent|iteration limit/i.test(current || text) && pluginTool) { + return toolCall(`call_qa_plugin_echo_loop_limit_${Date.now()}`, pluginTool, { text: LOOP_LIMIT_INPUT }); + } + + if (/TOOL_ERROR_RECOVERY|tool-error-recovery-local-agent|qa_plugin_fail/i.test(current || text)) { + if (/(?:Error:|Tool execution failed:|ActionCallError:|RuntimeError:)/i.test(text) + && /(?:qa-plugin-smoke forced failure|qa_plugin_fail|tool-error-recovery-local-agent)/i.test(text)) { + return { role: "assistant", content: TOOL_ERROR_FINAL }; + } + if (pluginFailTool) { + return toolCall("call_qa_plugin_fail_recovery", pluginFailTool, { text: TOOL_ERROR_INPUT }); + } + } + + const isParallelComboRequest = /PARALLEL_COMBO|parallel-tool-a-local-agent|parallel-tool-b-local-agent|PARALLEL_RAG_GOAL/i.test(current || text); + if (isParallelComboRequest && pluginTool && !text.includes(PARALLEL_TOOL_A_RESULT) && !text.includes(PARALLEL_TOOL_B_RESULT)) { + return toolCalls([ + ["call_qa_plugin_echo_parallel_a", pluginTool, { text: PARALLEL_TOOL_A_INPUT }], + ["call_qa_plugin_echo_parallel_b", pluginTool, { text: PARALLEL_TOOL_B_INPUT }], + ]); + } + if (isParallelComboRequest && (text.includes(PARALLEL_TOOL_A_RESULT) || text.includes(PARALLEL_TOOL_B_RESULT))) { + return missingOrFinal(text, [ + [PARALLEL_SENTINEL, "parallel-memory"], + [RAG_SENTINEL, "rag"], + [PARALLEL_TOOL_A_RESULT, "tool-a"], + [PARALLEL_TOOL_B_RESULT, "tool-b"], + ], "PARALLEL_COMBO_MISSING", PARALLEL_FINAL); + } + + const isMultiToolComboRequest = /MULTITOOL_COMBO|multi-tool-a-local-agent|multi-tool-b-local-agent|MULTITOOL_RAG_GOAL/i.test(current || text); + if (isMultiToolComboRequest && pluginTool && !text.includes(MULTITOOL_TOOL_A_RESULT)) { + return toolCall("call_qa_plugin_echo_multi_a", pluginTool, { text: MULTITOOL_TOOL_A_INPUT }); + } + if (isMultiToolComboRequest && pluginTool && text.includes(MULTITOOL_TOOL_A_RESULT) && !text.includes(MULTITOOL_TOOL_B_RESULT)) { + return toolCall("call_qa_plugin_echo_multi_b", pluginTool, { text: MULTITOOL_TOOL_B_INPUT }); + } + if (isMultiToolComboRequest && text.includes(MULTITOOL_TOOL_A_RESULT) && text.includes(MULTITOOL_TOOL_B_RESULT)) { + return missingOrFinal(text, [ + [MULTITOOL_SENTINEL, "multi-memory"], + [RAG_SENTINEL, "rag"], + [MULTITOOL_TOOL_A_RESULT, "tool-a"], + [MULTITOOL_TOOL_B_RESULT, "tool-b"], + ], "MULTITOOL_COMBO_MISSING", MULTITOOL_FINAL); + } + + const isComboRequest = /qa_combo|\bCOMBO_FINAL\b|combo-tool-ok-local-agent|RAG_TOOL_COMBO_GOAL/i.test(current || text); + if (isComboRequest && pluginTool && !text.includes(COMBO_TOOL_RESULT)) { + return toolCall("call_qa_plugin_echo_combo", pluginTool, { text: COMBO_TOOL_INPUT }); + } + if (isComboRequest && text.includes(COMBO_TOOL_RESULT)) { + return missingOrFinal(text, [ + [COMBO_SENTINEL, "combo-memory"], + [RAG_SENTINEL, "rag"], + [COMBO_TOOL_RESULT, "tool-result"], + ], "COMBO_MISSING", COMBO_FINAL); + } + + if (/qa-plugin-smoke:plugin-tool-ok-local-agent/.test(text)) { + return { role: "assistant", content: "qa-plugin-smoke:plugin-tool-ok-local-agent" }; + } + if (/qa_plugin_echo|plugin-tool-ok-local-agent/i.test(current || text) && pluginTool && !/qa-plugin-smoke:plugin-tool-ok-local-agent/.test(text)) { + return toolCall("call_qa_plugin_echo", pluginTool, { text: "plugin-tool-ok-local-agent" }); + } + + const e2eTool = firstToolName(tools, ["e2e_lookup"]); + if (/Use the e2e lookup tool|e2e lookup tool|tool loop/i.test(current || text) && e2eTool && !/tool-result:alpha/.test(text)) { + return toolCall("call_e2e_lookup", e2eTool, { query: "alpha" }); + } + if (/tool-result:alpha/.test(text)) return { role: "assistant", content: "Tool loop final answer after tool-result:alpha" }; + if (/RAG_SENTINEL/.test(text)) return { role: "assistant", content: "RAG final answer with RAG_SENTINEL" }; + if (/NONSTREAM_OK/i.test(current || text)) return { role: "assistant", content: "NONSTREAM_OK" }; + if (/IMAGE_OK/i.test(current || text) + && /(?:\[Image\]|langbot_input_attachments|data:image\/|\"type\":\s*\"image\")/i.test(current || text)) { + return { role: "assistant", content: "IMAGE_OK" }; + } + if (/azalea-cobalt-7421/.test(text)) return { role: "assistant", content: "azalea-cobalt-7421" }; + + return { role: "assistant", content: responseTextForBody(payload) }; +} + +function toolCall(id, name, args) { + return toolCalls([[id, name, args]]); +} + +function toolCalls(calls) { + return { + role: "assistant", + content: "", + tool_calls: calls.map(([id, name, args]) => ({ + id, + type: "function", + function: { + name, + arguments: JSON.stringify(args), + }, + })), + }; +} + +function missingOrFinal(text, requirements, prefix, finalText) { + const missing = requirements + .filter(([needle]) => !text.includes(needle)) + .map(([, label]) => label); + return { + role: "assistant", + content: missing.length ? `${prefix}_${missing.join("_")}` : finalText, + }; +} + function flattenContent(content) { if (typeof content === "string") return content; if (Array.isArray(content)) { diff --git a/skills/scripts/e2e/install-qa-plugin-smoke.mjs b/skills/scripts/e2e/install-qa-plugin-smoke.mjs index 73b89af69..523588232 100755 --- a/skills/scripts/e2e/install-qa-plugin-smoke.mjs +++ b/skills/scripts/e2e/install-qa-plugin-smoke.mjs @@ -26,7 +26,12 @@ const packagePath = resolve( || "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", ); const expectedPluginId = env.LANGBOT_E2E_EXPECTED_PLUGIN_ID || "qa/plugin-smoke"; -const expectedTool = env.LANGBOT_E2E_EXPECTED_TOOL || (expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : ""); +const expectedTools = (env.LANGBOT_E2E_EXPECTED_TOOLS || env.LANGBOT_E2E_EXPECTED_TOOL || ( + expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : "" +)) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; const result = { @@ -40,6 +45,7 @@ const result = { package_preview: null, task_id: null, task: null, + reinstall_reason: "", plugin_present_before: false, plugin_present_after: false, tool_names: [], @@ -64,21 +70,23 @@ try { } result.plugin_present_before = await hasPlugin(backendUrl, auth.token); - if (!result.plugin_present_before) { - const form = new FormData(); - form.set("file", new Blob([bytes]), packagePath.split("/").pop()); - const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, { - method: "POST", - headers: { Authorization: `Bearer ${auth.token}` }, - body: form, - }); - const json = await response.json().catch(() => ({})); - if (response.status >= 400 || json.code !== 0) { - throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`); + if (expectedTools.length > 0) { + result.tool_names = await listToolNames(backendUrl, auth.token); + } + const missingToolsBefore = expectedTools.filter((tool) => !result.tool_names.includes(tool)); + if (result.plugin_present_before && missingToolsBefore.length > 0) { + result.reinstall_reason = `Installed plugin is missing expected tools: ${missingToolsBefore.join(", ")}`; + const removeTask = await removePlugin(backendUrl, auth.token); + if (!isTaskComplete(removeTask)) { + throw new Error(`Plugin reinstall cleanup did not complete successfully: ${JSON.stringify(removeTask)}`); } - result.task_id = json.data?.task_id ?? null; - if (!result.task_id) throw new Error("Plugin install response did not include task_id."); - result.task = await waitForTask(backendUrl, auth.token, result.task_id); + result.plugin_present_before = false; + await sleep(1000); + } + + if (!result.plugin_present_before) { + result.task = await installPlugin(backendUrl, auth.token, bytes, packagePath); + result.task_id = result.task?.id || result.task_id; if (!isTaskComplete(result.task)) { throw new Error(`Plugin install task did not complete successfully: ${JSON.stringify(result.task)}`); } @@ -87,10 +95,11 @@ try { await sleep(1000); result.plugin_present_after = await hasPlugin(backendUrl, auth.token); if (!result.plugin_present_after) throw new Error(`${expectedPluginId} is not listed by /api/v1/plugins after install.`); - if (expectedTool) { + if (expectedTools.length > 0) { result.tool_names = await listToolNames(backendUrl, auth.token); - if (!result.tool_names.includes(expectedTool)) { - throw new Error(`${expectedTool} is not listed by /api/v1/tools after install.`); + const missingTools = expectedTools.filter((tool) => !result.tool_names.includes(tool)); + if (missingTools.length > 0) { + throw new Error(`${missingTools.join(", ")} is not listed by /api/v1/tools after install.`); } } if (expectedRunnerId) { @@ -121,6 +130,41 @@ async function hasPlugin(backendUrl, token) { }); } +async function installPlugin(backendUrl, token, bytes, packagePath) { + const form = new FormData(); + form.set("file", new Blob([bytes]), packagePath.split("/").pop()); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`); + } + const taskId = json.data?.task_id ?? null; + if (!taskId) throw new Error("Plugin install response did not include task_id."); + const task = await waitForTask(backendUrl, token, taskId); + return { id: taskId, ...task }; +} + +async function removePlugin(backendUrl, token) { + const [author, pluginName] = expectedPluginId.split("/"); + if (!author || !pluginName) throw new Error(`Invalid expected plugin id: ${expectedPluginId}`); + const response = await apiJson( + backendUrl, + `/api/v1/plugins/${encodeURIComponent(author)}/${encodeURIComponent(pluginName)}?delete_data=false`, + { method: "DELETE", token }, + ); + if (response.status >= 400 || response.json.code !== 0) { + throw new Error(response.json.msg || `Plugin delete request failed with HTTP ${response.status}.`); + } + const taskId = response.json.data?.task_id ?? null; + if (!taskId) throw new Error("Plugin delete response did not include task_id."); + const task = await waitForTask(backendUrl, token, taskId); + return { id: taskId, ...task }; +} + async function previewPackage(backendUrl, token, bytes, packagePath) { const form = new FormData(); form.set("file", new Blob([bytes]), packagePath.split("/").pop()); diff --git a/skills/scripts/e2e/lib/langbot-e2e.mjs b/skills/scripts/e2e/lib/langbot-e2e.mjs index a7584c904..e733a58d6 100755 --- a/skills/scripts/e2e/lib/langbot-e2e.mjs +++ b/skills/scripts/e2e/lib/langbot-e2e.mjs @@ -1,5 +1,5 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { env } from "node:process"; const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi; @@ -71,6 +71,86 @@ export async function writeResult(paths, result) { } } +function browserDiagnosticFindings(source, text) { + const findings = []; + const lines = String(text || "").split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line) continue; + const lineNumber = index + 1; + + if (source === "console") { + const checks = [ + ["pageerror", /\[pageerror\]/i], + ["frontend_uncaught_error", /\[error\].*(?:\bUncaught\b|Unhandled(?: promise rejection|Rejection)|TypeError|ReferenceError)/i], + ["http_5xx", /Failed to load resource: the server responded with a status of 5\d\d/i], + ["api_server_error", /\[error\].*Server error:/i], + ["plugin_runtime_timeout", /\[error\].*Action [A-Za-z0-9_]+ call timed out/i], + ]; + for (const [kind, regex] of checks) { + if (!regex.test(line)) continue; + findings.push({ + source, + severity: "fail", + kind, + line: lineNumber, + excerpt: redact(line.trim()), + }); + break; + } + continue; + } + + if (source === "network") { + if (/\[response\]\s+5\d\d\b/i.test(line)) { + findings.push({ + source, + severity: "fail", + kind: "http_5xx", + line: lineNumber, + excerpt: redact(line.trim()), + }); + continue; + } + if (/\[requestfailed\]/i.test(line) && !/net::ERR_ABORTED/i.test(line)) { + findings.push({ + source, + severity: "warning", + kind: "request_failed", + line: lineNumber, + excerpt: redact(line.trim()), + }); + } + } + } + return findings; +} + +export async function scanBrowserDiagnostics(paths) { + const sources = [ + ["console", paths.consoleLog], + ["network", paths.networkLog], + ]; + const findings = []; + for (const [source, path] of sources) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + continue; + } + findings.push(...browserDiagnosticFindings(source, text)); + } + const hasFailure = findings.some((finding) => finding.severity === "fail"); + return { + status: hasFailure ? "fail" : "pass", + findings, + reason: hasFailure + ? `Browser diagnostics found ${findings.filter((finding) => finding.severity === "fail").length} failing signal(s).` + : "No failing browser diagnostics found.", + }; +} + export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) { const processEnvKeys = new Set(Object.keys(env)); for (const path of paths) { @@ -92,8 +172,28 @@ export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) } } -export async function readRecoveryKey(repo = env.LANGBOT_REPO || "../LangBot") { - const configPath = resolve(repo, "data/config.yaml"); +export async function resolveLangBotRepo(repo = env.LANGBOT_REPO || "", cwd = process.cwd()) { + if (repo) return resolve(repo); + + const candidates = [ + resolve(cwd), + basename(cwd) === "skills" ? resolve(cwd, "..") : "", + resolve(cwd, "../LangBot"), + resolve(cwd, "LangBot"), + ].filter(Boolean); + + const seen = new Set(); + for (const candidate of candidates) { + if (seen.has(candidate)) continue; + seen.add(candidate); + if (await pathExists(resolve(candidate, "data/config.yaml"))) return candidate; + } + + return resolve(cwd, "../LangBot"); +} + +export async function readRecoveryKey(repo = env.LANGBOT_REPO || "") { + const configPath = resolve(await resolveLangBotRepo(repo), "data/config.yaml"); const config = await readFile(configPath, "utf8"); const match = config.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m); return match?.[1] || ""; @@ -202,6 +302,62 @@ export async function verifyBrowserToken(page, backendUrl) { }, backendUrl); } +export async function ensureAuthenticatedBrowser(page, { + frontendUrl = env.LANGBOT_FRONTEND_URL || "", + backendUrl = env.LANGBOT_BACKEND_URL || "", + user = env.LANGBOT_E2E_LOGIN_USER || "", + password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026", + recoveryKey = "", +} = {}) { + if (!frontendUrl) return { status: "env_issue", reason: "LANGBOT_FRONTEND_URL is not configured." }; + if (!backendUrl) return { status: "env_issue", reason: "LANGBOT_BACKEND_URL is not configured." }; + + const current = await verifyBrowserToken(page, backendUrl).catch((error) => ({ + authenticated: false, + reason: error.message, + })); + if (current.authenticated) { + return { + status: "pass", + reason: "Existing browser token is valid.", + backend_token_check: null, + browser_token_check: current, + injected: false, + }; + } + + if (!user) { + return { + status: "blocked", + reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL, and LANGBOT_E2E_LOGIN_USER is not configured for automatic local login.", + backend_token_check: null, + browser_token_check: current, + injected: false, + }; + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password, recoveryKey }); + await setBrowserToken(page, frontendUrl, auth.token); + const browserCheck = await verifyBrowserToken(page, backendUrl); + if (!browserCheck.authenticated) { + return { + status: "blocked", + reason: browserCheck.reason || "Browser token check failed after automatic local login.", + backend_token_check: auth.check, + browser_token_check: browserCheck, + injected: true, + }; + } + + return { + status: "pass", + reason: "Browser token injected from local recovery login.", + backend_token_check: auth.check, + browser_token_check: browserCheck, + injected: true, + }; +} + export function exitCode(status) { if (status === "pass") return 0; if (status === "blocked" || status === "env_issue") return 2; diff --git a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs index dae2e265c..5a73f17bd 100755 --- a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs +++ b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs @@ -11,6 +11,7 @@ import { } from "./lib/debug-chat.mjs"; import { createBrowser, + ensureAuthenticatedBrowser, ensureEvidence, evidencePaths, exitCode, @@ -30,7 +31,7 @@ await ensureEvidence(paths); const backendUrl = (env.LANGBOT_BACKEND_URL || "").replace(/\/$/, ""); const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_LOCAL_AGENT_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || ""; const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || ""; -const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot/local-agent/default"; +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot-team/LocalAgent/default"; const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "qa_steering_sentinel_6194"; const responseTimeoutMs = positiveInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, 240000); const followupDelayMs = 1000; @@ -74,6 +75,7 @@ const result = { pipeline_config: null, debug_chat_reset: null, tool_diagnostic: null, + browser_auth: null, steering: null, evidence: { console_log: paths.consoleLog, @@ -95,6 +97,18 @@ try { browser = await createBrowser(paths); const { page } = browser; + const authDiagnostic = await ensureAuthenticatedBrowser(page, { + frontendUrl: env.LANGBOT_FRONTEND_URL || "", + backendUrl, + }); + result.browser_auth = authDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + if (authDiagnostic.status === "env_issue" || authDiagnostic.status === "blocked" || authDiagnostic.status === "fail") { + result.status = authDiagnostic.status; + result.reason = authDiagnostic.reason || "Browser authentication failed."; + throw new Error(result.reason); + } + const openResult = await openPipelineDebugChat(page, { pipelineUrl, pipelineName, @@ -432,7 +446,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex } const config = pipeline.config || {}; const runner = config.ai?.runner || {}; - const runnerId = runner.id || runner.runner || ""; + const runnerId = runner.id || ""; if (!runnerId) { return { status: "blocked", @@ -441,7 +455,7 @@ async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, ex pipeline_id: pipelineId, pipeline_name: pipeline.name, matched_by: matchedBy, - reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + reason: "Pipeline has no ai.runner.id.", }; } if (expectedRunnerId && runnerId !== expectedRunnerId) { diff --git a/skills/scripts/e2e/mcp-stdio-register.mjs b/skills/scripts/e2e/mcp-stdio-register.mjs index e2e31a9ad..07747ac55 100755 --- a/skills/scripts/e2e/mcp-stdio-register.mjs +++ b/skills/scripts/e2e/mcp-stdio-register.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { existsSync, readFileSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { env } from "node:process"; import { @@ -46,6 +46,8 @@ const startupTimeoutSec = Number(env.LANGBOT_MCP_STARTUP_TIMEOUT_SEC || "300"); const readyTimeoutMs = Number(env.LANGBOT_MCP_READY_TIMEOUT_MS || "360000"); const backendUrl = env.LANGBOT_BACKEND_URL || ""; const apiDiagnosticPath = resolve(paths.evidenceDir, "api-diagnostic.json"); +const envLocalPath = resolve("skills/.env.local"); +const serverUuidEnvKey = env.LANGBOT_MCP_SERVER_UUID_ENV_KEY || "LANGBOT_MCP_QA_STDIO_SERVER_UUID"; let browser; const result = { @@ -70,6 +72,7 @@ const result = { result_json: paths.resultJson, }, evidence_collected: ["api_diagnostic"], + wrote_env: false, }; async function run() { @@ -159,6 +162,7 @@ async function run() { const deadline = Date.now() + readyTimeoutMs; let lastTools = []; let lastRuntime = null; + let lastServer = null; while (Date.now() < deadline) { await new Promise((resolveReady) => setTimeout(resolveReady, 500)); const tools = await getJson("/api/v1/tools"); @@ -167,7 +171,8 @@ async function run() { .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") .filter(Boolean) .sort(); - lastRuntime = server.json.data?.server?.runtime_info || null; + lastServer = server.json.data?.server || null; + lastRuntime = lastServer?.runtime_info || null; if (lastTools.includes(expectedTool)) break; } @@ -177,6 +182,7 @@ async function run() { save_status: save.status, save_code: save.json.code ?? null, save_msg: save.json.msg || "", + server_uuid: lastServer?.uuid || save.json.data?.uuid || "", tool_names: lastTools, has_expected_tool: lastTools.includes(expectedTool), runtime_status: lastRuntime?.status || null, @@ -212,11 +218,47 @@ async function run() { result.reason = `MCP server ${serverName} did not expose ${expectedTool}. See ${apiDiagnosticPath}.`; return; } + if (!diagnostic.server_uuid) { + result.status = "fail"; + result.reason = `MCP server ${serverName} exposed ${expectedTool}, but the server UUID was not returned. See ${apiDiagnosticPath}.`; + return; + } + + await upsertEnvLocal(envLocalPath, { + [serverUuidEnvKey]: diagnostic.server_uuid, + }); + result.wrote_env = true; + result.server_uuid = diagnostic.server_uuid; + result.server_uuid_env_key = serverUuidEnvKey; result.status = "pass"; result.reason = `MCP server ${serverName} is connected and exposes ${expectedTool} through LangBot /api/v1/tools.`; } +async function upsertEnvLocal(path, updates) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + + const lines = text ? text.split(/\r?\n/) : []; + const seen = new Set(); + const updated = lines.map((line) => { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (!match || !Object.prototype.hasOwnProperty.call(updates, match[1])) return line; + seen.add(match[1]); + return `${match[1]}=${updates[match[1]]}`; + }); + + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) updated.push(`${key}=${value}`); + } + + await writeFile(path, `${updated.filter((line, index) => line || index < updated.length - 1).join("\n")}\n`, "utf8"); +} + try { await run(); } catch (error) { diff --git a/skills/scripts/e2e/onebot-runtime-probe.py b/skills/scripts/e2e/onebot-runtime-probe.py new file mode 100644 index 000000000..a080571fa --- /dev/null +++ b/skills/scripts/e2e/onebot-runtime-probe.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Drive one deterministic event through an enabled OneBot reverse WebSocket.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import time + +import websockets + + +async def run(port: int) -> None: + uri = f'ws://127.0.0.1:{port}/ws' + headers = { + 'X-Self-ID': '900001', + 'X-Client-Role': 'Universal', + 'User-Agent': 'LangBot-E2E-OneBot/1.0', + } + connect_deadline = time.monotonic() + 15 + connection = None + while time.monotonic() < connect_deadline: + try: + connection = await websockets.connect(uri, additional_headers=headers) + break + except OSError: + await asyncio.sleep(0.25) + if connection is None: + raise RuntimeError(f'OneBot reverse WebSocket did not open on port {port}') + + actions: list[str] = [] + event_deadline = time.monotonic() + 20 + async with connection as ws: + await ws.send( + json.dumps( + { + 'post_type': 'notice', + 'notice_type': 'group_increase', + 'sub_type': 'invite', + 'time': int(time.time()), + 'self_id': 900001, + 'group_id': 20001, + 'operator_id': 10002, + 'user_id': 10003, + } + ) + ) + + delivered = False + while time.monotonic() < event_deadline and not delivered: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=2) + except asyncio.TimeoutError: + continue + request = json.loads(raw) + action = request.get('action', '') + actions.append(action) + if action == 'get_group_info': + data = {'group_id': 20001, 'group_name': 'LangBot Runtime QA'} + elif action == 'get_group_member_info': + data = { + 'group_id': 20001, + 'user_id': 10003, + 'nickname': 'Runtime QA Member', + 'card': 'Runtime QA Member', + } + elif action == 'send_group_msg': + data = {'message_id': 70001} + delivered = True + else: + data = {} + await ws.send( + json.dumps( + { + 'status': 'ok', + 'retcode': 0, + 'data': data, + 'echo': request.get('echo'), + } + ) + ) + + if not delivered: + raise RuntimeError(f'Agent output was not delivered; actions={actions}') + print(json.dumps({'connected': True, 'actions': actions, 'delivered': True})) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--port', type=int, required=True) + args = parser.parse_args() + asyncio.run(run(args.port)) diff --git a/skills/scripts/e2e/pipeline-debug-chat.mjs b/skills/scripts/e2e/pipeline-debug-chat.mjs index 4b20f7757..7d96b4c2e 100755 --- a/skills/scripts/e2e/pipeline-debug-chat.mjs +++ b/skills/scripts/e2e/pipeline-debug-chat.mjs @@ -11,12 +11,14 @@ import { } from "./lib/debug-chat.mjs"; import { createBrowser, + ensureAuthenticatedBrowser, ensureEvidence, evidencePaths, exitCode, localIsoWithOffset, pathExists, safeScreenshot, + scanBrowserDiagnostics, writeResult, } from "./lib/langbot-e2e.mjs"; @@ -50,15 +52,19 @@ const pipelineName = pipelineRequired const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; const resetDebugChat = boolFromEnv(env.LANGBOT_E2E_RESET_DEBUG_CHAT, false); const restoreRunnerConfig = boolFromEnv(env.LANGBOT_E2E_RESTORE_RUNNER_CONFIG, true); +const restoreExtensions = boolFromEnv(env.LANGBOT_E2E_RESTORE_EXTENSIONS, true); const debugChatSessionType = env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; const pipelineConfigDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-diagnostic.json"); +const pipelineExtensionsDiagnosticPath = resolve(paths.evidenceDir, "pipeline-extensions-diagnostic.json"); const debugChatResetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); const pipelineConfigRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-restore-diagnostic.json"); +const pipelineExtensionsRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-extensions-restore-diagnostic.json"); const metricsPath = resolve(paths.evidenceDir, "metrics.json"); const startedAt = new Date(); let browser; -let restorePlan = null; +let restoreConfigPlan = null; +let restoreExtensionsPlan = null; let result = { source: "automation", case_id: caseId, @@ -106,7 +112,9 @@ function parseJsonEnv(key, fallback) { } function positiveNumberEnv(key, fallback) { - const value = Number(env[key] || ""); + const raw = env[key]; + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); return Number.isFinite(value) && value >= 0 ? value : fallback; } @@ -317,6 +325,11 @@ function sanitizePipelineDiagnostic(diagnostic) { return safe; } +function sanitizePipelineExtensionsDiagnostic(diagnostic) { + const { restore_extensions: _restoreExtensions, ...safe } = diagnostic || {}; + return safe; +} + async function prepareImageFixture(paths) { if (imagePathEnv) return resolve(imagePathEnv); if (!imageBase64Path) return ""; @@ -418,7 +431,7 @@ async function inspectAndPatchPipelineConfig(page, { const config = JSON.parse(JSON.stringify(pipeline.config || {})); const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; - const runnerId = runner.id || runner.runner || ""; + const runnerId = runner.id || ""; if (!runnerId) { return { status: "blocked", @@ -427,7 +440,7 @@ async function inspectAndPatchPipelineConfig(page, { pipeline_id: pipelineId, pipeline_name: pipeline.name, matched_by: matchedBy, - reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + reason: "Pipeline has no ai.runner.id.", }; } if (expectedRunnerId && runnerId !== expectedRunnerId) { @@ -451,6 +464,31 @@ async function inspectAndPatchPipelineConfig(page, { ? runnerConfigs[runnerId] : {}; const patchKeys = Object.keys(runnerConfigPatch || {}); + const sensitiveConfigKeyRe = /(?:api[_-]?key|authorization|bearer|credential|jwt|oauth|password|secret)/i; + const sanitizeConfigValue = (key, value, depth = 0) => { + if (sensitiveConfigKeyRe.test(String(key))) return "[redacted]"; + if (typeof value === "string") { + return value.length > 1200 ? `${value.slice(0, 1200)}...[truncated ${value.length - 1200} chars]` : value; + } + if (Array.isArray(value)) { + const items = value.slice(0, 50).map((item) => sanitizeConfigValue(key, item, depth + 1)); + if (value.length > 50) items.push(`[truncated ${value.length - 50} items]`); + return items; + } + if (value && typeof value === "object") { + if (depth >= 3) return "[object truncated]"; + return Object.fromEntries( + Object.entries(value).map(([childKey, childValue]) => [ + childKey, + sanitizeConfigValue(childKey, childValue, depth + 1), + ]), + ); + } + return value; + }; + const pickPatchValues = (config) => Object.fromEntries( + patchKeys.map((key) => [key, sanitizeConfigValue(key, config?.[key])]), + ); const baseDiagnostic = { status: "ready", authenticated: true, @@ -462,6 +500,7 @@ async function inspectAndPatchPipelineConfig(page, { expected_runner_id: expectedRunnerId || "", patch_keys: patchKeys, runner_config_before_keys: Object.keys(currentRunnerConfig), + runner_config_patch_before: pickPatchValues(currentRunnerConfig), patched: patchKeys.length > 0, }; @@ -506,6 +545,7 @@ async function inspectAndPatchPipelineConfig(page, { put_status: update.status, put_code: update.json.code ?? null, runner_config_after_keys: Object.keys(updatedRunnerConfig), + runner_config_patch_after: pickPatchValues(updatedRunnerConfig), restore_config: config, }; }, { @@ -548,6 +588,201 @@ async function restorePipelineConfig(page, { backendUrl, pipelineId, config }) { }, { backendUrl, pipelineId, config }); } +async function inspectAndPatchPipelineExtensions(page, { + backendUrl, + pipelineId, + extensionsPatch, +}) { + return await page.evaluate(async ({ + backendUrl, + pipelineId, + extensionsPatch, + }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + reason: "Browser profile has no localStorage token.", + }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const putJson = async (path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method: "PUT", + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + if (!pipelineId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + reason: "Pipeline id is required before patching extensions.", + }; + } + + const before = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}/extensions`); + let extensions = before.json.data || {}; + let getStatus = before.status; + let getCode = before.json.code ?? null; + let fallbackReason = ""; + if (before.status >= 400 || before.json.code !== 0) { + const pipelineResponse = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = pipelineResponse.json.data?.pipeline || {}; + const prefs = pipeline.extensions_preferences || {}; + if (pipelineResponse.status >= 400 || pipelineResponse.json.code !== 0 || !pipeline.uuid) { + return { + status: "fail", + authenticated: true, + pipeline_id: pipelineId, + get_status: before.status, + get_code: before.json.code ?? null, + fallback_pipeline_status: pipelineResponse.status, + fallback_pipeline_code: pipelineResponse.json.code ?? null, + reason: before.json.msg || "Could not load pipeline extensions.", + }; + } + fallbackReason = before.json.msg || "Could not load pipeline extensions; restored from pipeline preferences."; + extensions = { + enable_all_plugins: prefs.enable_all_plugins ?? true, + enable_all_mcp_servers: prefs.enable_all_mcp_servers ?? true, + enable_all_skills: prefs.enable_all_skills ?? true, + bound_plugins: prefs.plugins || [], + bound_mcp_servers: prefs.mcp_servers || [], + bound_skills: prefs.skills || [], + }; + } + + const patchKeys = Object.keys(extensionsPatch || {}); + const restoreExtensions = { + enable_all_plugins: extensions.enable_all_plugins ?? true, + enable_all_mcp_servers: extensions.enable_all_mcp_servers ?? true, + enable_all_skills: extensions.enable_all_skills ?? true, + bound_plugins: extensions.bound_plugins || [], + bound_mcp_servers: extensions.bound_mcp_servers || [], + bound_skills: extensions.bound_skills || [], + }; + const baseDiagnostic = { + status: "ready", + authenticated: true, + pipeline_id: pipelineId, + patch_keys: patchKeys, + patched: patchKeys.length > 0, + get_status: getStatus, + get_code: getCode, + fallback_reason: fallbackReason, + extensions_before: { + enable_all_plugins: restoreExtensions.enable_all_plugins, + enable_all_mcp_servers: restoreExtensions.enable_all_mcp_servers, + enable_all_skills: restoreExtensions.enable_all_skills, + bound_plugins: restoreExtensions.bound_plugins, + bound_mcp_servers: restoreExtensions.bound_mcp_servers, + bound_skills: restoreExtensions.bound_skills, + }, + }; + + if (patchKeys.length === 0) { + return baseDiagnostic; + } + + const updateBody = { + enable_all_plugins: Object.prototype.hasOwnProperty.call(extensionsPatch, "enable_all_plugins") + ? Boolean(extensionsPatch.enable_all_plugins) + : restoreExtensions.enable_all_plugins, + enable_all_mcp_servers: Object.prototype.hasOwnProperty.call(extensionsPatch, "enable_all_mcp_servers") + ? Boolean(extensionsPatch.enable_all_mcp_servers) + : restoreExtensions.enable_all_mcp_servers, + enable_all_skills: Object.prototype.hasOwnProperty.call(extensionsPatch, "enable_all_skills") + ? Boolean(extensionsPatch.enable_all_skills) + : restoreExtensions.enable_all_skills, + bound_plugins: Object.prototype.hasOwnProperty.call(extensionsPatch, "bound_plugins") + ? extensionsPatch.bound_plugins + : restoreExtensions.bound_plugins, + bound_mcp_servers: Object.prototype.hasOwnProperty.call(extensionsPatch, "bound_mcp_servers") + ? extensionsPatch.bound_mcp_servers + : restoreExtensions.bound_mcp_servers, + bound_skills: Object.prototype.hasOwnProperty.call(extensionsPatch, "bound_skills") + ? extensionsPatch.bound_skills + : restoreExtensions.bound_skills, + }; + + const update = await putJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}/extensions`, updateBody); + if (update.status >= 400 || update.json.code !== 0) { + return { + ...baseDiagnostic, + status: "fail", + put_status: update.status, + put_code: update.json.code ?? null, + reason: update.json.msg || "Pipeline extensions update failed.", + }; + } + + return { + ...baseDiagnostic, + put_status: update.status, + put_code: update.json.code ?? null, + extensions_after: updateBody, + restore_extensions: restoreExtensions, + }; + }, { + backendUrl, + pipelineId, + extensionsPatch, + }); +} + +async function restorePipelineExtensions(page, { backendUrl, pipelineId, extensions }) { + return await page.evaluate(async ({ backendUrl, pipelineId, extensions }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch(`${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/extensions`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(extensions), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 || json.code !== 0 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + put_status: response.status, + put_code: json.code ?? null, + reason: response.status >= 400 || json.code !== 0 + ? json.msg || "Pipeline extensions restore failed." + : "Pipeline extensions restored.", + }; + }, { backendUrl, pipelineId, extensions }); +} + async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionType }) { return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { const token = localStorage.getItem("token"); @@ -590,11 +825,13 @@ try { const promptSteps = promptStepsFromEnv(); const filesystemChecks = parseJsonEnv("LANGBOT_E2E_FILESYSTEM_CHECKS_JSON", []); const runnerConfigPatch = parseJsonEnv("LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON", {}); + const extensionsPatch = parseJsonEnv("LANGBOT_E2E_EXTENSIONS_PATCH_JSON", {}); const runnerPatchKeys = Object.keys(runnerConfigPatch); - if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + const extensionsPatchKeys = Object.keys(extensionsPatch); + if (runnerPatchKeys.length > 0 || extensionsPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { if (!backendUrl) { result.status = "env_issue"; - result.reason = "LANGBOT_BACKEND_URL is required for runner config patch, runner assertion, or Debug Chat reset."; + result.reason = "LANGBOT_BACKEND_URL is required for runner config patch, extensions patch, runner assertion, or Debug Chat reset."; throw new Error(result.reason); } } @@ -602,13 +839,30 @@ try { result.prompt = promptSteps.length === 1 ? promptSteps[0].prompt : `${promptSteps.length} prompts`; result.expected_text = promptSteps.at(-1)?.expectedText || expectedText; - const openResult = await openPipelineDebugChat(page, { - pipelineUrl, - pipelineName, - envHint: pipelineRequired - ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" - : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + const authDiagnostic = await ensureAuthenticatedBrowser(page, { + frontendUrl: env.LANGBOT_FRONTEND_URL || "", + backendUrl, }); + result.browser_auth = authDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + const authFailed = authDiagnostic.status === "env_issue" || authDiagnostic.status === "blocked" || authDiagnostic.status === "fail"; + if (authFailed) { + result.status = authDiagnostic.status; + result.reason = authDiagnostic.reason || "Browser authentication failed."; + } else { + result.status = "running"; + result.reason = ""; + } + + const openResult = authFailed + ? { opened: false, status: result.status, reason: result.reason } + : await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: pipelineRequired + ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" + : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + }); result.url = page.url(); if (!openResult.opened) { @@ -617,7 +871,7 @@ try { } else { result.status = "running"; result.reason = ""; - if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + if (runnerPatchKeys.length > 0 || extensionsPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { const pipelineDiagnostic = await inspectAndPatchPipelineConfig(page, { backendUrl, pipelineUrl, @@ -636,13 +890,35 @@ try { result.reason = pipelineDiagnostic.reason || "Pipeline config preparation failed."; } else { if (pipelineDiagnostic.restore_config && restoreRunnerConfig) { - restorePlan = { + restoreConfigPlan = { backendUrl, pipelineId: pipelineDiagnostic.pipeline_id, config: pipelineDiagnostic.restore_config, }; } - if (resetDebugChat) { + if (extensionsPatchKeys.length > 0) { + const extensionsDiagnostic = await inspectAndPatchPipelineExtensions(page, { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + extensionsPatch, + }); + const safeExtensionsDiagnostic = sanitizePipelineExtensionsDiagnostic(extensionsDiagnostic); + await writeFile(pipelineExtensionsDiagnosticPath, `${JSON.stringify(safeExtensionsDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_extensions_diagnostic_json = pipelineExtensionsDiagnosticPath; + result.pipeline_extensions = safeExtensionsDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + if (extensionsDiagnostic.status === "fail" || extensionsDiagnostic.status === "blocked") { + result.status = extensionsDiagnostic.status; + result.reason = extensionsDiagnostic.reason || "Pipeline extensions preparation failed."; + } else if (extensionsDiagnostic.restore_extensions && restoreExtensions) { + restoreExtensionsPlan = { + backendUrl, + pipelineId: extensionsDiagnostic.pipeline_id, + extensions: extensionsDiagnostic.restore_extensions, + }; + } + } + if (!["fail", "blocked", "env_issue"].includes(result.status) && resetDebugChat) { const resetDiagnostic = await resetPipelineDebugChat(page, { backendUrl, pipelineId: pipelineDiagnostic.pipeline_id, @@ -720,6 +996,15 @@ try { result.reason = filesystemResult.reason || "Filesystem checks failed."; } } + + if (result.status === "pass" && !boolFromEnv(env.LANGBOT_E2E_ALLOW_BROWSER_ERRORS, false)) { + const browserDiagnostics = await scanBrowserDiagnostics(paths); + result.browser_diagnostics = browserDiagnostics; + if (browserDiagnostics.status === "fail") { + result.status = "fail"; + result.reason = browserDiagnostics.reason; + } + } } } catch (error) { if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { @@ -728,10 +1013,20 @@ try { result.reason = result.reason || error.message; } finally { if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); - if (browser?.page && restorePlan) { - const restoreDiagnostic = await restorePipelineConfig(browser.page, restorePlan).catch((error) => ({ + if (browser?.page && restoreExtensionsPlan) { + const restoreDiagnostic = await restorePipelineExtensions(browser.page, restoreExtensionsPlan).catch((error) => ({ + status: "fail", + pipeline_id: restoreExtensionsPlan.pipelineId, + reason: error.message, + })); + await writeFile(pipelineExtensionsRestoreDiagnosticPath, `${JSON.stringify(restoreDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_extensions_restore_diagnostic_json = pipelineExtensionsRestoreDiagnosticPath; + result.pipeline_extensions_restore = restoreDiagnostic; + } + if (browser?.page && restoreConfigPlan) { + const restoreDiagnostic = await restorePipelineConfig(browser.page, restoreConfigPlan).catch((error) => ({ status: "fail", - pipeline_id: restorePlan.pipelineId, + pipeline_id: restoreConfigPlan.pipelineId, reason: error.message, })); await writeFile(pipelineConfigRestoreDiagnosticPath, `${JSON.stringify(restoreDiagnostic, null, 2)}\n`, "utf8"); diff --git a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs new file mode 100644 index 000000000..b270634e3 --- /dev/null +++ b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs @@ -0,0 +1,348 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + pathExists, + resolveLangBotRepo, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "wizard-onebot-agent-runtime"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +let browser; +let botId = ""; +let agentId = ""; +let token = ""; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + bot_id: "", + agent_id: "", + visible_signals: [], + api: {}, + runtime: {}, + cleanup: {}, + diagnostics: null, + evidence: { + screenshot: paths.screenshot, + console_log: paths.consoleLog, + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +async function getFreePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => server.close(resolve)); + if (!port) throw new Error("Could not allocate a temporary OneBot port."); + return port; +} + +async function api(page, path, options = {}) { + return await page.evaluate( + async ({ baseUrl, path, options, token }) => { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { + method: options.method || "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: + options.body === undefined ? undefined : JSON.stringify(options.body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }, + { baseUrl: backendUrl, path, options, token }, + ); +} + +async function runProbe(port) { + const repo = await resolveLangBotRepo(); + const python = join(repo, ".venv", "bin", "python"); + const script = join( + dirname(fileURLToPath(import.meta.url)), + "onebot-runtime-probe.py", + ); + if (!(await pathExists(python))) { + throw new Error(`LangBot virtualenv Python is missing: ${python}`); + } + return await new Promise((resolve, reject) => { + const child = spawn(python, [script, "--port", String(port)], { + cwd: repo, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(JSON.parse(stdout.trim().split("\n").at(-1))); + } else { + reject( + new Error( + stderr.trim() || stdout.trim() || `OneBot probe exited ${code}`, + ), + ); + } + }); + }); +} + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + const port = await getFreePort(); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser token is unavailable."); + } + + // Keep this case isolated from the instance's real onboarding state. + await page.route("**/api/v1/system/wizard/progress", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ code: 0, msg: "ok", data: {} }), + }); + }); + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, { + waitUntil: "domcontentloaded", + }); + await page + .getByRole("button", { + name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/, + }) + .click(); + await page.getByText("OneBot v11", { exact: true }).last().click(); + + const botResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/v1\/platform\/bots$/.test(new URL(response.url()).pathname), + ); + await page + .getByRole("button", { + name: /Confirm, Create Bot|确定,创建机器人|確定、ボットを作成/, + }) + .click(); + const botPayload = await (await botResponse).json(); + botId = botPayload.data?.uuid || ""; + if (!botId) throw new Error("Wizard did not create a Bot."); + result.bot_id = botId; + result.api.create_bot = { code: botPayload.code ?? null }; + + await page + .getByText(/Configure Your Bot|配置机器人|ボットを設定/) + .first() + .waitFor({ timeout: 15_000 }); + const portInput = page.getByRole("spinbutton").first(); + await portInput.fill(String(port)); + await portInput.press("Tab"); + await page + .getByRole("button", { + name: /Save & Enable Bot|保存并启用|保存して有効化/, + }) + .click(); + await page + .getByText( + /Bot configuration saved and enabled|机器人配置已保存并启用|ボット設定が保存され、有効になりました/, + ) + .waitFor({ timeout: 15_000 }); + result.visible_signals.push("bot-created", "adapter-enabled"); + + await page.getByRole("button", { name: /Next|下一步|次へ/ }).click(); + await page + .getByText(/Local Agent|本地 Agent/) + .first() + .click(); + await page.waitForFunction( + () => + Array.from(document.querySelectorAll("textarea")).some((element) => { + const value = element.value || ""; + return [ + "Welcome new group members", + "欢迎新群成员", + "新しいグループメンバー", + ].some((expected) => value.includes(expected)); + }), + null, + { timeout: 15_000 }, + ); + result.visible_signals.push("scenario-prompt-visible"); + + await page + .getByText(/QA Deterministic Runner|QA 确定性 Runner/) + .first() + .click(); + + const agentResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/v1\/agents$/.test(new URL(response.url()).pathname), + ); + await page + .getByRole("button", { + name: /Create & Deploy|创建并部署|作成&デプロイ/, + }) + .click(); + const agentPayload = await (await agentResponse).json(); + agentId = agentPayload.data?.uuid || ""; + if (!agentId) throw new Error("Wizard did not create an Agent."); + result.agent_id = agentId; + result.api.create_agent = { code: agentPayload.code ?? null }; + await page.getByText(/All Set!|一切就绪!/).waitFor({ timeout: 15_000 }); + result.visible_signals.push("agent-created", "wizard-complete"); + + const savedBot = await api(page, `/api/v1/platform/bots/${botId}`); + const route = savedBot.json.data?.bot?.event_bindings?.[0]; + result.api.saved_route = { + http_status: savedBot.status, + event_pattern: route?.event_pattern || null, + target_type: route?.target_type || null, + target_matches: route?.target_uuid === agentId, + }; + if ( + route?.event_pattern !== "group.member_joined" || + route?.target_type !== "agent" || + route?.target_uuid !== agentId + ) { + throw new Error("Wizard did not persist the expected Agent route."); + } + result.visible_signals.push("route-persisted"); + + result.runtime.onebot = await runProbe(port); + let routeStatus; + for (let attempt = 0; attempt < 30; attempt += 1) { + routeStatus = await api( + page, + `/api/v1/platform/bots/${botId}/event-routes/status`, + ); + if (routeStatus.json.data?.routes?.[0]?.last_status === "delivered") break; + await page.waitForTimeout(250); + } + const latest = routeStatus?.json?.data?.routes?.[0]; + result.runtime.route_status = latest || null; + if (latest?.last_status !== "delivered") { + throw new Error( + `Runtime route did not reach delivered status: ${latest?.last_status || "none"}`, + ); + } + result.visible_signals.push( + "platform-event-converted", + "agent-ran", + "reply-delivered", + ); + + const botUrl = `${frontendUrl.replace(/\/$/, "")}/home/bots?id=${encodeURIComponent(botId)}`; + await page.goto(botUrl, { waitUntil: "domcontentloaded" }); + result.url = page.url(); + const deliveredStatus = page + .getByText(/Delivered|已投递|配信済み/, { exact: true }) + .first(); + await deliveredStatus.waitFor({ timeout: 15_000 }); + await deliveredStatus.scrollIntoViewIfNeeded(); + await page.waitForTimeout(250); + result.visible_signals.push("delivered-status-visible"); + await safeScreenshot(page, paths.screenshot); + + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Quick Start created an enabled OneBot scenario that converted a platform event, ran an Agent, delivered its reply, and showed the final status."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + if (browser?.page && token) { + if (botId) { + const deleted = await api( + browser.page, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE" }, + ).catch(() => ({ status: 0, json: {} })); + result.cleanup.bot_deleted = + deleted.status < 400 && deleted.json.code === 0; + } + if (agentId) { + const deleted = await api( + browser.page, + `/api/v1/agents/${encodeURIComponent(agentId)}`, + { method: "DELETE" }, + ).catch(() => ({ status: 0, json: {} })); + result.cleanup.agent_deleted = + deleted.status < 400 && deleted.json.code === 0; + } + } + const cleanupFailed = + (botId && !result.cleanup.bot_deleted) || + (agentId && !result.cleanup.agent_deleted); + if (cleanupFailed && result.status === "pass") { + result.status = "fail"; + result.reason = "The temporary OneBot wizard resources were not deleted."; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs b/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs new file mode 100644 index 000000000..d3fad74d3 --- /dev/null +++ b/skills/scripts/e2e/wizard-runner-marketplace-catalog.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +import { + apiJson, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "wizard-runner-marketplace-catalog"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); +const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +let browser; +let token = ""; +let botId = ""; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + visible_signals: [], + api: {}, + marketplace_request: null, + diagnostics: null, + cleanup: {}, + evidence: { + screenshot: paths.screenshot, + mobile_screenshot: mobileScreenshot, + console_log: paths.consoleLog, + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + page.on("request", (request) => { + if ( + !/\/api\/v1\/marketplace\/(extensions|plugins)\/search$/.test( + new URL(request.url()).pathname, + ) + ) { + return; + } + try { + const payload = request.postDataJSON(); + if (payload?.component_filter === "AgentRunner") { + result.marketplace_request = { + endpoint: new URL(request.url()).pathname, + component_filter: payload.component_filter, + type_filter: payload.type_filter || null, + page_size: payload.page_size || null, + }; + } + } catch { + // The assertion below reports a missing or malformed catalog request. + } + }); + + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser token is unavailable."); + } + + const [plugins, metadata, system] = await Promise.all([ + apiJson(backendUrl, "/api/v1/plugins", { token }), + apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }), + apiJson(backendUrl, "/api/v1/system/info", { token }), + ]); + const runnerStage = metadata.json.data?.configs + ?.find((config) => config.name === "ai") + ?.stages?.find((stage) => stage.name === "runner"); + const runnerOptions = + runnerStage?.config?.find((item) => item.name === "id")?.options || []; + const installedPlugins = plugins.json.data?.plugins || []; + result.api.clean_state = { + plugin_count: installedPlugins.length, + runner_count: runnerOptions.length, + wizard_status: system.json.data?.wizard_status || null, + }; + if (installedPlugins.length !== 0 || runnerOptions.length !== 0) { + result.status = "blocked"; + throw new Error( + `This case requires zero plugins and zero runners; found ${installedPlugins.length} plugins and ${runnerOptions.length} runners.`, + ); + } + if (system.json.data?.wizard_status !== "none") { + result.status = "blocked"; + throw new Error( + `This case requires a first-run instance; wizard status is ${system.json.data?.wizard_status}.`, + ); + } + + const suffix = paths.runId.slice(-40); + const bot = await apiJson(backendUrl, "/api/v1/platform/bots", { + method: "POST", + token, + body: { + name: `Runner Catalog Wizard ${suffix}`, + description: "Temporary clean-runner catalog fixture", + adapter: "aiocqhttp", + adapter_config: { + host: "127.0.0.1", + port: 2280, + "access-token": "", + }, + enable: false, + event_bindings: [], + }, + }); + botId = bot.json.data?.uuid || ""; + if (bot.status >= 400 || bot.json.code !== 0 || !botId) { + throw new Error(bot.json.msg || "Failed to create the temporary Bot."); + } + + const progress = await apiJson(backendUrl, "/api/v1/system/wizard/progress", { + method: "PUT", + token, + body: { + step: 2, + selected_scenario: "message_reply", + selected_adapter: "aiocqhttp", + created_bot_uuid: botId, + bot_saved: true, + selected_runner: null, + }, + }); + if (progress.status >= 400 || progress.json.code !== 0) { + throw new Error(progress.json.msg || "Failed to prepare Wizard progress."); + } + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, { + waitUntil: "domcontentloaded", + }); + result.url = page.url(); + await page + .getByText(/Select an AI Engine|选择 AI 引擎|AIエンジンを選択/, { + exact: true, + }) + .waitFor({ timeout: 15_000 }); + await page + .getByText( + /No AgentRunner extensions are published yet|市场暂未发布 AgentRunner 扩展|AgentRunner 拡張機能はまだ公開されていません/, + { exact: true }, + ) + .waitFor({ timeout: 15_000 }); + const browseLink = page.getByRole("link", { + name: /Browse Runner Extensions|浏览运行器扩展|Runner 拡張機能を見る/, + }); + await browseLink.waitFor(); + const href = await browseLink.getAttribute("href"); + if (href !== "/home/extensions?type=plugin&component=AgentRunner") { + throw new Error(`Unexpected Runner marketplace URL: ${href}`); + } + const nextButton = page.getByRole("button", { + name: /Create & Deploy|创建并部署|作成&デプロイ/, + }); + if (!(await nextButton.isDisabled())) { + throw new Error("Wizard allowed continuing without an installed Runner."); + } + if (!result.marketplace_request) { + throw new Error( + "Wizard did not request the AgentRunner Marketplace catalog.", + ); + } + + result.visible_signals.push( + "first-run-ai-engine-step", + "empty-runner-catalog-state", + "runner-marketplace-link", + "next-disabled-without-runner", + ); + await safeScreenshot(page, paths.screenshot); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + if (overflow > 1) { + throw new Error(`Runner catalog Wizard overflows mobile by ${overflow}px.`); + } + await browseLink.scrollIntoViewIfNeeded(); + await safeScreenshot(page, mobileScreenshot); + result.visible_signals.push("mobile-layout"); + + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "A clean first-run instance requested the AgentRunner catalog, showed a safe empty state, and prevented continuing without a Runner."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + const cleanup = {}; + if (token && backendUrl) { + const resetProgress = await apiJson( + backendUrl, + "/api/v1/system/wizard/progress", + { + method: "PUT", + token, + body: { + step: 0, + selected_scenario: null, + selected_adapter: null, + created_bot_uuid: null, + bot_saved: false, + selected_runner: null, + }, + }, + ).catch(() => ({ status: 0, json: {} })); + cleanup.progress_reset = + resetProgress.status < 400 && resetProgress.json.code === 0; + } + if (botId && token && backendUrl) { + const deletedBot = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE", token }, + ).catch(() => ({ status: 0, json: {} })); + cleanup.bot_deleted = deletedBot.status < 400 && deletedBot.json.code === 0; + } + result.cleanup = cleanup; + if ( + result.status === "pass" && + (!cleanup.progress_reset || (botId && !cleanup.bot_deleted)) + ) { + result.status = "fail"; + result.reason = "The clean Wizard fixtures were not fully reset."; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/wizard-scenario-routing.mjs b/skills/scripts/e2e/wizard-scenario-routing.mjs new file mode 100644 index 000000000..a0e0fc2a5 --- /dev/null +++ b/skills/scripts/e2e/wizard-scenario-routing.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +import { + apiJson, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "wizard-scenario-routing"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); +const messageScreenshot = paths.screenshot.replace( + /\.png$/, + "-message-scenario.png", +); +const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; + +let browser; +let token = ""; +let botId = ""; +let agentId = ""; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + visible_signals: [], + api: {}, + diagnostics: null, + cleanup: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + message_scenario_screenshot: messageScreenshot, + mobile_screenshot: mobileScreenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser has no reusable local token."); + } + + const adapters = await apiJson(backendUrl, "/api/v1/platform/adapters", { + token, + }); + const catalog = adapters.json.data?.adapters || []; + const httpBot = catalog.find((adapter) => adapter.name === "http_bot"); + const welcomeAdapters = catalog.filter((adapter) => + (adapter.spec?.supported_events || []).includes("group.member_joined"), + ); + result.api.adapters = { + http_status: adapters.status, + code: adapters.json.code ?? null, + http_bot_supports_message: + !httpBot?.spec?.supported_events?.length || + httpBot.spec.supported_events.includes("message.received"), + http_bot_supports_member_joined: + httpBot?.spec?.supported_events?.includes("group.member_joined") || false, + welcome_adapter_count: welcomeAdapters.length, + }; + if (adapters.status >= 400 || adapters.json.code !== 0) { + throw new Error(adapters.json.msg || "Adapter discovery failed."); + } + if (!httpBot || welcomeAdapters.length === 0) { + throw new Error( + "The adapter catalog does not contain the fixtures required by this case.", + ); + } + if (result.api.adapters.http_bot_supports_member_joined) { + throw new Error( + "HTTP Bot unexpectedly declares group.member_joined support; update the case expectation.", + ); + } + + const progressUrl = `${backendUrl.replace(/\/$/, "")}/api/v1/system/wizard/progress`; + await page.route(progressUrl, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ code: 0, msg: "ok", data: {} }), + }); + }); + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, { + waitUntil: "domcontentloaded", + }); + result.url = page.url(); + await page + .getByText( + /What should this bot do\?|这个机器人要完成什么?|このボットで何を実現しますか?/, + ) + .waitFor({ timeout: 15_000 }); + + await page + .getByRole("button", { + name: /Reply to messages|回复收到的消息|受信メッセージに返信/, + }) + .click(); + await page + .getByText(/HTTP Bot|HTTP 通用接入|HTTP ボット/, { exact: true }) + .waitFor(); + result.visible_signals.push("scenario-first", "message-http-compatible"); + await safeScreenshot(page, messageScreenshot); + + await page + .getByRole("button", { + name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/, + }) + .click(); + await page + .getByText(/Discord/, { exact: true }) + .first() + .waitFor(); + const httpBotCount = await page + .getByText(/HTTP Bot|HTTP 通用接入|HTTP ボット/, { exact: true }) + .count(); + if (httpBotCount !== 0) { + throw new Error("HTTP Bot remained visible for group.member_joined."); + } + const selectedScenario = page.getByRole("button", { + name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/, + }); + if ( + !(await selectedScenario.getByText("Agent", { exact: true }).isVisible()) + ) { + throw new Error( + "The non-message scenario is not visibly labeled as Agent.", + ); + } + result.visible_signals.push( + "welcome-compatible-channel", + "http-filtered", + "agent-behavior-label", + ); + + await safeScreenshot(page, paths.screenshot); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + const horizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + if (horizontalOverflow > 1) { + throw new Error( + `The mobile wizard overflows horizontally by ${horizontalOverflow}px.`, + ); + } + await selectedScenario.scrollIntoViewIfNeeded(); + await page + .getByText(/Discord/, { exact: true }) + .first() + .waitFor(); + await safeScreenshot(page, mobileScreenshot); + result.visible_signals.push("mobile-layout"); + + const fixtureSuffix = paths.runId.slice(-48); + const agent = await apiJson(backendUrl, "/api/v1/agents", { + method: "POST", + token, + body: { + kind: "agent", + name: `Wizard Scenario Agent ${fixtureSuffix}`, + description: "Temporary wizard scenario routing fixture", + emoji: "W", + enabled: true, + supported_event_patterns: ["group.member_joined"], + }, + }); + agentId = agent.json.data?.uuid || ""; + result.api.create_agent = { + http_status: agent.status, + code: agent.json.code ?? null, + }; + if (agent.status >= 400 || agent.json.code !== 0 || !agentId) { + throw new Error(agent.json.msg || "Failed to create the temporary Agent."); + } + + const bot = await apiJson(backendUrl, "/api/v1/platform/bots", { + method: "POST", + token, + body: { + name: `Wizard Scenario Bot ${fixtureSuffix}`, + description: "Temporary disabled wizard scenario routing fixture", + adapter: "aiocqhttp", + adapter_config: { + host: "127.0.0.1", + port: 2280, + "access-token": "", + }, + enable: false, + event_bindings: [], + }, + }); + botId = bot.json.data?.uuid || ""; + result.api.create_bot = { + http_status: bot.status, + code: bot.json.code ?? null, + }; + if (bot.status >= 400 || bot.json.code !== 0 || !botId) { + throw new Error(bot.json.msg || "Failed to create the temporary Bot."); + } + + const update = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { + method: "PUT", + token, + body: { + event_bindings: [ + { + event_pattern: "group.member_joined", + target_type: "agent", + target_uuid: agentId, + filters: [], + priority: 0, + enabled: true, + description: "Welcome new members", + }, + ], + }, + }, + ); + result.api.bind_route = { + http_status: update.status, + code: update.json.code ?? null, + }; + if (update.status >= 400 || update.json.code !== 0) { + throw new Error(update.json.msg || "Failed to bind the temporary route."); + } + + const savedBot = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { token }, + ); + const savedRoute = savedBot.json.data?.bot?.event_bindings?.[0]; + result.api.read_route = { + http_status: savedBot.status, + code: savedBot.json.code ?? null, + event_pattern: savedRoute?.event_pattern || null, + target_type: savedRoute?.target_type || null, + target_matches: savedRoute?.target_uuid === agentId, + }; + if ( + savedBot.status >= 400 || + savedBot.json.code !== 0 || + savedRoute?.event_pattern !== "group.member_joined" || + savedRoute?.target_type !== "agent" || + savedRoute?.target_uuid !== agentId + ) { + throw new Error("The saved Bot did not return the expected Agent route."); + } + result.visible_signals.push("agent-route-persisted"); + + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Quick Start visibly filtered channels by scenario at desktop and mobile widths."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + const cleanup = {}; + if (botId && token && backendUrl) { + const deletedBot = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + cleanup.bot_deleted = deletedBot.status < 400 && deletedBot.json.code === 0; + cleanup.bot_http_status = deletedBot.status; + } + if (agentId && token && backendUrl) { + const deletedAgent = await apiJson( + backendUrl, + `/api/v1/agents/${encodeURIComponent(agentId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + cleanup.agent_deleted = + deletedAgent.status < 400 && deletedAgent.json.code === 0; + cleanup.agent_http_status = deletedAgent.status; + } + result.cleanup = cleanup; + const cleanupFailed = + (botId && !cleanup.bot_deleted) || (agentId && !cleanup.agent_deleted); + if (cleanupFailed && result.status === "pass") { + result.status = "fail"; + result.reason = "The temporary wizard routing fixtures were not deleted."; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/skills.index.json b/skills/skills.index.json index 640996adc..7590d65eb 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -64,7 +64,7 @@ { "directory": "langbot-mcp-ops", "name": "langbot-mcp-ops", - "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", + "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", "references": [], "cases": [], "case_summaries": [], @@ -134,6 +134,7 @@ "references/pipeline-debug-chat.md", "references/plugin-e2e-smoke.md", "references/sandbox-skill-authoring.md", + "references/skill-all-tool-acceptance.md", "references/troubleshooting.md", "references/web-ui-testing.md" ], @@ -142,6 +143,7 @@ "agent-runner-async-db-readiness", "agent-runner-behavior-matrix", "agent-runner-fixture-contract", + "agent-runner-health-visibility", "agent-runner-ledger-concurrency", "agent-runner-ledger-contention", "agent-runner-ledger-invariants", @@ -150,6 +152,7 @@ "agent-runner-qa-debug-chat", "agent-runner-release-preflight", "agent-runner-runtime-chaos", + "bot-event-routing-product-flow", "dify-agent-debug-chat", "langbot-fake-provider-debug-chat-cross-pipeline-isolation", "langbot-fake-provider-debug-chat-fault-recovery", @@ -165,14 +168,19 @@ "langrag-parser-golden-e2e", "langrag-sentinel-kb-discover", "local-agent-basic-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", "local-agent-context-compaction-debug-chat", "local-agent-effective-prompt-debug-chat", "local-agent-multimodal-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", "local-agent-nonstreaming-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat", "local-agent-plugin-tool-call-debug-chat", "local-agent-rag-debug-chat", "local-agent-rag-multimodal-debug-chat", "local-agent-steering-debug-chat", + "local-agent-tool-error-recovery-debug-chat", + "local-agent-tool-loop-limit-debug-chat", "mcp-stdio-register", "mcp-stdio-tool-call", "pipeline-debug-chat", @@ -182,7 +190,11 @@ "qa-plugin-smoke-live-install", "sandbox-skill-authoring-e2e", "sandbox-skill-authoring-edit-existing-e2e", - "webui-login-state" + "skill-discovery-via-mcp-gateway", + "webui-login-state", + "wizard-onebot-agent-runtime", + "wizard-runner-marketplace-catalog", + "wizard-scenario-routing" ], "case_summaries": [ { @@ -281,6 +293,31 @@ "filesystem" ] }, + { + "id": "agent-runner-health-visibility", + "title": "Agent configuration shows whether its selected runner is usable", + "mode": "agent-browser", + "area": "agent", + "type": "feature", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "agent-runner", + "agent", + "productization", + "release-gate" + ], + "automation": "scripts/e2e/agent-runner-health-visibility.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] + }, { "id": "agent-runner-ledger-concurrency", "title": "AgentRunner run ledger concurrency and auth pytest probe", @@ -475,6 +512,31 @@ "filesystem" ] }, + { + "id": "bot-event-routing-product-flow", + "title": "Bot event routing can be configured and tested from the WebUI", + "mode": "agent-browser", + "area": "bot", + "type": "feature", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "bot", + "eba", + "event-routing", + "productization" + ], + "automation": "scripts/e2e/bot-event-routing-product-flow.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] + }, { "id": "dify-agent-debug-chat", "title": "Dify AgentRunner returns a response through Pipeline Debug Chat", @@ -909,6 +971,43 @@ "backend_log" ] }, + { + "id": "local-agent-combo-rag-compaction-tool-debug-chat", + "title": "Local Agent preserves RAG, compacted history, and plugin tool result in one Debug Chat run", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "langrag", + "context", + "compaction", + "plugin", + "tools", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, { "id": "local-agent-context-compaction-debug-chat", "title": "Local Agent compacts long Debug Chat history and preserves older facts", @@ -1000,9 +1099,47 @@ "backend_log" ] }, + { + "id": "local-agent-multitool-rag-compaction-debug-chat", + "title": "Local Agent preserves RAG, compacted history, and two-step plugin tool loop", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "langrag", + "context", + "compaction", + "plugin", + "tools", + "tool-loop", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, { "id": "local-agent-nonstreaming-debug-chat", - "title": "Local Agent Debug Chat returns a deterministic non-streaming response", + "title": "Local Agent Debug Chat returns a deterministic response with UI streaming disabled", "mode": "agent-browser", "area": "pipeline", "type": "regression", @@ -1028,6 +1165,44 @@ "backend_log" ] }, + { + "id": "local-agent-parallel-tools-rag-compaction-debug-chat", + "title": "Local Agent preserves RAG, compacted history, and parallel plugin tools", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "langrag", + "context", + "compaction", + "plugin", + "tools", + "parallel-tools", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, { "id": "local-agent-plugin-tool-call-debug-chat", "title": "Local Agent can call a plugin-provided tool", @@ -1155,6 +1330,73 @@ "api_diagnostic" ] }, + { + "id": "local-agent-tool-error-recovery-debug-chat", + "title": "Local Agent feeds plugin tool errors back to the model", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "plugin", + "tools", + "tool-error", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-tool-loop-limit-debug-chat", + "title": "Local Agent stops repeated tool calls at max-tool-iterations", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "plugin", + "tools", + "tool-loop", + "limit", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, { "id": "mcp-stdio-register", "title": "MCP stdio fixture is registered and exposes qa_mcp_echo", @@ -1201,7 +1443,8 @@ ], "setup_provides_env": [ "LANGBOT_LOCAL_AGENT_PIPELINE_URL", - "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_MCP_QA_STDIO_SERVER_UUID" ], "evidence_required": [ "ui", @@ -1387,6 +1630,31 @@ "filesystem" ] }, + { + "id": "skill-discovery-via-mcp-gateway", + "title": "External harness discovers LangBot skills via langbot_list_assets (all-tool model)", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "skills", + "mcp-gateway", + "acp-agent-runner", + "all-tool-model", + "tools" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "backend_log" + ] + }, { "id": "webui-login-state", "title": "Configured frontend opens with authenticated LangBot WebUI state", @@ -1408,11 +1676,89 @@ "screenshot", "console" ] + }, + { + "id": "wizard-onebot-agent-runtime", + "title": "Quick Start runs a real OneBot member event through an Agent", + "mode": "agent-browser", + "area": "wizard", + "type": "feature", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "wizard", + "eba", + "onebot", + "agent-runner", + "release-gate" + ], + "automation": "scripts/e2e/wizard-onebot-agent-runtime.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] + }, + { + "id": "wizard-runner-marketplace-catalog", + "title": "Quick Start discovers AgentRunner extensions on a clean instance", + "mode": "agent-browser", + "area": "wizard", + "type": "feature", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "wizard", + "agent-runner", + "marketplace", + "clean-install", + "productization" + ], + "automation": "scripts/e2e/wizard-runner-marketplace-catalog.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] + }, + { + "id": "wizard-scenario-routing", + "title": "Quick Start filters channels by the selected bot scenario", + "mode": "agent-browser", + "area": "wizard", + "type": "feature", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "wizard", + "eba", + "event-routing", + "productization" + ], + "automation": "scripts/e2e/wizard-scenario-routing.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] } ], "suites": [ "agent-runner-release-gate", "core-smoke", + "eba-release-gate", "langbot-debug-chat-isolation-gate", "langbot-debug-chat-load-gate", "langbot-live-backend-gate", @@ -1456,6 +1802,11 @@ "local-agent-context-compaction-debug-chat", "local-agent-rag-debug-chat", "local-agent-plugin-tool-call-debug-chat", + "local-agent-tool-error-recovery-debug-chat", + "local-agent-tool-loop-limit-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat", "mcp-stdio-register", "mcp-stdio-tool-call", "local-agent-nonstreaming-debug-chat", @@ -1481,6 +1832,26 @@ "local-agent-basic-debug-chat" ] }, + { + "id": "eba-release-gate", + "title": "Event-based bot product release gate", + "description": "Browser and runtime gate for scenario onboarding, route diagnostics, and real platform-to-Agent execution.", + "type": "release_gate", + "priority": "p0", + "tags": [ + "eba", + "event-routing", + "wizard", + "release-gate" + ], + "cases": [ + "wizard-scenario-routing", + "wizard-runner-marketplace-catalog", + "agent-runner-health-visibility", + "bot-event-routing-product-flow", + "wizard-onebot-agent-runtime" + ] + }, { "id": "langbot-debug-chat-isolation-gate", "title": "LangBot Debug Chat isolation gate", @@ -1606,6 +1977,11 @@ "local-agent-context-compaction-debug-chat", "local-agent-rag-debug-chat", "local-agent-plugin-tool-call-debug-chat", + "local-agent-tool-error-recovery-debug-chat", + "local-agent-tool-loop-limit-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat", "local-agent-steering-debug-chat", "mcp-stdio-tool-call", "local-agent-nonstreaming-debug-chat", @@ -1665,7 +2041,10 @@ "path": "fixtures/rag/sentinel-doc.txt", "related_cases": [ "langrag-kb-retrieve", - "local-agent-rag-debug-chat" + "local-agent-rag-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat" ] }, { @@ -1697,6 +2076,11 @@ "plugin-e2e-smoke", "local-agent-effective-prompt-debug-chat", "local-agent-plugin-tool-call-debug-chat", + "local-agent-tool-error-recovery-debug-chat", + "local-agent-tool-loop-limit-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat", "local-agent-steering-debug-chat" ] }, @@ -1707,7 +2091,8 @@ "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", "related_cases": [ "qa-plugin-smoke-live-install", - "plugin-e2e-smoke" + "plugin-e2e-smoke", + "local-agent-tool-error-recovery-debug-chat" ] } ], diff --git a/skills/skills/langbot-env-setup/references/service-startup.md b/skills/skills/langbot-env-setup/references/service-startup.md index b63960cdb..ddff574c5 100644 --- a/skills/skills/langbot-env-setup/references/service-startup.md +++ b/skills/skills/langbot-env-setup/references/service-startup.md @@ -24,7 +24,7 @@ Healthy startup includes: ```text Running on http://0.0.0.0: Connected to plugin runtime. -Plugin langbot/local-agent initialized +Plugin langbot-team/LocalAgent initialized ``` Quick check: diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 94f25a3a4..0b17e1a60 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -1,6 +1,6 @@ --- name: langbot-mcp-ops -description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". +description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, agents, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". --- # LangBot MCP Operations @@ -58,6 +58,8 @@ The tools wrap the LangBot service layer. Current tools (v1): | --- | --- | | `get_system_info` | Version, edition, instance id | | `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | +| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages | +| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types | | `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | | `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | | `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) | @@ -68,6 +70,12 @@ Mutating tools (`create_*`, `update_*`) take a JSON object matching the same shape as the corresponding HTTP API request body. Discover resources with the `list_*` / `get_*` tools before mutating; identifiers are UUIDs. +`test_bot_event_route` uses the bot's saved runtime route table, injects a +synthetic event such as `message.received`, and suppresses platform delivery. +It still executes the selected processor, so tools and external services may +have side effects. Use `payload` for sample event fields, for example +`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`. + ## How to use 1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). diff --git a/skills/skills/langbot-plugin-dev/SKILL.md b/skills/skills/langbot-plugin-dev/SKILL.md index d7858c40e..579364393 100644 --- a/skills/skills/langbot-plugin-dev/SKILL.md +++ b/skills/skills/langbot-plugin-dev/SKILL.md @@ -422,7 +422,7 @@ When a plugin doesn't work: 3. **Verify plugin loaded**: `GET /api/v1/plugins` — should list your plugin 4. **Test person mode first**: `session_type=person` always triggers pipeline, isolating trigger rule issues 5. **Check trigger rules**: Group mode requires @bot, prefix match, or random% to enter pipeline -6. **Verify model configured**: Pipeline's `config.ai.local-agent.model.primary` must point to a valid model UUID with working API keys +6. **Verify model configured**: Pipeline's `config.ai.runner_config[config.ai.runner.id].model.primary` must point to a valid model UUID with working API keys ## Publishing Plugins diff --git a/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml index ae10cea66..7a246267b 100644 --- a/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml @@ -34,7 +34,7 @@ automation_env: - LANGBOT_E2E_RESPONSE_TIMEOUT_MS automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME -automation_expected_runner_id: "plugin:langbot/acp-agent-runner/default" +automation_expected_runner_id: "plugin:langbot-team/ACPAgentRunner/default" automation_prompt: "Use the injected LangBot MCP server tool langbot_get_current_event once. If the MCP call succeeds, reply with exactly ACP_AGENT_RUNNER_E2E_OK." automation_expected_text: "ACP_AGENT_RUNNER_E2E_OK" automation_response_timeout_ms: "300000" @@ -49,7 +49,7 @@ preconditions: steps: - "Open LANGBOT_FRONTEND_URL." - "Open the ACP AgentRunner QA pipeline." - - "Confirm the pipeline AI runner is plugin:langbot/acp-agent-runner/default." + - "Confirm the pipeline AI runner is plugin:langbot-team/ACPAgentRunner/default." - "Open Debug Chat." - "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_AGENT_RUNNER_E2E_OK exactly." checks: @@ -72,7 +72,7 @@ success_patterns: failure_patterns: - "acp.command_not_found" - "acp.process_exited" - - "Agent runner plugin:langbot/acp-agent-runner/default execution failed" + - "Agent runner plugin:langbot-team/ACPAgentRunner/default execution failed" troubleshooting: - backend-not-listening - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/agent-runner-health-visibility.yaml b/skills/skills/langbot-testing/cases/agent-runner-health-visibility.yaml new file mode 100644 index 000000000..84bd95c2e --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-health-visibility.yaml @@ -0,0 +1,53 @@ +id: agent-runner-health-visibility +title: "Agent configuration shows whether its selected runner is usable" +mode: agent-browser +area: agent +type: feature +priority: p0 +risk: medium +ci_eligible: false +tags: + - agent-runner + - agent + - productization + - release-gate +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/agent-runner-health-visibility.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The plugin runtime is enabled and connected, with at least one AgentRunner registered." + - "The target is a local test instance where a temporary Agent may be created and deleted." +steps: + - "Read the live plugin runtime status and select a registered runner from Agent metadata." + - "Create a temporary Agent using that runner and open its Runner settings in the WebUI." + - "Confirm the UI reports that the runner is ready." + - "Replace the temporary Agent binding with a syntactically valid but unregistered runner id." + - "Reload Runner settings and confirm the UI reports that the selected runner is unavailable." +checks: + - "UI: A registered runner and connected plugin runtime produce a visible ready status." + - "UI: A stale runner binding produces a visible unavailable status with recovery actions." + - "API: Plugin runtime status is connected and Agent metadata contains the registered runner." + - "Console: No unexpected frontend errors appear during the flow." + - "Cleanup: The temporary Agent is deleted after evidence is collected." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "A disconnected plugin runtime is env_issue, not a passing unavailable-runner product check." + - "A ready status inferred only from API metadata without opening the Runner UI is not a pass." +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml index 578cd8a31..f56124570 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml @@ -17,7 +17,7 @@ env: automation: skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs steps: - "Run `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` first; remove `--dry-run` after checking the planned evidence directory." - - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../langbot-plugin-sdk/src." + - "Automation resolves LANGBOT_REPO, defaulting to the parent LangBot checkout, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../../langbot-plugin-sdk/src." - "Automation checks run status sets, terminal status validation, ledger table/index DDL, and a minimal synchronous insert/read path." checks: - "automation-result.json status is pass." diff --git a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml index 1d86b5ec7..afcee8fa6 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml @@ -33,6 +33,7 @@ automation_expected_runner_id: "plugin:qa/agent-runner/default" automation_prompt: "hello-live" automation_expected_text: "QA_AGENT_RUNNER_OK:hello-live" automation_response_timeout_ms: "120000" +automation_debug_chat_response_p95_ms: "120000" automation_reset_debug_chat: "1" setup_automation: - "case:agent-runner-live-install" diff --git a/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml index b70000591..c3f72d2c7 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml @@ -45,7 +45,7 @@ steps: - "Run the local-agent primary model test endpoint unless LANGBOT_PREFLIGHT_TEST_MODELS=0." checks: - "API diagnostic: api-diagnostic.json has no blockers and no env_issues." - - "API diagnostic: required pipelines resolve to plugin:langbot/local-agent/default and plugin:langbot/acp-agent-runner/default." + - "API diagnostic: required pipelines resolve to plugin:langbot-team/LocalAgent/default and plugin:langbot-team/ACPAgentRunner/default." - "API diagnostic: qa_plugin_echo is exposed by /api/v1/tools." - "API diagnostic: local-agent model check catches invalid credentials or missing func_call/vision before release E2E starts." - "Secret safety: token values, api keys, and provider secrets are not printed." diff --git a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml index d7aeca392..6c4a70149 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml @@ -18,7 +18,7 @@ env: automation: skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs steps: - "Run `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` first; remove `--dry-run` after checking the SDK repo target and evidence directory." - - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../langbot-plugin-sdk when the env var is unset." + - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../../langbot-plugin-sdk when the env var is unset." - "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_agent_runner.py and tests/runtime/test_pull_api_handlers.py." checks: - "automation-result.json status is pass." @@ -28,7 +28,7 @@ evidence_required: - filesystem diagnostics: - "This probe does not open the WebUI; it runs SDK pytest targets directly." - - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." - "fail means the existing SDK runtime pytest target failed or timed out." success_patterns: - "pytest passed" diff --git a/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml new file mode 100644 index 000000000..4e8a6dbab --- /dev/null +++ b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml @@ -0,0 +1,57 @@ +id: bot-event-routing-product-flow +title: "Bot event routing can be configured and tested from the WebUI" +mode: agent-browser +area: bot +type: feature +priority: p0 +risk: medium +ci_eligible: false +tags: + - bot + - eba + - event-routing + - productization +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/bot-event-routing-product-flow.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a local test instance where a temporary HTTP Bot may be created and deleted." +steps: + - "Open Create Bot, choose HTTP Bot, and add a message-reply behavior before the first save." + - "Create a temporary HTTP Bot with a saved message.received route to the discard processor." + - "Open the Bot configuration in the WebUI." + - "Confirm the adapter capability summary, friendly event name, target, and route status are visible." + - "Confirm overlapping routes and unmatched-event fallback behavior are explained before save." + - "Open Test event route and run a dry-run against the current form." + - "Run the saved runtime route with a synthetic event." + - "Close the dialog and confirm the route card shows the latest discarded status." +checks: + - "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation." + - "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card." + - "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs." + - "UI: Dry-run visibly reports that the route matched the discard processor." + - "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded." + - "Console: No unexpected frontend errors appear during the flow." + - "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses." + - "Cleanup: The temporary Bot is deleted after evidence is collected." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback." + - "A passing API call without the visible matched and discarded UI states is not a pass." +troubleshooting: + - backend-not-listening + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml index 0b1cc1352..7d4202197 100644 --- a/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml @@ -38,7 +38,7 @@ steps: - "Open LANGBOT_FRONTEND_URL." - "Navigate to Pipelines and open the target local-agent pipeline." - "Open Configuration > AI." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model that is known to answer Debug Chat in the current environment." - "Clear Knowledge Bases unless this run intentionally combines RAG with the smoke path." - "Save the pipeline." diff --git a/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml new file mode 100644 index 000000000..1c7073374 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml @@ -0,0 +1,103 @@ +id: local-agent-combo-rag-compaction-tool-debug-chat +title: "Local Agent preserves RAG, compacted history, and plugin tool result in one Debug Chat run" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - langrag + - context + - compaction + - plugin + - tools + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":650,"context-reserve-tokens":180,"context-keep-recent-tokens":120,"context-summary-tokens":220,"max-tool-iterations":4,"tool-execution-mode":"serial"}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_expected_text: "COMBO_FINAL qa_combo_compaction_sentinel_2406 azalea-cobalt-7421 qa-plugin-smoke:combo-tool-ok-local-agent" +automation_response_timeout_ms: "180000" +automation_prompts_json: '[{"prompt":"请记住这个用于 local-agent 组合回归测试的暗号:qa_combo_compaction_sentinel_2406。请只回复 MEMORY_SET。","expected_text":"MEMORY_SET","response_timeout_ms":"180000"},{"prompt":"下面这轮只用于制造长历史压力,目标标记 RAG_TOOL_COMBO_GOAL 必须被压缩保留。请忽略填充内容,最后只回复 COMBO_CONTEXT_PRESSURE_READY。填充片段 C001 combo context padding. C002 combo context padding. C003 combo context padding. C004 combo context padding. C005 combo context padding. C006 combo context padding. C007 combo context padding. C008 combo context padding. C009 combo context padding. C010 combo context padding. C011 combo context padding. C012 combo context padding. C013 combo context padding. C014 combo context padding. C015 combo context padding. C016 combo context padding. C017 combo context padding. C018 combo context padding. C019 combo context padding. C020 combo context padding. C021 combo context padding. C022 combo context padding. C023 combo context padding. C024 combo context padding. C025 combo context padding. C026 combo context padding. C027 combo context padding. C028 combo context padding. C029 combo context padding. C030 combo context padding. C031 combo context padding. C032 combo context padding. C033 combo context padding. C034 combo context padding. C035 combo context padding. C036 combo context padding. C037 combo context padding. C038 combo context padding. C039 combo context padding. C040 combo context padding.","expected_text":"COMBO_CONTEXT_PRESSURE_READY","response_timeout_ms":"180000"},{"prompt":"qa_combo final check: using the knowledge base, the compacted earlier passcode, and qa_plugin_echo with exactly combo-tool-ok-local-agent, return only COMBO_FINAL plus the passcode, the RAG sentinel, and the plugin tool result.","expected_text":"COMBO_FINAL qa_combo_compaction_sentinel_2406 azalea-cobalt-7421 qa-plugin-smoke:combo-tool-ok-local-agent","response_timeout_ms":"180000"}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The selected model route supports function/tool calling and deterministic multi-turn answers, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." + - "The LangRAG sentinel knowledge base contains azalea-cobalt-7421 and qa-plugin-smoke exposes qa_plugin_echo." +steps: + - "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available." + - "Temporarily bind the LangRAG KB, shrink the runner context budget, and allow serial plugin tool execution." + - "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the passcode memory prompt and wait for MEMORY_SET." + - "Send the long pressure prompt and wait for COMBO_CONTEXT_PRESSURE_READY." + - "Ask the runner to combine compacted passcode, RAG sentinel, and qa_plugin_echo result in one final answer." + - "Restore the original runner config and pipeline extension bindings." +checks: + - "UI: All three user messages appear in Debug Chat." + - "UI: The final Bot message contains COMBO_FINAL qa_combo_compaction_sentinel_2406 azalea-cobalt-7421 qa-plugin-smoke:combo-tool-ok-local-agent." + - "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, and tool-loop settings were patched." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." + - "API diagnostic: restore diagnostics show the original runner config and extension bindings were restored." + - "Logs: Backend completes RAG retrieval, plugin tool call, and Debug Chat response without runner timeout or model setup failure." + - "Console: No unexpected frontend runtime errors appear during the run." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case." + - "The ensure-langrag-sentinel-kb setup script verifies LANGBOT_E2E_RAG_EXPECTED_TEXT, defaulting to azalea-cobalt-7421, while this case's Debug Chat expected text is the full COMBO_FINAL string." + - "The fake provider intentionally returns COMBO_MISSING_* if the final model input lacks compacted memory, RAG context, or plugin tool result." + - "If the final answer lacks azalea-cobalt-7421, first rerun ensure-langrag-sentinel-kb and local-agent-rag-debug-chat." + - "If the final answer lacks qa-plugin-smoke:combo-tool-ok-local-agent, inspect /api/v1/tools and the pipeline extensions diagnostic." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "COMBO_MISSING_" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml index 7c0d6a32a..637f67db8 100644 --- a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml @@ -19,6 +19,8 @@ env: - LANGBOT_BACKEND_URL - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL automation: scripts/e2e/pipeline-debug-chat.mjs automation_env: - LANGBOT_FRONTEND_URL @@ -29,9 +31,11 @@ automation_env: - LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME -automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"context-window-tokens":225,"context-reserve-tokens":50,"context-keep-recent-tokens":30,"context-summary-tokens":105,"knowledge-bases":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" +automation_restore_extensions: "1" automation_reset_debug_chat: "1" automation_debug_chat_session_type: "person" automation_expected_text: "qa_compaction_sentinel_7391" @@ -43,12 +47,13 @@ setup_provides_env: - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME preconditions: - - "The selected model route can follow short deterministic instructions across multiple Debug Chat turns." + - "The selected model route can follow short deterministic instructions across multiple Debug Chat turns, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." steps: - "Open the target local-agent pipeline through LANGBOT_FRONTEND_URL." - "Use the authenticated browser token only inside automation to GET and PUT /api/v1/pipelines/{uuid}." - - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Assert the saved runner is plugin:langbot-team/LocalAgent/default." - "Temporarily set context-window-tokens, context-reserve-tokens, context-keep-recent-tokens, and context-summary-tokens to force compaction, and clear knowledge-bases so RAG does not answer the memory question." + - "Temporarily bind only the local-agent plugin, disable MCP servers, and disable skills so unrelated visible tools cannot answer or distract the memory question." - "Reset the person Debug Chat session for the target pipeline." - "Send the sentinel memory prompt and wait for MEMORY_SET." - "Send the long padding prompt and wait for CONTEXT_PRESSURE_READY." @@ -59,6 +64,8 @@ checks: - "UI: The final Bot message contains qa_compaction_sentinel_7391." - "API diagnostic: pipeline-config-diagnostic.json shows patched=true and patch_keys include the four token context compaction fields plus knowledge-bases." - "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored." + - "API diagnostic: pipeline-extensions-diagnostic.json shows enable_all_plugins=false, bound_plugins contains langbot-team/LocalAgent, enable_all_mcp_servers=false, and enable_all_skills=false." + - "API diagnostic: pipeline-extensions-restore-diagnostic.json shows the original extension bindings were restored." - "Logs: Backend completes the multi-turn Debug Chat path without runner timeout or model setup failure." - "Console: No unexpected frontend runtime errors appear during the run." evidence_required: @@ -68,7 +75,8 @@ evidence_required: - backend_log - api_diagnostic diagnostics: - - "If the final sentinel is missing, inspect whether pipeline-config-diagnostic.json targeted ai.runner_config[runnerId], cleared knowledge-bases, and whether the backend log shows the local-agent runner loading the small context settings." + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case; setup will bind the local-agent pipeline to that fake OpenAI-compatible model." + - "If the final sentinel is missing, inspect whether pipeline-config-diagnostic.json targeted ai.runner_config[runnerId], cleared knowledge-bases, whether pipeline-extensions-diagnostic.json isolated unrelated tools, and whether the backend log shows the local-agent runner loading the small context settings." - "If the model ignores deterministic replies, rerun with a known-good model route before diagnosing ContextAssembler." - "If restore fails, use pipeline-config-restore-diagnostic.json and GET /api/v1/pipelines/{uuid} to confirm the current saved config before retrying." success_patterns: diff --git a/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml index d19d4a109..a5cadcc05 100644 --- a/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml @@ -44,7 +44,7 @@ steps: - "Confirm the fixture plugin is bound to the target pipeline through Extensions." - "Open the target local-agent pipeline." - "Open Configuration > AI." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model that is known to follow system prompts in Debug Chat." - "Save the pipeline." - "Open Debug Chat." diff --git a/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml index 298b59dca..184381309 100644 --- a/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml @@ -42,7 +42,7 @@ steps: - "Open LANGBOT_FRONTEND_URL." - "Navigate to Pipelines and open the target local-agent pipeline." - "Open Configuration > AI." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model that supports image input in the current environment, or use a known model that at least accepts the uploaded image payload." - "Save the pipeline." - "Open Debug Chat." diff --git a/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml new file mode 100644 index 000000000..c9d530cbf --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml @@ -0,0 +1,102 @@ +id: local-agent-multitool-rag-compaction-debug-chat +title: "Local Agent preserves RAG, compacted history, and two-step plugin tool loop" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - langrag + - context + - compaction + - plugin + - tools + - tool-loop + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":5,"tool-execution-mode":"serial"}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_expected_text: "MULTITOOL_COMBO_FINAL qa_multitool_compaction_sentinel_6718 azalea-cobalt-7421 qa-plugin-smoke:multi-tool-a-local-agent qa-plugin-smoke:multi-tool-b-local-agent" +automation_response_timeout_ms: "180000" +automation_prompts_json: '[{"prompt":"请记住这个用于 local-agent 多工具组合回归测试的暗号:qa_multitool_compaction_sentinel_6718。请只回复 MEMORY_SET。","expected_text":"MEMORY_SET","response_timeout_ms":"180000"},{"prompt":"下面这轮只用于制造长历史压力,目标标记 MULTITOOL_RAG_GOAL 必须被压缩保留。请忽略填充内容,最后只回复 MULTITOOL_CONTEXT_PRESSURE_READY。填充片段 M001 multitool context padding. M002 multitool context padding. M003 multitool context padding. M004 multitool context padding. M005 multitool context padding. M006 multitool context padding. M007 multitool context padding. M008 multitool context padding. M009 multitool context padding. M010 multitool context padding. M011 multitool context padding. M012 multitool context padding. M013 multitool context padding. M014 multitool context padding. M015 multitool context padding. M016 multitool context padding. M017 multitool context padding. M018 multitool context padding. M019 multitool context padding. M020 multitool context padding. M021 multitool context padding. M022 multitool context padding. M023 multitool context padding. M024 multitool context padding. M025 multitool context padding. M026 multitool context padding. M027 multitool context padding. M028 multitool context padding. M029 multitool context padding. M030 multitool context padding. M031 multitool context padding. M032 multitool context padding.","expected_text":"MULTITOOL_CONTEXT_PRESSURE_READY","response_timeout_ms":"180000"},{"prompt":"MULTITOOL_COMBO final check: using the knowledge base, the compacted earlier passcode, and qa_plugin_echo twice with exactly multi-tool-a-local-agent then multi-tool-b-local-agent, return only MULTITOOL_COMBO_FINAL plus the passcode, the RAG sentinel, and both plugin tool results.","expected_text":"MULTITOOL_COMBO_FINAL qa_multitool_compaction_sentinel_6718 azalea-cobalt-7421 qa-plugin-smoke:multi-tool-a-local-agent qa-plugin-smoke:multi-tool-b-local-agent","response_timeout_ms":"180000"}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The selected model route supports function/tool calling and repeated tool loop turns, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." + - "The LangRAG sentinel knowledge base contains azalea-cobalt-7421 and qa-plugin-smoke exposes qa_plugin_echo." +steps: + - "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available." + - "Temporarily bind the LangRAG KB, shrink the runner context budget, and set max-tool-iterations high enough for two serial plugin tool calls." + - "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the passcode memory prompt and wait for MEMORY_SET." + - "Send the long pressure prompt and wait for MULTITOOL_CONTEXT_PRESSURE_READY." + - "Ask the runner to combine compacted passcode, RAG sentinel, and two qa_plugin_echo results in one final answer." + - "Restore the original runner config and pipeline extension bindings." +checks: + - "UI: All three user messages appear in Debug Chat." + - "UI: The final Bot message contains MULTITOOL_COMBO_FINAL with the compacted sentinel, azalea-cobalt-7421, and both qa-plugin-smoke tool results." + - "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, and max-tool-iterations were patched." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." + - "Logs: Backend completes RAG retrieval, two plugin tool calls, and Debug Chat response without runner timeout or model setup failure." + - "Console: No unexpected frontend runtime errors appear during the run." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case." + - "The fake provider intentionally returns MULTITOOL_COMBO_MISSING_* if the final model input lacks compacted memory, RAG context, or either plugin tool result." + - "If only one tool result is present, inspect max-tool-iterations and the runner tool-loop events." + - "If the final answer lacks azalea-cobalt-7421, first rerun ensure-langrag-sentinel-kb and local-agent-rag-debug-chat." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "MULTITOOL_COMBO_MISSING_" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml index 910032d79..f5a143128 100644 --- a/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml @@ -1,5 +1,5 @@ id: local-agent-nonstreaming-debug-chat -title: "Local Agent Debug Chat returns a deterministic non-streaming response" +title: "Local Agent Debug Chat returns a deterministic response with UI streaming disabled" mode: agent-browser area: pipeline type: regression @@ -39,7 +39,7 @@ steps: - "Open LANGBOT_FRONTEND_URL." - "Navigate to Pipelines and open the target local-agent pipeline." - "Open Configuration > AI." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model that is known to answer Debug Chat in the current environment." - "Save the pipeline." - "Open Debug Chat." @@ -48,14 +48,16 @@ steps: checks: - "UI: The user message appears in Debug Chat." - "UI: A Bot message appears and contains NONSTREAM_OK." - - "Logs: Backend completes the request as a normal response rather than only relying on the streaming-completed path." + - "UI: The Debug Chat stream switch is disabled for this send path." + - "Logs: Backend completes the request without plugin/runtime timeout or frontend streaming UI errors." - "Console: No unexpected frontend runtime errors appear during the send/receive path." evidence_required: - ui - console - backend_log diagnostics: - - "If the UI still streams after the switch is disabled, inspect the adapter streaming capability and runner config before diagnosing the model." + - "This browser case validates the Debug Chat non-streaming UI delivery path. The runner-internal invoke_llm path is covered by langbot-local-agent component tests using runtime_metadata.streaming_supported=false." + - "If the UI still streams after the switch is disabled, inspect the Debug Chat WebSocket stream flag before diagnosing the model." - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner and model config." troubleshooting: - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml new file mode 100644 index 000000000..704ac9975 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml @@ -0,0 +1,103 @@ +id: local-agent-parallel-tools-rag-compaction-debug-chat +title: "Local Agent preserves RAG, compacted history, and parallel plugin tools" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - langrag + - context + - compaction + - plugin + - tools + - parallel-tools + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":3,"tool-execution-mode":"parallel"}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_expected_text: "PARALLEL_COMBO_FINAL qa_parallel_compaction_sentinel_8142 azalea-cobalt-7421 qa-plugin-smoke:parallel-tool-a-local-agent qa-plugin-smoke:parallel-tool-b-local-agent" +automation_response_timeout_ms: "180000" +automation_prompts_json: '[{"prompt":"请记住这个用于 local-agent 并行工具组合回归测试的暗号:qa_parallel_compaction_sentinel_8142。请只回复 MEMORY_SET。","expected_text":"MEMORY_SET","response_timeout_ms":"180000"},{"prompt":"下面这轮只用于制造长历史压力,目标标记 PARALLEL_RAG_GOAL 必须被压缩保留。请忽略填充内容,最后只回复 PARALLEL_CONTEXT_PRESSURE_READY。填充片段 P001 parallel context padding. P002 parallel context padding. P003 parallel context padding. P004 parallel context padding. P005 parallel context padding. P006 parallel context padding. P007 parallel context padding. P008 parallel context padding. P009 parallel context padding. P010 parallel context padding. P011 parallel context padding. P012 parallel context padding. P013 parallel context padding. P014 parallel context padding. P015 parallel context padding. P016 parallel context padding. P017 parallel context padding. P018 parallel context padding. P019 parallel context padding. P020 parallel context padding. P021 parallel context padding. P022 parallel context padding. P023 parallel context padding. P024 parallel context padding. P025 parallel context padding. P026 parallel context padding. P027 parallel context padding. P028 parallel context padding. P029 parallel context padding. P030 parallel context padding. P031 parallel context padding. P032 parallel context padding.","expected_text":"PARALLEL_CONTEXT_PRESSURE_READY","response_timeout_ms":"180000"},{"prompt":"PARALLEL_COMBO final check: using the knowledge base, the compacted earlier passcode, and one model turn that calls qa_plugin_echo for both parallel-tool-a-local-agent and parallel-tool-b-local-agent, return only PARALLEL_COMBO_FINAL plus the passcode, the RAG sentinel, and both plugin tool results.","expected_text":"PARALLEL_COMBO_FINAL qa_parallel_compaction_sentinel_8142 azalea-cobalt-7421 qa-plugin-smoke:parallel-tool-a-local-agent qa-plugin-smoke:parallel-tool-b-local-agent","response_timeout_ms":"180000"}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The selected model route supports function/tool calling with multiple tool calls in one turn, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." + - "The LangRAG sentinel knowledge base contains azalea-cobalt-7421 and qa-plugin-smoke exposes qa_plugin_echo." +steps: + - "Ensure the local-agent pipeline, LangRAG sentinel KB, and qa-plugin-smoke fixture plugin are available." + - "Temporarily bind the LangRAG KB, shrink the runner context budget, and set tool-execution-mode to parallel." + - "Temporarily bind only langbot-team/LocalAgent and qa/plugin-smoke; disable MCP servers and skills to isolate the tool surface." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the passcode memory prompt and wait for MEMORY_SET." + - "Send the long pressure prompt and wait for PARALLEL_CONTEXT_PRESSURE_READY." + - "Ask the runner to combine compacted passcode, RAG sentinel, and two same-turn qa_plugin_echo results in one final answer." + - "Restore the original runner config and pipeline extension bindings." +checks: + - "UI: All three user messages appear in Debug Chat." + - "UI: The final Bot message contains PARALLEL_COMBO_FINAL with the compacted sentinel, azalea-cobalt-7421, and both qa-plugin-smoke tool results." + - "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases, context token settings, max-tool-iterations, and tool-execution-mode=parallel were patched." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." + - "Logs: Backend completes RAG retrieval, both plugin tool calls, and Debug Chat response without runner timeout or model setup failure." + - "Console: No unexpected frontend runtime errors appear during the run." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case." + - "The fake provider intentionally returns two qa_plugin_echo tool_calls in the same streamed model turn for this prompt." + - "The fake provider intentionally returns PARALLEL_COMBO_MISSING_* if the final model input lacks compacted memory, RAG context, or either plugin tool result." + - "If only one tool result is present, inspect the provider trace and runner parallel tool batch handling." + - "If the final answer lacks azalea-cobalt-7421, first rerun ensure-langrag-sentinel-kb and local-agent-rag-debug-chat." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "PARALLEL_COMBO_MISSING_" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml index 1c37e2fb1..96d9da8b6 100644 --- a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml @@ -19,6 +19,8 @@ env: - LANGBOT_BACKEND_URL - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL automation: scripts/e2e/pipeline-debug-chat.mjs automation_env: - LANGBOT_FRONTEND_URL @@ -38,13 +40,13 @@ setup_provides_env: - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME preconditions: - - "The selected model route supports function/tool calling." + - "The selected model route supports function/tool calling, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." steps: - "Install or enable the bundled qa-plugin-smoke fixture plugin." - "Confirm /api/v1/tools or the plugin detail shows qa_plugin_echo." - "Confirm the fixture plugin is bound to the target pipeline through Extensions, or that all plugins are enabled." - "Open the target local-agent pipeline." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model with function-calling ability that is known to work with tools in the current environment." - "Open Debug Chat." - "Send: Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." @@ -58,6 +60,7 @@ evidence_required: - backend_log - api_diagnostic diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case; setup will bind the local-agent pipeline to that fake OpenAI-compatible model." - "If qa_plugin_echo is not listed, rebuild and reinstall the qa-plugin-smoke fixture plugin." - "If the selected model returns model_not_found or no available channel only when tools are provided, switch to a known-good function-calling model before diagnosing plugin tools or local-agent." troubleshooting: diff --git a/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml index b7e715b16..30989c100 100644 --- a/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml @@ -18,6 +18,8 @@ env: - LANGBOT_BACKEND_URL - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL automation: scripts/e2e/pipeline-debug-chat.mjs automation_env: - LANGBOT_FRONTEND_URL @@ -29,7 +31,7 @@ automation_env: - LANGBOT_LOCAL_AGENT_RAG_KB_UUID automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME -automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}' automation_restore_runner_config: "1" automation_reset_debug_chat: "1" @@ -43,7 +45,7 @@ setup_provides_env: - LANGBOT_LOCAL_AGENT_PIPELINE_NAME - LANGBOT_LOCAL_AGENT_RAG_KB_UUID preconditions: - - "The target pipeline already has a text-capable model route that is available for this run." + - "The target pipeline already has a text-capable model route that is available for this run, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." steps: - "Ensure case langrag-kb-retrieve has produced a knowledge base containing sentinel azalea-cobalt-7421." - "Open LANGBOT_FRONTEND_URL." @@ -68,6 +70,8 @@ evidence_required: - backend_log - api_diagnostic diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case; setup will bind the local-agent pipeline to that fake OpenAI-compatible model." + - "The ensure-langrag-sentinel-kb setup script verifies LANGBOT_E2E_RAG_EXPECTED_TEXT, defaulting to azalea-cobalt-7421, so the Debug Chat expected text does not leak into KB setup." - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner_config contains the knowledge base uuid." - "If the bot ignores the knowledge base, rerun Retrieve Test before debugging the runner." troubleshooting: diff --git a/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml index 973822f2b..38ed5720e 100644 --- a/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml @@ -29,7 +29,7 @@ automation_env: - LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME -automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_reset_debug_chat: "1" automation_stream_output: "1" automation_prompt: "You are running the LangBot steering E2E test. First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor. Do not answer before the tool result is available. After the tool returns, answer the latest user follow-up. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP." @@ -45,7 +45,7 @@ preconditions: - "The selected model route supports function/tool calling and can call qa_plugin_sleep." steps: - "Open the target local-agent pipeline." - - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Assert the saved runner is plugin:langbot-team/LocalAgent/default." - "Reset the person Debug Chat session for the target pipeline." - "Assert /api/v1/tools contains qa_plugin_sleep." - "Send the first prompt that instructs the model to call qa_plugin_sleep for 8 seconds." @@ -74,7 +74,6 @@ success_patterns: - "Steering" - "Streaming completed" failure_patterns: - - "STEERING_NO_FOLLOWUP" - "Action invoke_llm_stream call timed out" - "All models failed during streaming setup" - "Task exception was never retrieved" diff --git a/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml new file mode 100644 index 000000000..8b30ab8f7 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml @@ -0,0 +1,94 @@ +id: local-agent-tool-error-recovery-debug-chat +title: "Local Agent feeds plugin tool errors back to the model" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - plugin + - tools + - tool-error + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":3,"tool-execution-mode":"serial"}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_prompt: "TOOL_ERROR_RECOVERY regression: call qa_plugin_fail with exactly tool-error-recovery-local-agent. After observing the tool error, return only TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed." +automation_expected_text: "TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." + - "qa-plugin-smoke exposes qa_plugin_fail; the setup case reinstalls the fixture if an older installed package is missing it." +steps: + - "Ensure the local-agent pipeline and qa-plugin-smoke fixture plugin are available." + - "Temporarily bind only langbot-team/LocalAgent plus qa/plugin-smoke and clear RAG knowledge bases." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the TOOL_ERROR_RECOVERY prompt." + - "Verify the failing plugin tool result is fed back into the model and the final answer contains the recovery sentinel." + - "Restore the original runner config and pipeline extension bindings." +checks: + - "UI: The final Bot message contains TOOL_ERROR_RECOVERY_FINAL qa-plugin-smoke forced failure observed." + - "API diagnostic: pipeline-config-diagnostic.json shows max-tool-iterations=3 and knowledge-bases cleared." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." + - "Logs: Backend shows the request completed without runner.timeout, runner.tool_error run failure, or model setup failure." + - "Console: No unexpected frontend runtime errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case." + - "The fake provider calls qa_plugin_fail once, then only returns the final sentinel after it sees an Error: tool result." + - "If the run returns a generic answer, inspect whether qa_plugin_fail was exposed by /api/v1/tools and whether the installed fixture plugin was refreshed." +success_patterns: + - "TOOL_ERROR_RECOVERY_FINAL" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "runner.timeout" + - "runner.tool_error" + - "Tool call iteration limit reached" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml new file mode 100644 index 000000000..0788fd580 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml @@ -0,0 +1,92 @@ +id: local-agent-tool-loop-limit-debug-chat +title: "Local Agent stops repeated tool calls at max-tool-iterations" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - plugin + - tools + - tool-loop + - limit + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":2,"tool-execution-mode":"serial"}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_runner_config: "1" +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_prompt: "LOOP_LIMIT regression: keep requesting qa_plugin_echo with exactly loop-limit-repeat-local-agent until the runner stops you. The correct final visible answer is the runner limit message." +automation_expected_text: "Tool call iteration limit reached. I stopped before calling more tools." +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." + - "qa-plugin-smoke exposes qa_plugin_echo." +steps: + - "Ensure the local-agent pipeline and qa-plugin-smoke fixture plugin are available." + - "Temporarily set max-tool-iterations to 2 and bind only langbot-team/LocalAgent plus qa/plugin-smoke." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the LOOP_LIMIT prompt and wait for the runner limit message." + - "Restore the original runner config and pipeline extension bindings." +checks: + - "UI: The final Bot message contains Tool call iteration limit reached. I stopped before calling more tools." + - "API diagnostic: pipeline-config-diagnostic.json shows max-tool-iterations was patched to 2." + - "API diagnostic: pipeline-extensions-diagnostic.json shows only langbot-team/LocalAgent and qa/plugin-smoke were bound." + - "Logs: Backend shows the request completed without infinite loop, runner timeout, or model setup failure." + - "Console: No unexpected frontend runtime errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case." + - "The fake provider intentionally keeps requesting qa_plugin_echo; this case passes only when the runner stops at max-tool-iterations." + - "If the run times out, inspect whether max-tool-iterations was patched or whether the runner ignored tool loop limits." +success_patterns: + - "Tool call iteration limit reached" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "runner.timeout" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml index 6c9857ba9..3184e8ffe 100644 --- a/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml +++ b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml @@ -34,8 +34,10 @@ steps: - "Poll /api/v1/tools and /api/v1/mcp/servers/qa-local-stdio until qa_mcp_echo is visible." checks: - "API diagnostic: qa-local-stdio runtime_status is connected." + - "API diagnostic: qa-local-stdio server_uuid is present for pipeline extension binding." - "API diagnostic: runtime_tool_names includes qa_mcp_echo." - "API diagnostic: /api/v1/tools includes qa_mcp_echo." + - "Automation writes LANGBOT_MCP_QA_STDIO_SERVER_UUID into skills/.env.local for downstream tool-call cases." - "Console and network logs contain no unexpected frontend/runtime failures." evidence_required: - screenshot diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml index 593eecba3..093e23d50 100644 --- a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml +++ b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml @@ -18,15 +18,23 @@ env: - LANGBOT_BACKEND_URL - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_optional: + - LANGBOT_E2E_FAKE_PROVIDER_BASE_URL automation: scripts/e2e/pipeline-debug-chat.mjs automation_env: - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL - LANGBOT_BROWSER_PROFILE - LANGBOT_CHROMIUM_EXECUTABLE - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_MCP_QA_STDIO_SERVER_UUID automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":["${LANGBOT_MCP_QA_STDIO_SERVER_UUID}"],"enable_all_skills":false,"bound_skills":[]}' +automation_restore_extensions: "1" +automation_reset_debug_chat: "1" automation_prompt: "Call the qa_mcp_echo MCP tool with exactly this text: mcp-ok-local-agent. Return only the tool result." automation_expected_text: "qa_mcp_echo:mcp-ok-local-agent" automation_response_timeout_ms: "180000" @@ -36,6 +44,7 @@ setup_automation: setup_provides_env: - LANGBOT_LOCAL_AGENT_PIPELINE_URL - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_MCP_QA_STDIO_SERVER_UUID failure_patterns: - "qa-plugin-smoke:mcp-ok-local-agent" - "qa_echo:mcp-ok-local-agent" @@ -45,7 +54,7 @@ failure_patterns: - "no available channel for model" preconditions: - "box.local.allowed_mount_roots includes the bundled MCP fixture directory when LangBot runs stdio MCP servers through Box." - - "The selected model route supports function/tool calling." + - "The selected model route supports function/tool calling, or LANGBOT_E2E_FAKE_PROVIDER_BASE_URL points to scripts/e2e/fake-openai-provider.mjs." steps: - "Open LANGBOT_FRONTEND_URL." - "Navigate to MCP Servers." @@ -56,7 +65,7 @@ steps: - "Submit the server." - "Confirm the server detail page shows Tools: 1 and qa_mcp_echo." - "Open the target local-agent pipeline." - - "Use runner Default or the pluginized langbot/local-agent runner." + - "Use runner Default or the pluginized langbot-team/LocalAgent runner." - "Select a model with function-calling ability that is known to work with tools in the current environment." - "Open Debug Chat." - "Send: Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result." @@ -66,6 +75,7 @@ checks: - "API diagnostic: /api/v1/tools contains qa_mcp_echo." - "UI: Debug Chat bot response contains qa_mcp_echo:mcp-ok-local-agent." - "Logs: Backend logs show the MCP tool call was executed, not only listed." + - "API diagnostic: pipeline-extensions-diagnostic.json shows the qa-local-stdio server UUID is the only bound MCP server for the run." - "Console: No unexpected frontend errors appear during MCP form use or Debug Chat." evidence_required: - ui @@ -73,6 +83,7 @@ evidence_required: - backend_log - api_diagnostic diagnostics: + - "For token-free deterministic UI coverage, start scripts/e2e/fake-openai-provider.mjs and pass LANGBOT_E2E_FAKE_PROVIDER_BASE_URL to this case; setup will bind the local-agent pipeline to that fake OpenAI-compatible model." - "Run node scripts/e2e/mcp-stdio-fixture.mjs to verify the bundled stdio fixture can list and call qa_mcp_echo without involving a model provider." - "Run node scripts/e2e/mcp-stdio-register.mjs to upsert qa-local-stdio in LangBot and verify /api/v1/tools exposes qa_mcp_echo." - "If backend logs show host_path is outside allowed_mount_roots, add the fixture directory to box.local.allowed_mount_roots in the local LangBot data config." diff --git a/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml index f0ec3c336..ff01f68c0 100644 --- a/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml +++ b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml @@ -20,14 +20,14 @@ env: automation: scripts/e2e/install-qa-plugin-smoke.mjs automation_plugin_package: "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg" automation_expected_plugin_id: "qa/plugin-smoke" -automation_expected_tool: "qa_plugin_echo" +automation_expected_tool: "qa_plugin_echo,qa_plugin_fail" steps: - "Run `rtk bin/lbs test run qa-plugin-smoke-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." - "Automation authenticates the local test user, uploads the QA plugin smoke .lbpkg package when missing, waits for install, and checks tool exposure." checks: - "automation-result.json status is pass." - "/api/v1/plugins lists qa/plugin-smoke after install." - - "/api/v1/tools lists qa_plugin_echo after install." + - "/api/v1/tools lists qa_plugin_echo and qa_plugin_fail after install." evidence_required: - api_diagnostic - filesystem @@ -39,6 +39,7 @@ success_patterns: failure_patterns: - "Plugin install task did not complete successfully" - "qa_plugin_echo is not listed" + - "qa_plugin_fail is not listed" troubleshooting: - plugin-runtime-timeout - plugin-dependency-install-offline diff --git a/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml b/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml new file mode 100644 index 000000000..93e13f1d9 --- /dev/null +++ b/skills/skills/langbot-testing/cases/skill-discovery-via-mcp-gateway.yaml @@ -0,0 +1,58 @@ +id: skill-discovery-via-mcp-gateway +title: "External harness discovers LangBot skills via langbot_list_assets (all-tool model)" +mode: agent-browser +area: sandbox +type: regression +priority: p2 +risk: medium +ci_eligible: false +tags: + - skills + - mcp-gateway + - acp-agent-runner + - all-tool-model + - tools +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +preconditions: + - "An external-harness runner pipeline (e.g. ACP remote claude-code) is configured with langbot-assets-enabled=true so the LangBot MCP gateway is exposed to the harness." + - "The remote harness (claude-code) is reachable and responsive (claude -p returns within the runner timeout)." + - "At least one pipeline-visible skill exists in the Box skill store (otherwise the count is 0, which is still a valid pass for the discovery surface)." +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation_prompt: "You have LangBot tools available via an MCP server (tools prefixed langbot_). Call langbot_list_assets with asset_types = [\"skills\",\"tools\"]. Then reply with one single line: the literal token PROBEDONE, a space, the number of skills you found, a space, and the number of tools you found." +automation_expected_text: "PROBEDONE" +automation_response_timeout_ms: "540000" +steps: + - "Open LANGBOT_FRONTEND_URL and navigate to the external-harness (ACP) pipeline." + - "Open Debug Chat with langbot-assets-enabled on the runner." + - "Send the automation_prompt asking the harness to call langbot_list_assets with asset_types [skills, tools]." + - "Capture the final reply, backend logs, and the MCP gateway call trace." +checks: + - "UI: final reply contains PROBEDONE followed by a skill count and a tool count." + - "Logs: backend shows the harness invoked langbot_list_assets and the response included a 'skills' asset class (this is the all-tool-model discovery surface added on this branch)." + - "Behavior parity: a local-agent runner reaches the same skills via use_funcs / activate; the external harness reaches them via langbot_list_assets + langbot_call_tool." +evidence_required: + - ui + - screenshot + - backend_log +expected_failures: + - "runner.timeout when the remote claude-code harness is unauthenticated or slow to start — this is an environment issue, not a discovery-surface regression." +diagnostics: + - "If runner.timeout: ssh into the harness host and confirm `claude -p 'hi'` returns quickly; the ACP runner cannot complete until the harness responds." + - "Activated-skill OPERATE on docker+shared-fs is tracked separately by issue #2271 and is out of scope for this discovery case." +troubleshooting: + - sandbox-native-tools-unavailable diff --git a/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml new file mode 100644 index 000000000..220c5b3f5 --- /dev/null +++ b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml @@ -0,0 +1,61 @@ +id: wizard-onebot-agent-runtime +title: "Quick Start runs a real OneBot member event through an Agent" +mode: agent-browser +area: wizard +type: feature +priority: p0 +risk: high +ci_eligible: false +tags: + - wizard + - eba + - onebot + - agent-runner + - release-gate +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/wizard-onebot-agent-runtime.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a local test instance where temporary Bots and Agents may be created and deleted." + - "Local Agent is installed so the scenario prompt can be verified in its prompt editor." + - "The QA Deterministic Runner is installed and registered as plugin:qa/agent-runner/default." + - "The LangBot virtualenv includes the websockets package used by the local OneBot probe." +steps: + - "Open Quick Start and choose Welcome new members with the OneBot v11 channel." + - "Create and enable a temporary OneBot Bot on an available local port." + - "Select Local Agent and confirm the editable welcome prompt is prefilled." + - "Choose QA Deterministic Runner and finish the wizard." + - "Connect a local OneBot reverse WebSocket and send a group_increase notice." + - "Confirm the Agent output is sent back with send_group_msg." + - "Open the Bot route editor and confirm the route status is Delivered." +checks: + - "UI: The wizard reaches All Set after creating the Agent route." + - "UI: Local Agent receives a scenario-specific editable welcome prompt." + - "Runtime: OneBot group_increase converts to group.member_joined." + - "Runtime: The saved Agent route reaches delivered status." + - "Runtime: The deterministic Agent reply invokes send_group_msg." + - "UI: Delivered is visible on the saved route card." + - "Console: No unexpected frontend errors appear during the flow." + - "Cleanup: The temporary Bot and Agent are deleted." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "The case uses a local reverse WebSocket and deterministic runner, so it requires no platform or model credentials." + - "A passing API route status without the visible Delivered route card is not a pass." +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml b/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml new file mode 100644 index 000000000..4815d0f70 --- /dev/null +++ b/skills/skills/langbot-testing/cases/wizard-runner-marketplace-catalog.yaml @@ -0,0 +1,59 @@ +id: wizard-runner-marketplace-catalog +title: "Quick Start discovers AgentRunner extensions on a clean instance" +mode: agent-browser +area: wizard +type: feature +priority: p0 +risk: high +ci_eligible: false +tags: + - wizard + - agent-runner + - marketplace + - clean-install + - productization +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/wizard-runner-marketplace-catalog.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a newly initialized local instance with wizard_status=none." + - "No plugins or AgentRunner components are installed." +steps: + - "Confirm the backend reports zero installed plugins and zero registered runners." + - "Resume Quick Start at the AI Engine step with a temporary disabled Bot." + - "Confirm the browser requests Marketplace plugins with component_filter=AgentRunner." + - "When Space has not published Runner plugins, confirm the safe empty state and Runner Extensions link." + - "Confirm Create & Deploy remains disabled until a Runner is installed and selected." + - "Verify the layout at desktop and mobile widths." +checks: + - "API: The instance has zero installed plugins and zero registered runners." + - "API: The instance wizard status is none." + - "Network: Marketplace search uses component_filter=AgentRunner and type_filter=plugin." + - "UI: The AI Engine step remains usable when the Runner catalog is empty." + - "UI: Browse Runner Extensions links to the AgentRunner-filtered market." + - "UI: Create & Deploy remains disabled without a selected Runner." + - "Console: No unexpected frontend errors appear during the flow." + - "Cleanup: Wizard progress and the temporary Bot are removed." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "This case validates the clean-instance discovery and empty state. Run the install continuation after Space publishes at least one AgentRunner plugin." + - "A Marketplace API response alone is not a pass; the empty or installable Runner UI must be visible." +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml b/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml new file mode 100644 index 000000000..64c9bd31c --- /dev/null +++ b/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml @@ -0,0 +1,57 @@ +id: wizard-scenario-routing +title: "Quick Start filters channels by the selected bot scenario" +mode: agent-browser +area: wizard +type: feature +priority: p0 +risk: medium +ci_eligible: false +tags: + - wizard + - eba + - event-routing + - productization +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/wizard-scenario-routing.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a local test instance where a disabled temporary Bot and Agent may be created and deleted." +steps: + - "Open Quick Start and select Reply to messages." + - "Confirm HTTP Bot is available for the message scenario." + - "Select Welcome new members." + - "Confirm a compatible channel remains visible and HTTP Bot is removed." + - "Create a disabled temporary Bot and Agent, persist a group.member_joined route, and read it back through the API." + - "Verify the scenario and channel layout at desktop and mobile widths." +checks: + - "UI: Scenario selection appears before channel selection." + - "UI: Reply to messages includes HTTP Bot." + - "UI: Welcome new members includes a compatible event-capable channel." + - "UI: Welcome new members excludes HTTP Bot because it does not declare group.member_joined support." + - "UI: The selected non-message scenario is labeled as an Agent behavior." + - "Console: No unexpected frontend errors appear during the flow." + - "Network: Adapter discovery returns without a 5xx response." + - "API: A non-message event route to an Agent is normalized and persisted." + - "Cleanup: The temporary Bot and Agent are deleted after evidence is collected." + - "Isolation: Wizard progress writes are stubbed in the browser and do not change backend state." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "The temporary Bot remains disabled, so the case cannot connect to an external messaging platform." + - "A passing adapter API response without the visible filtered channel list is not a pass." +troubleshooting: + - backend-not-listening + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/fixtures/fixtures.json b/skills/skills/langbot-testing/fixtures/fixtures.json index 70badac14..24d4437fd 100644 --- a/skills/skills/langbot-testing/fixtures/fixtures.json +++ b/skills/skills/langbot-testing/fixtures/fixtures.json @@ -49,7 +49,13 @@ "title": "LangRAG sentinel text document", "kind": "text", "path": "fixtures/rag/sentinel-doc.txt", - "related_cases": ["langrag-kb-retrieve", "local-agent-rag-debug-chat"], + "related_cases": [ + "langrag-kb-retrieve", + "local-agent-rag-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat" + ], "checks": ["exists"] }, { @@ -78,6 +84,11 @@ "plugin-e2e-smoke", "local-agent-effective-prompt-debug-chat", "local-agent-plugin-tool-call-debug-chat", + "local-agent-tool-error-recovery-debug-chat", + "local-agent-tool-loop-limit-debug-chat", + "local-agent-combo-rag-compaction-tool-debug-chat", + "local-agent-multitool-rag-compaction-debug-chat", + "local-agent-parallel-tools-rag-compaction-debug-chat", "local-agent-steering-debug-chat" ], "checks": ["exists"] @@ -87,7 +98,7 @@ "title": "QA plugin smoke prebuilt package", "kind": "plugin_package", "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", - "related_cases": ["qa-plugin-smoke-live-install", "plugin-e2e-smoke"], + "related_cases": ["qa-plugin-smoke-live-install", "plugin-e2e-smoke", "local-agent-tool-error-recovery-debug-chat"], "checks": ["exists", "zip_package"] } ] diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md index e276f5774..c3b7f2057 100644 --- a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md @@ -7,3 +7,4 @@ Tools: - `qa_echo(text)` returns `qa-plugin-smoke:`. - `qa_plugin_echo(text)` returns `qa-plugin-smoke:`. - `qa_plugin_sleep(seconds, text)` waits up to 15 seconds and returns `qa-plugin-smoke:sleep::`. +- `qa_plugin_fail(text)` raises a deterministic error containing `text`. diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.py new file mode 100644 index 000000000..2c6e48f24 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAPluginFailTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + text = str(params.get("text", "qa-plugin-fail")) + raise RuntimeError(f"qa-plugin-smoke forced failure: {text}") diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.yaml new file mode 100644 index 000000000..7bcf36bd4 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_fail.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_plugin_fail + label: + en_US: QA Plugin Fail + zh_Hans: QA 插件失败工具 + description: + en_US: Raises a deterministic error for local-agent tool error recovery tests. + zh_Hans: 为 local-agent 工具错误恢复测试抛出确定性错误。 +spec: + parameters: + type: object + properties: + text: + type: string + description: Text included in the deterministic failure message. + required: + - text + llm_prompt: Always fail with a deterministic qa-plugin-smoke error for tool error recovery tests. +execution: + python: + path: qa_plugin_fail.py + attr: QAPluginFailTool diff --git a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs index 3bf1facf0..24652f03a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs @@ -80,13 +80,17 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_ASYNC_DB_READINESS_TIMEOUT_MS || "5000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "aiosqlite-readiness", diff --git a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs index bc7ded87b..f6d8a2419 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs @@ -142,15 +142,20 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const fixturePath = resolve(root, "skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, fixturePath], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-behavior-matrix", diff --git a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs index 00c447f71..31fea1c87 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs @@ -129,7 +129,7 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk"); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); const sdkSrc = resolve(sdkRepo, "src"); const fixturePath = resolve(root, "skills/langbot-testing/fixtures/plugins/qa-agent-runner"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); @@ -137,7 +137,7 @@ async function main() { const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: sdkRepo }; + const command = { executable: "rtk", args: ["uv", "run", "--no-sync", "python", "-c", script, fixturePath], cwd: sdkRepo }; const result = { source: "automation", probe: "agent-runner-fixture-contract", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs index f6c98389a..3ce2e9d3b 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs @@ -5,9 +5,9 @@ import { runPytestProbe } from "./pytest-probe.mjs"; await runPytestProbe({ caseId: "agent-runner-ledger-concurrency", repoEnvKey: "LANGBOT_REPO", - defaultRepo: "../LangBot", + defaultRepo: "..", pythonPathEnvKeys: ["LANGBOT_PLUGIN_SDK_REPO"], - defaultPythonPaths: ["../langbot-plugin-sdk/src"], + defaultPythonPaths: ["../../langbot-plugin-sdk/src"], description: "LangBot AgentRunner run ledger claim, lease, authorization, and runtime-admin pytest probe.", testTargets: [ "tests/unit_tests/agent/test_run_ledger_store.py::test_create_queued_run_claim_renew_release", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs index ec4670011..e8c54d21a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs @@ -152,15 +152,20 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const dbPath = join(evidenceDir, "ledger-contention.sqlite3"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, dbPath], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, dbPath], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-ledger-contention", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs index 1b5416d10..273ae50c9 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs @@ -127,13 +127,18 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", probeScript], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", probeScript], + cwd: langbotRepo, + }; const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); const result = { source: "automation", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs index 3575358a2..97a3a2c40 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs @@ -119,14 +119,19 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-ledger-stress", diff --git a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs index 07ee5f988..41c31fe3a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs @@ -5,7 +5,7 @@ import { runPytestProbe } from "./pytest-probe.mjs"; await runPytestProbe({ caseId: "agent-runner-runtime-chaos", repoEnvKey: "LANGBOT_PLUGIN_SDK_REPO", - defaultRepo: "../langbot-plugin-sdk", + defaultRepo: "../../langbot-plugin-sdk", description: "LangBot plugin SDK AgentRunner runtime failure, timeout, forwarding, and pull API pytest probe.", testTargets: [ "tests/runtime/plugin/test_mgr_agent_runner.py", diff --git a/skills/skills/langbot-testing/probes/pytest-probe.mjs b/skills/skills/langbot-testing/probes/pytest-probe.mjs index db98361e4..9e7090f21 100755 --- a/skills/skills/langbot-testing/probes/pytest-probe.mjs +++ b/skills/skills/langbot-testing/probes/pytest-probe.mjs @@ -129,7 +129,7 @@ export async function runPytestProbe({ const resultJson = join(evidenceDir, "result.json"); const command = { executable: "rtk", - args: ["uv", "run", "pytest", "-q", ...testTargets], + args: ["uv", "run", "--no-sync", "pytest", "-q", ...testTargets], cwd: repoPath, }; const result = { diff --git a/skills/skills/langbot-testing/references/agent-runner-release-gate.md b/skills/skills/langbot-testing/references/agent-runner-release-gate.md index 89a75fcfa..eb99d5d3f 100644 --- a/skills/skills/langbot-testing/references/agent-runner-release-gate.md +++ b/skills/skills/langbot-testing/references/agent-runner-release-gate.md @@ -110,14 +110,16 @@ Each probe writes `automation-result.json` and probe logs under | Generic Pipeline Debug Chat | `pipeline-debug-chat` | The WebUI Debug Chat path itself works before runner-specific failures are diagnosed. | | Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` AgentRunner package can install and register a runner. | | Deterministic QA runner Debug Chat | `agent-runner-qa-debug-chat` | The installed QA runner executes through WebUI Debug Chat without a model provider. | -| Required runner plugins | `agent-runner-release-preflight` | `langbot/local-agent` and `langbot/acp-agent-runner` are visible to the host. | -| Required QA plugin tool | `plugin-e2e-smoke`, `agent-runner-release-preflight` | The deterministic `qa_plugin_echo` tool is exposed before tool-loop cases start. | +| Required runner plugins | `agent-runner-release-preflight` | `langbot-team/LocalAgent` and `langbot-team/ACPAgentRunner` are visible to the host. | +| Required QA plugin tools | `plugin-e2e-smoke`, `agent-runner-release-preflight`, `qa-plugin-smoke-live-install` | The deterministic `qa_plugin_echo` and `qa_plugin_fail` tools are exposed before tool-loop and tool-error cases start. | | Knowledge base fixture | `langrag-kb-retrieve`, `local-agent-rag-debug-chat` | LangRAG data is queryable and the runner inserts retrieved context. | | Effective prompt bridge | `local-agent-effective-prompt-debug-chat` | Host prompt preprocessing reaches the runner. | | History and compaction | `local-agent-context-compaction-debug-chat` | Runner-owned history budgeting keeps recoverable older context. | | Streaming LLM | `local-agent-basic-debug-chat` | The default streaming path returns a visible answer. | | Non-streaming LLM | `local-agent-nonstreaming-debug-chat` | The non-streaming adapter path returns a visible answer. | | Plugin tool loop | `local-agent-plugin-tool-call-debug-chat` | Function-call capable models can call host plugin tools through authorization. | +| Plugin tool error recovery | `local-agent-tool-error-recovery-debug-chat` | Tool execution errors are serialized into model-facing tool results and the model can produce a final answer instead of failing the run. | +| Parallel plugin tool batch | `local-agent-parallel-tools-rag-compaction-debug-chat` | Local-agent executes multiple same-turn plugin tool calls and returns both results with RAG and compacted history. | | MCP registration | `mcp-stdio-register` | The deterministic stdio MCP server is registered and exposes `qa_mcp_echo`. | | MCP tool loop | `mcp-stdio-tool-call` | Local-agent can call the registered MCP tool through the same tool loop. | | Multimodal input | `local-agent-multimodal-debug-chat` | Image upload and structured input reach the runner. | diff --git a/skills/skills/langbot-testing/references/dify-agent-runner.md b/skills/skills/langbot-testing/references/dify-agent-runner.md index e57695950..f6f3dee98 100644 --- a/skills/skills/langbot-testing/references/dify-agent-runner.md +++ b/skills/skills/langbot-testing/references/dify-agent-runner.md @@ -1,6 +1,6 @@ # Dify AgentRunner -Use this reference when validating `langbot/dify-agent` through LangBot WebUI. +Use this reference when validating `langbot-team/DifyAgent` through LangBot WebUI. ## Prepare Dify @@ -38,7 +38,7 @@ Pass only when: ## Diagnostics -- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot/dify-agent/default` and runner config contains `app-type`, `base-url`, and `api-key`. +- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot-team/DifyAgent/default` and runner config contains `app-type`, `base-url`, and `api-key`. - Direct Dify streaming API calls are useful only to distinguish invalid Dify credentials from LangBot runner issues. - If Debug Chat returns `Agent runner execution failed`, inspect backend logs before changing UI settings. diff --git a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md index 04495f8d5..5fd62d8a2 100644 --- a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md +++ b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md @@ -1,6 +1,6 @@ # Local Agent Runner Coverage -Use this matrix when judging whether the external `langbot/local-agent` plugin still behaves like the old built-in local-agent runner. +Use this matrix when judging whether the external `langbot-team/LocalAgent` plugin still behaves like the old built-in local-agent runner. The QA target is end-to-end behavior. UI cases prove the host, SDK, plugin runtime, and WebUI work together. Unit or component tests are still needed for negative branches that are hard to trigger reliably through a live provider. @@ -29,10 +29,15 @@ These browser cases are the minimum gate for a local-agent migration check: | `local-agent-rag-debug-chat` | Knowledge-base authorization, retrieval, and RAG prompt insertion | Bot returns the KB sentinel, not a generic answer. | | `mcp-stdio-tool-call` | MCP stdio discovery, tool detail, model function calling, and tool execution | Bot returns `qa_mcp_echo:` and backend logs the MCP tool call. | | `local-agent-plugin-tool-call-debug-chat` | Plugin tool discovery, tool detail, model function calling, and tool execution | Bot returns `qa-plugin-smoke:` and backend logs the plugin tool call. | +| `local-agent-tool-error-recovery-debug-chat` | Plugin tool execution failure, model-facing error tool result, and final recovery turn | Bot returns `TOOL_ERROR_RECOVERY_FINAL...` only after the model sees the tool error result. | +| `local-agent-tool-loop-limit-debug-chat` | Repeated plugin tool requests and max tool iteration enforcement | Bot returns the runner limit message instead of looping indefinitely. | +| `local-agent-combo-rag-compaction-tool-debug-chat` | RAG, compacted history, and plugin tool execution in the same Debug Chat run | Bot returns `COMBO_FINAL` with the compacted sentinel, KB sentinel, and plugin tool result together. | +| `local-agent-multitool-rag-compaction-debug-chat` | RAG, compacted history, and repeated serial plugin tool loop turns in the same Debug Chat run | Bot returns `MULTITOOL_COMBO_FINAL` with the compacted sentinel, KB sentinel, and both plugin tool results together. | +| `local-agent-parallel-tools-rag-compaction-debug-chat` | RAG, compacted history, and two plugin tool calls emitted in the same model turn | Bot returns `PARALLEL_COMBO_FINAL` with the compacted sentinel, KB sentinel, and both plugin tool results together. | | `local-agent-steering-debug-chat` | Host steering claim, runner pull at turn boundary, and follow-up injection during an active tool loop | Two user messages produce one assistant response containing the steering sentinel. | | `local-agent-multimodal-debug-chat` | Image upload, structured input contents, and multimodal runner consumption | UI shows uploaded image and bot returns `IMAGE_OK`; backend receives an image input. | | `local-agent-rag-multimodal-debug-chat` | RAG insertion while structured image input is present | UI shows uploaded image, bot returns the KB sentinel, and backend logs the same request with `[Image]`. | -| `local-agent-nonstreaming-debug-chat` | Host non-streaming adapter path and runner non-streaming invocation | Bot returns `NONSTREAM_OK`; backend completes without the streaming-completed path. | +| `local-agent-nonstreaming-debug-chat` | Debug Chat non-streaming UI delivery path | Bot returns `NONSTREAM_OK` with the Debug Chat stream switch disabled; runner-internal `invoke_llm` mode is covered by local-agent component tests using `runtime_metadata.streaming_supported=false`. | ## Full Coverage Matrix @@ -44,14 +49,18 @@ These browser cases are the minimum gate for a local-agent migration check: | Multimodal plus RAG | Run `local-agent-rag-multimodal-debug-chat`. | RAG sentinel is still retrievable and the image is not dropped from the user message; exact image-preservation inside the model message is covered by unit tests. | | History and context compaction | Run `local-agent-context-compaction-debug-chat` with a small temporary `context-window-tokens` budget. | The runner compacts older history into `` and the final answer still recovers the older sentinel from the compacted context. | | Streaming model invocation | Enable Debug Chat streaming and ask for `OK`. | UI receives incremental bot output and backend logs streaming completion. | -| Non-streaming model invocation | Disable Debug Chat streaming or use a non-streaming adapter path. | UI receives a final bot message and backend logs a normal response completion. | +| Non-streaming UI delivery | Disable Debug Chat streaming. | UI receives a final bot message without frontend streaming errors. | +| Non-streaming model invocation | Use local-agent component tests with `runtime_metadata.streaming_supported=false` or a host adapter that does not support streaming. | Runner calls `invoke_llm` instead of `invoke_llm_stream` and emits `message.completed`. | | Model fallback before first chunk | Configure a failing primary and working fallback, preferably with a controlled test provider. | First model failure does not fail the run; fallback model produces the final answer. | | Failure after streaming commit | Use a controlled provider that emits one chunk and then fails. | Runner reports a terminal run failure and does not fallback after partial output. | | No authorized model | Clear model config or configure a model not in run resources. | Runner returns `runner.no_model` instead of calling an unauthorized model. | | MCP tool call | Use `qa-local-stdio` and `qa_mcp_echo`. | Bot returns the exact `qa_mcp_echo:` result; `/api/v1/tools` contains `qa_mcp_echo`. | | Plugin tool call | Install a fixture plugin exposing a deterministic tool and bind it to the pipeline. | Runner lists the plugin tool and can call it through the same tool loop as MCP tools. | +| Repeated tool loop | Run `local-agent-multitool-rag-compaction-debug-chat` with `max-tool-iterations` high enough for two serial calls. | Runner calls `qa_plugin_echo` twice and the final answer contains both distinct tool results with RAG and compacted history. | +| Parallel tool batch | Run `local-agent-parallel-tools-rag-compaction-debug-chat` with `tool-execution-mode: parallel`. | Runner executes two same-turn `qa_plugin_echo` calls and the final answer contains both results with RAG and compacted history. | +| Tool iteration limit | Run `local-agent-tool-loop-limit-debug-chat` with `max-tool-iterations: 2` and a fake provider that keeps requesting tools. | Runner stops and returns `Tool call iteration limit reached...`; it does not keep invoking tools indefinitely. | | Run steering | Use `local-agent-steering-debug-chat` with the fixture `qa_plugin_sleep` tool. | A follow-up sent while the sleep tool keeps the run active is claimed into the same run: two user messages, one assistant response, sentinel included. | -| Tool errors | Make the model request an unauthorized tool or invalid arguments in a controlled unit/component test. | Tool result contains an error message and the run does not bypass authorization. | +| Tool errors | Run `local-agent-tool-error-recovery-debug-chat` for plugin tool execution errors; keep unauthorized-tool branches in unit/component tests. | The model receives an `Error:` tool result and returns the recovery sentinel; unauthorized calls remain blocked in component tests. | | Tool iteration limit | Use a controlled model/tool fixture that repeatedly requests more tool calls. | Runner stops with `runner.tool_loop_limit` at the configured limit. | | Knowledge retrieval | Bind a KB containing a unique sentinel. | Bot returns the sentinel and backend logs LangRAG retrieval. | | Legacy `knowledge-base` config | Load a pipeline config using the old single-KB field. | Runner still retrieves from the KB. | diff --git a/skills/skills/langbot-testing/references/local-agent-runner.md b/skills/skills/langbot-testing/references/local-agent-runner.md index 63a2b7e94..0dc4d8274 100644 --- a/skills/skills/langbot-testing/references/local-agent-runner.md +++ b/skills/skills/langbot-testing/references/local-agent-runner.md @@ -1,6 +1,6 @@ # Local Agent Runner -Use this reference when validating the pluginized `langbot/local-agent` runner through the WebUI. +Use this reference when validating the pluginized `langbot-team/LocalAgent` runner through the WebUI. The goal is behavior parity with the old built-in local-agent runner. The code does not need to be identical, but the visible behavior should match: effective prompt, current input, history, model selection and fallback, tool calling, knowledge retrieval, multimodal input, streaming and non-streaming output all have to reach the runner through the host and SDK. @@ -36,7 +36,7 @@ If the direct MCP fixture passes but `/api/v1/tools` still shows the old MCP nam For a multimodal check, upload a small image and ask for a deterministic acknowledgement. Prefer the bundled 64x64 red-square fixture over a 1x1 image because some model providers reject tiny images before the runner path is exercised. -For a non-streaming check, disable the Debug Chat stream switch before sending the prompt. +For a Debug Chat non-streaming delivery check, disable the Debug Chat stream switch before sending the prompt. This validates the UI/adapter delivery path. Runner-internal non-streaming model invocation is covered by component tests that set `runtime_metadata.streaming_supported=false`. ## Timeout And Tool Regression Checks @@ -53,9 +53,14 @@ Run these cases before saying the pluginized local-agent behavior is healthy: - `local-agent-rag-debug-chat`: LangRAG retrieval reaches the runner and affects the answer. - `mcp-stdio-tool-call`: MCP tool discovery and local-agent tool loop. - `local-agent-plugin-tool-call-debug-chat`: plugin tool discovery and local-agent tool loop. +- `local-agent-tool-error-recovery-debug-chat`: plugin tool execution errors are returned to the model as tool results and can produce a final recovery answer. +- `local-agent-tool-loop-limit-debug-chat`: repeated plugin tool requests stop at the configured max tool iteration limit. +- `local-agent-combo-rag-compaction-tool-debug-chat`: one Debug Chat run combines compacted history, LangRAG context, and a plugin tool result. +- `local-agent-multitool-rag-compaction-debug-chat`: one Debug Chat run combines compacted history, LangRAG context, and two serial plugin tool calls. +- `local-agent-parallel-tools-rag-compaction-debug-chat`: one Debug Chat run combines compacted history, LangRAG context, and two same-turn parallel plugin tool calls. - `local-agent-multimodal-debug-chat`: uploaded image reaches `ctx.input.contents`. - `local-agent-rag-multimodal-debug-chat`: RAG retrieval still works when the same user message carries an image. -- `local-agent-nonstreaming-debug-chat`: runner works when the host adapter cannot or should not stream. +- `local-agent-nonstreaming-debug-chat`: Debug Chat still returns a complete visible response when UI streaming is disabled. ## Pass Criteria diff --git a/skills/skills/langbot-testing/references/mcp-stdio-testing.md b/skills/skills/langbot-testing/references/mcp-stdio-testing.md index c1930b81a..a92218c40 100644 --- a/skills/skills/langbot-testing/references/mcp-stdio-testing.md +++ b/skills/skills/langbot-testing/references/mcp-stdio-testing.md @@ -79,13 +79,15 @@ node scripts/e2e/mcp-stdio-register.mjs It upserts `qa-local-stdio` through the authenticated WebUI session, points it at the bundled `qa_mcp_echo_server.py`, then checks `/api/v1/tools` and the MCP runtime info. A pass proves LangBot has refreshed the saved server and exposes -`qa_mcp_echo` before any model provider is involved. +`qa_mcp_echo` before any model provider is involved. It also writes the resolved +server UUID to `LANGBOT_MCP_QA_STDIO_SERVER_UUID` in `skills/.env.local`; pipeline +extension binding uses this UUID, not the human-readable server name. ## Local-Agent Tool Call Check 1. Open the target pipeline. 2. Confirm `Extensions` allows the MCP server, or that all MCP servers are enabled. -3. Use runner `Default` or the pluginized `langbot/local-agent` runner. +3. Use runner `Default` or the pluginized `langbot-team/LocalAgent` runner. 4. Select a model with function-calling ability that is known to work with tools in the current environment. 5. Open `Debug Chat`. 6. Ask: diff --git a/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md new file mode 100644 index 000000000..a72c1db70 --- /dev/null +++ b/skills/skills/langbot-testing/references/skill-all-tool-acceptance.md @@ -0,0 +1,68 @@ +# Acceptance matrix — skill all-tool model + +Acceptance criteria for the branch that unifies LangBot skills as **authorized +tools** (`feat/agent-runner-plugin`). Skills are no longer gated behind the +`skill_authoring` capability; `activate` / `register_skill` / native `exec` are +exposed like native tools, gated only on **sandbox + skill_mgr**. Discovery is +tool-driven (`langbot_list_assets` gains a `skills` asset class for external +harnesses). Host persists activated skills to `host.activated_skills` +(last-write-wins) and prefills `ToolResource.parameters` so runners skip +per-tool `get_tool_detail`. + +## What changed (scope under test) + +| Layer | Change | +| --- | --- | +| host | `toolmgr.get_all_tools` drops `include_skill_authoring`; `SkillToolLoader` self-gates on sandbox+skill_mgr | +| host | `preproc` drops the `include_skill_authoring` branch; bound-skills + skills resource gate on `skill_mgr` | +| host | `resource_builder` stops gating skills on `skill_authoring`; fills `ToolResource.parameters` via `tool_mgr.get_tool_schema` | +| host | `persist_activated_skill` writes `host.activated_skills` (conversation scope) | +| sdk | `ToolResource.parameters` (full JSON schema); `langbot_list_assets` `skills` asset class | +| local-agent | `build_llm_tools` prefers `ctx.resources.tools.parameters`, falls back to `get_tool_detail`; `DEFAULT_MAX_TOOL_ITERATIONS` 20→100 | + +## Dimensions + +- **Runner**: `local-agent` (in-process logic, direct Run API, skill tools in `use_funcs`) · `acp-agent-runner` (external harness, remote-ssh claude-code over ACP, MCP gateway via **HTTP proxy**) · `claude-code-agent` (external harness, claude-code CLI, MCP gateway via **stdio bridge** — pipeline `28fd37ac`, remote-ssh→101). + +### Runner transport difference (both work out-of-the-box on remote-ssh) + +Both external runners receive the same host-generated gateway `AgentMCPServerConfig`, but inject it differently — and **both are made remote-reachable automatically; neither requires `public-url` on remote-ssh**: + +- **claude-code-agent → stdio bridge.** The mcp config is shipped to the remote host base64-over-SSH-stdin and consumed via `--mcp-config`; the gateway entry is a `command/args` (stdio) MCP server whose process tunnels back to the host over the SSH stdio pipe. +- **acp-agent-runner → HTTP proxy + SSH reverse tunnel.** The gateway is a localhost HTTP MCP proxy passed via ACP `session/new {mcpServers}`. On `remote-ssh` with no `public-url`, the SDK's `AgentRunMCPAccess` (`mcp_access.py` `_remote_reverse_tunnel`: location==remote-ssh and empty public_url) emits an `ssh -R 127.0.0.1::127.0.0.1:` reverse tunnel — consumed by acp `default.py:521` (`ssh_args.extend(access.reverse_tunnel.ssh_args())`) — and points `server_config.public_url` at the host-local `http_mcp_endpoint`. The remote claude hits `127.0.0.1:` which tunnels back to the host bridge. **`langbot-assets-gateway-public-url` is an optional alternative for topologies where the reverse tunnel can't be used — not a requirement.** + +This is a **runner-plugin transport detail, not a host all-tool-branch issue** — proven by **both** runners discovering skills end-to-end with the unmodified branch (see cases below). + +> **Correction (2026-06-22).** An earlier revision of this doc claimed acp was "blocked" on remote-ssh and *required* `langbot-assets-gateway-public-url`, based on a run that returned `PROBEDONE 0 0` / timeout. That was an **environment artifact, not an acp defect**: a duplicate backend instance (a second checkout `LangBot-master/` whose box runtime contended for the same `--ws-control-port 5410`) plus a wedged plugin runtime (host `emit_event` / `list_agent_runners` action calls timing out with `ActionCallTimeoutError`). Re-run on a clean single-instance runtime, **acp passes via the reverse tunnel with no `public-url`** (`PROBEDONE 1 17`, 8–24s). +- **Lifecycle**: discover → activate → operate (native exec under the activated mount path) → register. +- **Backend**: docker · nsjail · e2b. + +## Cases & status + +| Case | Asserts | Runner(s) | Status | +| --- | --- | --- | --- | +| `skill-tool-exposure-no-capability` | skill tools offered to a tool-calling runner **without** `skill_authoring`; gated only on sandbox+skill_mgr | local-agent | **covered (unit)** — `test_tool_manager_native.py`, `test_preproc.py` | +| `skill-activation-persistence` | activated skill survives a new run in the same conversation (`host.activated_skills` restore) | local-agent | **covered (unit)** — `test_skill_tools.py` | +| `toolresource-parameters-prefill` | runner builds LLM tools from `ctx.resources.tools.parameters` without per-tool `get_tool_detail` | local-agent | **covered (unit)** — `test_run_assembly.py::test_build_llm_tools_uses_prefilled_schema_without_fetch` | +| `regression-existing-runner-behavior` | existing local-agent cases (basic/rag/tool-call/steering/multimodal) unchanged | local-agent | **covered (unit)** — full host/sdk/local-agent suites green, 0 new failures | +| `sandbox-skill-authoring-e2e` | create → register → activate → exec-from-activated-path → `E2E_OK` | local-agent | **PASS (nsjail + docker)** — full chain green via local-agent Debug Chat (pipeline `3e645b04`): create+run in `/workspace` → `exit 0` `SANDBOX_COMPLEX_SKILL_OK sum=10 product=24`; register+activate; exec in `/workspace/.skills/` runs `scripts/use.py` (reads `data/input.json`) and writes `activated_writeback.txt` → `exit 0`, both markers, file written through to host skill store. Verified on **nsjail** first, then on **docker** after the #2271 fix ([langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87)). | +| `skill-discovery-via-mcp-gateway` | external harness calls `langbot_list_assets(['skills'])` and receives pipeline-visible skills | claude-code / acp | **PASS (both)** — clean single-instance runtime, remote-ssh→101. claude-code-agent (pipeline `28fd37ac`, stdio bridge): `PROBEDONE skills=1 tools=15`. acp-agent-runner (pipeline `b00794d2`, HTTP proxy + SSH reverse tunnel, **no public-url**): `PROBEDONE skills=1 tools=17`, 8–24s. Both prove the all-tool `skills` asset class is discoverable end-to-end by an external harness. | +| `skill-activation-cross-runner-parity` | local-agent and external harness both reach skills via their paths (`use_funcs` vs `langbot_call_tool`) | local-agent + claude-code + acp | **PASS** — local-agent (use_funcs) ✓, claude-code-agent (stdio gateway, `skills=1 tools=15`) ✓, and acp-agent-runner (HTTP-proxy gateway over reverse tunnel, `skills=1 tools=17`) ✓ all discover skills. `skills` count matches (1==1); the `tools` count (17 vs 15) is claude's self-reported tally and not yet checked against the authoritative gateway count — most likely model-counting variance, not an asset difference. | + +## Known issues + +- [#2271](https://github.com/langbot-app/LangBot/issues/2271) — activated `/workspace/.skills/` `scripts/`/`data/` missing on the docker backend. **FIXED** by [langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87) (`fix(box): recreate sandbox container when extra_mounts change`), rebased into this branch. **Corrected root cause:** not "docker masks the nested bind mount" (disproven) — the real bug is **container reuse**: `extra_mounts` was not part of the box session compatibility check, so when a skill is activated mid-conversation docker reused the already-running container and could not append the new bind mount; the activated skill therefore appeared empty. The fix records a mount signature on the session and recreates the container when the mount set changes (idempotent, no data loss). Pre-existing (Feat/sandbox #2072), reproduced on pure `origin/master` + the built-in local-agent runner, so not introduced by this branch — this branch only exposed the path end-to-end for the first time. After the fix, the OPERATE step passes on **both** docker and nsjail (see exit criterion 3). Merging needs a new SDK release + a `langbot-plugin` pin bump in LangBot's `pyproject.toml` to reach a released LangBot. +- **nsjail + stale docker workspace artifacts (environment, not a code bug).** If a prior docker run left root-owned dirs under the workspace (e.g. `data/box/default/.skills/`, created root-owned because docker runs as root), nsjail — which runs as the invoking user — cannot create the nested skill mount target under that root-owned dir and `runChild()` fails with `Launching child process failed`, poisoning **every** exec in the session (the exact symptom documented in `box/service.py::build_skill_extra_mounts`). Fix: remove the root-owned leftovers (`sudo rm -rf data/box/default/.skills data/box/default/`) before running nsjail e2e. New nsjail runs create user-owned artifacts, so this is a one-time cleanup after switching off docker. + +## Exit criteria + +1. Unit matrix green across host/sdk/local-agent, 0 new failures. **(DONE)** +2. `skill-tool-exposure-no-capability` + `skill-activation-persistence` + `toolresource-parameters-prefill` covered by unit. **(DONE)** +3. `sandbox-skill-authoring-e2e` OPERATE step passes on a real backend, proving end-to-end skill use. **(DONE — nsjail + docker)** — full create→exec→register→activate→exec-from-`/workspace/.skills/` chain returns `exit 0`; the activated mount runs `scripts/use.py` (reads `data/input.json` → `SANDBOX_COMPLEX_SKILL_OK sum=10 product=24`) and writes `activated_writeback.txt` through to the host skill store. Verified on nsjail, then on docker after the #2271 fix ([langbot-plugin-sdk#87](https://github.com/langbot-app/langbot-plugin-sdk/pull/87)). +4. `skill-discovery-via-mcp-gateway` passes on an external harness. **(DONE — claude-code-agent: skills=1 tools=15, 24s)** +5. `skill-activation-cross-runner-parity` passes on acp. **(DONE — acp: skills=1 tools=17, 8s, via SSH reverse tunnel with no public-url; clean single-instance runtime)** + +## How to run + +- **Unit**: LangBot `make test`; SDK `uv run pytest`; local-agent `uv run pytest tests/`. +- **Browser e2e**: per-pipeline Debug Chat; canonical skill prompt pattern in [`sandbox-skill-authoring.md`](./sandbox-skill-authoring.md). Automatable cases use the `automation_*` fields + `scripts/e2e/pipeline-debug-chat.mjs`. diff --git a/skills/skills/langbot-testing/references/troubleshooting.md b/skills/skills/langbot-testing/references/troubleshooting.md index 286990c59..66dfe63c3 100644 --- a/skills/skills/langbot-testing/references/troubleshooting.md +++ b/skills/skills/langbot-testing/references/troubleshooting.md @@ -20,7 +20,7 @@ An old `langbot_plugin` runtime process survived a backend restart, or multiple ### Fix -Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot/local-agent`, and initializes the default agent runner. +Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot-team/LocalAgent`, and initializes the default agent runner. ### Verification diff --git a/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml index a2c150669..733b2c37e 100644 --- a/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml +++ b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml @@ -30,6 +30,11 @@ cases: - local-agent-context-compaction-debug-chat - local-agent-rag-debug-chat - local-agent-plugin-tool-call-debug-chat + - local-agent-tool-error-recovery-debug-chat + - local-agent-tool-loop-limit-debug-chat + - local-agent-combo-rag-compaction-tool-debug-chat + - local-agent-multitool-rag-compaction-debug-chat + - local-agent-parallel-tools-rag-compaction-debug-chat - mcp-stdio-register - mcp-stdio-tool-call - local-agent-nonstreaming-debug-chat diff --git a/skills/skills/langbot-testing/suites/eba-release-gate.yaml b/skills/skills/langbot-testing/suites/eba-release-gate.yaml new file mode 100644 index 000000000..c7fa39584 --- /dev/null +++ b/skills/skills/langbot-testing/suites/eba-release-gate.yaml @@ -0,0 +1,16 @@ +id: eba-release-gate +title: "Event-based bot product release gate" +description: "Browser and runtime gate for scenario onboarding, route diagnostics, and real platform-to-Agent execution." +type: release_gate +priority: p0 +tags: + - eba + - event-routing + - wizard + - release-gate +cases: + - wizard-scenario-routing + - wizard-runner-marketplace-catalog + - agent-runner-health-visibility + - bot-event-routing-product-flow + - wizard-onebot-agent-runtime diff --git a/skills/skills/langbot-testing/suites/local-agent-gate.yaml b/skills/skills/langbot-testing/suites/local-agent-gate.yaml index 42eee0228..40ed15342 100644 --- a/skills/skills/langbot-testing/suites/local-agent-gate.yaml +++ b/skills/skills/langbot-testing/suites/local-agent-gate.yaml @@ -14,6 +14,11 @@ cases: - local-agent-context-compaction-debug-chat - local-agent-rag-debug-chat - local-agent-plugin-tool-call-debug-chat + - local-agent-tool-error-recovery-debug-chat + - local-agent-tool-loop-limit-debug-chat + - local-agent-combo-rag-compaction-tool-debug-chat + - local-agent-multitool-rag-compaction-debug-chat + - local-agent-parallel-tools-rag-compaction-debug-chat - local-agent-steering-debug-chat - mcp-stdio-tool-call - local-agent-nonstreaming-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml index 74e03df3d..1f703da4b 100644 --- a/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml +++ b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml @@ -16,7 +16,7 @@ fix_steps: - "Change metadata.label to the provider or runner family name, such as Dify, Local Agent, Coze, DashScope, Langflow, n8n, or Tbox." - "Restart LangBot or plugin runtime to refresh runner metadata cache." - "Check /api/v1/pipelines/_/metadata and the WebUI runner selector." -verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot/dify-agent/default." +verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot-team/DifyAgent/default." related_cases: - dify-agent-debug-chat - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml index 3b0f33f36..1fdc3609a 100644 --- a/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml +++ b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml @@ -22,7 +22,7 @@ fix_steps: - "Stop the LangBot backend." - "Stop orphaned langbot_plugin.cli runtime processes." - "Confirm the configured backend URL is free or reachable as appropriate." - - "Start LangBot again and wait for Connected to plugin runtime plus langbot/local-agent initialization." + - "Start LangBot again and wait for Connected to plugin runtime plus langbot-team/LocalAgent initialization." - "For disposable test data only, move suspected fixture plugins out of data/plugins and restart to isolate plugin discovery failures." verification: "Open Installed Plugins and confirm the plugin list loads, then run pipeline-debug-chat and confirm the UI shows a Bot response while backend logs include Streaming completed." related_cases: diff --git a/skills/src/commands/log.ts b/skills/src/commands/log.ts index aad9eaa66..fbadd44d5 100644 --- a/skills/src/commands/log.ts +++ b/skills/src/commands/log.ts @@ -117,7 +117,7 @@ function patternContextFromOptions(root: string, options: Record): string { const explicit = optionString(options, "backend-log"); if (explicit) return resolve(explicit); - const auto = latestLangBotLogPath(loadEnv(root)); + const auto = latestLangBotLogPath(loadEnv(root), root); return auto ? resolve(auto) : ""; } diff --git a/skills/src/commands/test.ts b/skills/src/commands/test.ts index 67ddc3122..97356840b 100644 --- a/skills/src/commands/test.ts +++ b/skills/src/commands/test.ts @@ -343,6 +343,78 @@ function manualEvidenceTemplate(mode: string): ManualEvidenceTemplate { }; } +function existingEvidencePath(evidence: Record | undefined, key: string): string { + const path = evidence?.[key]; + return path && existsSync(path) ? path : ""; +} + +function renderAutomationEvidence( + mode: string, + automation: AutomationResultEvidence, + logGuard: LogGuardResult, +): ManualEvidenceTemplate { + if (automation.status !== "loaded") return manualEvidenceTemplate(mode); + + const isProbe = isProbeMode(mode); + const screenshot = existingEvidencePath(automation.evidence, "screenshot"); + const consoleLog = existingEvidencePath(automation.evidence, "console_log"); + const networkLog = existingEvidencePath(automation.evidence, "network_log"); + const apiDiagnostics = Object.entries(automation.evidence ?? {}) + .filter(([key, value]) => key.endsWith("_diagnostic_json") && value && existsSync(value)) + .map(([, value]) => value); + const successLines = logGuard.success_signals + .map((signal) => signal.excerpt || `${signal.source}:${signal.line ?? ""} ${signal.pattern}`.trim()) + .slice(0, 3); + const findings = logGuard.findings + .map((finding) => `${finding.severity}/${finding.kind}${finding.excerpt ? `: ${finding.excerpt}` : ""}`) + .slice(0, 3); + const chatSummary = automation.chat_results?.length + ? automation.chat_results + .map((item) => `#${(item.index ?? 0) + 1} ${item.status ?? "unknown"} ${item.expected_text ?? ""}`.trim()) + .join("; ") + : automation.reason || ""; + const diagnostics = [ + automation.browser_diagnostics?.reason, + apiDiagnostics.length ? `api diagnostics: ${apiDiagnostics.join(", ")}` : "", + ].filter(Boolean).join("; ") || "No extra diagnostics recorded."; + + if (isProbe) { + return { + result: automation.result || "unknown", + target_tested: String(automation.url || automation.path || "automation target"), + execution_path: "automation script", + probe_result: automation.reason || chatSummary || "Automation completed.", + logs_or_artifacts: [ + consoleLog ? `console=${consoleLog}` : "", + networkLog ? `network=${networkLog}` : "", + automation.path ? `automation=${automation.path}` : "", + ].filter(Boolean).join(", ") || "No log artifacts recorded.", + diagnostics, + matched_troubleshooting: findings.length ? findings.join(" | ") : "None.", + assets_to_update: automation.result === "pass" ? "None." : "Review case/troubleshooting assets if this failure is expected to recur.", + }; + } + + return { + result: automation.result || "unknown", + url_tested: automation.url || "Not recorded.", + browser_path: "direct Playwright automation", + ui_result: chatSummary || automation.reason || "Automation completed.", + console_errors: automation.browser_diagnostics?.status === "fail" + ? automation.browser_diagnostics.reason || "Browser diagnostics failed." + : "None found by browser diagnostics.", + network_symptoms: networkLog ? `Captured in ${networkLog}` : "No network log artifact recorded.", + backend_logs: successLines.length + ? successLines.join(" | ") + : `Log Guard status: ${logGuard.status}${findings.length ? `; ${findings.join(" | ")}` : ""}`, + frontend_logs: consoleLog ? `Captured in ${consoleLog}` : "No frontend console log artifact recorded.", + screenshots: screenshot || "No screenshot artifact recorded.", + diagnostics, + matched_troubleshooting: findings.length ? findings.join(" | ") : "None.", + assets_to_update: automation.result === "pass" ? "None." : "Review case/troubleshooting assets if this failure is expected to recur.", + }; +} + function envSummary(item: StructuredItem, env: Record): Record { const keys = [ ...listValue(item.fields, "env"), @@ -1321,6 +1393,8 @@ export function commandTestRun(ctx: CommandContext): number { function buildReport(root: string, item: StructuredItem, options: Record): TestReport { const env = loadEnv(root); const mode = caseMode(item); + const automationResult = readAutomationResultEvidence(options); + const logGuard = scanStructuredLogSources(root, item, options); const related = relatedTroubleshooting(root, item).map((entry) => ({ id: scalar(entry.fields, "id"), title: scalar(entry.fields, "title"), @@ -1332,8 +1406,8 @@ function buildReport(root: string, item: StructuredItem, options: Record; + evidence_collected?: string[]; + browser_diagnostics?: { + status?: string; + reason?: string; + finding_count?: number; + }; + chat_results?: Array<{ + index?: number; + expected_text?: string; + status?: string; + reason?: string; + }>; metrics_summary?: Record; thresholds_summary?: Record; artifacts?: Record; @@ -216,13 +232,13 @@ export function scanLogSources( for (const configured of configuredSources) { const explicitPath = options[configured.option]; const autoPath = configured.source === "backend" && options["no-auto-log"] !== true - ? latestLangBotLogPath(env) + ? latestLangBotLogPath(env, root) : null; const rawPath = typeof explicitPath === "string" ? explicitPath : autoPath; const autoDetected = typeof explicitPath !== "string" && rawPath === autoPath; if (!rawPath) { if (configured.source === "backend" && options["no-auto-log"] !== true) { - const logsDir = env.LANGBOT_REPO ? join(env.LANGBOT_REPO, "data", "logs") : "LANGBOT_REPO/data/logs"; + const logsDir = langBotLogsDir(env, root) ?? "LANGBOT_REPO/data/logs"; sources.push({ source: "backend", path: join(logsDir, "langbot-*.log"), status: "auto_not_found", line_count: 0, auto_detected: true }); } continue; @@ -610,6 +626,47 @@ function objectField(data: Record, key: string): Record, key: string): Record | undefined { + const value = data[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0); + return entries.length ? Object.fromEntries(entries) : undefined; +} + +function stringListField(data: Record, key: string): string[] | undefined { + const value = data[key]; + if (!Array.isArray(value)) return undefined; + const items = value.map((item) => typeof item === "string" ? item : "").filter(Boolean); + return items.length ? items : undefined; +} + +function chatResultsField(data: Record): AutomationResultEvidence["chat_results"] { + const value = data.chat_results; + if (!Array.isArray(value)) return undefined; + const results = value + .filter((item): item is Record => Boolean(item) && typeof item === "object" && !Array.isArray(item)) + .map((item) => ({ + index: numberField(item, "index"), + expected_text: stringField(item, "expected_text"), + status: stringField(item, "status"), + reason: stringField(item, "reason"), + })); + return results.length ? results : undefined; +} + +function browserDiagnosticsField(data: Record): AutomationResultEvidence["browser_diagnostics"] { + const value = data.browser_diagnostics; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const diagnostics = value as Record; + const findings = Array.isArray(diagnostics.findings) ? diagnostics.findings.length : undefined; + return { + status: stringField(diagnostics, "status"), + reason: stringField(diagnostics, "reason"), + finding_count: findings, + }; +} + function evidenceDirFromOptions(options: Record): string | undefined { const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined; if (explicit) return resolve(explicit); @@ -652,6 +709,13 @@ export function readAutomationResultEvidence(options: Record): string | null { - const repo = env.LANGBOT_REPO; - if (!repo) return null; - const logsDir = join(repo, "data", "logs"); +function langBotRepoFromRoot(root: string): string | null { + const candidates = [ + resolve(root), + basename(resolve(root)) === "skills" ? resolve(root, "..") : "", + resolve(root, "../LangBot"), + resolve(root, "LangBot"), + ].filter(Boolean); + + const seen = new Set(); + for (const candidate of candidates) { + if (seen.has(candidate)) continue; + seen.add(candidate); + if (existsSync(join(candidate, "data", "config.yaml"))) return candidate; + } + return null; +} + +function langBotLogsDir(env: Record, root: string): string | null { + const repo = env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : langBotRepoFromRoot(root); + return repo ? join(repo, "data", "logs") : null; +} + +export function latestLangBotLogPath(env: Record, root = process.cwd()): string | null { + const logsDir = langBotLogsDir(env, root); + if (!logsDir) return null; if (!existsSync(logsDir)) return null; const candidates = readdirSync(logsDir) diff --git a/skills/src/readiness.ts b/skills/src/readiness.ts index 945fcb598..b20a23ecc 100644 --- a/skills/src/readiness.ts +++ b/skills/src/readiness.ts @@ -111,6 +111,8 @@ export function automationEnvDefaults(item: StructuredItem, env: EnvSource = pro ["automation_image_base64_fixture", "LANGBOT_E2E_IMAGE_BASE64_PATH"], ["automation_runner_config_patch_json", "LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON"], ["automation_restore_runner_config", "LANGBOT_E2E_RESTORE_RUNNER_CONFIG"], + ["automation_extensions_patch_json", "LANGBOT_E2E_EXTENSIONS_PATCH_JSON"], + ["automation_restore_extensions", "LANGBOT_E2E_RESTORE_EXTENSIONS"], ["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"], ["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"], ["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"], diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts index 66c0411f2..221f2b2a5 100644 --- a/skills/test/lbs-cli.test.ts +++ b/skills/test/lbs-cli.test.ts @@ -1,16 +1,51 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { + appendFileSync, + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CommandContext } from "../src/types.ts"; -import { commandCaseList, commandCaseNew, commandCaseShow } from "../src/commands/case.ts"; +import { + commandCaseList, + commandCaseNew, + commandCaseShow, +} from "../src/commands/case.ts"; import { commandEnvDoctor, commandEnvShow } from "../src/commands/env.ts"; -import { commandFixtureCheck, commandFixtureList } from "../src/commands/fixture.ts"; -import { commandLogGuard, commandLogScan, commandLogWatch } from "../src/commands/log.ts"; -import { commandSuiteList, commandSuiteNew, commandSuitePlan, commandSuiteReport, commandSuiteRun, commandSuiteShow, commandSuiteStart } from "../src/commands/suite.ts"; -import { commandTestPlan, commandTestRecommend, commandTestReport, commandTestResult, commandTestRun, commandTestStart } from "../src/commands/test.ts"; +import { + commandFixtureCheck, + commandFixtureList, +} from "../src/commands/fixture.ts"; +import { + commandLogGuard, + commandLogScan, + commandLogWatch, +} from "../src/commands/log.ts"; +import { + commandSuiteList, + commandSuiteNew, + commandSuitePlan, + commandSuiteReport, + commandSuiteRun, + commandSuiteShow, + commandSuiteStart, +} from "../src/commands/suite.ts"; +import { + commandTestPlan, + commandTestRecommend, + commandTestReport, + commandTestResult, + commandTestRun, + commandTestStart, +} from "../src/commands/test.ts"; import { commandTroubleSearch } from "../src/commands/trouble.ts"; import { commandValidate } from "../src/commands/validate.ts"; import { commandIndex } from "../src/commands/skill.ts"; @@ -21,6 +56,11 @@ import { findNewFailureSignal, minExpectedOccurrences, } from "../scripts/e2e/lib/debug-chat.mjs"; +import { + ensureAuthenticatedBrowser, + resolveLangBotRepo, + scanBrowserDiagnostics, +} from "../scripts/e2e/lib/langbot-e2e.mjs"; const root = process.cwd(); @@ -37,6 +77,149 @@ test("repo root detects the skills tree before generated bin exists", () => { } }); +test("e2e helpers resolve embedded LangBot repo from skills cwd", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-langbot-repo-detect-")); + try { + const repo = join(tmp, "LangBot"); + const skills = join(repo, "skills"); + mkdirSync(join(repo, "data"), { recursive: true }); + mkdirSync(skills, { recursive: true }); + writeFileSync( + join(repo, "data", "config.yaml"), + "system:\n recovery_key: test\n", + ); + + assert.equal(await resolveLangBotRepo("", skills), repo); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("e2e browser diagnostics fail on console server errors", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-browser-diagnostics-")); + try { + const consoleLog = join(tmp, "console.log"); + const networkLog = join(tmp, "network.log"); + writeFileSync( + consoleLog, + [ + "[2026-06-30T03:02:49.702+08:00] [error] Failed to load resource: the server responded with a status of 500 ()", + "[2026-06-30T03:02:49.703+08:00] [error] Server error: Action list_plugins call timed out", + ].join("\n"), + ); + writeFileSync( + networkLog, + "[2026-06-30T03:02:49.701+08:00] [response] 500 http://127.0.0.1:5300/api/v1/plugins\n", + ); + + const result = await scanBrowserDiagnostics({ consoleLog, networkLog }); + + assert.equal(result.status, "fail"); + assert.match(result.reason, /Browser diagnostics found/); + assert.ok( + result.findings.some( + (finding: { kind: string }) => finding.kind === "api_server_error", + ), + ); + assert.ok( + result.findings.some( + (finding: { kind: string }) => finding.kind === "http_5xx", + ), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("e2e helper can inject a local login token into a fresh browser context", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-auth-helper-")); + const previousFetch = globalThis.fetch; + const previousRepo = process.env.LANGBOT_REPO; + try { + const repo = join(tmp, "LangBot"); + mkdirSync(join(repo, "data"), { recursive: true }); + writeFileSync( + join(repo, "data", "config.yaml"), + "system:\n recovery_key: recovery-test\n", + ); + process.env.LANGBOT_REPO = repo; + + globalThis.fetch = (async (url: string, init?: RequestInit) => { + if (url.endsWith("/api/v1/user/reset-password")) { + return new Response(JSON.stringify({ code: 0 }), { status: 200 }); + } + if (url.endsWith("/api/v1/user/auth")) { + return new Response( + JSON.stringify({ code: 0, data: { token: "fresh-token" } }), + { status: 200 }, + ); + } + if (url.endsWith("/api/v1/user/check-token")) { + const auth = + init?.headers && typeof init.headers === "object" + ? (init.headers as Record).Authorization + : ""; + return new Response( + JSON.stringify({ + code: auth === "Bearer fresh-token" ? 0 : -1, + msg: auth === "Bearer fresh-token" ? "ok" : "missing token", + }), + { status: auth === "Bearer fresh-token" ? 200 : 401 }, + ); + } + return new Response(JSON.stringify({ code: -1, msg: "unexpected" }), { + status: 404, + }); + }) as typeof fetch; + + let token = ""; + const page = { + addInitScript: async () => {}, + goto: async () => {}, + evaluate: async (fn: unknown, arg: string) => { + const text = String(fn); + if (text.includes("localStorage.setItem")) { + token = arg; + return undefined; + } + if (text.includes("localStorage.getItem")) { + if (!token) { + return { + authenticated: false, + http_status: 0, + code: null, + reason: "No localStorage token.", + }; + } + return { + authenticated: true, + http_status: 200, + code: 0, + reason: "Token accepted by backend.", + }; + } + return undefined; + }, + }; + + const result = await ensureAuthenticatedBrowser(page, { + frontendUrl: "http://127.0.0.1:3000", + backendUrl: "http://127.0.0.1:5300", + user: "qa@example.invalid", + password: "password", + }); + + assert.equal(result.status, "pass"); + assert.equal(result.injected, true); + assert.equal(token, "fresh-token"); + } finally { + globalThis.fetch = previousFetch; + if (previousRepo === undefined) delete process.env.LANGBOT_REPO; + else process.env.LANGBOT_REPO = previousRepo; + rmSync(tmp, { recursive: true, force: true }); + } +}); + function ctx(args: string[]): CommandContext { return { root, args }; } @@ -55,7 +238,11 @@ function capture(fn: () => number): { code: number; output: string } { } } -function captureAll(fn: () => number): { code: number; output: string; error: string } { +function captureAll(fn: () => number): { + code: number; + output: string; + error: string; +} { const originalLog = console.log; const originalWrite = process.stderr.write; const lines: string[] = []; @@ -76,7 +263,12 @@ function captureAll(fn: () => number): { code: number; output: string; error: st } } -function suiteResult(caseId: string, runId: string, status = "pass", evidence = ["ui", "screenshot", "console", "backend_log"]): string { +function suiteResult( + caseId: string, + runId: string, + status = "pass", + evidence = ["ui", "screenshot", "console", "backend_log"], +): string { return JSON.stringify({ source: "final", case_id: caseId, @@ -90,7 +282,9 @@ function suiteResult(caseId: string, runId: string, status = "pass", evidence = } function withEnv(values: Record, fn: () => T): T { - const previous = new Map(Object.keys(values).map((key) => [key, process.env[key]])); + const previous = new Map( + Object.keys(values).map((key) => [key, process.env[key]]), + ); try { for (const [key, value] of Object.entries(values)) process.env[key] = value; return fn(); @@ -102,7 +296,9 @@ function withEnv(values: Record, fn: () => T): T { } } -async function captureAsync(fn: () => Promise): Promise<{ code: number; output: string }> { +async function captureAsync( + fn: () => Promise, +): Promise<{ code: number; output: string }> { const originalLog = console.log; const lines: string[] = []; console.log = (...args: unknown[]) => { @@ -116,6 +312,88 @@ async function captureAsync(fn: () => Promise): Promise<{ code: number; } } +async function startFakeProviderForTest(): Promise<{ + baseUrl: string; + stop: () => Promise; +}> { + const child = spawn( + process.execPath, + [ + join(root, "scripts/e2e/fake-openai-provider.mjs"), + "--host=127.0.0.1", + "--port=0", + ], + { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const started = await new Promise<{ baseUrl: string }>((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("Timed out waiting for fake provider startup.")); + }, 5000); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + const newline = stdout.indexOf("\n"); + if (newline < 0) return; + clearTimeout(timer); + try { + const payload = JSON.parse(stdout.slice(0, newline)); + resolve({ baseUrl: payload.base_url }); + } catch (error) { + reject(error); + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("exit", (code) => { + if (code === null) return; + clearTimeout(timer); + reject( + new Error(`Fake provider exited before startup: ${code}; ${stderr}`), + ); + }); + }); + + return { + baseUrl: started.baseUrl, + stop: async () => { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", () => resolve())); + }, + }; +} + +async function requestFakeProvider( + provider: { baseUrl: string }, + payload: Record, +): Promise> { + const response = await fetch(`${provider.baseUrl}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "langbot-e2e-fake-model", + stream: false, + ...payload, + }), + }); + assert.equal(response.status, 200); + return await response.json(); +} + +function fakeProviderMessage(json: Record): Record { + return json.choices?.[0]?.message || {}; +} + test("validate accepts the repository assets", () => { const result = capture(() => commandValidate(root)); assert.equal(result.code, 0); @@ -130,10 +408,18 @@ test("validate allows blank shared env values but requires declared keys", () => const testingDir = join(skillsDir, "langbot-testing"); mkdirSync(schemasDir, { recursive: true }); mkdirSync(testingDir, { recursive: true }); - for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + for (const schemaName of [ + "case.schema.json", + "suite.schema.json", + "troubleshooting.schema.json", + "skill-index.schema.json", + ]) { writeFileSync(join(schemasDir, schemaName), "{}"); } - writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(testingDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); const envText = [ "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", "LANGBOT_BACKEND_URL=http://127.0.0.1:5300", @@ -158,23 +444,48 @@ test("validate allows blank shared env values but requires declared keys", () => test("index includes case summaries for agent discovery", () => { const result = capture(() => commandIndex({ root, args: ["index"] })); assert.equal(result.code, 0); - const index = JSON.parse(readFileSync(join(root, "skills.index.json"), "utf8")); - const testing = index.skills.find((skill: { name: string }) => skill.name === "langbot-testing"); + const index = JSON.parse( + readFileSync(join(root, "skills.index.json"), "utf8"), + ); + const testing = index.skills.find( + (skill: { name: string }) => skill.name === "langbot-testing", + ); assert.ok(testing); - assert.ok(testing.case_summaries.some((item: { id: string; priority: string; evidence_required: string[] }) => ( - item.id === "pipeline-debug-chat" && item.priority === "p0" && item.evidence_required.includes("backend_log") - ))); - assert.ok(testing.case_summaries.some((item: { id: string; setup_automation: string[]; setup_provides_env: string[] }) => ( - item.id === "agent-runner-qa-debug-chat" && - item.setup_automation.includes("case:agent-runner-live-install") && - item.setup_provides_env.includes("LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL") - ))); - assert.ok(testing.suite_summaries.some((item: { id: string; cases: string[] }) => ( - item.id === "core-smoke" && item.cases.includes("pipeline-debug-chat") - ))); - assert.ok(testing.fixtures.some((item: { id: string; related_cases: string[] }) => ( - item.id === "mcp-stdio-echo-server" && item.related_cases.includes("mcp-stdio-tool-call") - ))); + assert.ok( + testing.case_summaries.some( + (item: { id: string; priority: string; evidence_required: string[] }) => + item.id === "pipeline-debug-chat" && + item.priority === "p0" && + item.evidence_required.includes("backend_log"), + ), + ); + assert.ok( + testing.case_summaries.some( + (item: { + id: string; + setup_automation: string[]; + setup_provides_env: string[]; + }) => + item.id === "agent-runner-qa-debug-chat" && + item.setup_automation.includes("case:agent-runner-live-install") && + item.setup_provides_env.includes( + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + ), + ), + ); + assert.ok( + testing.suite_summaries.some( + (item: { id: string; cases: string[] }) => + item.id === "core-smoke" && item.cases.includes("pipeline-debug-chat"), + ), + ); + assert.ok( + testing.fixtures.some( + (item: { id: string; related_cases: string[] }) => + item.id === "mcp-stdio-echo-server" && + item.related_cases.includes("mcp-stdio-tool-call"), + ), + ); }); test("index check detects stale index without writing", () => { @@ -184,12 +495,16 @@ test("index check detects stale index without writing", () => { const fresh = readFileSync(path, "utf8"); try { - const ok = capture(() => commandIndex({ root, args: ["index", "--check"] })); + const ok = capture(() => + commandIndex({ root, args: ["index", "--check"] }), + ); assert.equal(ok.code, 0); assert.match(ok.output, /^OK /); writeFileSync(path, "{}\n"); - const stale = captureAll(() => commandIndex({ root, args: ["index", "--check"] })); + const stale = captureAll(() => + commandIndex({ root, args: ["index", "--check"] }), + ); assert.equal(stale.code, 1); assert.match(stale.error, /index is stale/); assert.equal(readFileSync(path, "utf8"), "{}\n"); @@ -207,22 +522,24 @@ test("case list exposes seeded QA cases", () => { }); test("case list JSON filters by reusable agent-selection metadata", () => { - const result = capture(() => commandCaseList(ctx([ - "case", - "list", - "--json", - "--priority", - "p0", - "--automation", - ]))); + const result = capture(() => + commandCaseList( + ctx(["case", "list", "--json", "--priority", "p0", "--automation"]), + ), + ); assert.equal(result.code, 0); const rows = JSON.parse(result.output); assert.ok(rows.length >= 2); assert.ok(rows.every((row: { priority: string }) => row.priority === "p0")); assert.ok(rows.every((row: { automation: string }) => row.automation)); - assert.ok(rows.some((row: { id: string; evidence_required: string[]; readiness: string }) => ( - row.id === "pipeline-debug-chat" && row.evidence_required.includes("backend_log") && row.readiness - ))); + assert.ok( + rows.some( + (row: { id: string; evidence_required: string[]; readiness: string }) => + row.id === "pipeline-debug-chat" && + row.evidence_required.includes("backend_log") && + row.readiness, + ), + ); }); test("case list distinguishes machine readiness from manual precondition checks", () => { @@ -234,7 +551,10 @@ test("case list distinguishes machine readiness from manual precondition checks" join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", ); - writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000\n"); + writeFileSync( + join(tmp, "skills", ".env"), + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000\n", + ); writeFileSync( join(skillDir, "cases", "manual-case.yaml"), [ @@ -263,12 +583,16 @@ test("case list distinguishes machine readiness from manual precondition checks" ].join("\n"), ); - const machineReady = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--machine-ready"] })); + const machineReady = capture(() => + commandCaseList({ root: tmp, args: ["case", "list", "--machine-ready"] }), + ); assert.equal(machineReady.code, 0); assert.match(machineReady.output, /manual-case/); assert.match(machineReady.output, /manual-check/); - const ready = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--ready"] })); + const ready = capture(() => + commandCaseList({ root: tmp, args: ["case", "list", "--ready"] }), + ); assert.equal(ready.code, 0); assert.doesNotMatch(ready.output, /manual-case/); } finally { @@ -277,7 +601,9 @@ test("case list distinguishes machine readiness from manual precondition checks" }); test("case show prints structured agent-browser case", () => { - const result = capture(() => commandCaseShow(ctx(["case", "show", "pipeline-debug-chat"]))); + const result = capture(() => + commandCaseShow(ctx(["case", "show", "pipeline-debug-chat"])), + ); assert.equal(result.code, 0); assert.match(result.output, /^id: pipeline-debug-chat/m); assert.match(result.output, /^mode: agent-browser/m); @@ -294,10 +620,12 @@ test("case new writes required selection metadata", () => { "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", ); - const result = capture(() => commandCaseNew({ - root: tmp, - args: ["case", "new", "new-case", "--title", "New Case"], - })); + const result = capture(() => + commandCaseNew({ + root: tmp, + args: ["case", "new", "new-case", "--title", "New Case"], + }), + ); assert.equal(result.code, 0); const text = readFileSync(join(skillDir, "cases", "new-case.yaml"), "utf8"); @@ -311,34 +639,59 @@ test("case new writes required selection metadata", () => { }); test("suite list and plan expose reusable case groups", () => { - const list = capture(() => commandSuiteList(ctx(["suite", "list", "--json", "--priority", "p0"]))); + const list = capture(() => + commandSuiteList(ctx(["suite", "list", "--json", "--priority", "p0"])), + ); assert.equal(list.code, 0); const suites = JSON.parse(list.output); - assert.ok(suites.some((suite: { id: string; cases: string[] }) => ( - suite.id === "core-smoke" && suite.cases.includes("webui-login-state") - ))); + assert.ok( + suites.some( + (suite: { id: string; cases: string[] }) => + suite.id === "core-smoke" && suite.cases.includes("webui-login-state"), + ), + ); - const plan = capture(() => commandSuitePlan(ctx(["suite", "plan", "core-smoke", "--json"]))); + const plan = capture(() => + commandSuitePlan(ctx(["suite", "plan", "core-smoke", "--json"])), + ); assert.equal(plan.code, 0); const suitePlan = JSON.parse(plan.output); assert.equal(suitePlan.id, "core-smoke"); - assert.ok(suitePlan.cases.some((item: { id: string; evidence_required: string[] }) => ( - item.id === "pipeline-debug-chat" && item.evidence_required.includes("backend_log") - ))); - assert.ok(suitePlan.commands.some((item: { id: string; automation: string }) => ( - item.id === "pipeline-debug-chat" && item.automation.includes("test run") - ))); - - const localAgent = capture(() => commandSuitePlan(ctx(["suite", "plan", "local-agent-gate", "--json"]))); + assert.ok( + suitePlan.cases.some( + (item: { id: string; evidence_required: string[] }) => + item.id === "pipeline-debug-chat" && + item.evidence_required.includes("backend_log"), + ), + ); + assert.ok( + suitePlan.commands.some( + (item: { id: string; automation: string }) => + item.id === "pipeline-debug-chat" && + item.automation.includes("test run"), + ), + ); + + const localAgent = capture(() => + commandSuitePlan(ctx(["suite", "plan", "local-agent-gate", "--json"])), + ); assert.equal(localAgent.code, 0); const localAgentPlan = JSON.parse(localAgent.output); - assert.ok(["ready", "missing", "manual_check"].includes(localAgentPlan.readiness.status)); - const basic = localAgentPlan.cases.find((item: { id: string }) => item.id === "local-agent-basic-debug-chat"); + assert.ok( + ["ready", "missing", "manual_check"].includes( + localAgentPlan.readiness.status, + ), + ); + const basic = localAgentPlan.cases.find( + (item: { id: string }) => item.id === "local-agent-basic-debug-chat", + ); assert.equal(basic.automation_readiness.pipeline_env_required, true); }); test("suite show prints structured suite YAML", () => { - const result = capture(() => commandSuiteShow(ctx(["suite", "show", "local-agent-gate"]))); + const result = capture(() => + commandSuiteShow(ctx(["suite", "show", "local-agent-gate"])), + ); assert.equal(result.code, 0); assert.match(result.output, /^id: local-agent-gate/m); assert.match(result.output, /^cases:/m); @@ -349,16 +702,20 @@ test("suite start creates a run handoff with per-case evidence commands", () => const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-start-")); try { const evidenceRoot = join(tmp, "evidence"); - const result = capture(() => commandSuiteStart(ctx([ - "suite", - "start", - "core-smoke", - "--run-id", - "core-smoke-local", - "--evidence-dir", - evidenceRoot, - "--json", - ]))); + const result = capture(() => + commandSuiteStart( + ctx([ + "suite", + "start", + "core-smoke", + "--run-id", + "core-smoke-local", + "--evidence-dir", + evidenceRoot, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const start = JSON.parse(result.output); assert.equal(start.suite.id, "core-smoke"); @@ -369,12 +726,23 @@ test("suite start creates a run handoff with per-case evidence commands", () => assert.match(start.report_command, /bin\/lbs suite report core-smoke/); assert.ok(existsSync(join(evidenceRoot, "suite-start.json"))); assert.ok(existsSync(join(evidenceRoot, "suite-start.md"))); - const pipeline = start.cases.find((item: { id: string }) => item.id === "pipeline-debug-chat"); + const pipeline = start.cases.find( + (item: { id: string }) => item.id === "pipeline-debug-chat", + ); assert.ok(pipeline); assert.ok(existsSync(join(evidenceRoot, "pipeline-debug-chat"))); - assert.match(pipeline.automation_command, /bin\/lbs test run pipeline-debug-chat/); - assert.match(pipeline.report_command, /--evidence-dir .+pipeline-debug-chat/); - assert.match(pipeline.result_command_template, /bin\/lbs test result pipeline-debug-chat/); + assert.match( + pipeline.automation_command, + /bin\/lbs test run pipeline-debug-chat/, + ); + assert.match( + pipeline.report_command, + /--evidence-dir .+pipeline-debug-chat/, + ); + assert.match( + pipeline.result_command_template, + /bin\/lbs test result pipeline-debug-chat/, + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -398,25 +766,33 @@ test("suite report aggregates case result JSON files", () => { ); } - const result = capture(() => commandSuiteReport(ctx([ - "suite", - "report", - "core-smoke", - "--run-id", - runId, - "--evidence-dir", - evidenceRoot, - "--json", - ]))); + const result = capture(() => + commandSuiteReport( + ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.status, "env_issue"); assert.equal(report.counts.pass, 2); assert.equal(report.counts.env_issue, 1); - assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( - item.id === "local-agent-basic-debug-chat" && item.result.status === "env_issue" - ))); + assert.ok( + report.cases.some( + (item: { id: string; result: { status: string } }) => + item.id === "local-agent-basic-debug-chat" && + item.result.status === "env_issue", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -427,7 +803,11 @@ test("suite report treats pass without required evidence as incomplete", () => { try { const evidenceRoot = join(tmp, "suite-evidence"); const runId = "suite-report-evidence"; - for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + for (const caseId of [ + "webui-login-state", + "pipeline-debug-chat", + "local-agent-basic-debug-chat", + ]) { const dir = join(evidenceRoot, caseId); mkdirSync(dir, { recursive: true }); writeFileSync( @@ -436,23 +816,31 @@ test("suite report treats pass without required evidence as incomplete", () => { ); } - const result = capture(() => commandSuiteReport(ctx([ - "suite", - "report", - "core-smoke", - "--run-id", - runId, - "--evidence-dir", - evidenceRoot, - "--json", - ]))); + const result = capture(() => + commandSuiteReport( + ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.status, "incomplete"); - assert.ok(report.cases.some((item: { id: string; result: { evidence_missing: string[] } }) => ( - item.id === "pipeline-debug-chat" && item.result.evidence_missing.includes("backend_log") - ))); + assert.ok( + report.cases.some( + (item: { id: string; result: { evidence_missing: string[] } }) => + item.id === "pipeline-debug-chat" && + item.result.evidence_missing.includes("backend_log"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -464,25 +852,35 @@ test("suite report marks missing case evidence as incomplete", () => { const evidenceRoot = join(tmp, "suite-evidence"); const runId = "suite-report-missing"; mkdirSync(join(evidenceRoot, "webui-login-state"), { recursive: true }); - writeFileSync(join(evidenceRoot, "webui-login-state", "result.json"), suiteResult("webui-login-state", runId, "pass")); - - const result = capture(() => commandSuiteReport(ctx([ - "suite", - "report", - "core-smoke", - "--run-id", - runId, - "--evidence-dir", - evidenceRoot, - "--json", - ]))); + writeFileSync( + join(evidenceRoot, "webui-login-state", "result.json"), + suiteResult("webui-login-state", runId, "pass"), + ); + + const result = capture(() => + commandSuiteReport( + ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.status, "incomplete"); - assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( - item.id === "pipeline-debug-chat" && item.result.status === "missing" - ))); + assert.ok( + report.cases.some( + (item: { id: string; result: { status: string } }) => + item.id === "pipeline-debug-chat" && item.result.status === "missing", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -493,34 +891,61 @@ test("suite report rejects result files from the wrong case or run", () => { try { const evidenceRoot = join(tmp, "suite-evidence"); const runId = "suite-report-mismatch"; - for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + for (const caseId of [ + "webui-login-state", + "pipeline-debug-chat", + "local-agent-basic-debug-chat", + ]) { const dir = join(evidenceRoot, caseId); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "result.json"), suiteResult(caseId, runId, "pass")); + writeFileSync( + join(dir, "result.json"), + suiteResult(caseId, runId, "pass"), + ); } - writeFileSync(join(evidenceRoot, "pipeline-debug-chat", "result.json"), suiteResult("webui-login-state", runId, "pass")); - writeFileSync(join(evidenceRoot, "local-agent-basic-debug-chat", "result.json"), suiteResult("local-agent-basic-debug-chat", "old-run", "pass")); - - const result = capture(() => commandSuiteReport(ctx([ - "suite", - "report", - "core-smoke", - "--run-id", - runId, - "--evidence-dir", - evidenceRoot, - "--json", - ]))); + writeFileSync( + join(evidenceRoot, "pipeline-debug-chat", "result.json"), + suiteResult("webui-login-state", runId, "pass"), + ); + writeFileSync( + join(evidenceRoot, "local-agent-basic-debug-chat", "result.json"), + suiteResult("local-agent-basic-debug-chat", "old-run", "pass"), + ); + + const result = capture(() => + commandSuiteReport( + ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.status, "fail"); - assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( - item.id === "pipeline-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("case_id mismatch") - ))); - assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( - item.id === "local-agent-basic-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("run_id mismatch") - ))); + assert.ok( + report.cases.some( + (item: { id: string; result: { status: string; reason: string } }) => + item.id === "pipeline-debug-chat" && + item.result.status === "invalid" && + item.result.reason.includes("case_id mismatch"), + ), + ); + assert.ok( + report.cases.some( + (item: { id: string; result: { status: string; reason: string } }) => + item.id === "local-agent-basic-debug-chat" && + item.result.status === "invalid" && + item.result.reason.includes("run_id mismatch"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -536,7 +961,10 @@ test("suite run executes automated cases and aggregates a verdict", () => { mkdirSync(casesDir, { recursive: true }); mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "one.yaml"), @@ -602,16 +1030,30 @@ test("suite run executes automated cases and aggregates a verdict", () => { ].join("\n"), ); - const result = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], - })); + const result = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "mini", + "--run-id", + "mini-run", + "--evidence-dir", + join(tmp, "evidence"), + "--json", + ], + }), + ); assert.equal(result.code, 0); const payload = JSON.parse(result.output); assert.equal(payload.report.status, "pass"); assert.equal(payload.report.counts.pass, 2); - assert.deepEqual(payload.executions.map((item: { status: string }) => item.status), ["ok", "ok"]); + assert.deepEqual( + payload.executions.map((item: { status: string }) => item.status), + ["ok", "ok"], + ); assert.ok(existsSync(join(tmp, "evidence", "one", "result.json"))); assert.ok(existsSync(join(tmp, "evidence", "two", "result.json"))); } finally { @@ -629,7 +1071,10 @@ test("suite run JSON captures failed case output", () => { mkdirSync(casesDir, { recursive: true }); mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "fail-case.yaml"), @@ -659,12 +1104,26 @@ test("suite run JSON captures failed case output", () => { " - fail-case", ].join("\n"), ); - writeFileSync(join(scriptsDir, "fail.mjs"), "console.error('child failure detail'); process.exit(1);\n"); + writeFileSync( + join(scriptsDir, "fail.mjs"), + "console.error('child failure detail'); process.exit(1);\n", + ); - const result = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], - })); + const result = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "mini", + "--run-id", + "mini-run", + "--evidence-dir", + join(tmp, "evidence"), + "--json", + ], + }), + ); assert.equal(result.code, 1); const payload = JSON.parse(result.output); @@ -686,7 +1145,10 @@ test("suite run preserves classified env_issue automation results", () => { mkdirSync(casesDir, { recursive: true }); mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "env-case.yaml"), @@ -737,10 +1199,21 @@ test("suite run preserves classified env_issue automation results", () => { ].join("\n"), ); - const result = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], - })); + const result = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "mini", + "--run-id", + "mini-run", + "--evidence-dir", + join(tmp, "evidence"), + "--json", + ], + }), + ); assert.equal(result.code, 2); const payload = JSON.parse(result.output); @@ -764,7 +1237,10 @@ test("suite run failure cannot be masked by stale pass result", () => { mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); mkdirSync(join(evidenceDir, "fail-case"), { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "fail-case.yaml"), @@ -797,17 +1273,31 @@ test("suite run failure cannot be masked by stale pass result", () => { ].join("\n"), ); writeFileSync(join(scriptsDir, "fail.mjs"), "process.exit(1);\n"); - writeFileSync(join(evidenceDir, "fail-case", "result.json"), JSON.stringify({ - case_id: "fail-case", - run_id: "stale-run-fail-case", - status: "pass", - evidence_collected: ["filesystem"], - })); - - const result = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "mini", "--run-id", "stale-run", "--evidence-dir", evidenceDir, "--json"], - })); + writeFileSync( + join(evidenceDir, "fail-case", "result.json"), + JSON.stringify({ + case_id: "fail-case", + run_id: "stale-run-fail-case", + status: "pass", + evidence_collected: ["filesystem"], + }), + ); + + const result = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "mini", + "--run-id", + "stale-run", + "--evidence-dir", + evidenceDir, + "--json", + ], + }), + ); assert.equal(result.code, 1); const payload = JSON.parse(result.output); @@ -829,7 +1319,10 @@ test("suite run dry-run plans automation without creating evidence", () => { mkdirSync(casesDir, { recursive: true }); mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "dry-case.yaml"), @@ -862,10 +1355,22 @@ test("suite run dry-run plans automation without creating evidence", () => { writeFileSync(join(scriptsDir, "fail-if-run.mjs"), "process.exit(9);\n"); const evidenceDir = join(tmp, "evidence"); - const result = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "dry-suite", "--run-id", "dry-run", "--evidence-dir", evidenceDir, "--dry-run", "--json"], - })); + const result = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "dry-suite", + "--run-id", + "dry-run", + "--evidence-dir", + evidenceDir, + "--dry-run", + "--json", + ], + }), + ); assert.equal(result.code, 0); const payload = JSON.parse(result.output); @@ -875,13 +1380,27 @@ test("suite run dry-run plans automation without creating evidence", () => { assert.equal(existsSync(evidenceDir), false); assert.equal(existsSync(join(tmp, "reports", "dry-run.md")), false); - const markdown = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "dry-suite", "--run-id", "dry-run-markdown", "--evidence-dir", join(tmp, "evidence-md"), "--dry-run"], - })); + const markdown = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "dry-suite", + "--run-id", + "dry-run-markdown", + "--evidence-dir", + join(tmp, "evidence-md"), + "--dry-run", + ], + }), + ); assert.equal(markdown.code, 0); assert.match(markdown.output, /# Suite Report: dry-suite/); - assert.equal(existsSync(join(tmp, "reports", "dry-run-markdown.md")), false); + assert.equal( + existsSync(join(tmp, "reports", "dry-run-markdown.md")), + false, + ); assert.equal(existsSync(join(tmp, "evidence-md")), false); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -898,7 +1417,10 @@ test("suite run skips manual-check cases unless explicitly included", () => { mkdirSync(casesDir, { recursive: true }); mkdirSync(suitesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "manual-case.yaml"), @@ -942,25 +1464,53 @@ test("suite run skips manual-check cases unless explicitly included", () => { ].join("\n"), ); - const skipped = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "manual-suite", "--run-id", "manual-run", "--evidence-dir", join(tmp, "evidence"), "--json"], - })); + const skipped = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "manual-suite", + "--run-id", + "manual-run", + "--evidence-dir", + join(tmp, "evidence"), + "--json", + ], + }), + ); assert.equal(skipped.code, 1); const skippedPayload = JSON.parse(skipped.output); assert.equal(skippedPayload.executions[0].status, "skipped"); assert.match(skippedPayload.executions[0].reason, /manual_check/); - assert.equal(existsSync(join(tmp, "evidence", "manual-case", "result.json")), false); + assert.equal( + existsSync(join(tmp, "evidence", "manual-case", "result.json")), + false, + ); - const included = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "manual-suite", "--run-id", "manual-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-manual-check", "--json"], - })); + const included = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "manual-suite", + "--run-id", + "manual-run-included", + "--evidence-dir", + join(tmp, "evidence-included"), + "--include-manual-check", + "--json", + ], + }), + ); assert.equal(included.code, 0); const includedPayload = JSON.parse(included.output); assert.equal(includedPayload.executions[0].status, "ok"); assert.equal(includedPayload.report.status, "pass"); - assert.ok(existsSync(join(tmp, "evidence-included", "manual-case", "result.json"))); + assert.ok( + existsSync(join(tmp, "evidence-included", "manual-case", "result.json")), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -978,7 +1528,10 @@ test("suite run skips cases with missing machine readiness unless explicitly inc mkdirSync(suitesDir, { recursive: true }); mkdirSync(fixturesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "not-ready-case.yaml"), @@ -1002,14 +1555,20 @@ test("suite run skips cases with missing machine readiness unless explicitly inc ); writeFileSync( join(fixturesDir, "fixtures.json"), - `${JSON.stringify([{ - id: "missing-fixture", - title: "Missing fixture", - kind: "file", - path: "fixtures/missing.txt", - related_cases: ["not-ready-case"], - checks: ["exists"], - }], null, 2)}\n`, + `${JSON.stringify( + [ + { + id: "missing-fixture", + title: "Missing fixture", + kind: "file", + path: "fixtures/missing.txt", + related_cases: ["not-ready-case"], + checks: ["exists"], + }, + ], + null, + 2, + )}\n`, ); writeFileSync( join(suitesDir, "readiness-suite.yaml"), @@ -1035,28 +1594,64 @@ test("suite run skips cases with missing machine readiness unless explicitly inc ].join("\n"), ); - const skipped = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run", "--evidence-dir", join(tmp, "evidence"), "--json"], - })); + const skipped = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "readiness-suite", + "--run-id", + "readiness-run", + "--evidence-dir", + join(tmp, "evidence"), + "--json", + ], + }), + ); assert.equal(skipped.code, 1); const skippedPayload = JSON.parse(skipped.output); assert.equal(skippedPayload.executions[0].status, "skipped"); assert.match(skippedPayload.executions[0].reason, /readiness missing/); - assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_ENV/); - assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV/); + assert.match( + skippedPayload.executions[0].reason, + /LBS_TEST_SUITE_RUN_MISSING_ENV/, + ); + assert.match( + skippedPayload.executions[0].reason, + /LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV/, + ); assert.match(skippedPayload.executions[0].reason, /missing-fixture/); - assert.equal(existsSync(join(tmp, "evidence", "not-ready-case", "result.json")), false); + assert.equal( + existsSync(join(tmp, "evidence", "not-ready-case", "result.json")), + false, + ); - const included = capture(() => commandSuiteRun({ - root: tmp, - args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-not-ready", "--json"], - })); + const included = capture(() => + commandSuiteRun({ + root: tmp, + args: [ + "suite", + "run", + "readiness-suite", + "--run-id", + "readiness-run-included", + "--evidence-dir", + join(tmp, "evidence-included"), + "--include-not-ready", + "--json", + ], + }), + ); assert.equal(included.code, 0); const includedPayload = JSON.parse(included.output); assert.equal(includedPayload.executions[0].status, "ok"); assert.equal(includedPayload.report.status, "pass"); - assert.ok(existsSync(join(tmp, "evidence-included", "not-ready-case", "result.json"))); + assert.ok( + existsSync( + join(tmp, "evidence-included", "not-ready-case", "result.json"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1072,13 +1667,18 @@ test("suite new writes a reusable suite skeleton", () => { "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", ); - const result = capture(() => commandSuiteNew({ - root: tmp, - args: ["suite", "new", "new-suite", "--title", "New Suite"], - })); + const result = capture(() => + commandSuiteNew({ + root: tmp, + args: ["suite", "new", "new-suite", "--title", "New Suite"], + }), + ); assert.equal(result.code, 0); - const text = readFileSync(join(skillDir, "suites", "new-suite.yaml"), "utf8"); + const text = readFileSync( + join(skillDir, "suites", "new-suite.yaml"), + "utf8", + ); assert.match(text, /^description:/m); assert.match(text, /^priority: p2/m); assert.match(text, /^cases:/m); @@ -1088,18 +1688,29 @@ test("suite new writes a reusable suite skeleton", () => { }); test("fixture list and check expose reusable fixture readiness", () => { - const list = capture(() => commandFixtureList(ctx(["fixture", "list", "langbot-testing", "--json"]))); + const list = capture(() => + commandFixtureList(ctx(["fixture", "list", "langbot-testing", "--json"])), + ); assert.equal(list.code, 0); const fixtures = JSON.parse(list.output); - assert.ok(fixtures.some((item: { id: string; exists: boolean }) => ( - item.id === "mcp-stdio-echo-server" && item.exists === true - ))); + assert.ok( + fixtures.some( + (item: { id: string; exists: boolean }) => + item.id === "mcp-stdio-echo-server" && item.exists === true, + ), + ); - const check = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + const check = capture(() => + commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"])), + ); assert.equal(check.code, 0); const report = JSON.parse(check.output); assert.equal(report.status, "pass"); - assert.ok(report.fixtures.some((item: { id: string }) => item.id === "qa-plugin-smoke-package")); + assert.ok( + report.fixtures.some( + (item: { id: string }) => item.id === "qa-plugin-smoke-package", + ), + ); }); test("fixture check reports missing manifest paths", () => { @@ -1113,15 +1724,30 @@ test("fixture check reports missing manifest paths", () => { ); writeFileSync( join(skillDir, "fixtures", "fixtures.json"), - JSON.stringify([{ id: "missing-fixture", title: "Missing Fixture", path: "fixtures/missing.txt" }]), + JSON.stringify([ + { + id: "missing-fixture", + title: "Missing Fixture", + path: "fixtures/missing.txt", + }, + ]), ); - const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + const result = capture(() => + commandFixtureCheck({ + root: tmp, + args: ["fixture", "check", "langbot-testing", "--json"], + }), + ); assert.equal(result.code, 1); const report = JSON.parse(result.output); assert.equal(report.status, "fail"); - assert.ok(report.findings.some((finding: { id?: string }) => finding.id === "missing-fixture")); + assert.ok( + report.findings.some( + (finding: { id?: string }) => finding.id === "missing-fixture", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1132,42 +1758,63 @@ test("fixture check verifies QA AgentRunner source shape", () => { try { const skillDir = join(tmp, "skills", "langbot-testing"); const fixtureDir = join(skillDir, "fixtures", "plugins", "qa-agent-runner"); - mkdirSync(join(fixtureDir, "components", "agent_runner"), { recursive: true }); + mkdirSync(join(fixtureDir, "components", "agent_runner"), { + recursive: true, + }); writeFileSync( join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", ); writeFileSync( join(skillDir, "fixtures", "fixtures.json"), - JSON.stringify([{ - id: "qa-agent-runner-source", - title: "QA AgentRunner", - path: "fixtures/plugins/qa-agent-runner/manifest.yaml", - checks: ["exists", "qa_agent_runner_source"], - }]), + JSON.stringify([ + { + id: "qa-agent-runner-source", + title: "QA AgentRunner", + path: "fixtures/plugins/qa-agent-runner/manifest.yaml", + checks: ["exists", "qa_agent_runner_source"], + }, + ]), + ); + writeFileSync( + join(fixtureDir, "manifest.yaml"), + "spec:\n components:\n AgentRunner: {}\nexecution:\n python:\n attr: QAAgentRunnerPlugin\n", ); - writeFileSync(join(fixtureDir, "manifest.yaml"), "spec:\n components:\n AgentRunner: {}\nexecution:\n python:\n attr: QAAgentRunnerPlugin\n"); - const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + const result = capture(() => + commandFixtureCheck({ + root: tmp, + args: ["fixture", "check", "langbot-testing", "--json"], + }), + ); assert.equal(result.code, 1); const report = JSON.parse(result.output); - assert.ok(report.findings.some((finding: { kind?: string; path?: string }) => ( - finding.kind === "fixture_check_missing_file" - && finding.path?.endsWith("components/agent_runner/default.py") - ))); + assert.ok( + report.findings.some( + (finding: { kind?: string; path?: string }) => + finding.kind === "fixture_check_missing_file" && + finding.path?.endsWith("components/agent_runner/default.py"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } }); test("fixture check accepts complete QA AgentRunner source shape", () => { - const result = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + const result = capture(() => + commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"])), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); - assert.ok(report.fixtures.some((item: { id: string; checks: string[] }) => ( - item.id === "qa-agent-runner-source" && item.checks.includes("qa_agent_runner_source") - ))); + assert.ok( + report.fixtures.some( + (item: { id: string; checks: string[] }) => + item.id === "qa-agent-runner-source" && + item.checks.includes("qa_agent_runner_source"), + ), + ); }); test("fixture check rejects invalid plugin package files", () => { @@ -1182,21 +1829,32 @@ test("fixture check rejects invalid plugin package files", () => { writeFileSync(join(skillDir, "fixtures", "bad.lbpkg"), "not a zip"); writeFileSync( join(skillDir, "fixtures", "fixtures.json"), - JSON.stringify([{ - id: "bad-package", - title: "Bad Package", - path: "fixtures/bad.lbpkg", - checks: ["exists", "zip_package"], - }]), + JSON.stringify([ + { + id: "bad-package", + title: "Bad Package", + path: "fixtures/bad.lbpkg", + checks: ["exists", "zip_package"], + }, + ]), ); - const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + const result = capture(() => + commandFixtureCheck({ + root: tmp, + args: ["fixture", "check", "langbot-testing", "--json"], + }), + ); assert.equal(result.code, 1); const report = JSON.parse(result.output); - assert.ok(report.findings.some((finding: { kind?: string; id?: string }) => ( - finding.kind === "fixture_check_invalid_zip" && finding.id === "bad-package" - ))); + assert.ok( + report.findings.some( + (finding: { kind?: string; id?: string }) => + finding.kind === "fixture_check_invalid_zip" && + finding.id === "bad-package", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1218,7 +1876,10 @@ test("debug chat classifier prefers latest response leaf over body counts", () = test("debug chat classifier distinguishes new failure signals from old history", () => { assert.equal( - findNewFailureSignal("Agent runner temporarily unavailable", "Agent runner temporarily unavailable"), + findNewFailureSignal( + "Agent runner temporarily unavailable", + "Agent runner temporarily unavailable", + ), "", ); assert.equal( @@ -1400,12 +2061,27 @@ test("env doctor explains a missing backend listener with a startup hint", async ].join("\n"), ); - const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + const result = await captureAsync(() => + commandEnvDoctor({ root: tmp, args: ["env", "doctor"] }), + ); assert.equal(result.code, 1); - assert.match(result.output, /FAIL: LANGBOT_BACKEND_URL: no HTTP service reachable because 127\.0\.0\.1:59998 is not listening/); - assert.match(result.output, new RegExp(`WARN: LANGBOT_BACKEND_URL: start backend: cd ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && uv run main.py`)); - assert.match(result.output, new RegExp(`WARN: LANGBOT_FRONTEND_URL: start frontend: cd ${webDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && pnpm dev`)); + assert.match( + result.output, + /FAIL: LANGBOT_BACKEND_URL: no HTTP service reachable because 127\.0\.0\.1:59998 is not listening/, + ); + assert.match( + result.output, + new RegExp( + `WARN: LANGBOT_BACKEND_URL: start backend: cd ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && uv run main.py`, + ), + ); + assert.match( + result.output, + new RegExp( + `WARN: LANGBOT_FRONTEND_URL: start frontend: cd ${webDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && pnpm dev`, + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1436,10 +2112,15 @@ test("env doctor does not require proxy variables", async () => { ].join("\n"), ); - const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + const result = await captureAsync(() => + commandEnvDoctor({ root: tmp, args: ["env", "doctor"] }), + ); assert.equal(result.code, 1); - assert.doesNotMatch(result.output, /missing LANGBOT_PROXY|missing LANGBOT_NO_PROXY/); + assert.doesNotMatch( + result.output, + /missing LANGBOT_PROXY|missing LANGBOT_NO_PROXY/, + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1480,7 +2161,9 @@ test("env doctor reports missing socksio for active SOCKS proxy", async () => { ].join("\n"), ); - const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + const result = await captureAsync(() => + commandEnvDoctor({ root: tmp, args: ["env", "doctor"] }), + ); assert.equal(result.code, 1); assert.match(result.output, /FAIL: SOCKS proxy ALL_PROXY is configured/); @@ -1508,13 +2191,20 @@ test("env show redacts secret-like values by default", () => { ].join("\n"), ); - const text = capture(() => commandEnvShow({ root: tmp, args: ["env", "show"] })); + const text = capture(() => + commandEnvShow({ root: tmp, args: ["env", "show"] }), + ); assert.equal(text.code, 0); assert.match(text.output, /LANGBOT_API_KEY=\[redacted\]/); - assert.match(text.output, /LANGBOT_PROXY_HTTP=http:\/\/\[redacted\]@127\.0\.0\.1:7890/); + assert.match( + text.output, + /LANGBOT_PROXY_HTTP=http:\/\/\[redacted\]@127\.0\.0\.1:7890/, + ); assert.doesNotMatch(text.output, /sk-test-secret|user:pass/); - const json = capture(() => commandEnvShow({ root: tmp, args: ["env", "show", "--json"] })); + const json = capture(() => + commandEnvShow({ root: tmp, args: ["env", "show", "--json"] }), + ); assert.equal(json.code, 0); const parsed = JSON.parse(json.output); assert.equal(parsed.LANGBOT_API_KEY, "[redacted]"); @@ -1525,10 +2215,15 @@ test("env show redacts secret-like values by default", () => { }); test("test plan renders agent-browser QA guidance", () => { - const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat"]))); + const result = capture(() => + commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat"])), + ); assert.equal(result.code, 0); assert.match(result.output, /Mode: agent-browser/); - assert.match(result.output, /Use browser\/UI interaction as the primary QA path/); + assert.match( + result.output, + /Use browser\/UI interaction as the primary QA path/, + ); assert.match(result.output, /API\/curl\/log checks are diagnostic only/); assert.match(result.output, /## Browser Steps/); assert.match(result.output, /## Success Signals/); @@ -1540,29 +2235,45 @@ test("test plan renders agent-browser QA guidance", () => { }); test("test plan JSON is parseable and includes troubleshooting patterns", () => { - const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + const result = capture(() => + commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"])), + ); assert.equal(result.code, 0); const plan = JSON.parse(result.output); assert.equal(plan.id, "pipeline-debug-chat"); assert.equal(plan.mode, "agent-browser"); assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_PROMPT")); - assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_EXPECTED_TEXT")); + assert.ok( + plan.automation_readiness.defaulted.includes("LANGBOT_E2E_EXPECTED_TEXT"), + ); assert.equal(plan.manual_readiness.status, "manual_check"); assert.ok(plan.success_patterns.includes("Streaming completed")); - assert.ok(plan.troubleshooting.some((entry: { id: string }) => entry.id === "plugin-runtime-timeout")); -}); + assert.ok( + plan.troubleshooting.some( + (entry: { id: string }) => entry.id === "plugin-runtime-timeout", + ), + ); +}); test("test plan JSON exposes missing case-specific pipeline readiness", () => { - const result = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); + const result = capture(() => + commandTestPlan( + ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]), + ), + ); assert.equal(result.code, 0); const plan = JSON.parse(result.output); assert.equal(plan.env_readiness.status, "ready"); assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); assert.ok(plan.automation_readiness.pipeline_env_required); assert.ok( - plan.automation_readiness.missing.includes("LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME") - || plan.automation_readiness.configured.some((key: string) => key.startsWith("LANGBOT_LOCAL_AGENT_PIPELINE_")), + plan.automation_readiness.missing.includes( + "LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ) || + plan.automation_readiness.configured.some((key: string) => + key.startsWith("LANGBOT_LOCAL_AGENT_PIPELINE_"), + ), ); }); @@ -1570,31 +2281,53 @@ test("generic pipeline readiness accepts either URL or name target", () => { const originalUrl = process.env.LANGBOT_PIPELINE_URL; const originalName = process.env.LANGBOT_PIPELINE_NAME; try { - withEnv({ - LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", - LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", - }, () => { - process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/pipelines?id=only-url"; - process.env.LANGBOT_PIPELINE_NAME = ""; - - const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); - assert.equal(ready.code, 0); - const plan = JSON.parse(ready.output); - assert.equal(plan.env_readiness.status, "ready"); - assert.equal(plan.automation_readiness.status, "ready"); - assert.ok(plan.automation_readiness.required.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); - }); + withEnv( + { + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, + () => { + process.env.LANGBOT_PIPELINE_URL = + "http://127.0.0.1:3000/home/agents?id=only-url"; + process.env.LANGBOT_PIPELINE_NAME = ""; + + const ready = capture(() => + commandTestPlan( + ctx(["test", "plan", "pipeline-debug-chat", "--json"]), + ), + ); + assert.equal(ready.code, 0); + const plan = JSON.parse(ready.output); + assert.equal(plan.env_readiness.status, "ready"); + assert.equal(plan.automation_readiness.status, "ready"); + assert.ok( + plan.automation_readiness.required.includes( + "LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME", + ), + ); + }, + ); process.env.LANGBOT_PIPELINE_URL = ""; process.env.LANGBOT_PIPELINE_NAME = ""; - const missing = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + const missing = capture(() => + commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"])), + ); assert.equal(missing.code, 0); const missingPlan = JSON.parse(missing.output); assert.equal(missingPlan.env_readiness.status, "missing"); - assert.ok(missingPlan.env_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + assert.ok( + missingPlan.env_readiness.missing.includes( + "LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME", + ), + ); assert.equal(missingPlan.automation_readiness.status, "missing"); - assert.ok(missingPlan.automation_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + assert.ok( + missingPlan.automation_readiness.missing.includes( + "LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME", + ), + ); } finally { if (originalUrl === undefined) delete process.env.LANGBOT_PIPELINE_URL; else process.env.LANGBOT_PIPELINE_URL = originalUrl; @@ -1604,15 +2337,19 @@ test("generic pipeline readiness accepts either URL or name target", () => { }); test("test recommend maps AgentRunner ledger changes to focused probes", () => { - const result = capture(() => commandTestRecommend(ctx([ - "test", - "recommend", - "--file", - "LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py", - "--file", - "LangBot/tests/unit_tests/agent/test_run_ledger_store.py", - "--json", - ]))); + const result = capture(() => + commandTestRecommend( + ctx([ + "test", + "recommend", + "--file", + "LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py", + "--file", + "LangBot/tests/unit_tests/agent/test_run_ledger_store.py", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); const ids = report.recommendations.map((item: { id: string }) => item.id); @@ -1621,18 +2358,30 @@ test("test recommend maps AgentRunner ledger changes to focused probes", () => { assert.ok(ids.includes("agent-runner-ledger-contention")); assert.ok(ids.includes("agent-runner-async-db-readiness")); assert.ok(ids.includes("agent-runner-ledger-concurrency")); - assert.ok(report.commands.every((command: string) => !command.startsWith("bin/lbs test run ") || command.endsWith(" --dry-run"))); - assert.ok(report.notes.some((note: string) => note.includes("Remove --dry-run"))); + assert.ok( + report.commands.every( + (command: string) => + !command.startsWith("bin/lbs test run ") || + command.endsWith(" --dry-run"), + ), + ); + assert.ok( + report.notes.some((note: string) => note.includes("Remove --dry-run")), + ); }); test("test recommend maps AgentRunner result changes to fixture contract", () => { - const result = capture(() => commandTestRecommend(ctx([ - "test", - "recommend", - "--file", - "langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/result.py", - "--json", - ]))); + const result = capture(() => + commandTestRecommend( + ctx([ + "test", + "recommend", + "--file", + "langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/result.py", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); const ids = report.recommendations.map((item: { id: string }) => item.id); @@ -1642,13 +2391,17 @@ test("test recommend maps AgentRunner result changes to fixture contract", () => }); test("test recommend maps QA AgentRunner fixture changes to live install", () => { - const result = capture(() => commandTestRecommend(ctx([ - "test", - "recommend", - "--file", - "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py", - "--json", - ]))); + const result = capture(() => + commandTestRecommend( + ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); const ids = report.recommendations.map((item: { id: string }) => item.id); @@ -1658,13 +2411,17 @@ test("test recommend maps QA AgentRunner fixture changes to live install", () => }); test("test recommend maps QA plugin smoke fixture changes to live install", () => { - const result = capture(() => commandTestRecommend(ctx([ - "test", - "recommend", - "--file", - "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py", - "--json", - ]))); + const result = capture(() => + commandTestRecommend( + ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); const ids = report.recommendations.map((item: { id: string }) => item.id); @@ -1681,26 +2438,66 @@ test("test recommend keeps git status paths intact", () => { }; try { const repo = join(tmp, "LangBot"); - mkdirSync(join(repo, "src", "langbot", "pkg", "agent", "runner"), { recursive: true }); + mkdirSync(join(repo, "src", "langbot", "pkg", "agent", "runner"), { + recursive: true, + }); spawnSync("git", ["init"], { cwd: repo }); - spawnSync("git", ["config", "user.email", "qa@example.test"], { cwd: repo }); + spawnSync("git", ["config", "user.email", "qa@example.test"], { + cwd: repo, + }); spawnSync("git", ["config", "user.name", "QA"], { cwd: repo }); writeFileSync(join(repo, "README.md"), "test\n"); - writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# test\n"); - spawnSync("git", ["add", "README.md", "src/langbot/pkg/agent/runner/run_ledger_store.py"], { cwd: repo }); + writeFileSync( + join( + repo, + "src", + "langbot", + "pkg", + "agent", + "runner", + "run_ledger_store.py", + ), + "# test\n", + ); + spawnSync( + "git", + ["add", "README.md", "src/langbot/pkg/agent/runner/run_ledger_store.py"], + { cwd: repo }, + ); spawnSync("git", ["commit", "-m", "init"], { cwd: repo }); - writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# changed\n"); + writeFileSync( + join( + repo, + "src", + "langbot", + "pkg", + "agent", + "runner", + "run_ledger_store.py", + ), + "# changed\n", + ); process.env.LANGBOT_REPO = repo; process.env.LANGBOT_PLUGIN_SDK_REPO = join(tmp, "missing-sdk"); process.env.LANGBOT_AGENT_RUNNER_REPO = join(tmp, "missing-runner"); process.env.LANGBOT_LOCAL_AGENT_REPO = join(tmp, "missing-local"); - const result = capture(() => commandTestRecommend({ root, args: ["test", "recommend", "--json"] })); + const result = capture(() => + commandTestRecommend({ root, args: ["test", "recommend", "--json"] }), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); - assert.ok(report.changed_files.includes("LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py")); - assert.ok(!report.changed_files.some((file: string) => file.includes("LangBot/rc/"))); + assert.ok( + report.changed_files.includes( + "LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py", + ), + ); + assert.ok( + !report.changed_files.some((file: string) => + file.includes("LangBot/rc/"), + ), + ); } finally { for (const [key, value] of Object.entries(originalRepos)) { if (value === undefined) delete process.env[key]; @@ -1711,25 +2508,41 @@ test("test recommend keeps git status paths intact", () => { }); test("test start creates a run handoff with a bounded report command", () => { - const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat"]))); + const result = capture(() => + commandTestStart(ctx(["test", "start", "pipeline-debug-chat"])), + ); assert.equal(result.code, 0); assert.match(result.output, /^# Test Start: pipeline-debug-chat/m); assert.match(result.output, /bin\/lbs test plan pipeline-debug-chat/); - assert.match(result.output, /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports\/evidence\/.+pipeline-debug-chat/); - assert.match(result.output, /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports\/evidence\/.+\/console\.log --evidence-dir reports\/evidence\/.+ --output reports\/.+pipeline-debug-chat\.md/); + assert.match( + result.output, + /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports\/evidence\/.+pipeline-debug-chat/, + ); + assert.match( + result.output, + /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports\/evidence\/.+\/console\.log --evidence-dir reports\/evidence\/.+ --output reports\/.+pipeline-debug-chat\.md/, + ); assert.match(result.output, /Streaming completed/); }); test("test start JSON is parseable for agent orchestration", () => { - const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat", "--json"]))); + const result = capture(() => + commandTestStart(ctx(["test", "start", "pipeline-debug-chat", "--json"])), + ); assert.equal(result.code, 0); const start = JSON.parse(result.output); assert.equal(start.case.id, "pipeline-debug-chat"); assert.match(start.run_id, /pipeline-debug-chat$/); assert.match(start.started_at_local, /\d{4}-\d{2}-\d{2}T/); assert.match(start.report_command, /--since/); - assert.match(start.result_command_template, /bin\/lbs test result pipeline-debug-chat/); - assert.match(start.automation.command, /bin\/lbs test run pipeline-debug-chat/); + assert.match( + start.result_command_template, + /bin\/lbs test result pipeline-debug-chat/, + ); + assert.match( + start.automation.command, + /bin\/lbs test run pipeline-debug-chat/, + ); assert.ok(start.success_patterns.includes("Streaming completed")); assert.ok(start.evidence_required.includes("backend_log")); }); @@ -1738,22 +2551,26 @@ test("test result writes a suite-readable result.json and enforces pass evidence const tmp = mkdtempSync(join(tmpdir(), "lbs-test-result-")); try { const evidenceDir = join(tmp, "pipeline-run"); - const ok = capture(() => commandTestResult(ctx([ - "test", - "result", - "pipeline-debug-chat", - "--result", - "pass", - "--reason", - "Debug Chat returned OK and logs were clean.", - "--evidence-dir", - evidenceDir, - "--started-at", - "2026-05-21T10:30:00.000+08:00", - "--evidence", - "ui,screenshot,console,backend_log", - "--json", - ]))); + const ok = capture(() => + commandTestResult( + ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Debug Chat returned OK and logs were clean.", + "--evidence-dir", + evidenceDir, + "--started-at", + "2026-05-21T10:30:00.000+08:00", + "--evidence", + "ui,screenshot,console,backend_log", + "--json", + ]), + ), + ); assert.equal(ok.code, 0); const record = JSON.parse(ok.output); @@ -1761,21 +2578,29 @@ test("test result writes a suite-readable result.json and enforces pass evidence assert.equal(record.status, "pass"); assert.equal(record.evidence_status, "complete"); assert.deepEqual(record.evidence_missing, []); - assert.equal(JSON.parse(readFileSync(join(evidenceDir, "result.json"), "utf8")).case_id, "pipeline-debug-chat"); - - const missing = captureAll(() => commandTestResult(ctx([ - "test", - "result", + assert.equal( + JSON.parse(readFileSync(join(evidenceDir, "result.json"), "utf8")) + .case_id, "pipeline-debug-chat", - "--result", - "pass", - "--reason", - "Missing backend evidence should not be accepted as pass.", - "--evidence-dir", - join(tmp, "missing-evidence"), - "--evidence", - "ui", - ]))); + ); + + const missing = captureAll(() => + commandTestResult( + ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Missing backend evidence should not be accepted as pass.", + "--evidence-dir", + join(tmp, "missing-evidence"), + "--evidence", + "ui", + ]), + ), + ); assert.equal(missing.code, 1); assert.match(missing.error, /missing required evidence/); } finally { @@ -1784,42 +2609,62 @@ test("test result writes a suite-readable result.json and enforces pass evidence }); test("test run dry-run exposes case automation script and evidence paths", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "pipeline-debug-chat", - "--run-id", - "run-123", - "--output", - "reports/evidence/run-123", - "--dry-run", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "pipeline-debug-chat", + "--run-id", + "run-123", + "--output", + "reports/evidence/run-123", + "--dry-run", + ]), + ), + ); assert.equal(result.code, 0); assert.match(result.output, /^# Test Automation: pipeline-debug-chat/m); assert.match(result.output, /scripts\/e2e\/pipeline-debug-chat\.mjs/); - assert.match(result.output, /console_log: reports\/evidence\/run-123\/console\.log/); - assert.match(result.output, /automation_result_json: reports\/evidence\/run-123\/automation-result\.json/); - assert.match(result.output, /result_json: reports\/evidence\/run-123\/result\.json/); + assert.match( + result.output, + /console_log: reports\/evidence\/run-123\/console\.log/, + ); + assert.match( + result.output, + /automation_result_json: reports\/evidence\/run-123\/automation-result\.json/, + ); + assert.match( + result.output, + /result_json: reports\/evidence\/run-123\/result\.json/, + ); assert.match(result.output, /LANGBOT_PIPELINE_URL/); }); test("test run dry-run JSON is parseable for automation orchestration", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "webui-login-state", - "--run-id", - "login-run", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "webui-login-state", + "--run-id", + "login-run", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.case.id, "webui-login-state"); assert.equal(run.run_id, "login-run"); assert.equal(run.automation.script, "scripts/e2e/webui-login-state.mjs"); assert.equal(run.automation.exists, true); - assert.match(run.automation.automation_result_json, /automation-result\.json$/); + assert.match( + run.automation.automation_result_json, + /automation-result\.json$/, + ); assert.match(run.automation.result_json, /result\.json$/); assert.match(run.automation.report_command, /--console-log/); }); @@ -1832,7 +2677,10 @@ test("test run JSON executes automation unless dry-run is explicit", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "json-exec.yaml"), @@ -1857,10 +2705,12 @@ test("test run JSON executes automation unless dry-run is explicit", () => { ].join("\n"), ); - const result = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "json-exec", "--run-id", "json-run", "--json"], - })); + const result = capture(() => + commandTestRun({ + root: tmp, + args: ["test", "run", "json-exec", "--run-id", "json-run", "--json"], + }), + ); assert.equal(result.code, 0); assert.equal(readFileSync(join(tmp, "json-ran.txt"), "utf8"), "yes"); @@ -1881,7 +2731,10 @@ test("test run lets explicit environment override automation defaults", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "env-override.yaml"), @@ -1895,7 +2748,7 @@ test("test run lets explicit environment override automation defaults", () => { "risk: low", "ci_eligible: false", "automation: scripts/write-env.mjs", - "automation_runner_config_patch_json: '{\"source\":\"default\"}'", + 'automation_runner_config_patch_json: \'{"source":"default"}\'', ].join("\n"), ); writeFileSync( @@ -1910,16 +2763,21 @@ test("test run lets explicit environment override automation defaults", () => { ); process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = '{"source":"explicit"}'; - const result = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "env-override", "--run-id", "env-run"], - })); + const result = capture(() => + commandTestRun({ + root: tmp, + args: ["test", "run", "env-override", "--run-id", "env-run"], + }), + ); assert.equal(result.code, 0); - const observed = JSON.parse(readFileSync(join(tmp, "env-out.json"), "utf8")); + const observed = JSON.parse( + readFileSync(join(tmp, "env-out.json"), "utf8"), + ); assert.equal(observed.patch, '{"source":"explicit"}'); } finally { - if (originalPatch === undefined) delete process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; + if (originalPatch === undefined) + delete process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; else process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = originalPatch; rmSync(tmp, { recursive: true, force: true }); } @@ -1933,8 +2791,14 @@ test("test run expands env references in automation defaults", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); - writeFileSync(join(tmp, "skills", ".env"), "QA_KB_UUID=kb-from-env\nQA_MODEL_UUID=model-from-env\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync( + join(tmp, "skills", ".env"), + "QA_KB_UUID=kb-from-env\nQA_MODEL_UUID=model-from-env\n", + ); writeFileSync( join(casesDir, "env-expand.yaml"), [ @@ -1947,7 +2811,7 @@ test("test run expands env references in automation defaults", () => { "risk: low", "ci_eligible: false", "automation: scripts/write-expanded-env.mjs", - "automation_runner_config_patch_json: '{\"knowledge-bases\":[\"${QA_KB_UUID}\"],\"model\":{\"primary\":\"${QA_MODEL_UUID}\"}}'", + 'automation_runner_config_patch_json: \'{"knowledge-bases":["${QA_KB_UUID}"],"model":{"primary":"${QA_MODEL_UUID}"}}\'', ].join("\n"), ); writeFileSync( @@ -1961,10 +2825,20 @@ test("test run expands env references in automation defaults", () => { ].join("\n"), ); - const dryRun = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "env-expand", "--run-id", "env-expand-dry", "--dry-run", "--json"], - })); + const dryRun = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "env-expand", + "--run-id", + "env-expand-dry", + "--dry-run", + "--json", + ], + }), + ); assert.equal(dryRun.code, 0); const plan = JSON.parse(dryRun.output); assert.equal( @@ -1972,13 +2846,20 @@ test("test run expands env references in automation defaults", () => { '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}', ); - const run = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "env-expand", "--run-id", "env-expand-run"], - })); + const run = capture(() => + commandTestRun({ + root: tmp, + args: ["test", "run", "env-expand", "--run-id", "env-expand-run"], + }), + ); assert.equal(run.code, 0); - const observed = JSON.parse(readFileSync(join(tmp, "expanded-env-out.json"), "utf8")); - assert.equal(observed.patch, '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}'); + const observed = JSON.parse( + readFileSync(join(tmp, "expanded-env-out.json"), "utf8"), + ); + assert.equal( + observed.patch, + '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}', + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -1992,7 +2873,10 @@ test("test run setup automation isolates evidence and reloads env", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); writeFileSync( join(casesDir, "setup-main.yaml"), @@ -2008,7 +2892,7 @@ test("test run setup automation isolates evidence and reloads env", () => { "env:", " - SETUP_VALUE", "setup_automation:", - " - \"node:scripts/write-setup-env.mjs --write-env\"", + ' - "node:scripts/write-setup-env.mjs --write-env"', "setup_provides_env:", " - SETUP_VALUE", "automation: scripts/read-setup-env.mjs", @@ -2026,7 +2910,7 @@ test("test run setup automation isolates evidence and reloads env", () => { "risk: low", "ci_eligible: false", "setup_automation:", - " - \"node:scripts/write-setup-env-issue.mjs\"", + ' - "node:scripts/write-setup-env-issue.mjs"', "automation: scripts/read-setup-env.mjs", ].join("\n"), ); @@ -2042,7 +2926,7 @@ test("test run setup automation isolates evidence and reloads env", () => { "risk: low", "ci_eligible: false", "setup_automation:", - " - \"node:scripts/write-setup-pass-then-fail.mjs\"", + ' - "node:scripts/write-setup-pass-then-fail.mjs"', "automation: scripts/read-setup-env.mjs", ].join("\n"), ); @@ -2092,45 +2976,118 @@ test("test run setup automation isolates evidence and reloads env", () => { ].join("\n"), ); - const dryRun = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence"), "--dry-run", "--json"], - })); + const dryRun = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "setup-main", + "--run-id", + "setup-run", + "--output", + join(tmp, "evidence"), + "--dry-run", + "--json", + ], + }), + ); assert.equal(dryRun.code, 0); const plan = JSON.parse(dryRun.output); assert.equal(plan.setup_automation.length, 1); - assert.match(plan.setup_automation[0].evidence_dir, /setup\/01-write-setup-env$/); - assert.match(plan.setup_automation[0].command, /^node scripts\/write-setup-env\.mjs --write-env$/); + assert.match( + plan.setup_automation[0].evidence_dir, + /setup\/01-write-setup-env$/, + ); + assert.match( + plan.setup_automation[0].command, + /^node scripts\/write-setup-env\.mjs --write-env$/, + ); assert.equal(plan.setup_automation[0].dry_run_command, ""); assert.equal(existsSync(join(tmp, "skills", ".env.local")), false); - const run = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence")], - })); + const run = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "setup-main", + "--run-id", + "setup-run", + "--output", + join(tmp, "evidence"), + ], + }), + ); assert.equal(run.code, 0); - const observed = JSON.parse(readFileSync(join(tmp, "main-observed.json"), "utf8")); + const observed = JSON.parse( + readFileSync(join(tmp, "main-observed.json"), "utf8"), + ); assert.equal(observed.value, "from-setup"); - const setupResult = JSON.parse(readFileSync(join(tmp, "evidence", "setup", "01-write-setup-env", "automation-result.json"), "utf8")); - const mainResult = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + const setupResult = JSON.parse( + readFileSync( + join( + tmp, + "evidence", + "setup", + "01-write-setup-env", + "automation-result.json", + ), + "utf8", + ), + ); + const mainResult = JSON.parse( + readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8"), + ); assert.equal(setupResult.stage, "setup"); assert.equal(mainResult.stage, "main"); - const envIssue = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-env-issue", "--run-id", "setup-env-issue-run", "--output", join(tmp, "evidence-env-issue")], - })); + const envIssue = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "setup-env-issue", + "--run-id", + "setup-env-issue-run", + "--output", + join(tmp, "evidence-env-issue"), + ], + }), + ); assert.equal(envIssue.code, 2); - const parentResult = JSON.parse(readFileSync(join(tmp, "evidence-env-issue", "automation-result.json"), "utf8")); + const parentResult = JSON.parse( + readFileSync( + join(tmp, "evidence-env-issue", "automation-result.json"), + "utf8", + ), + ); assert.equal(parentResult.status, "env_issue"); assert.equal(parentResult.reason, "setup env missing"); - const failAfterPass = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-fail-after-pass", "--run-id", "setup-fail-after-pass-run", "--output", join(tmp, "evidence-fail-after-pass")], - })); + const failAfterPass = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "setup-fail-after-pass", + "--run-id", + "setup-fail-after-pass-run", + "--output", + join(tmp, "evidence-fail-after-pass"), + ], + }), + ); assert.equal(failAfterPass.code, 1); - const failAfterPassResult = JSON.parse(readFileSync(join(tmp, "evidence-fail-after-pass", "automation-result.json"), "utf8")); + const failAfterPassResult = JSON.parse( + readFileSync( + join(tmp, "evidence-fail-after-pass", "automation-result.json"), + "utf8", + ), + ); assert.equal(failAfterPassResult.status, "fail"); assert.equal(failAfterPassResult.reason, "stale pass before crash"); } finally { @@ -2146,7 +3103,10 @@ test("test run setup automation can execute another case outside this source rep const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); writeFileSync( join(casesDir, "setup-child.yaml"), @@ -2174,7 +3134,7 @@ test("test run setup automation can execute another case outside this source rep "risk: low", "ci_eligible: true", "setup_automation:", - " - \"case:setup-child\"", + ' - "case:setup-child"', "setup_provides_env:", " - SETUP_VALUE", "automation: scripts/read-child-env.mjs", @@ -2203,14 +3163,30 @@ test("test run setup automation can execute another case outside this source rep ].join("\n"), ); - const run = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-parent", "--run-id", "setup-parent-run", "--output", join(tmp, "evidence")], - })); + const run = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "setup-parent", + "--run-id", + "setup-parent-run", + "--output", + join(tmp, "evidence"), + ], + }), + ); assert.equal(run.code, 0); - assert.ok(existsSync(join(tmp, "evidence", "setup", "01-setup-child", "result.json"))); - const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.ok( + existsSync( + join(tmp, "evidence", "setup", "01-setup-child", "result.json"), + ), + ); + const result = JSON.parse( + readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8"), + ); assert.equal(result.value, "from-child"); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -2225,7 +3201,10 @@ test("test run automation inherits parent process environment", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "env-inherit.yaml"), @@ -2252,13 +3231,25 @@ test("test run automation inherits parent process environment", () => { ].join("\n"), ); - const run = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "env-inherit", "--run-id", "env-inherit-run", "--output", join(tmp, "evidence")], - })); + const run = capture(() => + commandTestRun({ + root: tmp, + args: [ + "test", + "run", + "env-inherit", + "--run-id", + "env-inherit-run", + "--output", + join(tmp, "evidence"), + ], + }), + ); assert.equal(run.code, 0); - const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + const result = JSON.parse( + readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8"), + ); assert.equal(result.status, "pass"); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -2273,7 +3264,10 @@ test("test run dry-run marks missing setup case targets", () => { const scriptsDir = join(tmp, "scripts"); mkdirSync(casesDir, { recursive: true }); mkdirSync(scriptsDir, { recursive: true }); - writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync(join(tmp, "skills", ".env"), ""); writeFileSync( join(casesDir, "setup-parent.yaml"), @@ -2287,16 +3281,18 @@ test("test run dry-run marks missing setup case targets", () => { "risk: low", "ci_eligible: true", "setup_automation:", - " - \"case:missing-child\"", + ' - "case:missing-child"', "automation: scripts/pass.mjs", ].join("\n"), ); writeFileSync(join(scriptsDir, "pass.mjs"), "process.exit(0);\n"); - const result = capture(() => commandTestRun({ - root: tmp, - args: ["test", "run", "setup-parent", "--dry-run", "--json"], - })); + const result = capture(() => + commandTestRun({ + root: tmp, + args: ["test", "run", "setup-parent", "--dry-run", "--json"], + }), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); @@ -2310,159 +3306,352 @@ test("test run dry-run marks missing setup case targets", () => { }); test("local-agent effective prompt case has runnable automation defaults", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-effective-prompt-debug-chat", - "--run-id", - "effective-run", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-effective-prompt-debug-chat", + "--run-id", + "effective-run", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "qa-effective-prompt"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "PROMPT_PREPROCESS_OK"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, "180000"); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_PROMPT, + "qa-effective-prompt", + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, + "PROMPT_PREPROCESS_OK", + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, + "180000", + ); assert.equal(run.automation.pipeline_env_required, true); - assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( - alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_LOCAL_AGENT_PIPELINE_URL" - ))); + assert.ok( + run.automation.env_aliases.some( + (alias: { target: string; source: string }) => + alias.target === "LANGBOT_E2E_PIPELINE_URL" && + alias.source === "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + ), + ); }); test("local-agent basic case can setup the local-agent pipeline env", () => { - withEnv({ - LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", - LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", - }, () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-basic-debug-chat", - "--dry-run", - "--json", - ]))); - assert.equal(result.code, 0); - const run = JSON.parse(result.output); - assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", - ]); + withEnv( + { + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, + () => { + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-basic-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + ["node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env"], + ); - const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); - assert.equal(planResult.code, 0); - const plan = JSON.parse(planResult.output); - assert.deepEqual(plan.setup_provides_env, [ - "LANGBOT_LOCAL_AGENT_PIPELINE_URL", - "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", - ]); - assert.equal(plan.automation_readiness.status, "ready"); - }); + const planResult = capture(() => + commandTestPlan( + ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]), + ), + ); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); + }, + ); }); test("local-agent nonstreaming case disables stream output through automation defaults", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-nonstreaming-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-nonstreaming-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "Reply only NONSTREAM_OK."); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "NONSTREAM_OK"); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_PROMPT, + "Reply only NONSTREAM_OK.", + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, + "NONSTREAM_OK", + ); assert.equal(run.automation.env_defaults.LANGBOT_E2E_STREAM_OUTPUT, "0"); assert.equal(run.automation.pipeline_env_required, true); }); test("local-agent multimodal case exposes image fixture automation defaults", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-multimodal-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-multimodal-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "IMAGE_OK"); - assert.match(run.automation.env_defaults.LANGBOT_E2E_IMAGE_BASE64_PATH, /red-square\.png\.base64$/); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, + "IMAGE_OK", + ); + assert.match( + run.automation.env_defaults.LANGBOT_E2E_IMAGE_BASE64_PATH, + /red-square\.png\.base64$/, + ); assert.equal(run.automation.pipeline_env_required, true); }); +test("fake provider returns IMAGE_OK only when image metadata is present", async () => { + const provider = await startFakeProviderForTest(); + try { + const request = async (content: string) => { + const json = await requestFakeProvider(provider, { + messages: [{ role: "user", content }], + }); + return fakeProviderMessage(json).content; + }; + + assert.equal( + await request( + "I attached an image. Reply only IMAGE_OK if you received the image.[Image]", + ), + "IMAGE_OK", + ); + assert.equal(await request("Say hello for a plain text request."), "OK"); + } finally { + await provider.stop(); + } +}); + +test("fake provider drives steering through qa_plugin_sleep and follow-up context", async () => { + const provider = await startFakeProviderForTest(); + try { + const initial = fakeProviderMessage( + await requestFakeProvider(provider, { + tools: [ + { + type: "function", + function: { + name: "qa_plugin_sleep", + parameters: { + type: "object", + properties: { + seconds: { type: "number" }, + text: { type: "string" }, + }, + }, + }, + }, + ], + messages: [ + { + role: "user", + content: + "First call qa_plugin_sleep with text=steering-e2e-anchor. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP.", + }, + ], + }), + ); + assert.equal(initial.tool_calls?.[0]?.function?.name, "qa_plugin_sleep"); + assert.equal( + initial.tool_calls?.[0]?.function?.arguments, + JSON.stringify({ seconds: 8, text: "steering-e2e-anchor" }), + ); + + const noFollowup = fakeProviderMessage( + await requestFakeProvider(provider, { + messages: [ + { + role: "user", + content: + "First call qa_plugin_sleep with text=steering-e2e-anchor. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP.", + }, + initial, + { + role: "tool", + tool_call_id: "call_qa_plugin_sleep_steering", + content: "qa-plugin-smoke:sleep:8:steering-e2e-anchor", + }, + ], + }), + ); + assert.equal(noFollowup.content, "STEERING_NO_FOLLOWUP"); + + const withFollowup = fakeProviderMessage( + await requestFakeProvider(provider, { + messages: [ + { + role: "user", + content: + "First call qa_plugin_sleep with text=steering-e2e-anchor. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP.", + }, + initial, + { + role: "tool", + tool_call_id: "call_qa_plugin_sleep_steering", + content: "qa-plugin-smoke:sleep:8:steering-e2e-anchor", + }, + { + role: "user", + content: + "This is a steering follow-up. Return only qa_steering_sentinel_6194.", + }, + ], + }), + ); + assert.equal(withFollowup.content, "qa_steering_sentinel_6194"); + } finally { + await provider.stop(); + } +}); + test("MCP stdio case passes case-specific failure signals to automation defaults", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "mcp-stdio-tool-call", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx(["test", "run", "mcp-stdio-tool-call", "--dry-run", "--json"]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); - assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /qa-plugin-smoke:mcp-ok-local-agent/); - assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /model_not_found/); + assert.match( + run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, + /qa-plugin-smoke:mcp-ok-local-agent/, + ); + assert.match( + run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, + /model_not_found/, + ); }); test("MCP stdio tool-call case setups pipeline and registered MCP server", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "mcp-stdio-tool-call", - "--dry-run", - "--json", - ]))); - assert.equal(result.code, 0); - const run = JSON.parse(result.output); - assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", - "case:mcp-stdio-register", - ]); + withEnv({ LANGBOT_MCP_QA_STDIO_SERVER_UUID: "mcp-server-uuid" }, () => { + const result = capture(() => + commandTestRun( + ctx(["test", "run", "mcp-stdio-tool-call", "--dry-run", "--json"]), + ), + ); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:mcp-stdio-register", + ], + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, + "plugin:langbot-team/LocalAgent/default", + ); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_RESET_DEBUG_CHAT, "1"); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RESTORE_EXTENSIONS, + "1", + ); + assert.deepEqual( + JSON.parse(run.automation.env_defaults.LANGBOT_E2E_EXTENSIONS_PATCH_JSON), + { + enable_all_plugins: false, + bound_plugins: [{ author: "langbot-team", name: "LocalAgent" }], + enable_all_mcp_servers: false, + bound_mcp_servers: ["mcp-server-uuid"], + enable_all_skills: false, + bound_skills: [], + }, + ); - const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "mcp-stdio-tool-call", "--json"]))); - assert.equal(planResult.code, 0); - const plan = JSON.parse(planResult.output); - assert.deepEqual(plan.setup_provides_env, [ - "LANGBOT_LOCAL_AGENT_PIPELINE_URL", - "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", - ]); - assert.ok(!plan.preconditions.some((item: string) => item.includes("points to the local-agent pipeline"))); + const planResult = capture(() => + commandTestPlan(ctx(["test", "plan", "mcp-stdio-tool-call", "--json"])), + ); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_MCP_QA_STDIO_SERVER_UUID", + ]); + assert.ok( + !plan.preconditions.some((item: string) => + item.includes("points to the local-agent pipeline"), + ), + ); + }); }); test("generic pipeline automation can still use the shared pipeline env", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "pipeline-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx(["test", "run", "pipeline-debug-chat", "--dry-run", "--json"]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.automation.pipeline_env_required, false); assert.deepEqual(run.automation.env_aliases, []); - assert.ok(run.automation.required_env.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + assert.ok( + run.automation.required_env.includes( + "LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME", + ), + ); }); test("AgentRunner live install case exposes package automation defaults", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "agent-runner-live-install", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx(["test", "run", "agent-runner-live-install", "--dry-run", "--json"]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal( run.automation.env_defaults.LANGBOT_E2E_PLUGIN_PACKAGE, "skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", ); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_PLUGIN_ID, "qa/agent-runner"); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_PLUGIN_ID, + "qa/agent-runner", + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, + "plugin:qa/agent-runner/default", + ); }); test("QA plugin live install checks the fixture package before installed state", () => { @@ -2493,18 +3682,23 @@ test("QA plugin live install checks the fixture package before installed state", }); test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "agent-runner-qa-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx(["test", "run", "agent-runner-qa-debug-chat", "--dry-run", "--json"]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); assert.equal(run.automation.pipeline_env_required, true); - assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, + "plugin:qa/agent-runner/default", + ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS, + "120000", + ); assert.deepEqual( run.setup_automation.map((item: { entry: string }) => item.entry), [ @@ -2512,135 +3706,200 @@ test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => { "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env", ], ); - assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( - alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL" - ))); + assert.ok( + run.automation.env_aliases.some( + (alias: { target: string; source: string }) => + alias.target === "LANGBOT_E2E_PIPELINE_URL" && + alias.source === "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + ), + ); }); test("AgentRunner QA Debug Chat setup automation removes manual readiness", () => { - withEnv({ - LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", - LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", - }, () => { - const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "agent-runner-qa-debug-chat", "--json"]))); - assert.equal(planResult.code, 0); - const plan = JSON.parse(planResult.output); - assert.equal(plan.manual_readiness.status, "not_required"); - assert.deepEqual(plan.setup_provides_env, [ - "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", - "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME", - ]); - assert.equal(plan.automation_readiness.status, "ready"); + withEnv( + { + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, + () => { + const planResult = capture(() => + commandTestPlan( + ctx(["test", "plan", "agent-runner-qa-debug-chat", "--json"]), + ), + ); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.equal(plan.manual_readiness.status, "not_required"); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); - const suiteResult = capture(() => commandSuitePlan(ctx(["suite", "plan", "agent-runner-release-gate", "--json"]))); - assert.equal(suiteResult.code, 0); - const suite = JSON.parse(suiteResult.output); - assert.ok(!suite.readiness.manual_check_cases.includes("agent-runner-qa-debug-chat")); - }); + const suiteResult = capture(() => + commandSuitePlan( + ctx(["suite", "plan", "agent-runner-release-gate", "--json"]), + ), + ); + assert.equal(suiteResult.code, 0); + const suite = JSON.parse(suiteResult.output); + assert.ok( + !suite.readiness.manual_check_cases.includes( + "agent-runner-qa-debug-chat", + ), + ); + }, + ); }); test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "acp-agent-runner-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "acp-agent-runner-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); - assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ - "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env", - ]); - assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( - alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL" - ))); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + ["node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env"], + ); + assert.ok( + run.automation.env_aliases.some( + (alias: { target: string; source: string }) => + alias.target === "LANGBOT_E2E_PIPELINE_URL" && + alias.source === "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", + ), + ); - const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "acp-agent-runner-debug-chat", "--json"]))); + const planResult = capture(() => + commandTestPlan( + ctx(["test", "plan", "acp-agent-runner-debug-chat", "--json"]), + ), + ); assert.equal(planResult.code, 0); const plan = JSON.parse(planResult.output); assert.deepEqual(plan.setup_provides_env, [ "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", ]); - assert.ok(!plan.preconditions.some((item: string) => item.includes("pipeline AI runner"))); + assert.ok( + !plan.preconditions.some((item: string) => + item.includes("pipeline AI runner"), + ), + ); }); test("local-agent plugin cases setup the QA plugin smoke fixture", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-plugin-tool-call-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-plugin-tool-call-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); - assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", - "case:qa-plugin-smoke-live-install", - ]); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install", + ], + ); }); test("local-agent RAG case only requires the KB fixture env", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-rag-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx(["test", "run", "local-agent-rag-debug-chat", "--dry-run", "--json"]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); - assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); - assert.ok(!run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_TEXT_MODEL_UUID")); + assert.ok( + run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID"), + ); + assert.ok( + !run.automation.required_env.includes( + "LANGBOT_LOCAL_AGENT_RAG_TEXT_MODEL_UUID", + ), + ); assert.equal( run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, JSON.stringify({ - "knowledge-bases": [ - loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", - ], + "knowledge-bases": [loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || ""], }), ); }); test("LangRAG retrieve readiness requires a KB UUID alternative", () => { - const result = capture(() => commandTestPlan(ctx(["test", "plan", "langrag-kb-retrieve", "--json"]))); + const result = capture(() => + commandTestPlan(ctx(["test", "plan", "langrag-kb-retrieve", "--json"])), + ); assert.equal(result.code, 0); const plan = JSON.parse(result.output); - assert.ok(plan.automation_readiness.required.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID")); + assert.ok( + plan.automation_readiness.required.includes( + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID", + ), + ); }); test("local-agent RAG multimodal case setups the KB fixture env", () => { - const result = capture(() => commandTestRun(ctx([ - "test", - "run", - "local-agent-rag-multimodal-debug-chat", - "--dry-run", - "--json", - ]))); + const result = capture(() => + commandTestRun( + ctx([ + "test", + "run", + "local-agent-rag-multimodal-debug-chat", + "--dry-run", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const run = JSON.parse(result.output); - assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); + assert.ok( + run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID"), + ); assert.equal( run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, JSON.stringify({ - "knowledge-bases": [ - loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", - ], + "knowledge-bases": [loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || ""], }), ); - assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", - ]); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + ], + ); }); test("test report renders a reusable evidence template", () => { - const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--no-auto-log"]))); + const result = capture(() => + commandTestReport( + ctx(["test", "report", "pipeline-debug-chat", "--no-auto-log"]), + ), + ); assert.equal(result.code, 0); assert.match(result.output, /^# Test Report: pipeline-debug-chat/m); - assert.match(result.output, /result: pass \| fail \| blocked \| env_issue \| flaky/); + assert.match( + result.output, + /result: pass \| fail \| blocked \| env_issue \| flaky/, + ); assert.match(result.output, /## Log Guard/); assert.match(result.output, /## Automation Result/); assert.match(result.output, /## Required Evidence/); @@ -2660,17 +3919,24 @@ test("test report promotes loaded automation evidence into result section", () = }), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "langbot-live-backend-latency", - "--evidence-dir", - tmp, - "--no-auto-log", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "langbot-live-backend-latency", + "--evidence-dir", + tmp, + "--no-auto-log", + ]), + ), + ); assert.equal(result.code, 0); - assert.match(result.output, /## Result\n- result: pass\n- reason: latency thresholds passed/); + assert.match( + result.output, + /## Result\n- result: pass\n- reason: latency thresholds passed/, + ); assert.match(result.output, /- target_tested: http:\/\/127\.0\.0\.1:5300/); assert.doesNotMatch(result.output, /target_tested: TODO/); assert.match(result.output, /## Automation Result/); @@ -2691,11 +3957,22 @@ test("validate rejects dangling case references and missing automation scripts", mkdirSync(join(testingDir, "fixtures"), { recursive: true }); mkdirSync(join(testingDir, "suites"), { recursive: true }); mkdirSync(envSetupDir, { recursive: true }); - for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + for (const schemaName of [ + "case.schema.json", + "suite.schema.json", + "troubleshooting.schema.json", + "skill-index.schema.json", + ]) { writeFileSync(join(schemasDir, schemaName), "{}"); } - writeFileSync(join(envSetupDir, "SKILL.md"), "---\nname: langbot-env-setup\ndescription: Env setup.\n---\n\n# Env\n"); - writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(envSetupDir, "SKILL.md"), + "---\nname: langbot-env-setup\ndescription: Env setup.\n---\n\n# Env\n", + ); + writeFileSync( + join(testingDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); writeFileSync( join(skillsDir, ".env"), [ @@ -2742,7 +4019,10 @@ test("validate rejects dangling case references and missing automation scripts", " - missing-trouble", ].join("\n"), ); - for (const [id, target] of [["cycle-a", "cycle-b"], ["cycle-b", "cycle-a"]]) { + for (const [id, target] of [ + ["cycle-a", "cycle-b"], + ["cycle-b", "cycle-a"], + ]) { writeFileSync( join(testingDir, "cases", `${id}.yaml`), [ @@ -2785,7 +4065,14 @@ test("validate rejects dangling case references and missing automation scripts", ); writeFileSync( join(testingDir, "fixtures", "fixtures.json"), - JSON.stringify([{ id: "bad-fixture", title: "Bad Fixture", path: "fixtures/missing.txt", related_cases: ["missing-case"] }]), + JSON.stringify([ + { + id: "bad-fixture", + title: "Bad Fixture", + path: "fixtures/missing.txt", + related_cases: ["missing-case"], + }, + ]), ); const result = captureAll(() => commandValidate(tmp)); @@ -2817,21 +4104,43 @@ test("test report JSON scans logs and redacts secrets", () => { ].join("\n"), ); - const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--backend-log", logPath, "--json"]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]), + ), + ); assert.equal(result.code, 0); assert.doesNotMatch(result.output, /sk-test-secret/); const report = JSON.parse(result.output); assert.equal(report.log_guard.status, "fail"); - assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( - finding.kind === "case_failure_pattern" - ))); - assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( - finding.troubleshooting_id === "plugin-runtime-timeout" - ))); - assert.ok(report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); - - const secretFinding = report.log_guard.findings.find((finding: { kind: string }) => finding.kind === "secret_leak"); + assert.ok( + report.log_guard.findings.some( + (finding: { kind: string }) => finding.kind === "case_failure_pattern", + ), + ); + assert.ok( + report.log_guard.findings.some( + (finding: { troubleshooting_id?: string }) => + finding.troubleshooting_id === "plugin-runtime-timeout", + ), + ); + assert.ok( + report.log_guard.findings.some( + (finding: { kind: string }) => finding.kind === "python_traceback", + ), + ); + + const secretFinding = report.log_guard.findings.find( + (finding: { kind: string }) => finding.kind === "secret_leak", + ); assert.ok(secretFinding); assert.match(secretFinding.excerpt, /\[redacted\]/); } finally { @@ -2848,15 +4157,33 @@ test("test report does not treat invalid api key wording as a secret leak", () = "RequesterError: 模型请求失败: 无效的 api-key: Error code: 401 - invalid api key\n", ); - const result = capture(() => commandTestReport(ctx(["test", "report", "mcp-stdio-tool-call", "--backend-log", logPath, "--json"]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "mcp-stdio-tool-call", + "--backend-log", + logPath, + "--json", + ]), + ), + ); assert.equal(result.code, 0); assert.match(result.output, /api-key: Error code/); const report = JSON.parse(result.output); - assert.ok(!report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "secret_leak")); - assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( - finding.troubleshooting_id === "local-agent-model-route-unavailable" - ))); + assert.ok( + !report.log_guard.findings.some( + (finding: { kind: string }) => finding.kind === "secret_leak", + ), + ); + assert.ok( + report.log_guard.findings.some( + (finding: { troubleshooting_id?: string }) => + finding.troubleshooting_id === "local-agent-model-route-unavailable", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -2874,21 +4201,28 @@ test("test report records declared success signals from logs", () => { ].join("\n"), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--backend-log", - logPath, - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.status, "pass"); assert.equal(report.log_guard.success_signals.length, 2); - assert.ok(report.log_guard.success_signals.some((signal: { pattern: string }) => ( - signal.pattern === "Streaming completed" - ))); + assert.ok( + report.log_guard.success_signals.some( + (signal: { pattern: string }) => + signal.pattern === "Streaming completed", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -2900,20 +4234,27 @@ test("test report warns when declared success signals are missing", () => { const logPath = join(tmp, "backend.log"); writeFileSync(logPath, "INFO request started\nINFO request ended\n"); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--backend-log", - logPath, - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.status, "warning"); - assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( - finding.kind === "missing_success_signal" - ))); + assert.ok( + report.log_guard.findings.some( + (finding: { kind: string }) => + finding.kind === "missing_success_signal", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -2933,28 +4274,39 @@ test("test report can limit log guard to tail lines", () => { ].join("\n"), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--backend-log", - logPath, - "--tail-lines", - "2", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--tail-lines", + "2", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.scan.mode, "tail-lines"); assert.equal(report.log_guard.scan.tail_lines, 2); assert.equal(report.log_guard.sources[0].line_count, 2); assert.equal(report.log_guard.sources[0].start_line, 3); - assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( - finding.troubleshooting_id === "plugin-runtime-timeout" - ))); - assert.ok(!report.log_guard.findings.some((finding: { kind: string; excerpt?: string }) => ( - finding.kind === "error_log" && finding.excerpt?.includes("old failure") - ))); + assert.ok( + report.log_guard.findings.some( + (finding: { troubleshooting_id?: string }) => + finding.troubleshooting_id === "plugin-runtime-timeout", + ), + ); + assert.ok( + !report.log_guard.findings.some( + (finding: { kind: string; excerpt?: string }) => + finding.kind === "error_log" && + finding.excerpt?.includes("old failure"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -2974,26 +4326,38 @@ test("test report can limit log guard with since timestamp", () => { ].join("\n"), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--backend-log", - logPath, - "--since", - "2026-05-21T10:30:00+08:00", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.scan.mode, "since"); assert.equal(report.log_guard.sources[0].line_count, 3); assert.equal(report.log_guard.sources[0].start_line, 2); assert.equal(report.log_guard.sources[0].timestamped_line_count, 3); - assert.ok(report.log_guard.findings.some((finding: { line?: number; troubleshooting_id?: string }) => ( - finding.line === 2 && finding.troubleshooting_id === "plugin-runtime-timeout" - ))); - assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.ok( + report.log_guard.findings.some( + (finding: { line?: number; troubleshooting_id?: string }) => + finding.line === 2 && + finding.troubleshooting_id === "plugin-runtime-timeout", + ), + ); + assert.ok( + !report.log_guard.findings.some((finding: { excerpt?: string }) => + finding.excerpt?.includes("old failure"), + ), + ); assert.doesNotMatch(result.output, /sk-since-secret/); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -3014,18 +4378,22 @@ test("test report can limit log guard with since and until timestamps", () => { ].join("\n"), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--backend-log", - logPath, - "--since", - "2026-05-21T10:30:00+08:00", - "--until", - "2026-05-21T10:32:00+08:00", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--until", + "2026-05-21T10:32:00+08:00", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.scan.mode, "since+until"); @@ -3033,8 +4401,16 @@ test("test report can limit log guard with since and until timestamps", () => { assert.equal(report.log_guard.sources[0].start_line, 2); assert.equal(report.log_guard.sources[0].end_line, 3); assert.equal(report.log_guard.status, "pass"); - assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); - assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + assert.ok( + !report.log_guard.findings.some((finding: { excerpt?: string }) => + finding.excerpt?.includes("old failure"), + ), + ); + assert.ok( + !report.log_guard.findings.some((finding: { excerpt?: string }) => + finding.excerpt?.includes("later failure"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3049,20 +4425,28 @@ test("test report classifies model route failures as env_issue", () => { "[05-21 10:31:00.000] runner.py (2) - [ERROR] : runner.llm_error model_not_found no available channel for model gpt-test\n", ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "local-agent-plugin-tool-call-debug-chat", - "--backend-log", - logPath, - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "local-agent-plugin-tool-call-debug-chat", + "--backend-log", + logPath, + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.status, "env_issue"); - assert.ok(report.log_guard.findings.some((finding: { severity?: string; troubleshooting_id?: string }) => ( - finding.severity === "env_issue" && finding.troubleshooting_id === "local-agent-model-route-unavailable" - ))); + assert.ok( + report.log_guard.findings.some( + (finding: { severity?: string; troubleshooting_id?: string }) => + finding.severity === "env_issue" && + finding.troubleshooting_id === "local-agent-model-route-unavailable", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3094,15 +4478,19 @@ test("test report infers scan window from automation result evidence", () => { }), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "pipeline-debug-chat", - "--console-log", - consoleLog, - "--no-auto-log", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "pipeline-debug-chat", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.log_guard.scan.mode, "since+until"); @@ -3113,8 +4501,16 @@ test("test report infers scan window from automation result evidence", () => { assert.equal(report.automation_result.status, "loaded"); assert.equal(report.automation_result.result, "pass"); assert.equal(report.automation_result.reason, "UI sentinel appeared."); - assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); - assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + assert.ok( + !report.log_guard.findings.some((finding: { excerpt?: string }) => + finding.excerpt?.includes("old failure"), + ), + ); + assert.ok( + !report.log_guard.findings.some((finding: { excerpt?: string }) => + finding.excerpt?.includes("later failure"), + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3126,7 +4522,10 @@ test("test report does not treat final result as automation evidence", () => { const evidenceDir = join(tmp, "evidence", "run-final"); mkdirSync(evidenceDir, { recursive: true }); const consoleLog = join(evidenceDir, "console.log"); - writeFileSync(consoleLog, "[05-21 10:31:00.000] ui.js (1) - [INFO] : opened\n"); + writeFileSync( + consoleLog, + "[05-21 10:31:00.000] ui.js (1) - [INFO] : opened\n", + ); writeFileSync( join(evidenceDir, "result.json"), JSON.stringify({ @@ -3139,20 +4538,27 @@ test("test report does not treat final result as automation evidence", () => { }), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "webui-login-state", - "--console-log", - consoleLog, - "--no-auto-log", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.automation_result.status, "not_provided"); - assert.match(report.automation_result.reason, /only final result\.json is present/); + assert.match( + report.automation_result.reason, + /only final result\.json is present/, + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3164,7 +4570,10 @@ test("test report still scans untimestamped explicit console evidence within an const evidenceDir = join(tmp, "evidence", "run-untimestamped"); mkdirSync(evidenceDir, { recursive: true }); const consoleLog = join(evidenceDir, "console.log"); - writeFileSync(consoleLog, "[error] Uncaught TypeError: Cannot read properties of undefined\n"); + writeFileSync( + consoleLog, + "[error] Uncaught TypeError: Cannot read properties of undefined\n", + ); writeFileSync( join(evidenceDir, "result.json"), JSON.stringify({ @@ -3175,15 +4584,19 @@ test("test report still scans untimestamped explicit console evidence within an }), ); - const result = capture(() => commandTestReport(ctx([ - "test", - "report", - "webui-login-state", - "--console-log", - consoleLog, - "--no-auto-log", - "--json", - ]))); + const result = capture(() => + commandTestReport( + ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); @@ -3191,9 +4604,12 @@ test("test report still scans untimestamped explicit console evidence within an assert.equal(report.log_guard.sources[0].timestamped_line_count, 0); assert.ok(report.log_guard.sources[0].line_count >= 1); assert.equal(report.log_guard.status, "fail"); - assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( - finding.kind === "frontend_uncaught_error" - ))); + assert.ok( + report.log_guard.findings.some( + (finding: { kind: string }) => + finding.kind === "frontend_uncaught_error", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3203,10 +4619,17 @@ test("test report can write markdown to an output path", () => { const tmp = mkdtempSync(join(tmpdir(), "lbs-report-output-")); try { const output = join(tmp, "reports", "pipeline-debug-chat.md"); - const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--output", output]))); + const result = capture(() => + commandTestReport( + ctx(["test", "report", "pipeline-debug-chat", "--output", output]), + ), + ); assert.equal(result.code, 0); assert.match(result.output, /pipeline-debug-chat\.md$/); - assert.match(readFileSync(output, "utf8"), /^# Test Report: pipeline-debug-chat/m); + assert.match( + readFileSync(output, "utf8"), + /^# Test Report: pipeline-debug-chat/m, + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3225,32 +4648,49 @@ test("log scan reuses case-aware log guard patterns", () => { ].join("\n"), ); - const result = capture(() => commandLogScan(ctx([ - "log", - "scan", - "--backend-log", - logPath, - "--case", - "pipeline-debug-chat", - "--json", - ]))); + const result = capture(() => + commandLogScan( + ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]), + ), + ); assert.equal(result.code, 0); const report = JSON.parse(result.output); assert.equal(report.status, "fail"); - assert.ok(report.success_signals.some((signal: { pattern: string }) => signal.pattern === "Streaming completed")); - assert.ok(report.findings.some((finding: { kind: string }) => finding.kind === "case_failure_pattern")); + assert.ok( + report.success_signals.some( + (signal: { pattern: string }) => + signal.pattern === "Streaming completed", + ), + ); + assert.ok( + report.findings.some( + (finding: { kind: string }) => finding.kind === "case_failure_pattern", + ), + ); - const strict = capture(() => commandLogScan(ctx([ - "log", - "scan", - "--backend-log", - logPath, - "--case", - "pipeline-debug-chat", - "--strict", - "--json", - ]))); + const strict = capture(() => + commandLogScan( + ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--strict", + "--json", + ]), + ), + ); assert.equal(strict.code, 1); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -3264,42 +4704,54 @@ test("log guard start and stop bound a QA log window", () => { const outputDir = join(tmp, "guards"); writeFileSync(logPath, "INFO before guard\n"); - const start = capture(() => commandLogGuard(ctx([ - "log", - "guard", - "start", - "--run-id", - "qa-run", - "--output-dir", - outputDir, - "--backend-log", - logPath, - "--case", - "pipeline-debug-chat", - "--json", - ]))); + const start = capture(() => + commandLogGuard( + ctx([ + "log", + "guard", + "start", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]), + ), + ); assert.equal(start.code, 0); const session = JSON.parse(start.output); assert.equal(session.run_id, "qa-run"); assert.ok(existsSync(join(outputDir, "qa-run.json"))); appendFileSync(logPath, "Traceback (most recent call last):\n"); - const stop = capture(() => commandLogGuard(ctx([ - "log", - "guard", - "stop", - "--run-id", - "qa-run", - "--output-dir", - outputDir, - "--json", - ]))); + const stop = capture(() => + commandLogGuard( + ctx([ + "log", + "guard", + "stop", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--json", + ]), + ), + ); assert.equal(stop.code, 1); const report = JSON.parse(stop.output); assert.equal(report.session.run_id, "qa-run"); assert.equal(report.result.status, "fail"); - assert.ok(report.result.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + assert.ok( + report.result.findings.some( + (finding: { kind: string }) => finding.kind === "python_traceback", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -3311,18 +4763,22 @@ test("log watch observes appended LangBot backend lines", async () => { const logPath = join(tmp, "backend.log"); writeFileSync(logPath, "INFO existing line\n"); - const watching = captureAsync(() => commandLogWatch(ctx([ - "log", - "watch", - "--backend-log", - logPath, - "--duration-ms", - "220", - "--interval-ms", - "20", - "--strict", - "--json", - ]))); + const watching = captureAsync(() => + commandLogWatch( + ctx([ + "log", + "watch", + "--backend-log", + logPath, + "--duration-ms", + "220", + "--interval-ms", + "20", + "--strict", + "--json", + ]), + ), + ); setTimeout(() => { appendFileSync(logPath, "Traceback (most recent call last):\n"); }, 50); @@ -3333,14 +4789,20 @@ test("log watch observes appended LangBot backend lines", async () => { assert.equal(summary.mode, "watch"); assert.equal(summary.status, "fail"); assert.ok(summary.bytes_read > 0); - assert.ok(summary.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + assert.ok( + summary.findings.some( + (finding: { kind: string }) => finding.kind === "python_traceback", + ), + ); } finally { rmSync(tmp, { recursive: true, force: true }); } }); test("trouble search finds structured troubleshooting entries", () => { - const result = capture(() => commandTroubleSearch(ctx(["trouble", "search", "proxy"]))); + const result = capture(() => + commandTroubleSearch(ctx(["trouble", "search", "proxy"])), + ); assert.equal(result.code, 0); assert.match(result.output, /proxy-env-mismatch/); }); @@ -3349,7 +4811,10 @@ test("env local overrides shared env defaults", () => { const tmp = mkdtempSync(join(tmpdir(), "lbs-env-")); try { mkdirSync(join(tmp, "skills")); - writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_REPO=/shared\nLANGBOT_BACKEND_URL=http://127.0.0.1:5300\n"); + writeFileSync( + join(tmp, "skills", ".env"), + "LANGBOT_REPO=/shared\nLANGBOT_BACKEND_URL=http://127.0.0.1:5300\n", + ); writeFileSync(join(tmp, "skills", ".env.local"), "LANGBOT_REPO=/local\n"); assert.deepEqual(loadEnv(tmp), { diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index f453d0bff..9f5d1d936 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -438,8 +438,13 @@ async def send_proactive_message_to_one(self, target_id: str, content: str): try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=data) + try: + body = response.json() + except Exception: + body = {'text': response.text} if response.status_code == 200: - return + return body + raise Exception(f'Error: {response.status_code}, {body}') except Exception: await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}') @@ -464,8 +469,13 @@ async def send_proactive_message_to_group(self, target_id: str, content: str): try: async with httpx.AsyncClient() as client: response = await client.post(url, headers=headers, json=data) + try: + body = response.json() + except Exception: + body = {'text': response.text} if response.status_code == 200: - return + return body + raise Exception(f'Error: {response.status_code}, {body}') except Exception: await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}') diff --git a/src/langbot/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py index b474205dc..479643641 100644 --- a/src/langbot/libs/official_account_api/api.py +++ b/src/langbot/libs/official_account_api/api.py @@ -93,15 +93,30 @@ async def _handle_callback_internal(self, req): raise Exception('msg_signature不在请求体中') if req.method == 'GET': - # 校验签名 + if msg_signature: + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) + ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret == 0: + return reply_echo + await self.logger.error( + 'OfficialAccount encrypted URL verification failed: ' + f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, ' + f'echostr_present={bool(echostr)}' + ) + + # Plaintext callback verification. check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() if check_signature == signature: return echostr # 验证成功返回echostr else: - await self.logger.error('拒绝请求') - raise Exception('拒绝请求') + await self.logger.error( + 'OfficialAccount plaintext URL verification failed: ' + f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, ' + f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}' + ) + return 'signature verification failed', 403 elif req.method == 'POST': encryt_msg = await req.data wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) @@ -279,9 +294,27 @@ async def _handle_callback_internal(self, req): raise Exception('msg_signature不在请求体中') if req.method == 'GET': + if msg_signature: + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) + ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret == 0: + return reply_echo + await self.logger.error( + 'OfficialAccount encrypted URL verification failed: ' + f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, ' + f'echostr_present={bool(echostr)}' + ) + check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() - return echostr if check_signature == signature else '拒绝请求' + if check_signature == signature: + return echostr + await self.logger.error( + 'OfficialAccount plaintext URL verification failed: ' + f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, ' + f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}' + ) + return 'signature verification failed', 403 elif req.method == 'POST': encryt_msg = await req.data diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index b6f45cf22..f4e3a9888 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import base64 import json @@ -7,7 +9,7 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass, field import re -from typing import Any, Callable, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple from urllib.parse import unquote import httpx @@ -16,7 +18,9 @@ from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt -from langbot.pkg.platform.logger import EventLogger + +if TYPE_CHECKING: + from langbot.pkg.platform.logger import EventLogger @dataclass diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 5125a704a..8ad432fd9 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -15,13 +15,15 @@ import secrets import time import traceback -from typing import Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional import aiohttp from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession -from langbot.pkg.platform.logger import EventLogger + +if TYPE_CHECKING: + from langbot.pkg.platform.logger import EventLogger DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com' diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index 70270b727..3bc370477 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -207,7 +207,33 @@ async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: await self.logger.error(f'发送消息失败:{data}') - raise Exception('Failed to send message') + raise Exception(f'Failed to send message: {data}') + return data + + async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}' + payload = { + 'touser': external_userid, + 'open_kfid': open_kfid, + 'msgid': msgid, + 'msgtype': 'image', + 'image': { + 'media_id': media_id, + }, + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_image_msg(open_kfid, external_userid, msgid, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送图片消息失败:{data}') + raise Exception('Failed to send image message') return data async def handle_callback_request(self): @@ -322,7 +348,7 @@ async def upload_to_work(self, image: platform_message.Image): if not await self.check_access_token(): self.access_token = await self.get_access_token(self.secret) - url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=image' file_bytes = None file_name = 'uploaded_file.txt' @@ -368,7 +394,7 @@ async def upload_to_work(self, image: platform_message.Image): self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_to_work(image) if data.get('errcode', 0) != 0: - raise Exception('failed to upload file') + raise Exception(f'failed to upload image: {data}') media_id = data.get('media_id') return media_id diff --git a/src/langbot/pkg/agent/__init__.py b/src/langbot/pkg/agent/__init__.py new file mode 100644 index 000000000..df183b2cb --- /dev/null +++ b/src/langbot/pkg/agent/__init__.py @@ -0,0 +1,38 @@ +"""Agent runner subsystem for LangBot.""" + +from __future__ import annotations + +from .runner.descriptor import AgentRunnerDescriptor +from .runner.id import parse_runner_id, format_runner_id, RunnerIdParts, is_plugin_runner_id +from .runner.errors import ( + AgentRunnerError, + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerProtocolError, + RunnerExecutionError, +) +from .runner.registry import AgentRunnerRegistry +from .runner.context_builder import AgentRunContextBuilder +from .runner.resource_builder import AgentResourceBuilder +from .runner.result_normalizer import AgentResultNormalizer +from .runner.orchestrator import AgentRunOrchestrator +from .runner.config_resolver import RunnerConfigResolver + +__all__ = [ + 'AgentRunnerDescriptor', + 'parse_runner_id', + 'format_runner_id', + 'is_plugin_runner_id', + 'RunnerIdParts', + 'AgentRunnerError', + 'RunnerNotFoundError', + 'RunnerNotAuthorizedError', + 'RunnerProtocolError', + 'RunnerExecutionError', + 'AgentRunnerRegistry', + 'AgentRunContextBuilder', + 'AgentResourceBuilder', + 'AgentResultNormalizer', + 'AgentRunOrchestrator', + 'RunnerConfigResolver', +] diff --git a/src/langbot/pkg/agent/runner/__init__.py b/src/langbot/pkg/agent/runner/__init__.py new file mode 100644 index 000000000..03ce838d5 --- /dev/null +++ b/src/langbot/pkg/agent/runner/__init__.py @@ -0,0 +1,66 @@ +"""Agent runner modules.""" + +from __future__ import annotations + +from .descriptor import AgentRunnerDescriptor +from .id import parse_runner_id, format_runner_id, RunnerIdParts +from .errors import ( + AgentRunnerError, + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerProtocolError, + RunnerExecutionError, +) +from .registry import AgentRunnerRegistry +from .context_builder import AgentRunContextBuilder +from .resource_builder import AgentResourceBuilder +from .result_normalizer import AgentResultNormalizer +from .orchestrator import AgentRunOrchestrator +from .config_resolver import RunnerConfigResolver +from .default_config import AgentRunnerDefaultConfigService +from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError +from .session_registry import ( + AgentRunSessionRegistry, + AgentRunSession, + RunAuthorizationSnapshot, + get_session_registry, +) +from .run_ledger_store import RunLedgerStore +from .events import ( + MESSAGE_RECEIVED, + MESSAGE_RECALLED, + GROUP_MEMBER_JOINED, + FRIEND_REQUEST_RECEIVED, + RESERVED_EVENT_TYPES, +) + +__all__ = [ + 'AgentRunnerDescriptor', + 'parse_runner_id', + 'format_runner_id', + 'RunnerIdParts', + 'AgentRunnerError', + 'RunnerNotFoundError', + 'RunnerNotAuthorizedError', + 'RunnerProtocolError', + 'RunnerExecutionError', + 'AgentRunnerRegistry', + 'AgentRunContextBuilder', + 'AgentResourceBuilder', + 'AgentResultNormalizer', + 'AgentRunOrchestrator', + 'RunnerConfigResolver', + 'AgentRunnerDefaultConfigService', + 'AgentBindingResolver', + 'AgentBindingResolutionError', + 'AgentRunSessionRegistry', + 'AgentRunSession', + 'RunAuthorizationSnapshot', + 'get_session_registry', + 'RunLedgerStore', + 'MESSAGE_RECEIVED', + 'MESSAGE_RECALLED', + 'GROUP_MEMBER_JOINED', + 'FRIEND_REQUEST_RECEIVED', + 'RESERVED_EVENT_TYPES', +] diff --git a/src/langbot/pkg/agent/runner/binding_resolver.py b/src/langbot/pkg/agent/runner/binding_resolver.py new file mode 100644 index 000000000..fea5ad6b5 --- /dev/null +++ b/src/langbot/pkg/agent/runner/binding_resolver.py @@ -0,0 +1,70 @@ +"""Resolve host events to one effective Agent binding.""" + +from __future__ import annotations + +from .host_models import AgentConfig, AgentBinding, AgentEventEnvelope, BindingScope + + +class AgentBindingResolutionError(Exception): + """Raised when an event cannot resolve to exactly one Agent binding.""" + + +class AgentBindingResolver: + """Resolve an event to a single AgentBinding. + + The target product model is one bot / IM channel -> one Agent. Fan-out, + observer agents, or multi-runner arbitration require separate delivery and + state semantics and are intentionally not hidden in this resolver. + """ + + def resolve_one( + self, + event: AgentEventEnvelope, + agents: list[AgentConfig], + ) -> AgentBinding: + """Resolve exactly one enabled Agent for the event. + + Callers that source agents from bot/workspace/global configuration must + pre-filter candidates to the event scope before calling this resolver. + The current AgentConfig model represents one already-selected product + Agent and does not carry enough scope metadata to make that decision + safely here. + """ + matches = [ + agent + for agent in agents + if agent.enabled and event.event_type in agent.event_types + ] + + if not matches: + raise AgentBindingResolutionError( + f'No Agent binding matches event_type={event.event_type}' + ) + + if len(matches) > 1: + agent_ids = ', '.join(agent.agent_id or '' for agent in matches) + raise AgentBindingResolutionError( + f'Multiple Agent bindings match event_type={event.event_type}: {agent_ids}' + ) + + return self._to_binding(matches[0]) + + def _to_binding(self, agent: AgentConfig) -> AgentBinding: + """Project product-level Agent config into the run-time binding model.""" + scope = BindingScope( + scope_type='agent', + scope_id=agent.agent_id, + ) + + return AgentBinding( + binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}", + scope=scope, + event_types=list(agent.event_types), + runner_id=agent.runner_id, + runner_config=agent.runner_config, + resource_policy=agent.resource_policy, + state_policy=agent.state_policy, + delivery_policy=agent.delivery_policy, + enabled=agent.enabled, + agent_id=agent.agent_id, + ) diff --git a/src/langbot/pkg/agent/runner/config_resolver.py b/src/langbot/pkg/agent/runner/config_resolver.py new file mode 100644 index 000000000..378322f10 --- /dev/null +++ b/src/langbot/pkg/agent/runner/config_resolver.py @@ -0,0 +1,199 @@ +"""Resolve the current AgentRunner configuration shape.""" + +from __future__ import annotations + +import typing + + +HOST_SECURITY_BOOLEAN_FIELDS = ( + 'enable-all-tools', + 'mcp-resource-agent-read-enabled', +) + + +class RunnerConfigResolver: + """Configuration helpers for the current AgentRunner shape. + + Responsibilities: + - Resolve runner ID from ai.runner.id + - Extract Agent/runner config from ai.runner_config + - Read current conversation expiry settings + - Validate the persisted 4.x Agent binding container + """ + + @staticmethod + def validate_agent_config(config: typing.Any) -> dict[str, typing.Any]: + """Validate and return the persisted 4.x Agent config container.""" + if not isinstance(config, dict): + raise ValueError('Agent config must be an object') + + runner = config.get('runner') + if not isinstance(runner, dict): + raise ValueError("Agent config field 'runner' must be an object") + + runner_id = runner.get('id') + if not isinstance(runner_id, str): + raise ValueError("Agent config field 'runner.id' must be a string") + + runner_configs = config.get('runner_config') + if not isinstance(runner_configs, dict): + raise ValueError("Agent config field 'runner_config' must be an object") + + for configured_runner_id, runner_config in runner_configs.items(): + if not isinstance(configured_runner_id, str) or not configured_runner_id: + raise ValueError("Agent config field 'runner_config' must use non-empty string runner IDs") + if not isinstance(runner_config, dict): + raise ValueError(f'Agent runner_config[{configured_runner_id!r}] must be an object') + + if runner_id and runner_id not in runner_configs: + raise ValueError(f'Agent runner_config is missing selected runner {runner_id!r}') + + if runner_id: + RunnerConfigResolver.validate_runner_security_fields( + runner_configs[runner_id], + context=f'Agent runner_config[{runner_id!r}]', + ) + + return config + + @staticmethod + def validate_runner_security_fields( + runner_config: dict[str, typing.Any], + *, + context: str = 'Runner config', + ) -> dict[str, typing.Any]: + """Reject malformed Host-owned security toggles instead of enabling them.""" + for field_name in HOST_SECURITY_BOOLEAN_FIELDS: + if field_name in runner_config and not isinstance(runner_config[field_name], bool): + raise ValueError(f'{context} field {field_name!r} must be a boolean') + + RunnerConfigResolver.validate_mcp_resource_attachments( + runner_config.get('mcp-resources'), + context=context, + field_name='mcp-resources', + ) + return runner_config + + @staticmethod + def validate_mcp_resource_attachments( + resources: typing.Any, + *, + context: str, + field_name: str, + ) -> typing.Any: + """Validate explicit attachment enable flags shared by Agent and Pipeline inputs.""" + if not isinstance(resources, list): + return resources + for index, resource in enumerate(resources): + if not isinstance(resource, dict) or 'enabled' not in resource: + continue + if not isinstance(resource['enabled'], bool): + raise ValueError(f"{context} field '{field_name}[{index}].enabled' must be a boolean") + return resources + + @classmethod + def validate_pipeline_config(cls, config: typing.Any) -> dict[str, typing.Any]: + """Validate the selected Runner container in a current 4.x Pipeline config.""" + if not isinstance(config, dict): + raise ValueError('Pipeline config must be an object') + + ai_config = config.get('ai') + if ai_config is None: + return config + if not isinstance(ai_config, dict): + raise ValueError("Pipeline config field 'ai' must be an object") + + runner = ai_config.get('runner') + if runner is None: + return config + if not isinstance(runner, dict): + raise ValueError("Pipeline config field 'ai.runner' must be an object") + + runner_id = runner.get('id') + if not isinstance(runner_id, str): + raise ValueError("Pipeline config field 'ai.runner.id' must be a string") + if not runner_id: + return config + + runner_configs = ai_config.get('runner_config') + if not isinstance(runner_configs, dict): + raise ValueError("Pipeline config field 'ai.runner_config' must be an object") + if runner_id not in runner_configs: + raise ValueError(f'Pipeline runner_config is missing selected runner {runner_id!r}') + + selected_config = runner_configs[runner_id] + if not isinstance(selected_config, dict): + raise ValueError(f'Pipeline runner_config[{runner_id!r}] must be an object') + cls.validate_runner_security_fields( + selected_config, + context=f'Pipeline runner_config[{runner_id!r}]', + ) + return config + + @staticmethod + def resolve_agent_runner_id(config: dict[str, typing.Any]) -> str | None: + """Resolve a runner ID from a validated persisted Agent config.""" + runner = config.get('runner', {}) + runner_id = runner.get('id') if isinstance(runner, dict) else None + return runner_id if isinstance(runner_id, str) and runner_id else None + + @classmethod + def resolve_agent_runner_config( + cls, + config: typing.Any, + ) -> tuple[dict[str, typing.Any], str | None, dict[str, typing.Any]]: + """Validate an Agent config and return its selected runner configuration.""" + validated = cls.validate_agent_config(config) + runner_id = cls.resolve_agent_runner_id(validated) + runner_configs = typing.cast(dict[str, typing.Any], validated['runner_config']) + runner_config = runner_configs.get(runner_id, {}) if runner_id else {} + return validated, runner_id, typing.cast(dict[str, typing.Any], runner_config) + + @staticmethod + def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: + """Resolve runner ID from current configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Runner ID string, or None if not configured + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} + runner_id = runner.get('id') if isinstance(runner, dict) else None + return runner_id if isinstance(runner_id, str) and runner_id else None + + @staticmethod + def resolve_runner_config( + pipeline_config: dict[str, typing.Any], + runner_id: str, + ) -> dict[str, typing.Any]: + """Resolve Agent/runner configuration from the current container. + + Args: + pipeline_config: Current configuration container + runner_id: Resolved runner ID + + Returns: + Runner configuration dict (empty if not found) + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {} + runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {} + return runner_config if isinstance(runner_config, dict) else {} + + @staticmethod + def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: + """Get conversation expire time from configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Expire time in seconds (0 means no expiry) + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} + expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0 + return expire_time if isinstance(expire_time, int) else 0 diff --git a/src/langbot/pkg/agent/runner/config_schema.py b/src/langbot/pkg/agent/runner/config_schema.py new file mode 100644 index 000000000..ba841e59b --- /dev/null +++ b/src/langbot/pkg/agent/runner/config_schema.py @@ -0,0 +1,204 @@ +"""Helpers for interpreting AgentRunner DynamicForm configuration.""" +from __future__ import annotations + +import typing + +from .descriptor import AgentRunnerDescriptor + + +FORM_ITEM_TYPE_ALIASES = { + 'select-llm-model': 'llm-model-selector', + 'select-knowledge-bases': 'knowledge-base-multi-selector', +} +LLM_MODEL_SELECTOR_TYPES = {'model-fallback-selector', 'llm-model-selector'} +KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'} +PROMPT_EDITOR_TYPES = {'prompt-editor'} +NONE_SENTINELS = {'', '__none__', '__none'} + + +def normalize_schema_item_type(item_type: typing.Any) -> typing.Any: + """Normalize legacy/frontend DynamicForm aliases to protocol field types.""" + if not isinstance(item_type, str): + return item_type + return FORM_ITEM_TYPE_ALIASES.get(item_type, item_type) + + +def iter_schema_items( + descriptor: AgentRunnerDescriptor | None, + field_types: set[str], +) -> typing.Iterator[dict[str, typing.Any]]: + """Yield descriptor config schema items whose type is in field_types.""" + if descriptor is None: + return + for item in descriptor.config_schema or []: + if not isinstance(item, dict): + continue + if normalize_schema_item_type(item.get('type')) in field_types: + yield item + + +def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should resolve model resources for this runner.""" + return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES)) + + +def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should expose tool resources to this runner.""" + return descriptor is not None and descriptor.supports_tool_calling() + + +def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether LangBot should expose knowledge-base resources to this runner.""" + return descriptor is not None and descriptor.supports_knowledge_retrieval() + + +def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool: + """Return whether the runner wants Host skill-authoring tools.""" + if descriptor is None: + return False + return descriptor.capabilities.skill_authoring + + +def extract_prompt_config( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], + default_prompt: list[dict[str, typing.Any]], +) -> list[dict[str, typing.Any]]: + """Extract the prompt-editor value selected by the runner schema.""" + for item in iter_schema_items(descriptor, PROMPT_EDITOR_TYPES): + field_name = item.get('name') + if field_name and field_name in runner_config: + configured_prompt = runner_config[field_name] + if isinstance(configured_prompt, list): + return configured_prompt + default_value = item.get('default') + if isinstance(default_value, list): + return default_value + return default_prompt + + +def extract_model_selection( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], +) -> tuple[str, list[str]]: + """Extract primary/fallback LLM selections from schema-defined fields.""" + primary_uuid = '' + fallback_uuids: list[str] = [] + + for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES): + field_name = item.get('name') + if not field_name: + continue + + value = runner_config.get(field_name, item.get('default')) + item_type = normalize_schema_item_type(item.get('type')) + if item_type == 'model-fallback-selector': + if isinstance(value, str): + primary_uuid = value + elif isinstance(value, dict): + primary_uuid = value.get('primary') or '' + fallbacks = value.get('fallbacks', []) + if isinstance(fallbacks, list): + fallback_uuids = [fallback for fallback in fallbacks if isinstance(fallback, str)] + break + + if item_type == 'llm-model-selector' and isinstance(value, str): + primary_uuid = value + break + + return primary_uuid, fallback_uuids + + +def extract_knowledge_base_uuids( + descriptor: AgentRunnerDescriptor | None, + runner_config: dict[str, typing.Any], +) -> list[str]: + """Extract configured knowledge-base UUIDs from schema-defined fields.""" + if not uses_host_knowledge_bases(descriptor): + return [] + + kb_uuids: list[str] = [] + for item in iter_schema_items(descriptor, KB_SELECTOR_TYPES): + field_name = item.get('name') + if not field_name: + continue + value = runner_config.get(field_name, item.get('default', [])) + if isinstance(value, list): + kb_uuids.extend( + kb_uuid for kb_uuid in value if isinstance(kb_uuid, str) and kb_uuid not in NONE_SENTINELS + ) + + return list(dict.fromkeys(kb_uuids)) + + +def iter_config_model_refs( + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], +) -> typing.Iterator[tuple[str, str]]: + """Yield model references declared by schema-defined model selector fields.""" + for item in descriptor.config_schema or []: + if not isinstance(item, dict): + continue + + field_name = item.get('name') + field_type = normalize_schema_item_type(item.get('type')) + if not field_name or field_name not in runner_config: + continue + + value = runner_config.get(field_name) + if field_type == 'model-fallback-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'llm', value + elif isinstance(value, dict): + primary = value.get('primary') + if isinstance(primary, str) and primary not in NONE_SENTINELS: + yield 'llm', primary + fallbacks = value.get('fallbacks', []) + if isinstance(fallbacks, list): + for fallback_uuid in fallbacks: + if isinstance(fallback_uuid, str) and fallback_uuid not in NONE_SENTINELS: + yield 'llm', fallback_uuid + elif field_type == 'llm-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'llm', value + elif field_type == 'rerank-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + yield 'rerank', value + + +def set_empty_llm_model_selection( + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + model_uuid: str, +) -> bool: + """Set the first empty schema-defined LLM selector to model_uuid.""" + for item in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES): + field_name = item.get('name') + field_type = normalize_schema_item_type(item.get('type')) + if not field_name: + continue + + value = runner_config.get(field_name, item.get('default')) + if field_type == 'model-fallback-selector': + if isinstance(value, dict): + primary = value.get('primary') or '' + if primary not in NONE_SENTINELS: + return False + fallbacks = value.get('fallbacks', []) + runner_config[field_name] = { + 'primary': model_uuid, + 'fallbacks': fallbacks if isinstance(fallbacks, list) else [], + } + return True + if isinstance(value, str) and value not in NONE_SENTINELS: + return False + runner_config[field_name] = {'primary': model_uuid, 'fallbacks': []} + return True + + if field_type == 'llm-model-selector': + if isinstance(value, str) and value not in NONE_SENTINELS: + return False + runner_config[field_name] = model_uuid + return True + + return False diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py new file mode 100644 index 000000000..87656d5f9 --- /dev/null +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -0,0 +1,493 @@ +"""Agent run context builder for provisioning AgentRunContext envelopes.""" + +from __future__ import annotations + +import uuid +import time +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .persistent_state_store import get_persistent_state_store +from .host_models import AgentEventEnvelope, AgentBinding + + +DEFAULT_RUNNER_TIMEOUT_SECONDS = 300 + + +# Internal models for the agent runner context protocol. + + +class AgentTrigger(typing.TypedDict): + """Agent trigger information.""" + + type: str + source: str + timestamp: int | None + + +class ConversationContext(typing.TypedDict): + """Conversation context.""" + + conversation_id: str | None + thread_id: str | None + launcher_type: str | None + launcher_id: str | None + sender_id: str | None + bot_id: str | None + workspace_id: str | None + session_id: str | None + + +class AgentInput(typing.TypedDict): + """Agent input.""" + + text: str | None + contents: list[dict[str, typing.Any]] + attachments: list[dict[str, typing.Any]] + + +class AgentRunState(typing.TypedDict): + """Agent run state with 4 scopes.""" + + conversation: dict[str, typing.Any] + actor: dict[str, typing.Any] + subject: dict[str, typing.Any] + runner: dict[str, typing.Any] + + +# Resource payload models matching langbot-plugin-sdk/resources.py. + + +class ModelResource(typing.TypedDict): + """Model resource payload.""" + + model_id: str + model_type: str | None + provider: str | None + operations: list[str] + + +class ToolResource(typing.TypedDict): + """Tool resource payload.""" + + tool_name: str + tool_type: str | None + description: str | None + operations: list[str] + parameters: dict[str, typing.Any] | None + source: typing.NotRequired[str] + source_id: typing.NotRequired[str | None] + + +class KnowledgeBaseResource(typing.TypedDict): + """Knowledge base resource payload.""" + + kb_id: str + kb_name: str | None + kb_type: str | None + operations: list[str] + + +class SkillResource(typing.TypedDict): + """Skill resource payload.""" + + skill_name: str + display_name: str | None + description: str | None + + +class StorageResource(typing.TypedDict): + """Storage resource payload.""" + + plugin_storage: bool + workspace_storage: bool + + +class AgentResources(typing.TypedDict): + """Agent resources payload.""" + + models: list[ModelResource] + tools: list[ToolResource] + knowledge_bases: list[KnowledgeBaseResource] + skills: list[SkillResource] + storage: StorageResource + platform_capabilities: dict[str, typing.Any] + + +class AgentRuntimeContext(typing.TypedDict): + """Agent runtime context.""" + + langbot_version: str | None + trace_id: str | None + deadline_at: float | None + metadata: dict[str, typing.Any] + + +class AgentRunContextPayload(typing.TypedDict): + """AgentRunContext payload passed to an agent runner. + + Protocol v1 structure - matches SDK AgentRunContext. + + Note: The 'config' field contains the current Agent/runner config + from ai.runner_config[runner_id] while the current Query entry remains + a temporary configuration container. It is not plugin instance config. + """ + + run_id: str + trigger: AgentTrigger + conversation: ConversationContext | None + event: dict[str, typing.Any] # REQUIRED for Protocol v1 + actor: dict[str, typing.Any] | None + subject: dict[str, typing.Any] | None + input: AgentInput + delivery: dict[str, typing.Any] # REQUIRED for Protocol v1 + resources: AgentResources + context: dict[str, typing.Any] # ContextAccess - REQUIRED for Protocol v1 + state: AgentRunState + runtime: AgentRuntimeContext + config: dict[str, typing.Any] # Agent/runner config from ai.runner_config[runner_id] + adapter: dict[str, typing.Any] | None # Entry adapter context + metadata: dict[str, typing.Any] # Additional metadata + + +class AgentRunContextBuilder: + """Builder for provisioning AgentRunContext. + + Responsibilities: + - Generate new run_id (UUID, not query id) + - Set trigger type based on event source + - Build conversation context from event + - Build input from event + - Build state snapshot from PersistentStateStore + - Build runtime context with host info, trace_id, deadline + - Set config from current Agent/runner configuration. + + Query adaptation belongs to QueryEntryAdapter, not this builder. + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + @staticmethod + def _positive_int(value: typing.Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str) and value.isdigit(): + parsed_value = int(value) + if parsed_value > 0: + return parsed_value + return None + + @staticmethod + def _is_llm_model_resource(model_resource: ModelResource) -> bool: + operations = model_resource.get('operations') + if isinstance(operations, list) and operations: + return bool({'invoke', 'stream', 'count_tokens'} & {str(operation) for operation in operations}) + return model_resource.get('model_type') != 'rerank' + + async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None: + model_mgr = getattr(self.ap, 'model_mgr', None) + if model_mgr is None: + return None + + for model_resource in resources.get('models', []): + if not self._is_llm_model_resource(model_resource): + continue + + model_uuid = model_resource.get('model_id') + if not isinstance(model_uuid, str) or not model_uuid: + continue + + try: + model = await model_mgr.get_model_by_uuid(model_uuid) + except Exception as exc: + logger = getattr(self.ap, 'logger', None) + if logger is not None: + logger.debug(f'Failed to resolve model context window for {model_uuid}: {exc}') + continue + + model_entity = getattr(model, 'model_entity', None) + context_length = self._positive_int(getattr(model_entity, 'context_length', None)) + return context_length + + return None + + async def build_context_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + resources: AgentResources, + ) -> AgentRunContextPayload: + """Build AgentRunContext from event-first envelope. + + This is the main entry point for Protocol v1. + Does NOT inline full history by default. + + Args: + event: Event envelope + binding: Agent binding + descriptor: Runner descriptor + resources: Built resources + + Returns: + AgentRunContextPayload for the runner + """ + # Generate new run_id + run_id = str(uuid.uuid4()) + + # Build trigger from event + trigger: AgentTrigger = { + 'type': event.event_type, + 'source': event.source, + 'timestamp': event.event_time or int(time.time()), + } + + # Build conversation context from event + conversation: ConversationContext | None = None + if event.conversation_id: + conversation = { + 'session_id': None, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'launcher_type': None, # Will be filled from actor/subject if needed + 'launcher_id': None, + 'sender_id': event.actor.actor_id if event.actor else None, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + } + + # Build event context (Protocol v1 event-first) + event_context = { + 'event_id': event.event_id, + 'event_type': event.event_type, + 'event_time': event.event_time, + 'source': event.source, + 'source_event_type': event.source_event_type, + 'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None, + 'data': event.data, + } + + # Build actor context + actor_context = None + if event.actor: + actor_context = { + 'actor_type': event.actor.actor_type, + 'actor_id': event.actor.actor_id, + 'actor_name': event.actor.actor_name, + } + + # Build subject context + subject_context = None + if event.subject: + subject_context = { + 'subject_type': event.subject.subject_type, + 'subject_id': event.subject.subject_id, + 'data': event.subject.data, + } + + # Build input from event + input: AgentInput = { + 'text': event.input.text, + 'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents], + 'attachments': [ + a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments + ], + } + + # Build context access (no history inlined by default for Protocol v1) + # Populate with actual values from stores + context_access = await self._build_context_access(event, descriptor, binding) + + # Build state snapshot from persistent state store (event-first Protocol v1) + persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine()) + state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor) + + model_context_window_tokens = await self._build_model_context_window_tokens(resources) + + # Build runtime context + runtime: AgentRuntimeContext = { + 'langbot_version': self.ap.ver_mgr.get_current_version(), + 'trace_id': run_id, + 'deadline_at': self._build_deadline_from_binding(binding), + 'metadata': { + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'streaming_supported': event.delivery.supports_streaming, + 'model_context_window_tokens': model_context_window_tokens, + }, + } + + # Build delivery context + delivery_context = { + 'surface': event.delivery.surface, + 'reply_target': event.delivery.reply_target, + 'supports_streaming': event.delivery.supports_streaming, + 'supports_edit': event.delivery.supports_edit, + 'supports_reaction': event.delivery.supports_reaction, + 'max_message_size': event.delivery.max_message_size, + 'platform_capabilities': event.delivery.platform_capabilities, + } + + # Build adapter context (empty for event-first) + adapter_context = { + 'extra': {}, + } + + # Build full context - Protocol v1 structure + context: AgentRunContextPayload = { + 'run_id': run_id, + 'trigger': trigger, + 'conversation': conversation, + 'event': event_context, # REQUIRED + 'actor': actor_context, + 'subject': subject_context, + 'input': input, + 'delivery': delivery_context, # REQUIRED + 'resources': resources, + 'context': context_access, # ContextAccess - REQUIRED + 'state': state, + 'runtime': runtime, + 'config': binding.runner_config, + 'adapter': adapter_context, + 'metadata': {}, # Additional metadata + } + + return context + + def _build_deadline_from_binding(self, binding: AgentBinding) -> float | None: + """Build deadline timestamp from binding timeout config. + + Args: + binding: Agent binding with runner_config + + Returns: + Deadline timestamp or None + """ + timeout = binding.runner_config.get('timeout', DEFAULT_RUNNER_TIMEOUT_SECONDS) + if timeout is None: + return None + + try: + timeout_seconds = float(timeout) + except (TypeError, ValueError): + return None + + if timeout_seconds <= 0: + return None + + return time.time() + timeout_seconds + + async def _build_context_access( + self, + event: AgentEventEnvelope, + descriptor: AgentRunnerDescriptor, + binding: AgentBinding | None = None, + ) -> dict[str, typing.Any]: + """Build ContextAccess with actual values from stores. + + Args: + event: Event envelope + descriptor: Runner descriptor + binding: Agent binding (required for state_policy in event-first mode) + + Returns: + ContextAccess dict + """ + conversation_id = event.conversation_id + permissions = descriptor.permissions + history_perms = set(permissions.history) + event_perms = set(permissions.events) + storage_perms = set(permissions.storage) + + history_page_enabled = 'page' in history_perms and conversation_id is not None + history_search_enabled = 'search' in history_perms and conversation_id is not None + event_get_enabled = 'get' in event_perms + event_page_enabled = 'page' in event_perms and conversation_id is not None + steering_pull_enabled = ( + bool(getattr(descriptor.capabilities, 'steering', False)) and conversation_id is not None + ) + run_get_enabled = True + run_list_enabled = conversation_id is not None + run_events_page_enabled = True + run_cancel_enabled = True + run_append_result_enabled = False + run_finalize_enabled = False + run_claim_enabled = False + run_renew_claim_enabled = False + run_release_claim_enabled = False + runtime_register_enabled = False + runtime_heartbeat_enabled = False + runtime_list_enabled = False + + # Determine state API availability based on binding state_policy. + state_enabled = False + storage_enabled = False + if binding is not None: + state_policy = binding.state_policy + if state_policy.enable_state and state_policy.state_scopes: + state_enabled = True + + resource_policy = binding.resource_policy + storage_enabled = ('plugin' in storage_perms and resource_policy.allow_plugin_storage) or ( + 'workspace' in storage_perms and resource_policy.allow_workspace_storage + ) + + # Get latest cursor and has_history_before if conversation exists + latest_cursor = None + has_history_before = False + + if conversation_id: + try: + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + latest_cursor = await store.get_latest_cursor(conversation_id) + if latest_cursor: + has_history_before = True + except Exception as e: + self.ap.logger.warning(f'Failed to get transcript cursor: {e}') + + return { + 'conversation_id': conversation_id, + 'thread_id': event.thread_id, + 'latest_cursor': latest_cursor, + 'event_seq': None, # Will be populated when EventLog is written + 'transcript_seq': int(latest_cursor) if latest_cursor else None, + 'has_history_before': has_history_before, + 'inline_policy': { + 'mode': 'current_event', + 'delivered_count': 0, + 'source_total_count': None, + 'messages_complete': False, + 'reason': 'current_event_only', + }, + 'available_apis': { + 'prompt_get': False, + 'history_page': history_page_enabled, + 'history_search': history_search_enabled, + 'event_get': event_get_enabled, + 'event_page': event_page_enabled, + 'state': state_enabled, + 'storage': storage_enabled, + 'steering_pull': steering_pull_enabled, + 'run_get': run_get_enabled, + 'run_list': run_list_enabled, + 'run_events_page': run_events_page_enabled, + 'run_cancel': run_cancel_enabled, + 'run_append_result': run_append_result_enabled, + 'run_finalize': run_finalize_enabled, + 'run_claim': run_claim_enabled, + 'run_renew_claim': run_renew_claim_enabled, + 'run_release_claim': run_release_claim_enabled, + 'runtime_register': runtime_register_enabled, + 'runtime_heartbeat': runtime_heartbeat_enabled, + 'runtime_list': runtime_list_enabled, + }, + } diff --git a/src/langbot/pkg/agent/runner/default_config.py b/src/langbot/pkg/agent/runner/default_config.py new file mode 100644 index 000000000..a131dac9b --- /dev/null +++ b/src/langbot/pkg/agent/runner/default_config.py @@ -0,0 +1,72 @@ +"""Default AgentRunner binding configuration helpers.""" + +from __future__ import annotations + +import sqlalchemy + +from ...core import app +from ...entity.persistence import pipeline as persistence_pipeline +from . import config_schema +from .config_resolver import RunnerConfigResolver + + +class AgentRunnerDefaultConfigService: + """Apply AgentRunner schema-defined defaults to host binding config.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def _get_runner_descriptor(self, runner_id: str): + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + try: + return await registry.get(runner_id, bound_plugins=None) + except Exception as e: + logger = getattr(self.ap, 'logger', None) + if logger: + logger.warning(f'Failed to load AgentRunner descriptor while setting default model: {e}') + return None + + async def auto_set_default_pipeline_llm_model(self, model_uuid: str) -> bool: + """Set model_uuid into the default pipeline runner config when the selector is empty.""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.is_default == True + ) + ) + pipeline = result.first() + if pipeline is None: + return False + + return await self.set_pipeline_llm_model_if_empty(pipeline, model_uuid) + + async def set_pipeline_llm_model_if_empty( + self, + pipeline: persistence_pipeline.LegacyPipeline, + model_uuid: str, + ) -> bool: + """Set model_uuid into a pipeline's schema-defined LLM selector if it is empty.""" + pipeline_config = pipeline.config + if not isinstance(pipeline_config, dict): + return False + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + if not runner_id: + return False + + descriptor = await self._get_runner_descriptor(runner_id) + if descriptor is None: + return False + + ai_config = pipeline_config.setdefault('ai', {}) + runner_configs = ai_config.setdefault('runner_config', {}) + runner_config = runner_configs.setdefault(runner_id, {}) + + if not config_schema.set_empty_llm_model_selection(descriptor, runner_config, model_uuid): + return False + + await self.ap.pipeline_service.update_pipeline(pipeline.uuid, {'config': pipeline_config}) + return True diff --git a/src/langbot/pkg/agent/runner/descriptor.py b/src/langbot/pkg/agent/runner/descriptor.py new file mode 100644 index 000000000..2397f169e --- /dev/null +++ b/src/langbot/pkg/agent/runner/descriptor.py @@ -0,0 +1,82 @@ +"""Agent runner descriptor.""" +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerCapabilities, + AgentRunnerPermissions, +) + + +class AgentRunnerDescriptor(pydantic.BaseModel): + """Descriptor for an agent runner. + + Represents the discovered metadata for a runner, including + its identity, capabilities, permissions, and configuration schema. + """ + + id: str + """Unique runner ID: plugin:author/plugin_name/runner_name""" + + source: typing.Literal['plugin'] + """Runner source type""" + + label: dict[str, str] + """Display labels keyed by locale (e.g., en_US, zh_Hans)""" + + description: dict[str, str] | None = None + """Optional description keyed by locale""" + + plugin_author: str + """Plugin author from manifest""" + + plugin_name: str + """Plugin name from manifest""" + + runner_name: str + """AgentRunner component name from manifest""" + + plugin_version: str | None = None + """Optional plugin version""" + + config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list) + """Configuration schema using DynamicForm format""" + + capabilities: AgentRunnerCapabilities = pydantic.Field( + default_factory=AgentRunnerCapabilities + ) + """Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc.""" + + permissions: AgentRunnerPermissions = pydantic.Field( + default_factory=AgentRunnerPermissions + ) + """Requested LangBot resource permissions.""" + + raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Original manifest for reference""" + + model_config = pydantic.ConfigDict( + extra='allow', + ) + + def get_plugin_id(self) -> str: + """Return plugin identifier as author/name.""" + return f'{self.plugin_author}/{self.plugin_name}' + + def supports_streaming(self) -> bool: + """Check if runner supports streaming output.""" + return self.capabilities.streaming + + def supports_tool_calling(self) -> bool: + """Check if runner supports tool calling.""" + return self.capabilities.tool_calling + + def supports_knowledge_retrieval(self) -> bool: + """Check if runner supports knowledge retrieval.""" + return self.capabilities.knowledge_retrieval + + def supports_steering(self) -> bool: + """Check if runner supports run steering/follow-up input.""" + return bool(getattr(self.capabilities, 'steering', False)) diff --git a/src/langbot/pkg/agent/runner/errors.py b/src/langbot/pkg/agent/runner/errors.py new file mode 100644 index 000000000..1755eff9f --- /dev/null +++ b/src/langbot/pkg/agent/runner/errors.py @@ -0,0 +1,37 @@ +"""Agent runner errors.""" +from __future__ import annotations + + +class AgentRunnerError(Exception): + """Base error for agent runner operations.""" + pass + + +class RunnerNotFoundError(AgentRunnerError): + """Runner not found in registry.""" + def __init__(self, runner_id: str): + self.runner_id = runner_id + super().__init__(f'Agent runner not found: {runner_id}') + + +class RunnerNotAuthorizedError(AgentRunnerError): + """Runner not authorized for this binding.""" + def __init__(self, runner_id: str, bound_plugins: list[str] | None): + self.runner_id = runner_id + self.bound_plugins = bound_plugins + super().__init__(f'Agent runner {runner_id} not authorized for bound_plugins={bound_plugins}') + + +class RunnerProtocolError(AgentRunnerError): + """Runner protocol version mismatch or invalid manifest.""" + def __init__(self, runner_id: str, message: str): + self.runner_id = runner_id + super().__init__(f'Agent runner protocol error for {runner_id}: {message}') + + +class RunnerExecutionError(AgentRunnerError): + """Runner execution failed.""" + def __init__(self, runner_id: str, message: str, retryable: bool = False): + self.runner_id = runner_id + self.retryable = retryable + super().__init__(f'Agent runner {runner_id} execution failed: {message}') diff --git a/src/langbot/pkg/agent/runner/event_log_store.py b/src/langbot/pkg/agent/runner/event_log_store.py new file mode 100644 index 000000000..eb7277146 --- /dev/null +++ b/src/langbot/pkg/agent/runner/event_log_store.py @@ -0,0 +1,315 @@ +"""EventLog store for writing and querying event records.""" +from __future__ import annotations + +import json +import datetime +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.event_log import EventLog + + +UTC = datetime.timezone.utc + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +class EventLogStore: + """Store for EventLog records. + + Handles writing events to the event log and querying them. + All methods are async and use the provided database engine. + """ + + engine: AsyncEngine + + # Hard limits + MAX_INPUT_SUMMARY_LENGTH = 1000 + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async def append_event( + self, + event_id: str | None, + event_type: str, + source: str, + bot_id: str | None = None, + workspace_id: str | None = None, + conversation_id: str | None = None, + thread_id: str | None = None, + actor_type: str | None = None, + actor_id: str | None = None, + actor_name: str | None = None, + subject_type: str | None = None, + subject_id: str | None = None, + input_summary: str | None = None, + input_json: dict[str, typing.Any] | None = None, + raw_ref: str | None = None, + run_id: str | None = None, + runner_id: str | None = None, + event_time: datetime.datetime | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Append an event to the event log. + + Args: + event_id: Unique event ID (generated if None) + event_type: Event type + source: Event source + bot_id: Bot UUID + workspace_id: Workspace ID + conversation_id: Conversation ID + thread_id: Thread ID + actor_type: Actor type + actor_id: Actor ID + actor_name: Actor display name + subject_type: Subject type + subject_id: Subject ID + input_summary: Brief input summary + input_json: Full input JSON + raw_ref: Reference to raw event payload + run_id: Run ID processing this event + runner_id: Runner ID processing this event + event_time: When the event occurred + metadata: Additional metadata + + Returns: + The event_id + """ + if event_id is None: + event_id = str(uuid.uuid4()) + + # Truncate input summary if too long + if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH: + input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..." + + async with self._session_factory() as session: + event = EventLog( + event_id=event_id, + event_type=event_type, + event_time=event_time, + source=source, + bot_id=bot_id, + workspace_id=workspace_id, + conversation_id=conversation_id, + thread_id=thread_id, + actor_type=actor_type, + actor_id=actor_id, + actor_name=actor_name, + subject_type=subject_type, + subject_id=subject_id, + input_summary=input_summary, + input_json=json.dumps(input_json) if input_json else None, + raw_ref=raw_ref, + run_id=run_id, + runner_id=runner_id, + metadata_json=json.dumps(metadata) if metadata else None, + created_at=_utc_now(), + ) + session.add(event) + await session.commit() + + return event_id + + async def get_event( + self, + event_id: str, + ) -> dict[str, typing.Any] | None: + """Get a single event by ID. + + Args: + event_id: Event ID + + Returns: + Event record as dict, or None if not found + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(EventLog).where(EventLog.event_id == event_id) + ) + row = result.scalars().first() + if row is None: + return None + return self._row_to_dict(row) + + async def page_events( + self, + conversation_id: str | None = None, + event_types: list[str] | None = None, + before_seq: int | None = None, + limit: int = 50, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> tuple[list[dict[str, typing.Any]], int | None, bool]: + """Page through event records. + + Args: + conversation_id: Filter by conversation ID + event_types: Filter by event types + before_seq: Get events before this sequence number + limit: Maximum items to return (capped at 100) + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + Tuple of (items, next_seq, has_more) + """ + limit = min(limit, 100) # Hard cap + + async with self._session_factory() as session: + query = sqlalchemy.select(EventLog) + + if conversation_id is not None: + query = query.where(EventLog.conversation_id == conversation_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + if event_types: + query = query.where(EventLog.event_type.in_(event_types)) + + if before_seq is not None: + query = query.where(EventLog.id < before_seq) + + query = query.order_by(EventLog.id.desc()).limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + + items = [self._row_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + next_seq = items[-1]['id'] if items and has_more else None + + return items, next_seq, has_more + + async def get_latest_cursor( + self, + conversation_id: str, + ) -> str | None: + """Get the latest cursor for a conversation. + + Args: + conversation_id: Conversation ID + + Returns: + Cursor string (seq number), or None if no events + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(EventLog.id) + .where(EventLog.conversation_id == conversation_id) + .order_by(EventLog.id.desc()) + .limit(1) + ) + row = result.scalars().first() + if row is None: + return None + return str(row) + + async def has_events_before( + self, + conversation_id: str, + seq: int, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> bool: + """Check if there are events before a sequence number. + + Args: + conversation_id: Conversation ID + seq: Sequence number + + Returns: + True if there are events before + """ + async with self._session_factory() as session: + query = ( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(EventLog) + .where(EventLog.conversation_id == conversation_id, EventLog.id < seq) + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + result = await session.execute(query) + count = result.scalar() + return count > 0 + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(EventLog.bot_id == bot_id) + if workspace_id is not None: + query = query.where(EventLog.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(EventLog.thread_id.is_(None)) + else: + query = query.where(EventLog.thread_id == thread_id) + return query + + async def cleanup_events_older_than( + self, + before: datetime.datetime, + ) -> int: + """Delete EventLog rows created before the supplied timestamp.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.delete(EventLog).where(EventLog.created_at < before) + ) + await session.commit() + return result.rowcount or 0 + + def _row_to_dict(self, row: EventLog) -> dict[str, typing.Any]: + """Convert an EventLog row to dict.""" + return { + 'id': row.id, + 'event_id': row.event_id, + 'event_type': row.event_type, + 'event_time': _datetime_to_epoch(row.event_time), + 'source': row.source, + 'bot_id': row.bot_id, + 'workspace_id': row.workspace_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'actor_type': row.actor_type, + 'actor_id': row.actor_id, + 'actor_name': row.actor_name, + 'subject_type': row.subject_type, + 'subject_id': row.subject_id, + 'input_summary': row.input_summary, + 'input_json': json.loads(row.input_json) if row.input_json else None, + 'raw_ref': row.raw_ref, + 'run_id': row.run_id, + 'runner_id': row.runner_id, + 'created_at': _datetime_to_epoch(row.created_at), + 'metadata': json.loads(row.metadata_json) if row.metadata_json else {}, + } diff --git a/src/langbot/pkg/agent/runner/events.py b/src/langbot/pkg/agent/runner/events.py new file mode 100644 index 000000000..53ea266e2 --- /dev/null +++ b/src/langbot/pkg/agent/runner/events.py @@ -0,0 +1,25 @@ +"""Canonical AgentRunner event names reserved for future EBA integration.""" +from __future__ import annotations + + +MESSAGE_RECEIVED = 'message.received' +"""A normal message entered the current Pipeline.""" + +MESSAGE_RECALLED = 'message.recalled' +"""A platform message was recalled or deleted.""" + +GROUP_MEMBER_JOINED = 'group.member_joined' +"""A new member joined a group/channel conversation.""" + +FRIEND_REQUEST_RECEIVED = 'friend.request_received' +"""A new friend/contact request was received.""" + + +RESERVED_EVENT_TYPES = frozenset( + { + MESSAGE_RECEIVED, + MESSAGE_RECALLED, + GROUP_MEMBER_JOINED, + FRIEND_REQUEST_RECEIVED, + } +) diff --git a/src/langbot/pkg/agent/runner/execution_context.py b/src/langbot/pkg/agent/runner/execution_context.py new file mode 100644 index 000000000..eaf781891 --- /dev/null +++ b/src/langbot/pkg/agent/runner/execution_context.py @@ -0,0 +1,319 @@ +"""Host-only Query compatibility views for AgentRunner tool execution.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import session as provider_session + +from ...utils import constants +from .host_models import AgentEventEnvelope + + +HOST_BOX_SCOPE_VARIABLE = '_host_box_scope' +AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills' +MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments' +MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled' + + +def project_mcp_resource_config( + query: pipeline_query.Query, + runner_config: dict[str, typing.Any], +) -> dict[str, typing.Any]: + """Attach standard MCP resource settings to a Host execution Query.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + variables = {} + query.variables = variables + + attachments = runner_config.get('mcp-resources', []) + variables.setdefault( + MCP_RESOURCE_ATTACHMENTS_VARIABLE, + list(attachments) if isinstance(attachments, list) else [], + ) + variables.setdefault( + MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE, + runner_config.get('mcp-resource-agent-read-enabled', True) is True, + ) + return variables + + +async def build_mcp_resource_context_addition( + ap: typing.Any, + query: pipeline_query.Query, +) -> str: + """Build model-facing MCP context without mutating the canonical input.""" + tool_mgr = getattr(ap, 'tool_mgr', None) + if tool_mgr is None: + return '' + mcp_loader = getattr(tool_mgr, '__dict__', {}).get('mcp_tool_loader') + if mcp_loader is None: + return '' + + resource_context = await mcp_loader.build_resource_context_for_query(query) + if not resource_context: + return '' + + addition = ( + '\n\nMCP resource context selected by LangBot host:\n' + f'{resource_context}\n\n' + 'Use this context as read-only reference material. If it conflicts with the user message, ' + 'ask for clarification before taking external actions.' + ) + return addition + + +def append_mcp_resource_context_to_event(event: AgentEventEnvelope, addition: str) -> None: + """Append pinned context only to the run-scoped execution input.""" + if not addition: + return + has_text = event.input.text is not None + has_structured_content = bool(event.input.contents) + if has_text: + event.input.text += addition + if has_structured_content or not has_text: + if not _append_text_to_content(event.input.contents, addition): + event.input.contents.append(provider_message.ContentElement.from_text(addition.strip())) + + +def _append_text_to_content(content: typing.Any, addition: str) -> bool: + if isinstance(content, str): + return False + if not isinstance(content, list): + return False + for content_element in content: + if getattr(content_element, 'type', None) == 'text': + content_element.text = (content_element.text or '') + addition + return True + content.append(provider_message.ContentElement.from_text(addition.strip())) + return True + + +def prepare_execution_query( + query: pipeline_query.Query, + event: AgentEventEnvelope, + authorized_skill_names: list[str], +) -> pipeline_query.Query: + """Attach Host-owned execution metadata without changing Query identity.""" + variables = prepare_box_scope(query, event, preserve_existing=True) + variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names)) + return query + + +def prepare_box_scope( + query: pipeline_query.Query, + event: AgentEventEnvelope, + *, + preserve_existing: bool = False, +) -> dict[str, typing.Any]: + """Attach the Host Box scope before any Query-side file staging.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + variables = {} + query.variables = variables + + existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE) + if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip(): + return variables + + variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query) + return variables + + +def build_execution_query( + event: AgentEventEnvelope, + authorized_skill_names: list[str], +) -> pipeline_query.Query: + """Build the minimum Query view required by Host-owned tool loaders.""" + launcher_type, launcher_id, sender_id = _resolve_session_identity(event) + message_chain = _build_message_chain(event) + message_event = platform_events.MessageEvent( + type=event.source_event_type or event.event_type, + message_chain=message_chain, + time=float(event.event_time) if event.event_time is not None else None, + ) + session = provider_session.Session( + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + ) + + contents = copy.deepcopy(event.input.contents) + user_content: str | list[provider_message.ContentElement] + if contents: + user_content = contents + else: + user_content = event.input.text or '' + + query = pipeline_query.Query( + query_id=_synthetic_query_id(event.event_id), + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=message_event, + message_chain=message_chain, + bot_uuid=event.bot_id, + pipeline_uuid=None, + pipeline_config=None, + session=session, + messages=[], + user_message=provider_message.Message(role='user', content=user_content), + variables={}, + resp_messages=[], + ) + query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event) + query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names)) + return query + + +def build_host_box_scope( + event: AgentEventEnvelope, + *, + query: pipeline_query.Query | None = None, +) -> str | None: + """Return a stable Host scope for a Query session or event.""" + target_type, target_id = _resolve_box_target(event, query) + if target_type is None or target_id is None: + return None + + capabilities = event.delivery.platform_capabilities or {} + adapter_identity = None + if query is not None: + adapter = getattr(query, 'adapter', None) + if adapter is not None: + adapter_identity = adapter.__class__.__name__ + adapter_identity = _first_present( + adapter_identity, + capabilities.get('adapter'), + capabilities.get('source'), + event.source if query is None else None, + ) + + return json.dumps( + { + 'instance_id': _nonempty(constants.instance_id), + 'workspace_id': _nonempty(event.workspace_id), + 'bot_id': _nonempty(event.bot_id), + 'platform_adapter': adapter_identity, + 'target_type': target_type, + 'target_id': target_id, + 'thread_id': _nonempty(event.thread_id), + }, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + ) + + +def _resolve_session_identity( + event: AgentEventEnvelope, +) -> tuple[provider_session.LauncherTypes, str, str]: + subject_data = event.subject.data if event.subject is not None else {} + reply_target = event.delivery.reply_target or {} + + launcher_type_value = _first_present( + reply_target.get('target_type'), + subject_data.get('launcher_type'), + event.data.get('launcher_type'), + reply_target.get('launcher_type'), + ) + if launcher_type_value == provider_session.LauncherTypes.GROUP.value: + launcher_type_value = provider_session.LauncherTypes.GROUP.value + else: + launcher_type_value = provider_session.LauncherTypes.PERSON.value + + launcher_id = _first_nonempty( + reply_target.get('target_id'), + subject_data.get('launcher_id'), + event.data.get('launcher_id'), + reply_target.get('launcher_id'), + event.conversation_id, + event.subject.subject_id if event.subject is not None else None, + event.actor.actor_id if event.actor is not None else None, + event.event_id, + ) + sender_id = _first_nonempty( + subject_data.get('sender_id'), + event.data.get('sender_id'), + event.actor.actor_id if event.actor is not None else None, + launcher_id, + ) + + return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id + + +def _resolve_box_target( + event: AgentEventEnvelope, + query: pipeline_query.Query | None, +) -> tuple[str | None, str | None]: + if query is not None: + launcher_type = getattr(query, 'launcher_type', None) + if hasattr(launcher_type, 'value'): + launcher_type = launcher_type.value + launcher_id = getattr(query, 'launcher_id', None) + normalized_type = _nonempty(launcher_type) + normalized_id = _nonempty(launcher_id) + if normalized_type is not None and normalized_id is not None: + return normalized_type, normalized_id + + reply_target = event.delivery.reply_target or {} + target_type = _first_present( + reply_target.get('target_type'), + reply_target.get('launcher_type'), + ) + target_id = _first_present( + reply_target.get('target_id'), + reply_target.get('launcher_id'), + ) + if target_type is not None and target_id is not None: + return target_type, target_id + + conversation_id = _nonempty(event.conversation_id) + if conversation_id is not None: + return 'conversation', conversation_id + + event_id = _nonempty(event.event_id) + if event_id is not None: + return 'event', event_id + return None, None + + +def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain: + text = event.input.to_text() + if not text: + return platform_message.MessageChain([]) + return platform_message.MessageChain([platform_message.Plain(text=text)]) + + +def _synthetic_query_id(event_id: str) -> int: + digest = hashlib.sha256(event_id.encode('utf-8')).hexdigest() + return int(digest[:15], 16) or 1 + + +def _first_nonempty(*values: typing.Any) -> str: + normalized = _first_present(*values) + if normalized is not None: + return normalized + raise ValueError('Agent event does not contain a usable execution identity') + + +def _first_present(*values: typing.Any) -> str | None: + for value in values: + normalized = _nonempty(value) + if normalized is not None: + return normalized + return None + + +def _nonempty(value: typing.Any) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py new file mode 100644 index 000000000..8f881878f --- /dev/null +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -0,0 +1,217 @@ +"""Agent event envelope and binding models for LangBot Host. + +These are Host-internal models, not exposed to SDK. +""" + +from __future__ import annotations + +import typing +import pydantic + +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +class AgentEventEnvelope(pydantic.BaseModel): + """Event envelope for LangBot Host event gateway. + + This is the unified input model that replaces Query-first approach. + IM / WebUI / API / EventRouter all produce this envelope. + """ + + event_id: str + """Unique event identifier.""" + + event_type: str + """Event type (message.received, message.recalled, etc.).""" + + event_time: int | None = None + """Event timestamp (epoch seconds).""" + + source: str + """Event source (platform, webui, api, scheduler, system).""" + + source_event_type: str | None = None + """Original source event type, when available.""" + + bot_id: str | None = None + """Bot UUID handling this event.""" + + workspace_id: str | None = None + """Workspace ID (for multi-tenant).""" + + conversation_id: str | None = None + """Conversation ID.""" + + thread_id: str | None = None + """Thread ID (for platforms supporting threads).""" + + actor: ActorContext | None = None + """Actor (who triggered the event).""" + + subject: SubjectContext | None = None + """Subject (what the event is about).""" + + input: AgentInput + """Event input.""" + + delivery: DeliveryContext + """Delivery context.""" + + raw_ref: RawEventRef | None = None + """Reference to raw event payload.""" + + data: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Small structured event payload. Large payloads should be referenced via raw_ref.""" + + +# Binding scope types +class BindingScope(pydantic.BaseModel): + """Scope for agent binding.""" + + scope_type: typing.Literal['agent', 'bot', 'workspace', 'global'] = 'agent' + """Scope type.""" + + scope_id: str | None = None + """Scope identifier (agent_id, bot_uuid, etc.).""" + + +class ResourcePolicy(pydantic.BaseModel): + """Resource policy for agent binding. + + Controls what resources the runner can access. + """ + + allowed_model_uuids: list[str] | None = None + """Additional model UUID grants. None means no additional model grants.""" + + allowed_tool_names: list[str] | None = None + """Additional tool name grants. None means no additional tool grants.""" + + allowed_tool_sources: dict[str, dict[str, str | None]] | None = None + """Host-resolved implementation identity for each allowed tool name.""" + + allow_all_tools: bool = False + """Whether all tools visible to the current Host scope are granted.""" + + allowed_kb_uuids: list[str] | None = None + """Additional knowledge base UUID grants. None means no additional KB grants.""" + + allowed_skill_names: list[str] | None = None + """Allowed skill names. None means all currently visible skills are allowed.""" + + allow_plugin_storage: bool = True + """Whether plugin storage is allowed.""" + + allow_workspace_storage: bool = False + """Whether workspace storage is allowed.""" + + +class StatePolicy(pydantic.BaseModel): + """State policy for agent binding. + + Controls state management behavior. + """ + + enable_state: bool = True + """Whether host-owned state is enabled.""" + + state_scopes: list[typing.Literal['conversation', 'actor', 'subject', 'runner']] = pydantic.Field( + default_factory=lambda: ['conversation', 'actor'] + ) + """Enabled state scopes.""" + + +class DeliveryPolicy(pydantic.BaseModel): + """Delivery policy for agent binding. + + Controls how results are delivered. + """ + + enable_streaming: bool = True + """Whether streaming output is enabled.""" + + enable_reply: bool = True + """Whether reply is enabled.""" + + max_message_size: int | None = None + """Maximum message size.""" + + +class AgentConfig(pydantic.BaseModel): + """Host-side Agent configuration. + + Product-level Agents are independent from Pipelines. A Pipeline entry path + can project its config into this runtime-only model without creating or + updating a persisted Agent. + """ + + agent_id: str | None = None + """Host-side Agent/config identifier.""" + + runner_id: str + """Runner ID to invoke.""" + + runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Agent/runner binding configuration.""" + + resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy) + """Resource policy for this Agent.""" + + state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy) + """State policy for this Agent.""" + + delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) + """Delivery policy for this Agent.""" + + event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received']) + """Event types this Agent handles.""" + + enabled: bool = True + """Whether this Agent can be selected by a binding resolver.""" + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Non-protocol diagnostic metadata, such as legacy config source.""" + + +class AgentBinding(pydantic.BaseModel): + """Binding configuration for mapping events to runners. + + This is a Host-internal, runtime-only model for event-to-runner binding. + Projecting Pipeline config into it is not a persistence migration. + """ + + binding_id: str + """Unique binding identifier.""" + + scope: BindingScope = pydantic.Field(default_factory=BindingScope) + """Binding scope.""" + + event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received']) + """Event types this binding handles.""" + + runner_id: str + """Runner ID to invoke.""" + + runner_config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """Current Agent/runner configuration.""" + + resource_policy: ResourcePolicy = pydantic.Field(default_factory=ResourcePolicy) + """Resource policy.""" + + state_policy: StatePolicy = pydantic.Field(default_factory=StatePolicy) + """State policy.""" + + delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) + """Delivery policy.""" + + enabled: bool = True + """Whether binding is enabled.""" + + agent_id: str | None = None + """Host-side Agent/config identifier for this binding.""" diff --git a/src/langbot/pkg/agent/runner/id.py b/src/langbot/pkg/agent/runner/id.py new file mode 100644 index 000000000..e01099041 --- /dev/null +++ b/src/langbot/pkg/agent/runner/id.py @@ -0,0 +1,91 @@ +"""Agent runner ID parsing and formatting.""" +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class RunnerIdParts: + """Parsed runner ID components.""" + source: str # 'plugin' (future: 'builtin') + plugin_author: str + plugin_name: str + runner_name: str + + def to_plugin_id(self) -> str: + """Return plugin identifier as author/name.""" + return f'{self.plugin_author}/{self.plugin_name}' + + +def parse_runner_id(runner_id: str) -> RunnerIdParts: + """Parse runner ID string into components. + + Args: + runner_id: Runner ID in format 'plugin:author/plugin_name/runner_name' + + Returns: + RunnerIdParts with parsed components + + Raises: + ValueError: If runner_id format is invalid + """ + if runner_id.startswith('plugin:'): + parts = runner_id[7:].split('/') + if len(parts) != 3: + raise ValueError( + f'Invalid plugin runner ID format: {runner_id}. ' + f'Expected: plugin:author/plugin_name/runner_name' + ) + plugin_author, plugin_name, runner_name = parts + if not plugin_author or not plugin_name or not runner_name: + raise ValueError( + f'Invalid plugin runner ID: {runner_id}. ' + f'author, plugin_name, and runner_name must be non-empty' + ) + return RunnerIdParts( + source='plugin', + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + ) + else: + # Only plugin runner IDs are valid at the protocol boundary. + raise ValueError( + f'Invalid runner ID format: {runner_id}. ' + f'Expected: plugin:author/plugin_name/runner_name' + ) + + +def format_runner_id( + source: str, + plugin_author: str, + plugin_name: str, + runner_name: str, +) -> str: + """Format runner ID from components. + + Args: + source: Runner source ('plugin') + plugin_author: Plugin author + plugin_name: Plugin name + runner_name: Runner component name + + Returns: + Runner ID string + """ + if source == 'plugin': + return f'plugin:{plugin_author}/{plugin_name}/{runner_name}' + else: + raise ValueError(f'Invalid runner source: {source}') + + +def is_plugin_runner_id(runner_id: str) -> bool: + """Check if runner ID is a plugin runner. + + Args: + runner_id: Runner ID string + + Returns: + True if runner ID starts with 'plugin:' + """ + return runner_id.startswith('plugin:') diff --git a/src/langbot/pkg/agent/runner/invoker.py b/src/langbot/pkg/agent/runner/invoker.py new file mode 100644 index 000000000..4f45747b6 --- /dev/null +++ b/src/langbot/pkg/agent/runner/invoker.py @@ -0,0 +1,131 @@ +"""Plugin-runtime invocation for AgentRunner executions.""" + +from __future__ import annotations + +import asyncio +import time +import traceback +import typing + +from langbot_plugin.entities.io.errors import ActionCallTimeoutError + +from ...core import app +from .context_builder import AgentRunContextPayload +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerExecutionError + + +class AgentRunnerInvoker: + """Invoke an AgentRunner through the plugin runtime. + + This keeps runtime transport, deadline enforcement, and transport error + mapping out of the orchestration state machine. + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def invoke( + self, + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Invoke the runner and yield raw result dictionaries.""" + if not self.ap.plugin_connector.is_enable_plugin: + raise RunnerExecutionError( + descriptor.id, + 'Plugin system is disabled', + retryable=False, + ) + + try: + gen = self.ap.plugin_connector.run_agent( + plugin_author=descriptor.plugin_author, + plugin_name=descriptor.plugin_name, + runner_name=descriptor.runner_name, + context=context, + ) + + while True: + try: + result_dict = await self._next_with_deadline(gen, descriptor, context) + except StopAsyncIteration: + break + yield result_dict + + except asyncio.TimeoutError as e: + raise RunnerExecutionError( + descriptor.id, + 'Runner timed out (code: runner.timeout)', + retryable=True, + ) from e + except ActionCallTimeoutError as e: + raise RunnerExecutionError( + descriptor.id, + f'{e} (code: runner.timeout)', + retryable=True, + ) from e + except RunnerExecutionError: + raise + except Exception as e: + self.ap.logger.error( + f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}' + ) + raise RunnerExecutionError( + descriptor.id, + str(e), + retryable=False, + ) + + async def _next_with_deadline( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> dict[str, typing.Any]: + """Read the next runner result while enforcing the run deadline.""" + remaining = self._remaining_deadline_seconds(context) + if remaining is not None and remaining <= 0: + await self._close_generator(gen, descriptor) + raise asyncio.TimeoutError + + try: + if remaining is None: + return await anext(gen) + return await asyncio.wait_for(anext(gen), timeout=remaining) + except StopAsyncIteration: + if self._is_deadline_exhausted(context): + raise asyncio.TimeoutError + raise + except asyncio.TimeoutError: + await self._close_generator(gen, descriptor) + raise + + def _remaining_deadline_seconds( + self, + context: AgentRunContextPayload, + ) -> float | None: + runtime = context.get('runtime') or {} + deadline_at = runtime.get('deadline_at') + if deadline_at is None: + return None + try: + return float(deadline_at) - time.time() + except (TypeError, ValueError): + return None + + def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool: + remaining = self._remaining_deadline_seconds(context) + return remaining is not None and remaining <= 0 + + async def _close_generator( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + ) -> None: + try: + await gen.aclose() + except Exception as e: + self.ap.logger.warning(f'Failed to close timed-out runner {descriptor.id}: {e}') diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py new file mode 100644 index 000000000..c2743ecbf --- /dev/null +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -0,0 +1,569 @@ +"""Agent run orchestrator for coordinating runner execution.""" + +from __future__ import annotations + +import time +import typing + +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + +from ...core import app +from .binding_resolver import AgentBindingResolver +from .context_builder import AgentRunContextBuilder, AgentRunContextPayload +from .descriptor import AgentRunnerDescriptor +from .execution_context import ( + append_mcp_resource_context_to_event, + build_mcp_resource_context_addition, + build_execution_query, + prepare_box_scope, + prepare_execution_query, + project_mcp_resource_config, +) +from .host_models import AgentBinding, AgentEventEnvelope +from .invoker import AgentRunnerInvoker +from .query_bridge import QueryRunBridge +from .registry import AgentRunnerRegistry +from .resource_builder import AgentResourceBuilder +from .result_normalizer import AgentResultNormalizer +from .run_journal import AgentRunJournal +from .session_registry import AgentRunSessionRegistry, get_session_registry +from .state_scope import build_state_context +from ...provider.tools.loaders import skill as skill_loader + + +ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' + + +class AgentRunOrchestrator: + """Coordinate one AgentRunner execution. + + The orchestrator keeps the run state machine readable and delegates + transport, Query bridging, and persistence side effects to narrower + collaborators. + """ + + ap: app.Application + registry: AgentRunnerRegistry + context_builder: AgentRunContextBuilder + resource_builder: AgentResourceBuilder + result_normalizer: AgentResultNormalizer + binding_resolver: AgentBindingResolver + query_bridge: QueryRunBridge + invoker: AgentRunnerInvoker + journal: AgentRunJournal + _session_registry: AgentRunSessionRegistry + + def __init__( + self, + ap: app.Application, + registry: AgentRunnerRegistry, + ): + self.ap = ap + self.registry = registry + self.context_builder = AgentRunContextBuilder(ap) + self.resource_builder = AgentResourceBuilder(ap) + self.result_normalizer = AgentResultNormalizer(ap) + self.binding_resolver = AgentBindingResolver() + self.query_bridge = QueryRunBridge(self.binding_resolver) + self.invoker = AgentRunnerInvoker(ap) + self.journal = AgentRunJournal(ap) + self._session_registry = get_session_registry() + + async def run( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + bound_plugins: list[str] | None = None, + adapter_context: dict[str, typing.Any] | None = None, + ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: + """Run an AgentRunner from an event-first envelope.""" + runner_id = binding.runner_id + descriptor = await self.registry.get(runner_id, bound_plugins) + + execution_query = adapter_context.get('_query') if adapter_context else None + if execution_query is None: + execution_query = build_execution_query(event, []) + project_mcp_resource_config(execution_query, binding.runner_config) + + execution_event = event + resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query) + if resource_addition: + execution_event = event.model_copy(deep=True) + append_mcp_resource_context_to_event(execution_event, resource_addition) + + resources = await self.resource_builder.build_resources_from_binding( + event=event, + binding=binding, + descriptor=descriptor, + ) + + context = await self.context_builder.build_context_from_event( + event=execution_event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + session_query_id = None + if adapter_context: + if execution_query is not None: + skill_loader.restore_activated_skills_from_state( + self.ap, + execution_query, + context.get('state', {}), + ) + session_query_id = adapter_context.get('query_id') + if execution_query is not None or session_query_id is not None: + context['context']['available_apis']['prompt_get'] = True + if 'params' in adapter_context: + context['adapter']['extra']['params'] = adapter_context['params'] + + authorized_skill_names = [ + str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name') + ] + prepare_execution_query(execution_query, event, authorized_skill_names) + + state_context = build_state_context(event, binding, descriptor) + run_id = context['run_id'] + available_apis = context.get('context', {}).get('available_apis') + run_authorization = { + 'runner_id': descriptor.id, + 'binding_id': binding.binding_id, + 'plugin_identity': descriptor.get_plugin_id(), + 'resources': resources, + 'available_apis': available_apis, + 'conversation_id': event.conversation_id, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'thread_id': event.thread_id, + 'state_policy': { + 'enable_state': binding.state_policy.enable_state, + 'state_scopes': list(binding.state_policy.state_scopes), + }, + 'state_context': state_context, + } + + seen_sequences: set[int] = set() + last_sequence = 0 + assistant_transcript_written = False + terminal_status: str | None = None + terminal_reason: str | None = None + terminal_usage: dict[str, typing.Any] | None = None + + try: + await self.journal.create_run( + event=event, + binding=binding, + descriptor=descriptor, + context=context, + authorization=run_authorization, + ) + await self._session_registry.register( + run_id=run_id, + runner_id=descriptor.id, + query_id=session_query_id, + plugin_identity=descriptor.get_plugin_id(), + resources=resources, + available_apis=context.get('context', {}).get('available_apis'), + conversation_id=event.conversation_id, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + thread_id=event.thread_id, + state_policy={ + 'enable_state': binding.state_policy.enable_state, + 'state_scopes': list(binding.state_policy.state_scopes), + }, + state_context=state_context, + execution_query=execution_query, + ) + + event_log_id = await self.journal.write_event_log( + event=event, + binding=binding, + run_id=run_id, + runner_id=descriptor.id, + ) + if event.event_type == 'message.received' and event.conversation_id: + await self.journal.write_user_transcript( + event=event, + event_log_id=event_log_id, + ) + + async for result_dict in self.invoker.invoke(descriptor, context): + result_dict = dict(result_dict) + sequence = result_dict.get('sequence') + if sequence is not None: + try: + sequence_int = int(sequence) + except (TypeError, ValueError): + self.ap.logger.warning(f'Runner {descriptor.id} returned invalid result sequence: {sequence}') + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + else: + if sequence_int in seen_sequences: + self.ap.logger.warning( + f'Runner {descriptor.id} returned duplicate result sequence ' + f'{sequence_int} for run {run_id}; dropping duplicate' + ) + continue + if sequence_int <= 0: + self.ap.logger.warning( + f'Runner {descriptor.id} returned non-positive result sequence ' + f'{sequence_int} for run {run_id}' + ) + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + elif last_sequence and sequence_int != last_sequence + 1: + self.ap.logger.warning( + f'Runner {descriptor.id} result sequence gap or out-of-order ' + f'for run {run_id}: previous={last_sequence}, current={sequence_int}' + ) + seen_sequences.add(sequence_int) + last_sequence = max(last_sequence, sequence_int) + else: + sequence_int = last_sequence + 1 + result_dict['sequence'] = sequence_int + seen_sequences.add(sequence_int) + last_sequence = sequence_int + + result_type = result_dict.get('type') + if result_type and not self.result_normalizer.validate_payload( + result_type, + result_dict.get('data', {}), + descriptor, + ): + continue + + await self.journal.append_run_result( + result_dict=result_dict, + run_id=run_id, + sequence=sequence_int, + ) + + if result_type == 'state.updated': + await self.journal.handle_state_updated_event( + result_dict, + event, + binding, + descriptor, + run_id=run_id, + ) + await self.result_normalizer.normalize(result_dict, descriptor) + continue + + if result_type == 'run.completed': + terminal_status = 'completed' + terminal_reason = ( + result_dict.get('data', {}).get('finish_reason') + if isinstance(result_dict.get('data'), dict) + else None + ) + usage = result_dict.get('usage') + if isinstance(usage, dict): + terminal_usage = usage + elif result_type == 'run.failed': + terminal_status = 'failed' + data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {} + terminal_reason = data.get('error') or data.get('code') + usage = result_dict.get('usage') + if isinstance(usage, dict): + terminal_usage = usage + + has_completed_message = result_type == 'message.completed' or ( + result_type == 'run.completed' + and isinstance(result_dict.get('data'), dict) + and bool(result_dict['data'].get('message')) + ) + if has_completed_message and event.conversation_id and not assistant_transcript_written: + await self.journal.write_assistant_transcript( + result_dict=result_dict, + event=event, + run_id=run_id, + runner_id=descriptor.id, + ) + assistant_transcript_written = True + + result = await self.result_normalizer.normalize(result_dict, descriptor) + if result is not None: + yield result + + run_snapshot = await self.journal.get_run(run_id) + if run_snapshot and run_snapshot.get('cancel_requested_at') is not None: + terminal_status = 'cancelled' + terminal_reason = run_snapshot.get('status_reason') or 'cancel_requested' + break + await self.journal.finalize_run( + run_id=run_id, + status=terminal_status or 'completed', + status_reason=terminal_reason, + usage=terminal_usage, + ) + except Exception as exc: + failed_usage = terminal_usage + await self.journal.finalize_run( + run_id=run_id, + status='timeout' if self._is_deadline_exhausted(context) else 'failed', + status_reason=str(exc), + usage=failed_usage, + ) + raise + finally: + session = await self._session_registry.unregister(run_id) + pending_steering = session.get('steering_queue', []) if session else [] + if pending_steering: + try: + await self.journal.write_steering_dropped_audits( + pending_steering, + run_id, + descriptor.id, + ) + except Exception as exc: + self.ap.logger.warning( + f'Failed to write dropped steering audit for run {run_id}: {exc}', + exc_info=True, + ) + + async def run_from_query( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: + """Run an AgentRunner from the current Pipeline Query entry point.""" + plan = self.query_bridge.build_plan(query) + adapter_context = dict(plan.adapter_context) + adapter_context['_query'] = query + + # Inbound files and subsequent runner tools must share one Host scope. + prepare_box_scope(query, plan.event) + + # Materialize inbound attachments into sandbox before running + await self._materialize_inbound_attachments(query, plan.event) + + async for result in self.run( + plan.event, + plan.binding, + bound_plugins=plan.bound_plugins, + adapter_context=adapter_context, + ): + yield result + + async def _materialize_inbound_attachments( + self, + query: pipeline_query.Query, + event: AgentEventEnvelope, + ) -> None: + """Persist inbound attachments into the sandbox and update event.input.attachments. + + No-op when the box service is unavailable or there are no attachments. + On success, updates each attachment in event.input.attachments with the + sandbox path so runners can tell the model where to find the files. + """ + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + return + + try: + materialized = await box_service.materialize_inbound_attachments(query) + except Exception as e: + # Never break the chat turn over attachment IO + self.ap.logger.warning(f'Inbound attachment materialization failed: {e}') + return + + if not materialized: + return + + # Build a lookup by name for matching + materialized_by_name = {att.get('name'): att for att in materialized if att.get('name')} + + # Update event.input.attachments with sandbox paths + if event.input and event.input.attachments: + for attachment in event.input.attachments: + name = attachment.name + if name and name in materialized_by_name: + mat = materialized_by_name[name] + # Update the attachment with sandbox path + attachment.path = mat.get('path') + attachment.size = mat.get('size') or attachment.size + attachment.mime_type = attachment.mime_type or mat.get('mime_type') + + # Store materialized descriptors in query variables for downstream use + query.variables['_sandbox_inbound_attachments'] = materialized + + def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None: + """Resolve runner ID for telemetry/logging without full execution.""" + return self.query_bridge.resolve_runner_id_for_telemetry(query) + + async def try_claim_steering_from_query( + self, + query: pipeline_query.Query, + ) -> bool: + """Claim a query as steering input for an active run when possible.""" + plan = self.query_bridge.build_plan(query) + event = plan.event + binding = plan.binding + + if event.event_type != 'message.received' or not event.conversation_id: + return False + + descriptor = await self.registry.get(binding.runner_id, plan.bound_plugins) + if not descriptor.supports_steering(): + return False + + target_run_id = await self._session_registry.find_steering_target( + conversation_id=event.conversation_id, + runner_id=descriptor.id, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + thread_id=event.thread_id, + ) + if target_run_id is None: + return False + + execution_event = event + resource_addition = await build_mcp_resource_context_addition(self.ap, query) + if resource_addition: + execution_event = event.model_copy(deep=True) + append_mcp_resource_context_to_event(execution_event, resource_addition) + + steering_item = self._build_steering_item(execution_event, target_run_id, descriptor.id) + if not await self._session_registry.enqueue_steering(target_run_id, steering_item): + return False + + try: + event_log_id = await self.journal.write_event_log( + event=event, + binding=binding, + run_id=target_run_id, + runner_id=descriptor.id, + metadata={ + 'steering': { + 'status': 'queued', + 'trigger_behavior': 'absorbed_into_active_run', + 'claimed_by_run_id': target_run_id, + 'claimed_runner_id': descriptor.id, + 'claimed_at': steering_item.get('claimed_at'), + }, + }, + ) + await self.journal.write_user_transcript(event, event_log_id) + except Exception as exc: + self.ap.logger.warning( + f'Failed to persist steering event {event.event_id} for run {target_run_id}: {exc}', + exc_info=True, + ) + + self.ap.logger.info(f'Claimed event {event.event_id} as steering input for run {target_run_id}') + return True + + def _build_steering_item( + self, + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> dict[str, typing.Any]: + """Build the run-scoped steering item returned by the Host pull API.""" + return { + 'claimed_run_id': run_id, + 'runner_id': runner_id, + 'claimed_at': int(time.time()), + 'event': { + 'event_id': event.event_id, + 'event_type': event.event_type, + 'event_time': event.event_time, + 'source': event.source, + 'source_event_type': event.source_event_type, + 'raw_ref': event.raw_ref.model_dump(mode='json') if event.raw_ref else None, + 'data': event.data, + }, + 'conversation': { + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + }, + 'actor': event.actor.model_dump(mode='json') if event.actor else None, + 'subject': event.subject.model_dump(mode='json') if event.subject else None, + 'input': { + 'text': event.input.text if event.input else None, + 'contents': [ + c.model_dump(mode='json') if hasattr(c, 'model_dump') else c + for c in (event.input.contents if event.input else []) + ], + 'attachments': [ + a.model_dump(mode='json') if hasattr(a, 'model_dump') else a + for a in (event.input.attachments if event.input else []) + ], + }, + } + + async def _invoke_runner( + self, + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Compatibility delegate for older tests and internal callers.""" + async for result in self.invoker.invoke(descriptor, context): + yield result + + async def _next_with_deadline( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + context: AgentRunContextPayload, + ) -> dict[str, typing.Any]: + return await self.invoker._next_with_deadline(gen, descriptor, context) + + def _remaining_deadline_seconds( + self, + context: AgentRunContextPayload, + ) -> float | None: + return self.invoker._remaining_deadline_seconds(context) + + def _is_deadline_exhausted(self, context: AgentRunContextPayload) -> bool: + return self.invoker._is_deadline_exhausted(context) + + async def _close_generator( + self, + gen: typing.AsyncGenerator[dict[str, typing.Any], None], + descriptor: AgentRunnerDescriptor, + ) -> None: + await self.invoker._close_generator(gen, descriptor) + + async def _handle_state_updated_event( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> None: + await self.journal.handle_state_updated_event(result_dict, event, binding, descriptor) + + async def _write_event_log( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + run_id: str, + runner_id: str, + ) -> str: + return await self.journal.write_event_log(event, binding, run_id, runner_id) + + async def _write_user_transcript( + self, + event: AgentEventEnvelope, + event_log_id: str, + ) -> None: + await self.journal.write_user_transcript(event, event_log_id) + + async def _write_assistant_transcript( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> None: + await self.journal.write_assistant_transcript( + result_dict=result_dict, + event=event, + run_id=run_id, + runner_id=runner_id, + ) diff --git a/src/langbot/pkg/agent/runner/persistent_state_store.py b/src/langbot/pkg/agent/runner/persistent_state_store.py new file mode 100644 index 000000000..e5c2ad567 --- /dev/null +++ b/src/langbot/pkg/agent/runner/persistent_state_store.py @@ -0,0 +1,435 @@ +"""Persistent state store for AgentRunner protocol state. + +This module provides a database-backed state store for event-first Protocol v1. +""" +from __future__ import annotations + +import typing +import json +import threading +from datetime import datetime + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy import select, delete, update +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import IntegrityError + +from .descriptor import AgentRunnerDescriptor +from .host_models import AgentEventEnvelope, AgentBinding +from .state_scope import ( + VALID_STATE_SCOPES, + build_state_scope_key, + get_binding_identity, + normalize_state_key, +) +from ...entity.persistence.agent_runner_state import AgentRunnerState + + +# Maximum value_json size (256KB) +MAX_VALUE_JSON_BYTES = 256 * 1024 + + +class PersistentStateStore: + """Database-backed state store for AgentRunner protocol state. + + IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state. + + This store provides: + 1. Persistent storage across runs via database + 2. Scope isolation by runner_id + binding_identity + scope + 3. Policy enforcement (enable_state, state_scopes) + 4. JSON value validation and size limits + + Used by: + - Event-first Protocol v1 (async methods) + - State API handlers (get/set/delete/list) + """ + + def __init__(self, db_engine: AsyncEngine): + self._db_engine = db_engine + + def _get_scope_key( + self, + scope: str, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> str | None: + """Get scope key for given scope.""" + return build_state_scope_key(scope, event, binding, descriptor) + + def _check_scope_enabled(self, scope: str, binding: AgentBinding) -> bool: + """Check if scope is enabled by binding's state_policy.""" + state_policy = binding.state_policy + if not state_policy.enable_state: + return False + return scope in state_policy.state_scopes + + def _validate_json_value( + self, + value: typing.Any, + logger: typing.Any = None, + ) -> tuple[str | None, str | None]: + """Validate and serialize value to JSON. + + Returns: + Tuple of (json_string, error_message). If error_message is not None, + json_string will be None. + """ + try: + json_str = json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError) as e: + return None, f'Value is not JSON-serializable: {e}' + + # Check size limit + json_bytes = len(json_str.encode('utf-8')) + if json_bytes > MAX_VALUE_JSON_BYTES: + return None, f'Value size {json_bytes} bytes exceeds limit {MAX_VALUE_JSON_BYTES} bytes' + + return json_str, None + + async def _upsert_state_row( + self, + conn: typing.Any, + values: dict[str, typing.Any], + ) -> None: + """Insert or update a state row by the logical scope/key identity.""" + update_values = { + 'value_json': values['value_json'], + 'updated_at': values['updated_at'], + } + constraint_columns = ['scope_key', 'state_key'] + dialect_name = self._db_engine.dialect.name + + if dialect_name == 'sqlite': + stmt = sqlite_insert(AgentRunnerState).values(**values) + await conn.execute( + stmt.on_conflict_do_update( + index_elements=constraint_columns, + set_=update_values, + ) + ) + return + + if dialect_name == 'postgresql': + stmt = postgresql_insert(AgentRunnerState).values(**values) + await conn.execute( + stmt.on_conflict_do_update( + index_elements=constraint_columns, + set_=update_values, + ) + ) + return + + try: + await conn.execute(sqlalchemy.insert(AgentRunnerState).values(**values)) + except IntegrityError: + await conn.execute( + update(AgentRunnerState) + .where(AgentRunnerState.scope_key == values['scope_key']) + .where(AgentRunnerState.state_key == values['state_key']) + .values(**update_values) + ) + + # ========== Async DB Operations ========== + + async def build_snapshot_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> dict[str, dict[str, typing.Any]]: + """Build state snapshot for all scopes from event and binding. + + Reads from database, respects state_policy. + """ + state_policy = binding.state_policy + + # If state is disabled, return all empty scopes + if not state_policy.enable_state: + return { + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + + snapshot: dict[str, dict[str, typing.Any]] = { + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + + async with self._db_engine.connect() as conn: + for scope in VALID_STATE_SCOPES: + if not self._check_scope_enabled(scope, binding): + continue + + scope_key = self._get_scope_key(scope, event, binding, descriptor) + if not scope_key: + continue + + # Query all state entries for this scope_key + result = await conn.execute( + select(AgentRunnerState.state_key, AgentRunnerState.value_json) + .where(AgentRunnerState.scope_key == scope_key) + ) + rows = result.fetchall() + + for row in rows: + key = row.state_key + value_json = row.value_json + if value_json: + try: + snapshot[scope][key] = json.loads(value_json) + except json.JSONDecodeError: + pass # Skip invalid JSON + + # Seed external.conversation_id from event.conversation_id if not set + if self._check_scope_enabled('conversation', binding) and event.conversation_id: + if 'external.conversation_id' not in snapshot['conversation']: + snapshot['conversation']['external.conversation_id'] = event.conversation_id + + return snapshot + + async def apply_update_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + scope: str, + key: str, + value: typing.Any, + logger: typing.Any = None, + ) -> tuple[bool, str | None]: + """Apply a state update from event context. + + Returns: + Tuple of (success, error_message). If success is False, error_message + contains the reason. + """ + state_policy = binding.state_policy + + # Check if state is disabled + if not state_policy.enable_state: + return False, 'State is disabled by binding policy' + + # Validate scope + if scope not in VALID_STATE_SCOPES: + return False, f'Invalid scope: {scope}' + + # Check if scope is enabled + if not self._check_scope_enabled(scope, binding): + return False, f'Scope "{scope}" not enabled by binding policy' + + # Map accepted key aliases + key = normalize_state_key(key) + + # Get scope key + scope_key = self._get_scope_key(scope, event, binding, descriptor) + if not scope_key: + return False, f'Missing identity for scope "{scope}"' + + # Validate and serialize value + value_json, error = self._validate_json_value(value, logger) + if error: + return False, error + + # Build context fields + binding_identity = get_binding_identity(binding) + + now = datetime.utcnow() + async with self._db_engine.begin() as conn: + await self._upsert_state_row( + conn, + { + 'runner_id': descriptor.id, + 'binding_identity': binding_identity, + 'scope': scope, + 'scope_key': scope_key, + 'state_key': key, + 'value_json': value_json, + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + 'subject_type': event.subject.subject_type if event.subject else None, + 'subject_id': event.subject.subject_id if event.subject else None, + 'created_at': now, + 'updated_at': now, + }, + ) + + return True, None + + async def state_get( + self, + scope_key: str, + state_key: str, + ) -> typing.Any: + """Get a single state value by scope_key and state_key. + + Used by State API handlers. + """ + state_key = normalize_state_key(state_key) + + async with self._db_engine.connect() as conn: + result = await conn.execute( + select(AgentRunnerState.value_json) + .where(AgentRunnerState.scope_key == scope_key) + .where(AgentRunnerState.state_key == state_key) + ) + row = result.first() + + if not row or not row.value_json: + return None + + try: + return json.loads(row.value_json) + except json.JSONDecodeError: + return None + + async def state_set( + self, + scope_key: str, + state_key: str, + value: typing.Any, + runner_id: str, + binding_identity: str, + scope: str, + context: dict[str, typing.Any] | None = None, + logger: typing.Any = None, + ) -> tuple[bool, str | None]: + """Set a state value. + + Used by State API handlers. + Context contains optional fields like bot_id, conversation_id, etc. + """ + state_key = normalize_state_key(state_key) + + # Validate and serialize value + value_json, error = self._validate_json_value(value, logger) + if error: + return False, error + + context = context or {} + + now = datetime.utcnow() + async with self._db_engine.begin() as conn: + await self._upsert_state_row( + conn, + { + 'runner_id': runner_id, + 'binding_identity': binding_identity, + 'scope': scope, + 'scope_key': scope_key, + 'state_key': state_key, + 'value_json': value_json, + 'bot_id': context.get('bot_id'), + 'workspace_id': context.get('workspace_id'), + 'conversation_id': context.get('conversation_id'), + 'thread_id': context.get('thread_id'), + 'actor_type': context.get('actor_type'), + 'actor_id': context.get('actor_id'), + 'subject_type': context.get('subject_type'), + 'subject_id': context.get('subject_id'), + 'created_at': now, + 'updated_at': now, + }, + ) + + return True, None + + async def state_delete( + self, + scope_key: str, + state_key: str, + ) -> bool: + """Delete a state value. + + Returns True if deleted, False if not found. + """ + state_key = normalize_state_key(state_key) + + async with self._db_engine.begin() as conn: + result = await conn.execute( + delete(AgentRunnerState) + .where(AgentRunnerState.scope_key == scope_key) + .where(AgentRunnerState.state_key == state_key) + ) + return (result.rowcount or 0) > 0 + + async def state_list( + self, + scope_key: str, + prefix: str | None = None, + limit: int = 100, + ) -> tuple[list[str], bool]: + """List state keys in a scope. + + Returns tuple of (keys, has_more). + """ + # Enforce limit cap + limit = min(limit, 100) + + async with self._db_engine.connect() as conn: + query = ( + select(AgentRunnerState.state_key) + .where(AgentRunnerState.scope_key == scope_key) + .order_by(AgentRunnerState.state_key) + .limit(limit + 1) # Fetch one extra to check has_more + ) + + if prefix: + prefix = normalize_state_key(prefix) + query = query.where( + AgentRunnerState.state_key.like(f'{prefix}%') + ) + + result = await conn.execute(query) + rows = result.fetchall() + + keys = [row.state_key for row in rows[:limit]] + has_more = len(rows) > limit + + return keys, has_more + + async def clear_all(self) -> None: + """Clear all state entries (for testing).""" + async with self._db_engine.begin() as conn: + await conn.execute(delete(AgentRunnerState)) + + +# Global singleton persistent state store +_persistent_state_store: PersistentStateStore | None = None +_persistent_state_store_lock = threading.Lock() + + +def get_persistent_state_store(db_engine: AsyncEngine | None = None) -> PersistentStateStore: + """Get the global persistent state store singleton. + + Args: + db_engine: Database engine (required on first call) + + Returns: + PersistentStateStore singleton + """ + global _persistent_state_store + with _persistent_state_store_lock: + if _persistent_state_store is None: + if db_engine is None: + raise RuntimeError("db_engine required for first call to get_persistent_state_store") + _persistent_state_store = PersistentStateStore(db_engine) + return _persistent_state_store + + +def reset_persistent_state_store() -> None: + """Reset the global persistent state store (for testing).""" + global _persistent_state_store + with _persistent_state_store_lock: + _persistent_state_store = None diff --git a/src/langbot/pkg/agent/runner/query_bridge.py b/src/langbot/pkg/agent/runner/query_bridge.py new file mode 100644 index 000000000..609985b05 --- /dev/null +++ b/src/langbot/pkg/agent/runner/query_bridge.py @@ -0,0 +1,56 @@ +"""Pipeline Query bridge for AgentRunner execution.""" + +from __future__ import annotations + +import dataclasses +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + +from .binding_resolver import AgentBindingResolver +from .config_resolver import RunnerConfigResolver +from .errors import RunnerNotFoundError +from .host_models import AgentBinding, AgentEventEnvelope +from .query_entry_adapter import QueryEntryAdapter + + +@dataclasses.dataclass(frozen=True) +class QueryRunPlan: + """Projected event-first execution request for a Query-backed run.""" + + event: AgentEventEnvelope + binding: AgentBinding + bound_plugins: list[str] | None + adapter_context: dict[str, typing.Any] + + +class QueryRunBridge: + """Project the current Pipeline Query entry point into Protocol v1 inputs.""" + + binding_resolver: AgentBindingResolver + + def __init__(self, binding_resolver: AgentBindingResolver): + self.binding_resolver = binding_resolver + + def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan: + """Build an event-first run plan from a Pipeline Query.""" + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) + if not runner_id: + raise RunnerNotFoundError('no runner configured') + + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id) + binding = self.binding_resolver.resolve_one(event, [agent_config]) + bound_plugins = query.variables.get('_pipeline_bound_plugins') + adapter_context = QueryEntryAdapter.build_adapter_context(query, binding) + + return QueryRunPlan( + event=event, + binding=binding, + bound_plugins=bound_plugins, + adapter_context=adapter_context, + ) + + def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None: + """Resolve runner ID for telemetry/logging without full execution.""" + return RunnerConfigResolver.resolve_runner_id(query.pipeline_config) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py new file mode 100644 index 000000000..b72d3949b --- /dev/null +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -0,0 +1,664 @@ +"""Query entry adapter for converting Query to event-first envelope. + +This adapter bridges the current Query entry point with the event-first +Protocol v1 architecture without exposing Query internals to runners. +""" + +from __future__ import annotations + +import hashlib +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + AgentEventContext, + ConversationContext, + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + +from .host_models import ( + AgentConfig, + AgentEventEnvelope, + StatePolicy, + DeliveryPolicy, +) +from .config_resolver import RunnerConfigResolver +from .resource_policy import ResourcePolicyProjector +from . import events as runner_events +from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY + + +class QueryEntryAdapter: + """Adapter for converting Query to event-first envelope. + + This adapter is responsible for: + - Converting Query to AgentEventEnvelope + - Projecting current Pipeline config to temporary AgentConfig + - Putting Query-only fields into adapter context + """ + + INTERNAL_PREFIX = '_' + SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey') + PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission') + EVENT_DATA_MAX_STRING_BYTES = 512 + + @classmethod + def query_to_event( + cls, + query: pipeline_query.Query, + ) -> AgentEventEnvelope: + """Convert Query to AgentEventEnvelope. + + Args: + query: Current entry query + + Returns: + AgentEventEnvelope for event-first processing + """ + # Build event context + event = cls._build_event_context(query) + + # Build conversation context + conversation = cls._build_conversation_context(query) + + # Build actor context + actor = cls._build_actor_context(query) + + # Build subject context + subject = cls._build_subject_context(query) + + # Build input + input = cls._build_input(query) + + # Build delivery context + delivery = cls._build_delivery_context(query) + + # Build raw ref + raw_ref = cls._build_raw_ref(query) + + return AgentEventEnvelope( + event_id=event.event_id or str(query.query_id), + event_type=event.event_type or runner_events.MESSAGE_RECEIVED, + event_time=event.event_time, + source='host_adapter', + source_event_type=event.source_event_type, + bot_id=query.bot_uuid, + workspace_id=None, # Not available in Query + conversation_id=conversation.conversation_id, + thread_id=conversation.thread_id, + actor=actor, + subject=subject, + input=input, + delivery=delivery, + raw_ref=raw_ref, + data=event.data, + ) + + @classmethod + def config_to_agent_config( + cls, + query: pipeline_query.Query, + runner_id: str, + ) -> AgentConfig: + """Project the current Pipeline config container into target Agent config.""" + pipeline_config = query.pipeline_config or {} + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + agent_id = getattr(query, 'pipeline_uuid', None) + + resource_policy = ResourcePolicyProjector.from_runner_config( + runner_config, + resolved_model_uuids=cls._extract_allowed_models(query), + resolved_tool_names=cls._extract_allowed_tools(query), + resolved_tool_sources=cls._extract_allowed_tool_sources(query), + resolved_kb_uuids=cls._extract_allowed_kbs(query), + resolved_skill_names=cls._extract_allowed_skills(query), + ) + + # Build state policy + state_policy = StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor', 'subject', 'runner'], + ) + + # Build delivery policy + delivery_policy = DeliveryPolicy( + enable_streaming=True, + enable_reply=True, + ) + + return AgentConfig( + agent_id=agent_id, + runner_id=runner_id, + runner_config=runner_config, + resource_policy=resource_policy, + state_policy=state_policy, + delivery_policy=delivery_policy, + event_types=[runner_events.MESSAGE_RECEIVED], + enabled=True, + metadata={'source': 'pipeline_adapter'}, + ) + + @classmethod + def build_adapter_context( + cls, + query: pipeline_query.Query, + binding: AgentBinding, + ) -> dict[str, typing.Any]: + """Build Query-derived fields for the current entry adapter.""" + return { + 'params': cls.build_params(query), + 'query_id': getattr(query, 'query_id', None), + } + + @classmethod + def build_params(cls, query: pipeline_query.Query) -> dict[str, typing.Any]: + """Build adapter params from Pipeline variables with host filtering.""" + params: dict[str, typing.Any] = {} + variables = getattr(query, 'variables', None) + if not variables: + return params + + for key, value in variables.items(): + if key.startswith(cls.INTERNAL_PREFIX): + continue + key_lower = key.lower() + if any(pattern in key_lower for pattern in cls.SENSITIVE_PATTERNS): + continue + if any(key == perm_var or key.startswith(perm_var) for perm_var in cls.PERMISSION_VARS): + continue + if cls.is_json_serializable(value): + params[key] = value + + return params + + @classmethod + def is_json_serializable(cls, value: typing.Any) -> bool: + """Return whether a value can safely cross the adapter boundary as JSON.""" + if value is None or isinstance(value, (str, int, float, bool)): + return True + if isinstance(value, (list, tuple)): + return all(cls.is_json_serializable(item) for item in value) + if isinstance(value, dict): + return all(isinstance(k, str) and cls.is_json_serializable(v) for k, v in value.items()) + return False + + # Private helper methods + + @classmethod + def _build_event_context( + cls, + query: pipeline_query.Query, + ) -> AgentEventContext: + """Build AgentEventContext from Query.""" + message_event = getattr(query, 'message_event', None) + + event_data: dict[str, typing.Any] = {} + if message_event and hasattr(message_event, 'model_dump'): + try: + raw_event_data = message_event.model_dump(mode='json') + except TypeError: + raw_event_data = message_event.model_dump() + except Exception: + raw_event_data = {} + if isinstance(raw_event_data, dict): + event_data = cls._compact_event_data(raw_event_data) + + source_event_type = None + if message_event: + source_event_type = getattr(message_event, 'type', None) + + message_chain = getattr(query, 'message_chain', None) + message_id = getattr(message_chain, 'message_id', None) + if message_id == -1: + message_id = None + + event_time = None + if message_event: + event_time = getattr(message_event, 'time', None) + if isinstance(event_time, (int, float)): + event_time = int(event_time) + + source_event_id = str(message_id or query.query_id) + return AgentEventContext( + event_id=cls._build_scoped_event_id(query, source_event_id, event_time), + event_type=runner_events.MESSAGE_RECEIVED, + event_time=event_time, + source='host_adapter', + source_event_type=source_event_type, + data=event_data, + ) + + @classmethod + def _compact_event_data( + cls, + event_data: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Keep only small scalar source-event metadata in event.data.""" + compact: dict[str, typing.Any] = {} + for key, value in event_data.items(): + if key == 'source_platform_object' or key.startswith('_'): + continue + if value is None or isinstance(value, (bool, int, float)): + compact[key] = value + continue + if isinstance(value, str): + if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + continue + return compact + + @classmethod + def _build_scoped_event_id( + cls, + query: pipeline_query.Query, + source_event_id: str, + event_time: int | None, + ) -> str: + """Build a globally unique host event id from pipeline-local ids.""" + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = getattr(launcher_type, 'value', launcher_type) if launcher_type is not None else None + scope_parts = [ + 'host_adapter', + getattr(query, 'pipeline_uuid', None), + getattr(query, 'bot_uuid', None), + launcher_type_value, + getattr(query, 'launcher_id', None), + getattr(query, 'sender_id', None), + source_event_id, + event_time, + ] + scoped = '|'.join('' if part is None else str(part) for part in scope_parts) + digest = hashlib.sha256(scoped.encode('utf-8')).hexdigest()[:32] + return f'host:{digest}' + + @classmethod + def _build_conversation_context( + cls, + query: pipeline_query.Query, + ) -> ConversationContext: + """Build ConversationContext from Query.""" + # Handle launcher_type safely + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = None + if launcher_type is not None: + launcher_type_value = getattr(launcher_type, 'value', launcher_type) + + # Handle launcher_id + launcher_id = getattr(query, 'launcher_id', None) + + # Build session_id from launcher info if available + session_id = None + if launcher_type_value and launcher_id: + session_id = f'{launcher_type_value}_{launcher_id}' + + # Handle session and conversation_id + conversation_id = None + session = getattr(query, 'session', None) + if session: + conversation = getattr(session, 'using_conversation', None) + if conversation: + conversation_id = getattr(conversation, 'uuid', None) + + if not conversation_id: + variables = getattr(query, 'variables', None) or {} + conversation_id = variables.get('conversation_id') or None + + if not conversation_id: + conversation_id = session_id + + # Handle sender_id + sender_id = getattr(query, 'sender_id', None) + if sender_id is not None: + sender_id = str(sender_id) + + # Handle bot_uuid + bot_uuid = getattr(query, 'bot_uuid', None) + + return ConversationContext( + conversation_id=str(conversation_id) if conversation_id is not None else None, + thread_id=None, + launcher_type=launcher_type_value, + launcher_id=launcher_id, + sender_id=sender_id, + bot_id=bot_uuid, + workspace_id=None, + session_id=session_id, + ) + + @classmethod + def _build_actor_context( + cls, + query: pipeline_query.Query, + ) -> ActorContext: + """Build ActorContext from Query.""" + message_event = getattr(query, 'message_event', None) + sender = getattr(message_event, 'sender', None) if message_event else None + sender_id = getattr(query, 'sender_id', None) + actor_id = getattr(sender, 'id', None) if sender else None + if actor_id is None: + actor_id = sender_id + actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None + + return ActorContext( + actor_type='user', + actor_id=str(actor_id) if actor_id is not None else None, + actor_name=actor_name, + metadata={}, + ) + + @classmethod + def _build_subject_context( + cls, + query: pipeline_query.Query, + ) -> SubjectContext: + """Build SubjectContext from Query.""" + message_chain = getattr(query, 'message_chain', None) + message_id = getattr(message_chain, 'message_id', None) if message_chain else None + if message_id == -1: + message_id = None + + query_id = getattr(query, 'query_id', None) + + # Safely get launcher_type + launcher_type = getattr(query, 'launcher_type', None) + launcher_type_value = None + if launcher_type is not None: + launcher_type_value = getattr(launcher_type, 'value', launcher_type) + + return SubjectContext( + subject_type='message', + subject_id=str(message_id or query_id or ''), + data={ + 'launcher_type': launcher_type_value, + 'launcher_id': getattr(query, 'launcher_id', None), + 'sender_id': str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None, + 'bot_uuid': getattr(query, 'bot_uuid', None), + }, + ) + + @classmethod + def _build_input( + cls, + query: pipeline_query.Query, + ) -> AgentInput: + """Build AgentInput from Query.""" + text = None + text_parts: list[str] = [] + contents: list[dict[str, typing.Any]] = [] + + user_message = getattr(query, 'user_message', None) + if user_message: + content = getattr(user_message, 'content', None) + if isinstance(content, list): + for elem in content: + elem_dict = None + if hasattr(elem, 'model_dump'): + elem_dict = elem.model_dump(mode='json') + elif isinstance(elem, dict): + elem_dict = elem + + if not isinstance(elem_dict, dict): + continue + + contents.append(elem_dict) + if elem_dict.get('type') == 'text': + elem_text = elem_dict.get('text') + if elem_text: + text_parts.append(elem_text) + elif content is not None: + text = str(content) + contents.append({'type': 'text', 'text': text}) + + if not contents: + message_chain = getattr(query, 'message_chain', None) or [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + component_text = getattr(component, 'text', '') + if component_text: + text_parts.append(component_text) + contents.append({'type': 'text', 'text': component_text}) + elif isinstance(component, platform_message.Image): + image_base64 = getattr(component, 'base64', None) + image_url = getattr(component, 'url', None) + if image_base64: + contents.append({'type': 'image_base64', 'image_base64': image_base64}) + elif image_url: + contents.append({'type': 'image_url', 'image_url': {'url': image_url}}) + + if text_parts: + text = ''.join(text_parts) + + attachments = cls._build_attachments(query, contents) + + return AgentInput( + text=text, + contents=contents, + attachments=attachments, + ) + + @classmethod + def _build_attachments( + cls, + query: pipeline_query.Query, + contents: list[dict[str, typing.Any]], + ) -> list[dict[str, typing.Any]]: + """Extract attachments from query.""" + attachments: list[dict[str, typing.Any]] = [] + seen_keys: dict[tuple[str, str, str], set[str]] = {} + + def add_attachment(attachment: dict[str, typing.Any]) -> None: + key = cls._attachment_dedupe_key(attachment) + if key is not None: + source = str(attachment.get('source') or '') + sources = seen_keys.setdefault(key, set()) + if source and sources and source not in sources: + return + if source: + sources.add(source) + attachments.append(attachment) + + for elem in contents: + elem_type = elem.get('type') + + if elem_type == 'image_url': + image_url = elem.get('image_url') or {} + add_attachment( + { + 'type': 'image', + 'source': 'url', + 'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url), + } + ) + elif elem_type == 'image_base64': + add_attachment( + { + 'type': 'image', + 'source': 'base64', + 'content': elem.get('image_base64'), + } + ) + elif elem_type == 'file_url': + add_attachment( + { + 'type': 'file', + 'source': 'url', + 'url': elem.get('file_url'), + 'name': elem.get('file_name'), + } + ) + elif elem_type == 'file_base64': + add_attachment( + { + 'type': 'file', + 'source': 'base64', + 'content': elem.get('file_base64'), + 'name': elem.get('file_name'), + } + ) + + message_chain = getattr(query, 'message_chain', None) + if message_chain: + try: + message_components = iter(message_chain) + except TypeError: + message_components = iter(()) + + for component in message_components: + if isinstance(component, platform_message.Image): + image_id = component.image_id or None + image_url = component.url or None + image_base64 = component.base64 or None + add_attachment( + { + 'type': 'image', + 'source': 'message_chain', + 'id': image_id, + 'url': image_url, + 'content': image_base64, + } + ) + elif isinstance(component, platform_message.File): + add_attachment( + { + 'type': 'file', + 'source': 'message_chain', + 'id': component.id or None, + 'name': component.name or None, + 'url': component.url or None, + 'content': component.base64 or None, + } + ) + elif isinstance(component, platform_message.Voice): + add_attachment( + { + 'type': 'voice', + 'source': 'message_chain', + 'id': component.voice_id or None, + 'url': component.url or None, + 'content': component.base64 or None, + } + ) + + return attachments + + @classmethod + def _attachment_dedupe_key( + cls, + attachment: dict[str, typing.Any], + ) -> tuple[str, str, str] | None: + """Return a stable key for the same attachment across content sources.""" + attachment_type = attachment.get('type') + if not attachment_type: + return None + for field in ('id', 'url', 'content'): + value = attachment.get(field) + if value: + if field == 'content': + value = hashlib.sha256(str(value).encode('utf-8')).hexdigest() + return str(attachment_type), field, str(value) + return None + + @classmethod + def _build_delivery_context( + cls, + query: pipeline_query.Query, + ) -> DeliveryContext: + """Build DeliveryContext from Query.""" + message_chain = getattr(query, 'message_chain', None) + return DeliveryContext( + surface='platform', + reply_target={ + 'message_id': getattr(message_chain, 'message_id', None), + }, + supports_streaming=True, + supports_edit=False, + supports_reaction=False, + platform_capabilities={}, + ) + + @classmethod + def _build_raw_ref( + cls, + query: pipeline_query.Query, + ) -> RawEventRef | None: + """Build RawEventRef from Query.""" + # For now, we don't store raw event payload + return None + + @classmethod + def _extract_allowed_models( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed model UUIDs from query.""" + model_uuids: list[str] = [] + model_uuid = getattr(query, 'use_llm_model_uuid', None) + if model_uuid: + model_uuids.append(model_uuid) + + variables = getattr(query, 'variables', None) or {} + for fallback_uuid in variables.get('_fallback_model_uuids', []) or []: + if fallback_uuid and fallback_uuid not in model_uuids: + model_uuids.append(fallback_uuid) + + return model_uuids or None + + @classmethod + def _extract_allowed_tools( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed tool names from query.""" + use_funcs = getattr(query, 'use_funcs', None) + if use_funcs is None: + return None + try: + return ResourcePolicyProjector.extract_tool_names(use_funcs) + except (TypeError, AttributeError): + return [] + + @classmethod + def _extract_allowed_tool_sources( + cls, + query: pipeline_query.Query, + ) -> dict[str, typing.Any] | None: + """Extract the Host-frozen implementation for each allowed tool.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + return None + refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY) + return refs if isinstance(refs, dict) else None + + @classmethod + def _extract_allowed_kbs( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract allowed knowledge base UUIDs from query.""" + variables = getattr(query, 'variables', None) + if not variables: + return None + if '_knowledge_base_uuids' not in variables: + return None + return ResourcePolicyProjector.normalize_names(variables.get('_knowledge_base_uuids')) + + @classmethod + def _extract_allowed_skills( + cls, + query: pipeline_query.Query, + ) -> list[str] | None: + """Extract pipeline-visible skill names from query.""" + variables = getattr(query, 'variables', None) + if not variables or '_pipeline_bound_skills' not in variables: + return None + bound_skills = variables.get('_pipeline_bound_skills') + if bound_skills is None: + return None + if not isinstance(bound_skills, list): + return [] + return [str(skill_name) for skill_name in bound_skills if skill_name] diff --git a/src/langbot/pkg/agent/runner/registry.py b/src/langbot/pkg/agent/runner/registry.py new file mode 100644 index 000000000..80364de31 --- /dev/null +++ b/src/langbot/pkg/agent/runner/registry.py @@ -0,0 +1,275 @@ +"""Agent runner registry for discovering and caching runner descriptors.""" + +from __future__ import annotations + +import typing +import asyncio + +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerManifest, +) + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .id import parse_runner_id, format_runner_id +from .errors import RunnerNotFoundError, RunnerNotAuthorizedError + + +class AgentRunnerRegistry: + """Registry for discovering and managing agent runners. + + Responsibilities: + - Discover runners from plugin runtime via LIST_AGENT_RUNNERS + - Validate runner manifests (kind, metadata, spec) + - Cache discovered runners for performance + - Filter runners by bound plugins + - Handle manifest errors gracefully (log warning, skip runner) + """ + + ap: app.Application + + _cache: dict[str, AgentRunnerDescriptor] | None + """Cached runner descriptors keyed by runner ID""" + + _cache_lock: asyncio.Lock + """Lock for cache refresh operations""" + + def __init__(self, ap: app.Application): + self.ap = ap + self._cache = None + self._cache_lock = asyncio.Lock() + + async def _discover_runners(self) -> dict[str, AgentRunnerDescriptor]: + """Discover runners from plugin runtime. + + Always discovers ALL runners (no bound_plugins filter). + The cache should contain unfiltered discovery results. + + Returns: + Dict of runner descriptors keyed by runner ID + """ + if not self.ap.plugin_connector.is_enable_plugin: + return {} + + runners: dict[str, AgentRunnerDescriptor] = {} + + try: + # Always list all runners (bound_plugins=None) + plugin_runners = await self.ap.plugin_connector.list_agent_runners(None) + + for runner_data in plugin_runners: + try: + descriptor = self._validate_and_build_descriptor(runner_data) + if descriptor is not None: + runners[descriptor.id] = descriptor + except Exception as e: + plugin_author = runner_data.get('plugin_author', 'unknown') + plugin_name = runner_data.get('plugin_name', 'unknown') + runner_name = runner_data.get('runner_name', 'unknown') + self.ap.logger.warning( + f'Invalid runner manifest for plugin:{plugin_author}/{plugin_name}/{runner_name}: {e}' + ) + continue + + except Exception as e: + self.ap.logger.warning(f'Failed to list agent runners from plugin runtime: {e}') + return {} + + return runners + + def _validate_and_build_descriptor(self, runner_data: dict[str, typing.Any]) -> AgentRunnerDescriptor | None: + """Validate runner manifest and build descriptor. + + Args: + runner_data: Raw runner data from plugin runtime with fields: + - plugin_author, plugin_name, runner_name + - manifest (typed AgentRunnerManifest) + + Returns: + AgentRunnerDescriptor if valid, None if invalid + """ + plugin_author = runner_data.get('plugin_author', '') + plugin_name = runner_data.get('plugin_name', '') + runner_name = runner_data.get('runner_name', '') + + if not plugin_author or not plugin_name or not runner_name: + return None + + manifest = runner_data.get('manifest', {}) + runner_id = format_runner_id( + source='plugin', + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + ) + + typed_manifest = AgentRunnerManifest.model_validate(manifest) + config_schema = [ + item.model_dump(mode='json') for item in typed_manifest.config_schema + ] + + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label=typed_manifest.label, + description=typed_manifest.description, + plugin_author=plugin_author, + plugin_name=plugin_name, + runner_name=runner_name, + plugin_version=runner_data.get('plugin_version'), + config_schema=config_schema, + capabilities=typed_manifest.capabilities, + permissions=typed_manifest.permissions, + raw_manifest=manifest, + ) + + async def refresh(self) -> None: + """Refresh runner cache. + + Always discovers ALL runners (no bound_plugins filter). + The cache contains unfiltered discovery results. + """ + async with self._cache_lock: + self._cache = await self._discover_runners() + + async def list_runners( + self, + bound_plugins: list[str] | None = None, + use_cache: bool = True, + ) -> list[AgentRunnerDescriptor]: + """List available runners. + + Args: + bound_plugins: Optional filter for bound plugins (applied locally) + use_cache: Use cached data if available + + Returns: + List of runner descriptors + """ + if use_cache and self._cache is not None and self._cache: + # Filter from cache. Do not treat an empty cache as final because the + # plugin runtime may still be launching installed plugins when the + # first metadata request arrives. + return self._filter_runners_by_bound_plugins(self._cache, bound_plugins) + + # Discover fresh (always full list) + runners = await self._discover_runners() + + # Update cache (full list, unfiltered) + async with self._cache_lock: + self._cache = runners + + # Filter locally + return self._filter_runners_by_bound_plugins(runners, bound_plugins) + + def _filter_runners_by_bound_plugins( + self, + runners: dict[str, AgentRunnerDescriptor], + bound_plugins: list[str] | None, + ) -> list[AgentRunnerDescriptor]: + """Filter runners by bound plugins. + + Args: + runners: Dict of runner descriptors + bound_plugins: Optional filter (None means all plugins allowed) + + Returns: + Filtered list of runner descriptors + """ + if bound_plugins is None: + # All plugins allowed + return list(runners.values()) + + allowed_plugin_ids = set(bound_plugins) + filtered = [] + for descriptor in runners.values(): + plugin_id = descriptor.get_plugin_id() + if plugin_id in allowed_plugin_ids: + filtered.append(descriptor) + + return filtered + + async def get( + self, + runner_id: str, + bound_plugins: list[str] | None = None, + ) -> AgentRunnerDescriptor: + """Get a specific runner descriptor. + + Args: + runner_id: Runner ID to lookup + bound_plugins: Optional bound plugins filter + + Returns: + AgentRunnerDescriptor + + Raises: + RunnerNotFoundError: If runner not found + RunnerNotAuthorizedError: If runner not in bound plugins + """ + # Parse and validate runner ID format + try: + parse_runner_id(runner_id) + except ValueError as e: + raise RunnerNotFoundError(runner_id) from e + + # Get from cache or discover (always full list) + if self._cache is None: + await self.refresh() + + if self._cache is None: + raise RunnerNotFoundError(runner_id) + + descriptor = self._cache.get(runner_id) + if descriptor is None: + raise RunnerNotFoundError(runner_id) + + # Check authorization + if bound_plugins is not None: + plugin_id = descriptor.get_plugin_id() + if plugin_id not in bound_plugins: + raise RunnerNotAuthorizedError(runner_id, bound_plugins) + + return descriptor + + async def get_runner_metadata_for_pipeline(self) -> list[dict[str, typing.Any]]: + """Get runner metadata for pipeline configuration UI. + + Returns runner options and their config schemas for the DynamicForm. + """ + # Get all runners (no bound plugin filter for metadata listing) + runners = await self.list_runners(bound_plugins=None) + + options = [] + stages = [] + + for descriptor in runners: + config_schema = [] + for index, config_item in enumerate(descriptor.config_schema): + item = dict(config_item) + if not item.get('id'): + item_name = item.get('name') or str(index) + item['id'] = f'{descriptor.id}.{item_name}' + config_schema.append(item) + + # Add runner option + options.append( + { + 'name': descriptor.id, + 'label': descriptor.label, + 'description': descriptor.description, + } + ) + + # Add config schema as stage if not empty + if descriptor.config_schema: + stages.append( + { + 'name': descriptor.id, + 'label': descriptor.label, + 'description': descriptor.description, + 'config': config_schema, + } + ) + + return options, stages diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py new file mode 100644 index 000000000..0d1c10a3b --- /dev/null +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -0,0 +1,386 @@ +"""Agent resource builder for constructing authorized resources.""" + +from __future__ import annotations + +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .context_builder import ( + AgentResources, + ModelResource, + ToolResource, + KnowledgeBaseResource, + SkillResource, + StorageResource, +) +from . import config_schema +from .host_models import AgentEventEnvelope, AgentBinding +from .resource_policy import ResourcePolicyProjector +from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE +from ...provider.tools.toolmgr import ToolSourceRef + + +class AgentResourceBuilder: + """Builder for constructing run-scoped AgentResources with permission filtering. + + Responsibilities: + - Apply manifest permissions intersected with binding resource policy + - Build models list from authorized models + - Build tools list from bound plugins/MCP servers + - Build knowledge_bases list from config + - Build storage access summary + + Note: This only builds the resource declaration. The actual proxy actions + in handler.py must still validate against ctx.resources at runtime. + + Resource field names match the plugin SDK payload: + - ModelResource: model_id, model_type, provider + - ToolResource: tool_name, tool_type, description + - KnowledgeBaseResource: kb_id, kb_name, kb_type + - SkillResource: skill_name, display_name, description + - StorageResource: plugin_storage, workspace_storage + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def build_resources_from_binding( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + ) -> AgentResources: + """Build AgentResources from event and binding. + + This is the main entry point for Protocol v1. + + Args: + event: Event envelope + binding: Agent binding with resource policy + descriptor: Runner descriptor with capabilities, permissions, and config schema + + Returns: + AgentResources dict with filtered resource lists + """ + resource_policy = binding.resource_policy + runner_config = binding.runner_config + manifest_perms = descriptor.permissions + + # Build each resource category + models = await self._build_models_from_binding(manifest_perms, resource_policy, descriptor, runner_config) + tools = await self._build_tools_from_binding(manifest_perms, resource_policy, descriptor, runner_config) + knowledge_bases = await self._build_knowledge_bases_from_binding( + manifest_perms, resource_policy, descriptor, runner_config + ) + skills = self._build_skills_from_binding(resource_policy, descriptor) + storage = self._build_storage_from_binding(manifest_perms, binding) + + return { + 'models': models, + 'tools': tools, + 'knowledge_bases': knowledge_bases, + 'skills': skills, + 'storage': storage, + 'platform_capabilities': {}, # Reserved for EBA + } + + async def _build_models_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + ) -> list[ModelResource]: + """Build models list from binding.""" + models: list[ModelResource] = [] + seen_model_ids: set[str] = set() + + model_perms = set(manifest_perms.models) + include_llm = bool({'invoke', 'stream', 'count_tokens'} & model_perms) + include_rerank = 'rerank' in model_perms + llm_operations = [operation for operation in ('invoke', 'stream', 'count_tokens') if operation in model_perms] + if not include_llm and not include_rerank: + return models + + # Get additional model UUID grants from resource policy. + allowed_uuids = resource_policy.allowed_model_uuids + + # Add model resources from Agent/runner config schema + await self._append_config_declared_model_resources( + models=models, + seen_model_ids=seen_model_ids, + descriptor=descriptor, + runner_config=runner_config, + include_llm=include_llm, + include_rerank=include_rerank, + llm_operations=llm_operations, + ) + + # Add explicitly allowed models + if allowed_uuids and include_llm: + for model_uuid in allowed_uuids: + await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations) + + return models + + async def _build_tools_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + ) -> list[ToolResource]: + """Build tools list from binding.""" + tools: list[ToolResource] = [] + tool_perms = set(manifest_perms.tools) + if not ({'detail', 'call'} & tool_perms): + return tools + + if not config_schema.uses_host_tools(descriptor): + return tools + + allowed_names = resource_policy.allowed_tool_names + allowed_sources = resource_policy.allowed_tool_sources + if resource_policy.allow_all_tools or allowed_sources is None: + get_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None) + if get_catalog is None: + return tools + try: + catalog = await get_catalog( + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to resolve visible Host tools: {e}') + return tools + + if not resource_policy.allow_all_tools: + selected_names = set(allowed_names or []) + catalog = [item for item in catalog if item.get('name') in selected_names] + allowed_names = ResourcePolicyProjector.extract_tool_names(catalog) + allowed_sources = { + item['name']: ref for item in catalog if (ref := self._catalog_source_ref(item)) is not None + } + + if runner_config.get('mcp-resource-agent-read-enabled', True) is not True: + denied_resource_tools = {MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE} + allowed_names = [ + name + for name in (allowed_names or []) + if not ( + name in denied_resource_tools + and allowed_sources + and (source_ref := allowed_sources.get(name)) is not None + and source_ref['source'] == 'mcp' + and source_ref.get('source_id') is None + ) + ] + + tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms] + + # Prefill full tool schema (best-effort) so runners can build LLM tool + # definitions without a per-tool get_tool_detail round-trip. Degrades to + # None when no tool manager is available. + get_tool_schema = getattr(getattr(self.ap, 'tool_mgr', None), 'get_tool_schema', None) + if allowed_names: + for tool_name in allowed_names: + source_ref = allowed_sources.get(tool_name) if allowed_sources else None + if source_ref is None: + self.ap.logger.warning(f'Tool {tool_name} is not authorized because its Host source is unresolved') + continue + if get_tool_schema is not None: + description, parameters = await get_tool_schema(tool_name, source_ref=source_ref) + else: + description, parameters = None, None + tools.append( + { + 'tool_name': tool_name, + 'tool_type': source_ref['source'], + 'description': description, + 'operations': tool_operations, + 'parameters': parameters, + 'source': source_ref['source'], + 'source_id': source_ref.get('source_id'), + } + ) + + return tools + + @staticmethod + def _catalog_source_ref(item: dict[str, typing.Any]) -> ToolSourceRef | None: + source = item.get('source') + if not isinstance(source, str) or not source: + return None + source_id = item.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + + async def _build_knowledge_bases_from_binding( + self, + manifest_perms: typing.Any, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + ) -> list[KnowledgeBaseResource]: + """Build knowledge bases list from binding.""" + kb_resources: list[KnowledgeBaseResource] = [] + kb_perms = set(manifest_perms.knowledge_bases) + if not ({'list', 'retrieve'} & kb_perms): + return kb_resources + kb_operations = [operation for operation in ('list', 'retrieve') if operation in kb_perms] + + if not config_schema.uses_host_knowledge_bases(descriptor): + return kb_resources + + # Get KB UUID grants from schema-defined config fields. + kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config) + + # Also include resource policy grants. + allowed_uuids = resource_policy.allowed_kb_uuids + if allowed_uuids: + kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids])) + + for kb_uuid in kb_uuids: + try: + kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) + if kb: + kb_resources.append( + { + 'kb_id': kb_uuid, + 'kb_name': kb.get_name(), + 'kb_type': kb.knowledge_base_entity.kb_type + if hasattr(kb.knowledge_base_entity, 'kb_type') + else None, + 'operations': kb_operations, + } + ) + except Exception as e: + self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}') + + return kb_resources + + def _build_skills_from_binding( + self, + resource_policy: typing.Any, + descriptor: AgentRunnerDescriptor, + ) -> list[SkillResource]: + """Build pipeline-visible skill resource facts. + + Skills are exposed as authorized tools (activate / register_skill / native + exec), so skill facts are surfaced to every run that has a skill manager, + not gated by the ``skill_authoring`` capability. The capability is now a + semantic declaration only. + """ + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return [] + + loaded_skills = getattr(skill_mgr, 'skills', {}) or {} + allowed_names = resource_policy.allowed_skill_names + if allowed_names is None: + names = sorted(loaded_skills.keys()) + else: + names = sorted(name for name in allowed_names if name in loaded_skills) + + skills: list[SkillResource] = [] + for skill_name in names: + skill_data = loaded_skills.get(skill_name) or {} + skills.append( + { + 'skill_name': skill_name, + 'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name, + 'description': skill_data.get('description') or None, + } + ) + return skills + + def _build_storage_from_binding( + self, + manifest_perms: typing.Any, + binding: AgentBinding, + ) -> StorageResource: + """Build storage access summary from manifest and binding policy.""" + resource_policy = binding.resource_policy + storage_perms = set(manifest_perms.storage) + + return { + 'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage, + 'workspace_storage': 'workspace' in storage_perms and resource_policy.allow_workspace_storage, + } + + async def _append_config_declared_model_resources( + self, + models: list[ModelResource], + seen_model_ids: set[str], + descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], + include_llm: bool, + include_rerank: bool, + llm_operations: list[str], + ) -> None: + """Authorize model-like values selected through DynamicForm fields.""" + for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config): + if model_type == 'llm' and include_llm: + await self._append_llm_model_resource(models, seen_model_ids, model_uuid, llm_operations) + elif model_type == 'rerank' and include_rerank: + await self._append_rerank_model_resource(models, seen_model_ids, model_uuid) + + async def _append_llm_model_resource( + self, + models: list[ModelResource], + seen_model_ids: set[str], + model_uuid: str | None, + operations: list[str], + ) -> None: + """Append an LLM model resource if it exists and has not been added.""" + if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids: + return + + try: + model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) + if model and model.model_entity: + models.append( + { + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', None), + 'provider': getattr(model.provider_entity, 'name', None) + if hasattr(model, 'provider_entity') + else None, + 'operations': operations, + } + ) + seen_model_ids.add(model_uuid) + except Exception as e: + self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}') + + async def _append_rerank_model_resource( + self, + models: list[ModelResource], + seen_model_ids: set[str], + model_uuid: str | None, + ) -> None: + """Append a rerank model resource if it exists and has not been added.""" + if not model_uuid or model_uuid == '__none__' or model_uuid in seen_model_ids: + return + + try: + model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid) + if model and model.model_entity: + models.append( + { + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank', + 'provider': getattr(model.provider_entity, 'name', None) + if hasattr(model, 'provider_entity') + else None, + 'operations': ['rerank'], + } + ) + seen_model_ids.add(model_uuid) + except Exception as e: + self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}') diff --git a/src/langbot/pkg/agent/runner/resource_policy.py b/src/langbot/pkg/agent/runner/resource_policy.py new file mode 100644 index 000000000..92d934566 --- /dev/null +++ b/src/langbot/pkg/agent/runner/resource_policy.py @@ -0,0 +1,146 @@ +"""Project AgentRunner configuration into Host resource policy.""" + +from __future__ import annotations + +import collections.abc +import typing + +from .host_models import ResourcePolicy + + +class ResourcePolicyProjector: + """Build one generic resource policy for Pipeline and Agent bindings.""" + + @classmethod + def from_runner_config( + cls, + runner_config: dict[str, typing.Any] | None, + *, + resolved_model_uuids: collections.abc.Iterable[typing.Any] | None = None, + resolved_tool_names: collections.abc.Iterable[typing.Any] | None = None, + resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None, + resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None, + resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None, + ) -> ResourcePolicy: + """Project standard resource fields without depending on a runner ID. + + Resolved values are supplied by entry paths that already narrowed the + available resources, such as Pipeline preprocessing. Independent Agent + bindings omit them so the Host can resolve an explicit all-tools grant + against the live tool catalog when the run starts. + """ + config = runner_config if isinstance(runner_config, dict) else {} + selected_tool_names = cls.normalize_names(config.get('tools')) + enable_all_tools = config.get('enable-all-tools', True) is True + + if resolved_tool_names is not None: + available_tool_names = cls.normalize_names(resolved_tool_names) + if enable_all_tools: + allowed_tool_names = available_tool_names + else: + selected = set(selected_tool_names) + allowed_tool_names = [name for name in available_tool_names if name in selected] + allow_all_tools = False + elif enable_all_tools: + allowed_tool_names = None + allow_all_tools = True + else: + allowed_tool_names = selected_tool_names + allow_all_tools = False + + allowed_tool_sources = cls.normalize_tool_sources(resolved_tool_sources) + if allowed_tool_sources is not None: + allowed = set(allowed_tool_names or []) + allowed_tool_sources = {name: ref for name, ref in allowed_tool_sources.items() if name in allowed} + + if resolved_kb_uuids is not None: + allowed_kb_uuids = cls.normalize_names(resolved_kb_uuids) + elif 'knowledge-bases' in config: + allowed_kb_uuids = cls.normalize_names(config.get('knowledge-bases')) + else: + allowed_kb_uuids = None + + return ResourcePolicy( + allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids), + allowed_tool_names=allowed_tool_names, + allowed_tool_sources=allowed_tool_sources, + allow_all_tools=allow_all_tools, + allowed_kb_uuids=allowed_kb_uuids, + allowed_skill_names=cls.normalize_optional_names(resolved_skill_names), + ) + + @staticmethod + def normalize_names(values: typing.Any) -> list[str]: + """Return unique, non-empty string identifiers in source order.""" + if not isinstance(values, collections.abc.Iterable) or isinstance(values, (str, bytes, dict)): + return [] + + names: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or not value or value in seen: + continue + seen.add(value) + names.append(value) + return names + + @classmethod + def normalize_optional_names( + cls, + values: collections.abc.Iterable[typing.Any] | None, + ) -> list[str] | None: + if values is None: + return None + return cls.normalize_names(values) + + @classmethod + def extract_tool_names(cls, tools: collections.abc.Iterable[typing.Any] | None) -> list[str]: + """Extract tool identifiers from dictionary and SDK object shapes.""" + if tools is None: + return [] + + names: list[str] = [] + for tool in tools: + name = tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None) + if isinstance(name, str): + names.append(name) + return cls.normalize_names(names) + + @staticmethod + def normalize_tool_sources( + values: typing.Mapping[str, typing.Any] | None, + ) -> dict[str, dict[str, str | None]] | None: + if values is None: + return None + + normalized: dict[str, dict[str, str | None]] = {} + for name, value in values.items(): + if not isinstance(name, str) or not name or not isinstance(value, collections.abc.Mapping): + continue + source = value.get('source') + if not isinstance(source, str) or not source: + continue + source_id = value.get('source_id') + normalized[name] = { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + return normalized + + @classmethod + def filter_tools( + cls, + tools: collections.abc.Iterable[typing.Any], + policy: ResourcePolicy, + ) -> list[typing.Any]: + """Apply a projected policy to an already scoped tool collection.""" + tool_list = list(tools) + if policy.allow_all_tools: + return tool_list + + allowed_names = set(policy.allowed_tool_names or []) + return [ + tool + for tool in tool_list + if (tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None)) in allowed_names + ] diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py new file mode 100644 index 000000000..3f4d34d92 --- /dev/null +++ b/src/langbot/pkg/agent/runner/result_normalizer.py @@ -0,0 +1,234 @@ +"""Agent result normalizer for converting AgentRunResult to Pipeline messages.""" +from __future__ import annotations + +import typing + +import pydantic +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + ActionRequestedPayload, + MessageCompletedPayload, + MessageDeltaPayload, + RunCompletedPayload, + RunFailedPayload, + StateUpdatedPayload, + ToolCallCompletedPayload, + ToolCallStartedPayload, +) +from langbot_plugin.api.entities.builtin.provider import message as provider_message + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerExecutionError, RunnerProtocolError + + +# Maximum size for a single result payload (prevent memory exhaustion) +MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB + +STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = { + 'message.delta': MessageDeltaPayload, + 'message.completed': MessageCompletedPayload, + 'tool.call.started': ToolCallStartedPayload, + 'tool.call.completed': ToolCallCompletedPayload, + 'state.updated': StateUpdatedPayload, + 'action.requested': ActionRequestedPayload, + 'run.completed': RunCompletedPayload, + 'run.failed': RunFailedPayload, +} + + +class AgentResultNormalizer: + """Normalizer for converting AgentRunResult to Pipeline messages. + + Responsibilities: + - Accept only supported result types (message.delta, message.completed, etc.) + - Map message.delta -> MessageChunk + - Map message.completed -> Message + - Map run.completed (with message) -> Message + - Handle run.failed as controlled error + - Ignore unknown types with warning + - Validate result size + - Validate message schema + + Accepted result types: + - message.delta + - message.completed + - tool.call.started + - tool.call.completed + - state.updated + - run.completed + - run.failed + - action.requested (log only, don't execute) + """ + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def normalize( + self, + result_dict: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.Message | provider_message.MessageChunk | None: + """Normalize AgentRunResult to Message or MessageChunk. + + Args: + result_dict: Raw result dict from plugin runtime + descriptor: Runner descriptor for error context + + Returns: + Message, MessageChunk, or None (for non-message events) + + Raises: + RunnerExecutionError: On run.failed + RunnerProtocolError: On invalid result format + """ + # Validate result type + result_type = result_dict.get('type') + if not result_type: + raise RunnerProtocolError(descriptor.id, 'Missing result type') + + # Validate result size + try: + import json + result_json = json.dumps(result_dict) + if len(result_json) > MAX_RESULT_SIZE_BYTES: + self.ap.logger.warning( + f'Runner {descriptor.id} result too large ({len(result_json)} bytes), truncating' + ) + # Truncate content if possible + data = result_dict.get('data', {}) + if 'chunk' in data or 'message' in data: + content = data.get('chunk', {}).get('content', '') or data.get('message', {}).get('content', '') + if isinstance(content, str) and len(content) > 10000: + # Keep reasonable length + data['chunk'] = {'role': 'assistant', 'content': content[:10000] + '...[truncated]'} + except Exception as e: + self.ap.logger.warning(f'Failed to validate runner {descriptor.id} result size: {e}') + + # Handle each result type + data = result_dict.get('data', {}) + + if not self.validate_payload(result_type, data, descriptor): + return None + + if result_type == 'message.delta': + return self._normalize_message_delta(data, descriptor) + + elif result_type == 'message.completed': + return self._normalize_message_completed(data, descriptor) + + elif result_type == 'tool.call.started': + # Log only, don't yield to pipeline + self.ap.logger.debug( + f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}' + ) + return None + + elif result_type == 'tool.call.completed': + # Log only, don't yield to pipeline + self.ap.logger.debug( + f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}' + ) + return None + + elif result_type == 'state.updated': + # Log for telemetry, don't yield to pipeline + # Orchestrator already handles the actual PersistentStateStore update. + scope = data.get('scope', 'unknown') + key = data.get('key', 'unknown') + value_repr = repr(data.get('value', '...'))[:100] # Truncate for log + self.ap.logger.debug( + f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}' + ) + return None + + elif result_type == 'run.completed': + # May include final message + if 'message' in data: + return self._normalize_message_completed(data, descriptor) + # If no message, it's just completion signal + return None + + elif result_type == 'run.failed': + error_msg = data.get('error', 'Unknown error') + error_code = data.get('code', 'unknown') + retryable = data.get('retryable', False) + raise RunnerExecutionError( + descriptor.id, + f'{error_msg} (code: {error_code})', + retryable=retryable, + ) + + elif result_type == 'action.requested': + # Reserved for EBA - log only, don't execute + self.ap.logger.info( + f'Runner {descriptor.id} requested action (not executed in current phase): ' + f'{data.get("action", "unknown")}' + ) + return None + + else: + # Unknown type - warn and ignore. + self.ap.logger.warning( + f'Runner {descriptor.id} returned unknown result type: {result_type}. ' + f'Expected supported types (message.delta, message.completed, run.completed, run.failed, etc.)' + ) + return None + + def validate_payload( + self, + result_type: str, + data: typing.Any, + descriptor: AgentRunnerDescriptor, + ) -> bool: + """Validate typed payloads that affect Host state or delivery. + + Tool-call telemetry stays intentionally loose so older runners can keep + emitting diagnostic fields. Unknown result types are handled by the + caller and are not validated here. + """ + payload_model = STRICT_RESULT_PAYLOADS.get(result_type) + if payload_model is None: + return True + + try: + payload_model.model_validate(data) + return True + except Exception as e: + self.ap.logger.warning( + f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}' + ) + return False + + def _normalize_message_delta( + self, + data: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.MessageChunk: + """Normalize message.delta to MessageChunk.""" + chunk_data = data.get('chunk', {}) + if not chunk_data: + raise RunnerProtocolError(descriptor.id, 'message.delta missing chunk data') + + try: + chunk = provider_message.MessageChunk.model_validate(chunk_data) + return chunk + except Exception as e: + raise RunnerProtocolError(descriptor.id, f'Invalid chunk schema: {e}') + + def _normalize_message_completed( + self, + data: dict[str, typing.Any], + descriptor: AgentRunnerDescriptor, + ) -> provider_message.Message: + """Normalize message.completed to Message.""" + message_data = data.get('message', {}) + if not message_data: + raise RunnerProtocolError(descriptor.id, 'message.completed missing message data') + + try: + msg = provider_message.Message.model_validate(message_data) + return msg + except Exception as e: + raise RunnerProtocolError(descriptor.id, f'Invalid message schema: {e}') diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py new file mode 100644 index 000000000..2c478c84e --- /dev/null +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -0,0 +1,404 @@ +"""Run-side effects for AgentRunner executions.""" + +from __future__ import annotations + +import typing + +from ...core import app +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerProtocolError +from .host_models import AgentBinding, AgentEventEnvelope +from .persistent_state_store import PersistentStateStore, get_persistent_state_store +from .run_ledger_store import RunLedgerStore + + +class AgentRunJournal: + """Persist run events, transcript records, and state updates.""" + + ap: app.Application + + _persistent_state_store: PersistentStateStore | None + _run_ledger_store: RunLedgerStore | None + + def __init__(self, ap: app.Application): + self.ap = ap + self._persistent_state_store = None + self._run_ledger_store = None + + def _get_run_ledger_store(self) -> RunLedgerStore: + if self._run_ledger_store is None: + self._run_ledger_store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine()) + return self._run_ledger_store + + @staticmethod + def _to_plain_dict(value: typing.Any) -> dict[str, typing.Any]: + if hasattr(value, 'model_dump'): + value = value.model_dump(mode='json') + if isinstance(value, dict): + return dict(value) + return {} + + @classmethod + def _sanitize_content_item(cls, value: typing.Any) -> typing.Any: + item = cls._to_plain_dict(value) + if not item: + return value + item_type = item.get('type') + if item_type == 'image_base64' and item.get('image_base64'): + item['image_base64'] = None + item['content_redacted'] = True + elif item_type == 'file_base64' and item.get('file_base64'): + item['file_base64'] = None + item['content_redacted'] = True + return item + + @classmethod + def _sanitize_attachment_ref(cls, value: typing.Any) -> dict[str, typing.Any]: + item = cls._to_plain_dict(value) + if item.get('content'): + item['content'] = None + item['content_redacted'] = True + return item + + @classmethod + def _sanitize_contents(cls, contents: typing.Iterable[typing.Any]) -> list[typing.Any]: + return [cls._sanitize_content_item(content) for content in contents] + + @classmethod + def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]: + return [cls._sanitize_attachment_ref(attachment) for attachment in attachments] + + async def create_run( + self, + *, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + context: dict[str, typing.Any], + authorization: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Create the Host-owned run ledger record.""" + runtime = context.get('runtime') if isinstance(context, dict) else {} + return await self._get_run_ledger_store().create_run( + run_id=context['run_id'], + event_id=event.event_id, + binding_id=binding.binding_id, + runner_id=descriptor.id, + conversation_id=event.conversation_id, + thread_id=event.thread_id, + workspace_id=event.workspace_id, + bot_id=event.bot_id, + deadline_at=runtime.get('deadline_at') if isinstance(runtime, dict) else None, + authorization=authorization, + metadata={ + 'event_type': event.event_type, + 'source': event.source, + }, + ) + + async def append_run_result( + self, + *, + result_dict: dict[str, typing.Any], + run_id: str, + sequence: int, + source: str = 'runner', + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Persist one AgentRunResult in the run ledger.""" + usage = result_dict.get('usage') + if hasattr(usage, 'model_dump'): + usage = usage.model_dump(mode='json') + return await self._get_run_ledger_store().append_event( + run_id=run_id, + sequence=sequence, + event_type=str(result_dict.get('type') or 'unknown'), + data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}, + usage=usage if isinstance(usage, dict) else None, + source=source, + metadata=metadata, + ) + + async def finalize_run( + self, + *, + run_id: str, + status: str, + status_reason: str | None = None, + usage: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Finalize or update the Host-owned run ledger record.""" + return await self._get_run_ledger_store().finalize_run( + run_id=run_id, + status=status, + status_reason=status_reason, + usage=usage, + metadata=metadata, + ) + + async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: + """Return the persisted run ledger record.""" + return await self._get_run_ledger_store().get_run(run_id) + + async def handle_state_updated_event( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + run_id: str | None = None, + ) -> None: + """Handle state.updated result in event-first mode.""" + data = result_dict.get('data', {}) + + result_run_id = result_dict.get('run_id') + if run_id and result_run_id and result_run_id != run_id: + raise RunnerProtocolError( + descriptor.id, + f'state.updated run_id mismatch: expected {run_id}, got {result_run_id}', + ) + + scope = data.get('scope') + if not scope: + raise RunnerProtocolError( + descriptor.id, + 'state.updated missing required field: scope', + ) + + key = data.get('key') + value = data.get('value') + + if not key: + raise RunnerProtocolError( + descriptor.id, + 'state.updated missing required field: key', + ) + + if self._persistent_state_store is None: + self._persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine()) + + success, error = await self._persistent_state_store.apply_update_from_event( + event=event, + binding=binding, + descriptor=descriptor, + scope=scope, + key=key, + value=value, + logger=self.ap.logger, + ) + + if success: + self.ap.logger.debug(f'Runner {descriptor.id} state.updated (event mode): scope={scope}, key={key}') + elif error: + self.ap.logger.warning(f'Runner {descriptor.id} state.updated rejected: {error}') + + async def write_event_log( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + run_id: str, + runner_id: str, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Write incoming event to EventLog.""" + import datetime + + from .event_log_store import EventLogStore + + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + + input_summary = None + input_json = None + if event.input: + if event.input.text: + input_summary = event.input.text[:1000] + input_json = { + 'text': event.input.text, + 'contents': self._sanitize_contents(event.input.contents), + 'attachments': self._sanitize_attachments(event.input.attachments), + } + + return await store.append_event( + event_id=event.event_id, + event_type=event.event_type, + source=event.source, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + conversation_id=event.conversation_id, + thread_id=event.thread_id, + actor_type=event.actor.actor_type if event.actor else None, + actor_id=event.actor.actor_id if event.actor else None, + actor_name=event.actor.actor_name if event.actor else None, + subject_type=event.subject.subject_type if event.subject else None, + subject_id=event.subject.subject_id if event.subject else None, + input_summary=input_summary, + input_json=input_json, + run_id=run_id, + runner_id=runner_id, + event_time=( + datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None + ), + metadata=metadata, + ) + + async def write_user_transcript( + self, + event: AgentEventEnvelope, + event_log_id: str, + ) -> None: + """Write user message to Transcript.""" + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + content = event.input.text if event.input else None + content_json = None + if event.input: + content_json = { + 'role': 'user', + 'content': self._sanitize_contents(event.input.contents) if event.input.contents else [], + } + + attachment_refs = [] + if event.input and event.input.attachments: + for a in event.input.attachments: + attachment_refs.append(self._sanitize_attachment_ref(a)) + + await store.append_transcript( + transcript_id=None, + event_id=event_log_id, + conversation_id=event.conversation_id, + role='user', + bot_id=event.bot_id, + workspace_id=event.workspace_id, + content=content, + content_json=content_json, + attachment_refs=attachment_refs if attachment_refs else None, + thread_id=event.thread_id, + item_type='message', + metadata={ + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + }, + ) + + async def write_steering_dropped_audits( + self, + items: list[dict[str, typing.Any]], + run_id: str, + runner_id: str, + *, + reason: str = 'run_ended', + ) -> None: + """Write terminal audit events for steering items left unconsumed.""" + if not items: + return + + import datetime + import uuid + + from .event_log_store import EventLogStore + + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + + for item in items: + event = item.get('event') if isinstance(item.get('event'), dict) else {} + conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {} + actor = item.get('actor') if isinstance(item.get('actor'), dict) else {} + subject = item.get('subject') if isinstance(item.get('subject'), dict) else {} + + event_time = None + raw_event_time = event.get('event_time') + if raw_event_time: + try: + event_time = datetime.datetime.fromtimestamp( + raw_event_time, + datetime.timezone.utc, + ) + except (TypeError, ValueError, OSError): + event_time = None + + await store.append_event( + event_id=str(uuid.uuid4()), + event_type='steering.dropped', + source='host', + bot_id=conversation.get('bot_id'), + workspace_id=conversation.get('workspace_id'), + conversation_id=conversation.get('conversation_id'), + thread_id=conversation.get('thread_id'), + actor_type=actor.get('actor_type'), + actor_id=actor.get('actor_id'), + actor_name=actor.get('actor_name'), + subject_type=subject.get('subject_type'), + subject_id=subject.get('subject_id'), + input_summary='Unconsumed steering input dropped', + run_id=run_id, + runner_id=runner_id, + event_time=event_time, + metadata={ + 'steering': { + 'status': 'dropped', + 'reason': reason, + 'original_event_id': event.get('event_id'), + 'claimed_run_id': item.get('claimed_run_id'), + 'claimed_runner_id': item.get('runner_id'), + 'claimed_at': item.get('claimed_at'), + }, + }, + ) + + async def write_assistant_transcript( + self, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + run_id: str, + runner_id: str, + ) -> None: + """Write assistant message to Transcript.""" + import uuid + + from .transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + + data = result_dict.get('data', {}) + message = data.get('message', {}) + + content = None + content_json = None + + if isinstance(message.get('content'), str): + content = message['content'] + content_json = message + elif isinstance(message.get('content'), list): + text_parts = [] + for c in message['content']: + if isinstance(c, dict) and c.get('type') == 'text': + text_parts.append(c.get('text', '')) + content = ' '.join(text_parts) if text_parts else None + content_json = { + **message, + 'content': self._sanitize_contents(message['content']), + } + + assistant_event_id = str(uuid.uuid4()) + + await store.append_transcript( + transcript_id=str(uuid.uuid4()), + event_id=assistant_event_id, + conversation_id=event.conversation_id, + role='assistant', + bot_id=event.bot_id, + workspace_id=event.workspace_id, + content=content, + content_json=content_json, + thread_id=event.thread_id, + item_type='message', + run_id=run_id, + runner_id=runner_id, + metadata={ + 'run_id': run_id, + 'runner_id': runner_id, + }, + ) diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py new file mode 100644 index 000000000..c5e53c9f7 --- /dev/null +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -0,0 +1,1102 @@ +"""Run ledger store for Host-owned AgentRun and AgentRunEvent records.""" + +from __future__ import annotations + +import datetime +import json +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + + +UTC = datetime.timezone.utc +RUN_STATUSES = {'created', 'queued', 'claimed', 'running', 'completed', 'failed', 'cancelled', 'timeout'} +TERMINAL_STATUSES = {'completed', 'failed', 'cancelled', 'timeout'} + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +def _epoch_to_datetime(value: typing.Any) -> datetime.datetime | None: + if value is None: + return None + try: + return datetime.datetime.fromtimestamp(float(value), UTC) + except (TypeError, ValueError, OSError): + return None + + +def _json_dumps(value: typing.Any) -> str | None: + if value is None: + return None + return json.dumps(value) + + +def _json_loads(value: str | None, default: typing.Any) -> typing.Any: + if not value: + return default + try: + return json.loads(value) + except (TypeError, ValueError): + return default + + +def _validate_run_status(status: str) -> str: + normalized = str(status) + if normalized not in RUN_STATUSES: + raise ValueError(f'Unknown run status: {normalized}') + return normalized + + +def _claim_is_active( + run: AgentRun, + *, + runtime_id: str | None, + claim_token: str, + now: datetime.datetime, +) -> bool: + if run.status != 'claimed' or run.claim_token != claim_token: + return False + if runtime_id is not None and run.claimed_by_runtime_id != runtime_id: + return False + lease_expires_at = _as_utc(run.claim_lease_expires_at) + return lease_expires_at is not None and lease_expires_at > now + + +class RunLedgerStore: + """Store for Host-owned run lifecycle and result event facts.""" + + engine: AsyncEngine + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async def create_run( + self, + *, + run_id: str, + event_id: str | None, + binding_id: str | None, + runner_id: str, + conversation_id: str | None = None, + thread_id: str | None = None, + workspace_id: str | None = None, + bot_id: str | None = None, + agent_id: str | None = None, + deadline_at: int | float | None = None, + authorization: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + status: str = 'running', + queue_name: str | None = None, + priority: int = 0, + requested_runtime_id: str | None = None, + ) -> dict[str, typing.Any]: + """Create a run if it does not already exist.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + existing = await self._get_run_row(session, run_id) + if existing is not None: + return self._run_to_dict(existing) + + run = AgentRun( + run_id=run_id, + event_id=event_id, + agent_id=agent_id, + binding_id=binding_id, + runner_id=runner_id, + conversation_id=conversation_id, + thread_id=thread_id, + workspace_id=workspace_id, + bot_id=bot_id, + status=status, + queue_name=queue_name, + priority=priority, + requested_runtime_id=requested_runtime_id, + created_at=now, + started_at=now if status == 'running' else None, + updated_at=now, + deadline_at=_epoch_to_datetime(deadline_at), + authorization_json=_json_dumps(authorization), + metadata_json=_json_dumps(metadata), + ) + session.add(run) + await session.commit() + return self._run_to_dict(run) + + async def claim_next_run( + self, + *, + runtime_id: str, + queue_name: str | None = None, + lease_seconds: int = 60, + runner_ids: list[str] | None = None, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> dict[str, typing.Any] | None: + """Claim the next queued or expired-leased run for a runtime.""" + now = _utc_now() + lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1)) + async with self._session_factory() as session: + query = sqlalchemy.select(AgentRun).where( + sqlalchemy.or_( + AgentRun.status == 'queued', + sqlalchemy.and_( + AgentRun.status == 'claimed', + AgentRun.claim_lease_expires_at.is_not(None), + AgentRun.claim_lease_expires_at <= now, + ), + ), + sqlalchemy.or_( + AgentRun.requested_runtime_id.is_(None), + AgentRun.requested_runtime_id == runtime_id, + ), + ) + if queue_name is not None: + query = query.where(AgentRun.queue_name == queue_name) + if runner_ids: + query = query.where(AgentRun.runner_id.in_(runner_ids)) + if conversation_id is not None: + query = query.where(AgentRun.conversation_id == conversation_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + query = query.order_by(AgentRun.priority.desc(), AgentRun.id.asc()).limit(1).with_for_update( + skip_locked=True + ) + result = await session.execute(query) + run = result.scalars().first() + if run is None: + return None + + run.status = 'claimed' + run.claimed_by_runtime_id = runtime_id + run.claim_token = uuid.uuid4().hex + run.claim_lease_expires_at = lease_expires_at + run.dispatch_attempts = (run.dispatch_attempts or 0) + 1 + run.last_claimed_at = now + run.updated_at = now + await session.commit() + return self._run_to_dict(run, include_claim_token=True) + + async def renew_claim( + self, + *, + run_id: str, + claim_token: str, + runtime_id: str | None = None, + lease_seconds: int = 60, + ) -> dict[str, typing.Any] | None: + """Extend a current claim lease if the token still matches.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now): + return None + + run.claim_lease_expires_at = now + datetime.timedelta(seconds=max(int(lease_seconds), 1)) + run.updated_at = now + await session.commit() + return self._run_to_dict(run) + + async def release_claim( + self, + *, + run_id: str, + claim_token: str, + runtime_id: str | None = None, + status: str = 'queued', + status_reason: str | None = None, + ) -> dict[str, typing.Any] | None: + """Release a current claim lease if the token still matches.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None or not _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now): + return None + + run.status = status + run.status_reason = status_reason + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + run.updated_at = now + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or now + await session.commit() + return self._run_to_dict(run) + + async def release_expired_claims( + self, + *, + now: datetime.datetime | None = None, + status: str = 'queued', + status_reason: str = 'claim lease expired', + limit: int = 100, + ) -> list[dict[str, typing.Any]]: + """Release claimed runs whose claim lease has expired.""" + status = _validate_run_status(status) + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=UTC) + limit = min(max(int(limit), 1), 500) + + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRun) + .where( + AgentRun.status == 'claimed', + AgentRun.claim_lease_expires_at.is_not(None), + AgentRun.claim_lease_expires_at <= current_time, + ) + .order_by(AgentRun.claim_lease_expires_at.asc(), AgentRun.id.asc()) + .limit(limit) + ) + runs = result.scalars().all() + for run in runs: + run.status = status + run.status_reason = status_reason + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + run.updated_at = current_time + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or current_time + await session.commit() + return [self._run_to_dict(run) for run in runs] + + async def append_event( + self, + *, + run_id: str, + sequence: int, + event_type: str, + data: dict[str, typing.Any] | None = None, + usage: dict[str, typing.Any] | None = None, + source: str = 'runner', + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Append one run result event. + + If the same run_id + sequence already exists, the existing row is + returned. This supports retrying append calls idempotently. + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRunEvent).where( + AgentRunEvent.run_id == run_id, + AgentRunEvent.sequence == sequence, + ) + ) + existing = result.scalars().first() + if existing is not None: + return self._event_to_dict(existing) + + row = AgentRunEvent( + run_id=run_id, + sequence=sequence, + type=event_type, + data_json=_json_dumps(data or {}), + usage_json=_json_dumps(usage), + created_at=_utc_now(), + source=source, + metadata_json=_json_dumps(metadata), + ) + session.add(row) + await session.commit() + return self._event_to_dict(row) + + async def append_audit_event( + self, + *, + run_id: str, + event_type: str, + data: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Append a Host-authored audit event after the current max sequence.""" + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + + result = await session.execute( + sqlalchemy.select(sqlalchemy.func.max(AgentRunEvent.sequence)).where( + AgentRunEvent.run_id == run_id, + ) + ) + next_sequence = int(result.scalar_one_or_none() or 0) + 1 + row = AgentRunEvent( + run_id=run_id, + sequence=next_sequence, + type=event_type, + data_json=_json_dumps(data or {}), + usage_json=None, + created_at=_utc_now(), + source='host', + metadata_json=_json_dumps(metadata or {}), + ) + session.add(row) + await session.commit() + return self._event_to_dict(row) + + async def finalize_run( + self, + *, + run_id: str, + status: str, + status_reason: str | None = None, + usage: dict[str, typing.Any] | None = None, + cost: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Update a run to a terminal or current status.""" + status = _validate_run_status(status) + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + + if run.status in TERMINAL_STATUSES and run.status != status: + raise ValueError(f'Cannot transition terminal run {run_id} from {run.status} to {status}') + + run.status = status + if status_reason is not None: + run.status_reason = status_reason + run.updated_at = now + if status in TERMINAL_STATUSES: + run.finished_at = run.finished_at or now + run.claimed_by_runtime_id = None + run.claim_token = None + run.claim_lease_expires_at = None + if usage is not None: + run.usage_json = _json_dumps(usage) + if cost is not None: + run.cost_json = _json_dumps(cost) + if metadata is not None: + existing_metadata = _json_loads(run.metadata_json, {}) + if isinstance(existing_metadata, dict): + existing_metadata.update(metadata) + run.metadata_json = _json_dumps(existing_metadata) + else: + run.metadata_json = _json_dumps(metadata) + await session.commit() + return self._run_to_dict(run) + + async def validate_active_claim( + self, + *, + run_id: str, + runtime_id: str, + claim_token: str, + ) -> bool: + """Return whether a runtime currently owns an unexpired claim lease.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return False + return _claim_is_active(run, runtime_id=runtime_id, claim_token=claim_token, now=now) + + async def request_cancel( + self, + *, + run_id: str, + status_reason: str | None = None, + ) -> dict[str, typing.Any] | None: + """Record a cancellation request.""" + now = _utc_now() + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None: + return None + run.cancel_requested_at = now + run.updated_at = now + run.status_reason = status_reason or run.status_reason + await session.commit() + return self._run_to_dict(run) + + async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: + """Get one run by run_id.""" + async with self._session_factory() as session: + row = await self._get_run_row(session, run_id) + return self._run_to_dict(row) if row is not None else None + + async def register_runtime( + self, + *, + runtime_id: str, + status: str = 'online', + display_name: str | None = None, + endpoint: str | None = None, + version: str | None = None, + capabilities: dict[str, typing.Any] | None = None, + labels: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + heartbeat_deadline_seconds: int = 60, + ) -> dict[str, typing.Any]: + """Create or update a runtime registry row and record a heartbeat.""" + now = _utc_now() + async with self._session_factory() as session: + runtime = await self._get_runtime_row(session, runtime_id) + if runtime is None: + runtime = AgentRuntime(runtime_id=runtime_id, created_at=now) + session.add(runtime) + + runtime.status = status + runtime.display_name = display_name + runtime.endpoint = endpoint + runtime.version = version + runtime.capabilities_json = _json_dumps(capabilities or {}) + runtime.labels_json = _json_dumps(labels or {}) + runtime.metadata_json = _json_dumps(metadata or {}) + runtime.last_heartbeat_at = now + runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + runtime.updated_at = now + await session.commit() + return self._runtime_to_dict(runtime) + + async def heartbeat_runtime( + self, + *, + runtime_id: str, + status: str = 'online', + heartbeat_deadline_seconds: int = 60, + capabilities: dict[str, typing.Any] | None = None, + labels: dict[str, typing.Any] | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any] | None: + """Refresh a runtime heartbeat.""" + now = _utc_now() + async with self._session_factory() as session: + runtime = await self._get_runtime_row(session, runtime_id) + if runtime is None: + return None + + runtime.status = status + runtime.last_heartbeat_at = now + runtime.heartbeat_deadline_at = now + datetime.timedelta(seconds=max(int(heartbeat_deadline_seconds), 1)) + runtime.updated_at = now + if capabilities is not None: + runtime.capabilities_json = _json_dumps(capabilities) + if labels is not None: + runtime.labels_json = _json_dumps(labels) + if metadata is not None: + existing_metadata = _json_loads(runtime.metadata_json, {}) + if isinstance(existing_metadata, dict): + existing_metadata.update(metadata) + runtime.metadata_json = _json_dumps(existing_metadata) + else: + runtime.metadata_json = _json_dumps(metadata) + await session.commit() + return self._runtime_to_dict(runtime) + + async def get_runtime(self, runtime_id: str) -> dict[str, typing.Any] | None: + """Get one runtime by runtime_id.""" + async with self._session_factory() as session: + row = await self._get_runtime_row(session, runtime_id) + return self._runtime_to_dict(row) if row is not None else None + + async def list_runtimes( + self, + *, + statuses: list[str] | None = None, + labels: dict[str, str] | None = None, + limit: int = 100, + ) -> tuple[list[dict[str, typing.Any]], int]: + """List runtime registry rows. + + Args: + statuses: Filter by status list + labels: Filter by labels (key-value pairs) + limit: Maximum number of rows to return + + Returns: + Tuple of (runtimes, total_count). + """ + limit = min(max(int(limit), 1), 500) + async with self._session_factory() as session: + # Build base query with status filter + base_query = sqlalchemy.select(AgentRuntime) + if statuses: + base_query = base_query.where(AgentRuntime.status.in_(statuses)) + + # Get total count (before label filtering) + if not labels: + # Simple case - can count directly in DB + count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRuntime.id)) + if statuses: + count_query = count_query.where(AgentRuntime.status.in_(statuses)) + count_result = await session.execute(count_query) + total_count = count_result.scalar() or 0 + + # Get items + query = base_query.order_by(AgentRuntime.id.asc()).limit(limit) + result = await session.execute(query) + runtimes = [self._runtime_to_dict(row) for row in result.scalars().all()] + else: + # Need to fetch all and filter by labels in Python + query = base_query.order_by(AgentRuntime.id.asc()) + result = await session.execute(query) + all_runtimes = [self._runtime_to_dict(row) for row in result.scalars().all()] + + # Filter by labels + runtimes = [ + rt for rt in all_runtimes + if all(rt.get('labels', {}).get(k) == v for k, v in labels.items()) + ] + total_count = len(runtimes) + + # Apply limit after filtering + runtimes = runtimes[:limit] + + return runtimes, total_count + + async def mark_stale_runtimes( + self, + *, + now: datetime.datetime | None = None, + stale_status: str = 'stale', + stale_after_seconds: int | float | None = None, + ) -> list[dict[str, typing.Any]]: + """Mark runtimes stale when their heartbeat deadline has passed.""" + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=UTC) + stale_conditions: list[typing.Any] = [ + sqlalchemy.and_( + AgentRuntime.heartbeat_deadline_at.is_not(None), + AgentRuntime.heartbeat_deadline_at < current_time, + ) + ] + if stale_after_seconds is not None: + try: + stale_after_delta = datetime.timedelta(seconds=max(float(stale_after_seconds), 0)) + except (TypeError, ValueError): + stale_after_delta = None + if stale_after_delta is not None: + stale_conditions.append( + sqlalchemy.and_( + AgentRuntime.last_heartbeat_at.is_not(None), + AgentRuntime.last_heartbeat_at < current_time - stale_after_delta, + ) + ) + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentRuntime).where( + sqlalchemy.or_(*stale_conditions), + AgentRuntime.status != stale_status, + ) + ) + runtimes = result.scalars().all() + for runtime in runtimes: + runtime.status = stale_status + runtime.updated_at = current_time + await session.commit() + return [self._runtime_to_dict(runtime) for runtime in runtimes] + + async def list_runs( + self, + *, + conversation_id: str | None = None, + statuses: list[str] | None = None, + before_id: int | None = None, + limit: int = 50, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + runner_id: str | None = None, + ) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]: + """Page runs by scope. + + Returns: + Tuple of (items, next_cursor, has_more, total_count). + """ + limit = min(max(int(limit), 1), 100) + async with self._session_factory() as session: + # First get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)) + if conversation_id is not None: + count_query = count_query.where(AgentRun.conversation_id == conversation_id) + if statuses: + count_query = count_query.where(AgentRun.status.in_(statuses)) + if runner_id is not None: + count_query = count_query.where(AgentRun.runner_id == runner_id) + count_query = self._apply_scope_filters(count_query, bot_id, workspace_id, thread_id, strict_thread) + count_result = await session.execute(count_query) + total_count = count_result.scalar() or 0 + + # Then get items + query = sqlalchemy.select(AgentRun) + if conversation_id is not None: + query = query.where(AgentRun.conversation_id == conversation_id) + if statuses: + query = query.where(AgentRun.status.in_(statuses)) + if runner_id is not None: + query = query.where(AgentRun.runner_id == runner_id) + if before_id is not None: + query = query.where(AgentRun.id < before_id) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + query = query.order_by(AgentRun.id.desc()).limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + items = [self._run_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + next_cursor = items[-1]['id'] if items and has_more else None + + return items, next_cursor, has_more, total_count + + async def page_run_events( + self, + *, + run_id: str, + before_sequence: int | None = None, + after_sequence: int | None = None, + limit: int = 50, + direction: str = 'forward', + ) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]: + """Page result events for one run.""" + limit = min(max(int(limit), 1), 100) + direction = direction if direction in {'forward', 'backward'} else 'forward' + async with self._session_factory() as session: + query = sqlalchemy.select(AgentRunEvent).where(AgentRunEvent.run_id == run_id) + if before_sequence is not None: + query = query.where(AgentRunEvent.sequence < before_sequence) + if after_sequence is not None: + query = query.where(AgentRunEvent.sequence > after_sequence) + + if direction == 'backward': + query = query.order_by(AgentRunEvent.sequence.desc()) + else: + query = query.order_by(AgentRunEvent.sequence.asc()) + query = query.limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + items = [self._event_to_dict(row) for row in rows[:limit]] + has_more = len(rows) > limit + + if direction == 'backward': + next_cursor = items[-1]['sequence'] if items and has_more else None + prev_cursor = items[0]['sequence'] if items else None + else: + next_cursor = items[-1]['sequence'] if items and has_more else None + prev_cursor = items[0]['sequence'] if items else None + return items, next_cursor, prev_cursor, has_more + + async def _get_run_row( + self, + session: AsyncSession, + run_id: str, + ) -> AgentRun | None: + result = await session.execute(sqlalchemy.select(AgentRun).where(AgentRun.run_id == run_id)) + return result.scalars().first() + + async def _get_runtime_row( + self, + session: AsyncSession, + runtime_id: str, + ) -> AgentRuntime | None: + result = await session.execute(sqlalchemy.select(AgentRuntime).where(AgentRuntime.runtime_id == runtime_id)) + return result.scalars().first() + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(AgentRun.bot_id == bot_id) + if workspace_id is not None: + query = query.where(AgentRun.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(AgentRun.thread_id.is_(None)) + else: + query = query.where(AgentRun.thread_id == thread_id) + return query + + def _run_to_dict(self, row: AgentRun, *, include_claim_token: bool = False) -> dict[str, typing.Any]: + data = { + 'id': row.id, + 'run_id': row.run_id, + 'event_id': row.event_id, + 'agent_id': row.agent_id, + 'binding_id': row.binding_id, + 'runner_id': row.runner_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'workspace_id': row.workspace_id, + 'bot_id': row.bot_id, + 'status': row.status, + 'status_reason': row.status_reason, + 'queue_name': row.queue_name, + 'priority': row.priority, + 'requested_runtime_id': row.requested_runtime_id, + 'claimed_by_runtime_id': row.claimed_by_runtime_id, + 'claim_lease_expires_at': _datetime_to_epoch(row.claim_lease_expires_at), + 'dispatch_attempts': row.dispatch_attempts, + 'last_claimed_at': _datetime_to_epoch(row.last_claimed_at), + 'created_at': _datetime_to_epoch(row.created_at), + 'started_at': _datetime_to_epoch(row.started_at), + 'finished_at': _datetime_to_epoch(row.finished_at), + 'updated_at': _datetime_to_epoch(row.updated_at), + 'deadline_at': _datetime_to_epoch(row.deadline_at), + 'cancel_requested_at': _datetime_to_epoch(row.cancel_requested_at), + 'usage': _json_loads(row.usage_json, None), + 'cost': _json_loads(row.cost_json, None), + 'metadata': _json_loads(row.metadata_json, {}), + } + if include_claim_token: + data['claim_token'] = row.claim_token + return data + + def _runtime_to_dict(self, row: AgentRuntime) -> dict[str, typing.Any]: + return { + 'id': row.id, + 'runtime_id': row.runtime_id, + 'status': row.status, + 'display_name': row.display_name, + 'endpoint': row.endpoint, + 'version': row.version, + 'capabilities': _json_loads(row.capabilities_json, {}), + 'labels': _json_loads(row.labels_json, {}), + 'metadata': _json_loads(row.metadata_json, {}), + 'last_heartbeat_at': _datetime_to_epoch(row.last_heartbeat_at), + 'heartbeat_deadline_at': _datetime_to_epoch(row.heartbeat_deadline_at), + 'created_at': _datetime_to_epoch(row.created_at), + 'updated_at': _datetime_to_epoch(row.updated_at), + } + + def _event_to_dict(self, row: AgentRunEvent) -> dict[str, typing.Any]: + return { + 'id': row.id, + 'run_id': row.run_id, + 'sequence': row.sequence, + 'type': row.type, + 'data': _json_loads(row.data_json, {}), + 'usage': _json_loads(row.usage_json, None), + 'created_at': _datetime_to_epoch(row.created_at), + 'source': row.source, + 'metadata': _json_loads(row.metadata_json, {}), + } + + async def get_run_stats( + self, + *, + start_time: int, + end_time: int, + runner_id: str | None = None, + ) -> dict[str, typing.Any]: + """Get run statistics within a time window. + + Args: + start_time: Unix timestamp for start of window + end_time: Unix timestamp for end of window + runner_id: Optional filter by runner + + Returns: + Dict with status counts, rates, and duration stats. + """ + from sqlalchemy import func + + start_dt = _epoch_to_datetime(start_time) + end_dt = _epoch_to_datetime(end_time) + + async with self._session_factory() as session: + # Base filter for time window + base_filter = [ + AgentRun.created_at >= start_dt, + AgentRun.created_at <= end_dt, + ] + if runner_id: + base_filter.append(AgentRun.runner_id == runner_id) + + # Count by status + status_query = ( + sqlalchemy.select( + AgentRun.status, + func.count(AgentRun.id).label('count') + ) + .where(*base_filter) + .group_by(AgentRun.status) + ) + status_result = await session.execute(status_query) + status_counts = {row.status: row.count for row in status_result} + + total_count = sum(status_counts.values()) + completed_count = status_counts.get('completed', 0) + failed_count = status_counts.get('failed', 0) + status_counts.get('timeout', 0) + + # Calculate rates + window_hours = max((end_time - start_time) / 3600, 0.001) + throughput = total_count / window_hours if total_count > 0 else 0 + success_rate = completed_count / total_count if total_count > 0 else None + failure_rate = failed_count / total_count if total_count > 0 else None + + # Duration stats for completed runs - compute in Python for DB compatibility + avg_duration_seconds = None + avg_queue_wait_seconds = None + + # Fetch completed runs with timing data + timing_query = ( + sqlalchemy.select( + AgentRun.started_at, + AgentRun.finished_at, + AgentRun.created_at, + ) + .where( + AgentRun.status == 'completed', + AgentRun.started_at.is_not(None), + AgentRun.finished_at.is_not(None), + *base_filter + ) + ) + timing_result = await session.execute(timing_query) + timing_rows = timing_result.all() + + if timing_rows: + durations = [] + for row in timing_rows: + if row.finished_at and row.started_at: + delta = row.finished_at - row.started_at + durations.append(delta.total_seconds()) + if durations: + avg_duration_seconds = round(sum(durations) / len(durations), 2) + + # Queue wait time - compute in Python + queue_query = ( + sqlalchemy.select( + AgentRun.created_at, + AgentRun.started_at, + ) + .where( + AgentRun.started_at.is_not(None), + *base_filter + ) + ) + queue_result = await session.execute(queue_query) + queue_rows = queue_result.all() + + if queue_rows: + waits = [] + for row in queue_rows: + if row.started_at and row.created_at: + delta = row.started_at - row.created_at + wait_seconds = delta.total_seconds() + if wait_seconds >= 0: # Only count positive waits + waits.append(wait_seconds) + if waits: + avg_queue_wait_seconds = round(sum(waits) / len(waits), 2) + + return { + 'start_time': start_time, + 'end_time': end_time, + 'total_count': total_count, + 'created_count': status_counts.get('created', 0), + 'queued_count': status_counts.get('queued', 0), + 'claimed_count': status_counts.get('claimed', 0), + 'running_count': status_counts.get('running', 0), + 'completed_count': completed_count, + 'failed_count': status_counts.get('failed', 0), + 'cancelled_count': status_counts.get('cancelled', 0), + 'timeout_count': status_counts.get('timeout', 0), + 'throughput_per_hour': round(throughput, 2), + 'success_rate': round(success_rate, 4) if success_rate is not None else None, + 'failure_rate': round(failure_rate, 4) if failure_rate is not None else None, + 'avg_duration_seconds': avg_duration_seconds, + 'p50_duration_seconds': None, # Requires more complex calculation + 'p95_duration_seconds': None, + 'p99_duration_seconds': None, + 'avg_queue_wait_seconds': avg_queue_wait_seconds, + } + + async def get_runtime_stats(self) -> dict[str, typing.Any]: + """Get runtime registry statistics. + + Returns: + Dict with counts, heartbeat health, and capacity. + """ + from sqlalchemy import func + + now = _utc_now() + + async with self._session_factory() as session: + # Count by status + status_query = ( + sqlalchemy.select( + AgentRuntime.status, + func.count(AgentRuntime.id).label('count') + ) + .group_by(AgentRuntime.status) + ) + status_result = await session.execute(status_query) + status_counts = {row.status: row.count for row in status_result} + + total_count = sum(status_counts.values()) + online_count = status_counts.get('online', 0) + stale_count = status_counts.get('stale', 0) + + # Heartbeat age stats - compute in Python for DB compatibility + avg_heartbeat_age = None + max_heartbeat_age = None + + heartbeat_query = ( + sqlalchemy.select(AgentRuntime.last_heartbeat_at) + .where(AgentRuntime.last_heartbeat_at.is_not(None)) + ) + heartbeat_result = await session.execute(heartbeat_query) + heartbeat_rows = heartbeat_result.all() + + if heartbeat_rows: + ages = [] + for row in heartbeat_rows: + heartbeat_at = _as_utc(row.last_heartbeat_at) + if heartbeat_at: + delta = now - heartbeat_at + age_seconds = delta.total_seconds() + if age_seconds >= 0: + ages.append(age_seconds) + if ages: + avg_heartbeat_age = round(sum(ages) / len(ages), 2) + max_heartbeat_age = round(max(ages), 2) + + active_runs_query = ( + sqlalchemy.select(func.count(AgentRun.id)) + .where(AgentRun.status.in_(['running', 'claimed'])) + ) + active_runs_result = await session.execute(active_runs_query) + active_runs = active_runs_result.scalar() or 0 + claimed_runs_query = ( + sqlalchemy.select(func.count(AgentRun.id)) + .where(AgentRun.status == 'claimed') + ) + claimed_runs_result = await session.execute(claimed_runs_query) + claimed_runs = claimed_runs_result.scalar() or 0 + + return { + 'total_count': total_count, + 'online_count': online_count, + 'stale_count': stale_count, + 'avg_heartbeat_age_seconds': avg_heartbeat_age, + 'max_heartbeat_age_seconds': max_heartbeat_age, + 'active_runs': active_runs, + 'claimed_runs': claimed_runs, + } + + async def get_runner_stats( + self, + *, + start_time: int, + end_time: int, + limit: int = 50, + ) -> list[dict[str, typing.Any]]: + """Get runner-aggregated statistics. + + Args: + start_time: Unix timestamp for start of window + end_time: Unix timestamp for end of window + limit: Maximum number of runners to return + + Returns: + List of dicts with per-runner statistics. + """ + from sqlalchemy import func + + start_dt = _epoch_to_datetime(start_time) + end_dt = _epoch_to_datetime(end_time) + limit = min(max(limit, 1), 100) + + async with self._session_factory() as session: + # Aggregate runs by runner_id + query = ( + sqlalchemy.select( + AgentRun.runner_id, + func.count(AgentRun.id).label('total'), + func.sum( + sqlalchemy.case( + (AgentRun.status.in_(['queued', 'claimed', 'running']), 1), + else_=0 + ) + ).label('active'), + func.sum( + sqlalchemy.case( + (AgentRun.status == 'completed', 1), + else_=0 + ) + ).label('completed'), + func.sum( + sqlalchemy.case( + (AgentRun.status.in_(['failed', 'timeout']), 1), + else_=0 + ) + ).label('failed'), + ) + .where( + AgentRun.created_at >= start_dt, + AgentRun.created_at <= end_dt, + AgentRun.runner_id.is_not(None), + ) + .group_by(AgentRun.runner_id) + .order_by(func.count(AgentRun.id).desc()) + .limit(limit) + ) + + result = await session.execute(query) + rows = result.all() + + stats = [] + for row in rows: + runner_id = row.runner_id or 'unknown' + total = row.total or 0 + completed = row.completed or 0 + failed = row.failed or 0 + success_rate = completed / total if total > 0 else None + + stats.append({ + 'runner_id': runner_id, + 'runner_label': None, # Would need to join with runner descriptors + 'plugin_identity': None, + 'total_runs': total, + 'active_runs': row.active or 0, + 'completed_runs': completed, + 'failed_runs': failed, + 'success_rate': round(success_rate, 4) if success_rate is not None else None, + 'avg_duration_seconds': None, # Would need more complex query + }) + + return stats diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py new file mode 100644 index 000000000..7d5df93f6 --- /dev/null +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -0,0 +1,455 @@ +"""Agent run session registry for proxy action permission validation.""" + +from __future__ import annotations + +import asyncio +import copy +import typing +import time +import threading + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + +from .context_builder import AgentResources +from ...provider.tools.toolmgr import ToolSourceRef + + +MAX_STEERING_QUEUE_ITEMS = 100 + +DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { + 'model': {'invoke', 'stream', 'rerank', 'count_tokens'}, + 'tool': {'detail', 'call'}, + 'knowledge_base': {'list', 'retrieve'}, + 'skill': {'activate'}, +} + + +class AgentRunSessionStatus(typing.TypedDict): + """Status tracking for agent run session.""" + + started_at: int + last_activity_at: int + + +class RunAuthorizationSnapshot(typing.TypedDict): + """Frozen authorization data for one active run. + + ResourceBuilder creates the authorized resource list once before runner + execution. Runtime proxy handlers must validate against this run-scoped + snapshot instead of recomputing resource policy. + """ + + resources: AgentResources + available_apis: dict[str, bool] + conversation_id: str | None + bot_id: str | None + workspace_id: str | None + thread_id: str | None + state_policy: dict[str, typing.Any] + state_context: dict[str, typing.Any] + authorized_ids: dict[str, set[str]] + authorized_operations: dict[str, dict[str, set[str]]] + + +SteeringQueueItem = dict[str, typing.Any] + + +class AgentRunSession(typing.TypedDict): + """Session for an active agent runner execution. + + Stored in AgentRunSessionRegistry for proxy action permission validation. + + Fields: + run_id: Unique run identifier (UUID from AgentRunContext) + runner_id: Runner descriptor ID (plugin:author/name/runner) + query_id: Host entry query ID, only present for query-based adapters + execution_query: Host-only Query used by providers and tool loaders + plugin_identity: Plugin identifier (author/name) of the runner + authorization: Run-scoped authorization snapshot; runtime auth truth + status: Session status tracking + """ + + run_id: str + runner_id: str + query_id: int | None + execution_query: pipeline_query.Query | None + plugin_identity: str # author/name + authorization: RunAuthorizationSnapshot + status: AgentRunSessionStatus + steering_queue: list[SteeringQueueItem] + + +class AgentRunSessionRegistry: + """Registry for active agent run sessions. + + Host-owned registry for tracking active AgentRunner executions. + Used by proxy actions in handler.py to validate resource access. + + Key: run_id (UUID from AgentRunContext) + Value: AgentRunSession with authorized resources + + Thread-safe via asyncio.Lock. + """ + + _sessions: dict[str, AgentRunSession] + _lock: asyncio.Lock + + def __init__(self): + self._sessions = {} + self._lock = asyncio.Lock() + + async def register( + self, + run_id: str, + runner_id: str, + query_id: int | None, + plugin_identity: str, + resources: AgentResources, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + available_apis: dict[str, bool] | None = None, + state_policy: dict[str, typing.Any] | None = None, + state_context: dict[str, typing.Any] | None = None, + execution_query: pipeline_query.Query | None = None, + ) -> None: + """Register a new agent run session. + + Args: + run_id: Unique run identifier + runner_id: Runner descriptor ID + query_id: Host entry query ID, only present for query-based adapters + plugin_identity: Plugin identifier (author/name) + resources: Authorized resources for this run + conversation_id: Conversation ID for history/event access + bot_id: Bot UUID for history/event access + workspace_id: Workspace ID for history/event access + thread_id: Thread ID for history/event access + available_apis: Run-scoped pull APIs exposed in AgentRunContext + state_policy: State policy from binding (enable_state, state_scopes) + state_context: Context for state API (scope_keys, binding_identity, etc.) + execution_query: Host-only Query used for provider and tool execution + """ + if not isinstance(plugin_identity, str) or not plugin_identity.strip(): + raise ValueError('plugin_identity is required for agent run sessions') + + now = int(time.time()) + + available_apis = copy.deepcopy(available_apis or {}) + + # Normalize state_policy to defaults if None + if state_policy is None: + state_policy = {'enable_state': True, 'state_scopes': ['conversation', 'actor']} + + # Normalize state_context to empty dict if None + state_context = state_context or {} + + resources_snapshot = copy.deepcopy(resources) + authorization: RunAuthorizationSnapshot = { + 'resources': resources_snapshot, + 'available_apis': available_apis, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': copy.deepcopy(state_policy), + 'state_context': copy.deepcopy(state_context), + 'authorized_ids': self._build_authorized_ids(resources_snapshot), + 'authorized_operations': self._build_authorized_operations(resources_snapshot), + } + + session: AgentRunSession = { + 'run_id': run_id, + 'runner_id': runner_id, + 'query_id': query_id, + 'execution_query': execution_query, + 'plugin_identity': plugin_identity, + 'authorization': authorization, + 'status': { + 'started_at': now, + 'last_activity_at': now, + }, + 'steering_queue': [], + } + + async with self._lock: + self._sessions[run_id] = session + + def _build_authorized_ids(self, resources: AgentResources) -> dict[str, set[str]]: + """Pre-compute authorized resource IDs for O(1) lookup.""" + return { + 'model': {m.get('model_id') for m in resources.get('models', [])}, + 'tool': {t.get('tool_name') for t in resources.get('tools', [])}, + 'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])}, + 'skill': {s.get('skill_name') for s in resources.get('skills', [])}, + } + + def _build_authorized_operations( + self, + resources: AgentResources, + ) -> dict[str, dict[str, set[str]]]: + """Pre-compute resource operations for runtime action validation.""" + return { + 'model': { + m.get('model_id'): self._resource_operations('model', m) + for m in resources.get('models', []) + if m.get('model_id') + }, + 'tool': { + t.get('tool_name'): self._resource_operations('tool', t) + for t in resources.get('tools', []) + if t.get('tool_name') + }, + 'knowledge_base': { + kb.get('kb_id'): self._resource_operations('knowledge_base', kb) + for kb in resources.get('knowledge_bases', []) + if kb.get('kb_id') + }, + 'skill': { + s.get('skill_name'): self._resource_operations('skill', s) + for s in resources.get('skills', []) + if s.get('skill_name') + }, + } + + @staticmethod + def _resource_operations(resource_type: str, resource: dict[str, typing.Any]) -> set[str]: + """Return explicit operations or the compatibility default for old resources.""" + operations = resource.get('operations') + if isinstance(operations, list) and operations: + return {str(operation) for operation in operations} + return set(DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set())) + + async def unregister(self, run_id: str) -> AgentRunSession | None: + """Unregister an agent run session. + + Args: + run_id: Unique run identifier + + Returns: + The removed session, if one existed. Callers can inspect any + pending in-memory queues before they are discarded. + """ + async with self._lock: + return self._sessions.pop(run_id, None) + + async def get(self, run_id: str) -> AgentRunSession | None: + """Get session by run_id. + + Args: + run_id: Unique run identifier + + Returns: + AgentRunSession if found, None otherwise + """ + async with self._lock: + return self._sessions.get(run_id) + + async def update_activity(self, run_id: str) -> None: + """Update last activity timestamp for session. + + Args: + run_id: Unique run identifier + """ + async with self._lock: + if run_id in self._sessions: + self._sessions[run_id]['status']['last_activity_at'] = int(time.time()) + + async def find_steering_target( + self, + *, + conversation_id: str, + runner_id: str, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + ) -> str | None: + """Find the oldest active run that can accept steering for a conversation.""" + async with self._lock: + candidates: list[tuple[int, str]] = [] + for run_id, session in self._sessions.items(): + authorization = session['authorization'] + if session.get('runner_id') != runner_id: + continue + if authorization.get('conversation_id') != conversation_id: + continue + if authorization.get('bot_id') != bot_id: + continue + if authorization.get('workspace_id') != workspace_id: + continue + if authorization.get('thread_id') != thread_id: + continue + if not authorization.get('available_apis', {}).get('steering_pull', False): + continue + candidates.append((session['status'].get('started_at', 0), run_id)) + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + + async def enqueue_steering( + self, + run_id: str, + item: SteeringQueueItem, + ) -> bool: + """Append one steering item to an active run queue.""" + async with self._lock: + session = self._sessions.get(run_id) + if session is None: + return False + if len(session['steering_queue']) >= MAX_STEERING_QUEUE_ITEMS: + return False + session['steering_queue'].append(copy.deepcopy(item)) + session['status']['last_activity_at'] = int(time.time()) + return True + + async def pull_steering( + self, + run_id: str, + *, + mode: str = 'all', + limit: int | None = None, + ) -> list[SteeringQueueItem]: + """Pop pending steering items from a run queue.""" + async with self._lock: + session = self._sessions.get(run_id) + if session is None: + return [] + + queue = session['steering_queue'] + if not queue: + return [] + + normalized_mode = str(mode or 'all').lower() + if normalized_mode in {'one', 'one-at-a-time', 'one_at_a_time'}: + count = 1 + elif isinstance(limit, int) and limit > 0: + count = min(limit, len(queue)) + else: + count = len(queue) + + count = max(0, min(count, len(queue), 100)) + items = [copy.deepcopy(item) for item in queue[:count]] + del queue[:count] + session['status']['last_activity_at'] = int(time.time()) + return items + + def is_resource_allowed( + self, + session: AgentRunSession, + resource_type: str, + resource_id: str, + operation: str | None = None, + ) -> bool: + """Check if resource access is allowed for this session. + + Uses pre-computed authorized IDs for O(1) lookup. + + Args: + session: AgentRunSession to check + resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage') + resource_id: Resource identifier (model_id, tool_name, kb_id, 'plugin'/'workspace') + operation: Optional operation to check within the authorized resource + + Returns: + True if resource is authorized, False otherwise + """ + authorization = session['authorization'] + authorized_ids = authorization['authorized_ids'] + resources = authorization['resources'] + + if resource_type in ('model', 'tool', 'knowledge_base', 'skill'): + if resource_id not in authorized_ids.get(resource_type, set()): + return False + if operation is None: + return True + operation_map = authorization.get('authorized_operations', {}) + operations = operation_map.get(resource_type, {}).get(resource_id) + if not operations: + operations = DEFAULT_RESOURCE_OPERATIONS.get(resource_type, set()) + return operation in operations + + if resource_type == 'storage': + storage = resources.get('storage', {}) + if resource_id == 'plugin': + return storage.get('plugin_storage', False) + elif resource_id == 'workspace': + return storage.get('workspace_storage', False) + return False + + return False + + @staticmethod + def get_tool_source_ref( + session: AgentRunSession, + tool_name: str, + ) -> ToolSourceRef | None: + """Return the implementation identity frozen in this run's grant.""" + resources = session['authorization']['resources'] + for tool in resources.get('tools', []): + if tool.get('tool_name') != tool_name: + continue + source = tool.get('source') + if not isinstance(source, str) or not source: + return None + source_id = tool.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + return None + + async def list_active_runs(self) -> list[AgentRunSession]: + """List all active run sessions. + + Returns: + List of active AgentRunSession dicts + """ + async with self._lock: + return list(self._sessions.values()) + + async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int: + """Cleanup sessions that have been inactive for too long. + + Args: + max_age_seconds: Maximum inactivity time in seconds (default 1 hour) + + Returns: + Number of sessions cleaned up + """ + now = int(time.time()) + cleaned = 0 + + async with self._lock: + stale_run_ids = [] + for run_id, session in self._sessions.items(): + last_activity = session['status'].get('last_activity_at', 0) + if now - last_activity > max_age_seconds: + stale_run_ids.append(run_id) + + for run_id in stale_run_ids: + del self._sessions[run_id] + cleaned += 1 + + return cleaned + + +# Global registry instance (singleton) +_global_registry: AgentRunSessionRegistry | None = None +_global_registry_lock = threading.Lock() + + +def get_session_registry() -> AgentRunSessionRegistry: + """Get global session registry instance (thread-safe singleton). + + Returns: + AgentRunSessionRegistry singleton + """ + global _global_registry + with _global_registry_lock: + if _global_registry is None: + _global_registry = AgentRunSessionRegistry() + return _global_registry diff --git a/src/langbot/pkg/agent/runner/state_scope.py b/src/langbot/pkg/agent/runner/state_scope.py new file mode 100644 index 000000000..f2a32bb11 --- /dev/null +++ b/src/langbot/pkg/agent/runner/state_scope.py @@ -0,0 +1,136 @@ +"""State scope key helpers for AgentRunner host-owned state.""" +from __future__ import annotations + +import hashlib +import json +import typing + +from .descriptor import AgentRunnerDescriptor +from .host_models import AgentBinding, AgentEventEnvelope + + +VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner') + +STATE_KEY_ALIASES = { + 'conversation_id': 'external.conversation_id', +} + + +def normalize_state_key(key: str) -> str: + """Map accepted public aliases to protocol state keys.""" + return STATE_KEY_ALIASES.get(key, key) + + +def get_binding_identity(binding: AgentBinding) -> str: + """Return the stable binding identity used for state isolation.""" + if binding.binding_id: + return binding.binding_id + + scope = binding.scope + if scope.scope_type and scope.scope_id: + return f'{scope.scope_type}:{scope.scope_id}' + + return 'unknown_binding' + + +def _scope_hash(scope: str, parts: dict[str, typing.Any]) -> str: + """Encode state scope dimensions without separator ambiguity.""" + payload = { + 'version': 2, + 'scope': scope, + **parts, + } + raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + return f'{scope}:v2:{hashlib.sha256(raw.encode("utf-8")).hexdigest()}' + + +def _base_scope_parts( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, typing.Any]: + return { + 'runner_id': descriptor.id, + 'binding_identity': get_binding_identity(binding), + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + } + + +def build_state_scope_key( + scope: str, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> str | None: + """Build the storage key for one state scope. + + Returns None when the event lacks the identity required by that scope. + """ + base_parts = _base_scope_parts(event, binding, descriptor) + + if scope == 'conversation': + if not event.conversation_id: + return None + return _scope_hash(scope, { + **base_parts, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + }) + + if scope == 'actor': + if not event.actor or not event.actor.actor_id: + return None + return _scope_hash(scope, { + **base_parts, + 'actor_type': event.actor.actor_type or 'user', + 'actor_id': event.actor.actor_id, + }) + + if scope == 'subject': + if not event.subject or not event.subject.subject_id: + return None + return _scope_hash(scope, { + **base_parts, + 'subject_type': event.subject.subject_type or 'unknown', + 'subject_id': event.subject.subject_id, + }) + + if scope == 'runner': + return _scope_hash(scope, base_parts) + + return None + + +def build_state_scope_keys( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, str]: + """Build all available scope keys for an event/binding pair.""" + scope_keys: dict[str, str] = {} + for scope in VALID_STATE_SCOPES: + scope_key = build_state_scope_key(scope, event, binding, descriptor) + if scope_key: + scope_keys[scope] = scope_key + return scope_keys + + +def build_state_context( + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, +) -> dict[str, typing.Any]: + """Build the State API context stored in the run session.""" + return { + 'scope_keys': build_state_scope_keys(event, binding, descriptor), + 'binding_identity': get_binding_identity(binding), + 'bot_id': event.bot_id, + 'workspace_id': event.workspace_id, + 'conversation_id': event.conversation_id, + 'thread_id': event.thread_id, + 'actor_type': event.actor.actor_type if event.actor else None, + 'actor_id': event.actor.actor_id if event.actor else None, + 'subject_type': event.subject.subject_type if event.subject else None, + 'subject_id': event.subject.subject_id if event.subject else None, + } diff --git a/src/langbot/pkg/agent/runner/transcript_store.py b/src/langbot/pkg/agent/runner/transcript_store.py new file mode 100644 index 000000000..bd47e690d --- /dev/null +++ b/src/langbot/pkg/agent/runner/transcript_store.py @@ -0,0 +1,426 @@ +"""Transcript store for writing and querying conversation history.""" +from __future__ import annotations + +import json +import datetime +import typing +import uuid + +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.transcript import Transcript +from langbot_plugin.api.entities.builtin.provider import message as provider_message + + +UTC = datetime.timezone.utc + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + else: + value = value.astimezone(UTC) + return int(value.timestamp()) + + +class TranscriptStore: + """Store for Transcript records. + + Handles writing transcript items and querying them for history API. + All methods are async and use the provided database engine. + """ + + engine: AsyncEngine + + # Hard limits + MAX_CONTENT_LENGTH = 4000 + HARD_LIMIT = 100 + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async def append_transcript( + self, + transcript_id: str | None, + event_id: str, + conversation_id: str, + role: str, + bot_id: str | None = None, + workspace_id: str | None = None, + content: str | None = None, + content_json: dict[str, typing.Any] | None = None, + attachment_refs: list[dict[str, typing.Any]] | None = None, + thread_id: str | None = None, + item_type: str = "message", + run_id: str | None = None, + runner_id: str | None = None, + metadata: dict[str, typing.Any] | None = None, + ) -> str: + """Append a transcript item. + + Args: + transcript_id: Unique transcript ID (generated if None) + event_id: Source event ID + conversation_id: Conversation ID + role: Message role (user, assistant, system, tool) + bot_id: Bot UUID scope + workspace_id: Workspace scope + content: Text content + content_json: Full structured content + attachment_refs: Attachment references + thread_id: Thread ID + item_type: Item type + run_id: Run ID that generated this + runner_id: Runner ID that generated this + metadata: Additional metadata + + Returns: + The transcript_id + """ + if transcript_id is None: + transcript_id = str(uuid.uuid4()) + + # Truncate content if too long + if content and len(content) > self.MAX_CONTENT_LENGTH: + content = content[:self.MAX_CONTENT_LENGTH - 3] + "..." + + async with self._session_factory() as session: + item = Transcript( + transcript_id=transcript_id, + event_id=event_id, + bot_id=bot_id, + workspace_id=workspace_id, + conversation_id=conversation_id, + thread_id=thread_id, + role=role, + item_type=item_type, + content=content, + content_json=json.dumps(content_json) if content_json else None, + attachment_refs_json=json.dumps(attachment_refs) if attachment_refs else None, + seq=0, + run_id=run_id, + runner_id=runner_id, + created_at=_utc_now(), + metadata_json=json.dumps(metadata) if metadata else None, + ) + session.add(item) + await session.flush() + item.seq = item.id or await self._get_next_seq(conversation_id) + await session.commit() + + return transcript_id + + async def page_transcript( + self, + conversation_id: str, + before_seq: int | None = None, + after_seq: int | None = None, + limit: int = 50, + direction: str = "backward", + include_attachments: bool = False, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> tuple[list[dict[str, typing.Any]], int | None, int | None, bool]: + """Page through transcript items. + + Args: + conversation_id: Conversation ID + before_seq: Get items before this sequence (backward) + after_seq: Get items after this sequence (forward) + limit: Maximum items to return (capped at 100) + direction: 'backward' (older) or 'forward' (newer) + include_attachments: Include attachment refs + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + Tuple of (items, next_seq, prev_seq, has_more) + """ + limit = min(limit, self.HARD_LIMIT) + + async with self._session_factory() as session: + query = sqlalchemy.select(Transcript).where( + Transcript.conversation_id == conversation_id + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + if direction == "backward" and before_seq is not None: + query = query.where(Transcript.seq < before_seq) + query = query.order_by(Transcript.seq.desc()) + elif direction == "forward" and after_seq is not None: + query = query.where(Transcript.seq > after_seq) + query = query.order_by(Transcript.seq.asc()) + else: + # Default: most recent items first (backward from latest) + query = query.order_by(Transcript.seq.desc()) + + query = query.limit(limit + 1) + + result = await session.execute(query) + rows = result.scalars().all() + + items = [self._row_to_dict(row, include_attachments) for row in rows[:limit]] + has_more = len(rows) > limit + + # Calculate cursors + next_seq = None + prev_seq = None + + if direction == "backward": + # Items are in descending order + if items: + next_seq = items[-1].get('seq') if has_more else None + prev_seq = items[0].get('seq') + else: + # Items are in ascending order + if items: + next_seq = items[-1].get('seq') if has_more else None + prev_seq = items[0].get('seq') + + return items, next_seq, prev_seq, has_more + + async def search_transcript( + self, + conversation_id: str, + query_text: str, + filters: dict[str, typing.Any] | None = None, + top_k: int = 10, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> list[dict[str, typing.Any]]: + """Search transcript items. + + Basic implementation using LIKE filtering. + + Args: + conversation_id: Conversation ID + query_text: Search query + filters: Optional filters + top_k: Maximum results + bot_id: Optional bot scope filter + workspace_id: Optional workspace scope filter + thread_id: Optional thread scope filter + strict_thread: When true, require thread_id equality including NULL + + Returns: + List of matching items + """ + async with self._session_factory() as session: + query = sqlalchemy.select(Transcript).where( + Transcript.conversation_id == conversation_id, + Transcript.content.ilike(f"%{query_text}%"), + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + + # Apply additional filters + if filters: + if 'roles' in filters: + query = query.where(Transcript.role.in_(filters['roles'])) + if 'item_types' in filters: + query = query.where(Transcript.item_type.in_(filters['item_types'])) + + query = query.order_by(Transcript.seq.desc()).limit(top_k) + + result = await session.execute(query) + rows = result.scalars().all() + + return [self._row_to_dict(row, include_attachments=True) for row in rows] + + async def get_latest_cursor( + self, + conversation_id: str, + ) -> str | None: + """Get the latest cursor for a conversation. + + Args: + conversation_id: Conversation ID + + Returns: + Cursor string (seq number), or None if no items + """ + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(Transcript.seq) + .where(Transcript.conversation_id == conversation_id) + .order_by(Transcript.seq.desc()) + .limit(1) + ) + row = result.scalars().first() + if row is None: + return None + return str(row) + + async def get_legacy_provider_messages( + self, + conversation_id: str, + limit: int = HARD_LIMIT, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> list[provider_message.Message]: + """Project Transcript rows into the legacy provider Message view. + + AgentRunner history is canonical in Transcript. This view exists for + legacy Pipeline readers such as PromptPreProcessing that still expect + query.messages. + """ + items, _, _, _ = await self.page_transcript( + conversation_id=conversation_id, + limit=limit, + direction="backward", + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + strict_thread=strict_thread, + ) + + messages: list[provider_message.Message] = [] + for item in reversed(items): + message = self._transcript_item_to_provider_message(item) + if message is not None: + messages.append(message) + return messages + + async def has_history_before( + self, + conversation_id: str, + seq: int, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + strict_thread: bool = False, + ) -> bool: + """Check if there is history before a sequence number. + + Args: + conversation_id: Conversation ID + seq: Sequence number + + Returns: + True if there are items before + """ + async with self._session_factory() as session: + query = ( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(Transcript) + .where(Transcript.conversation_id == conversation_id, Transcript.seq < seq) + ) + query = self._apply_scope_filters(query, bot_id, workspace_id, thread_id, strict_thread) + result = await session.execute(query) + count = result.scalar() + return count > 0 + + def _apply_scope_filters( + self, + query: typing.Any, + bot_id: str | None, + workspace_id: str | None, + thread_id: str | None, + strict_thread: bool, + ) -> typing.Any: + if bot_id is not None: + query = query.where(Transcript.bot_id == bot_id) + if workspace_id is not None: + query = query.where(Transcript.workspace_id == workspace_id) + if strict_thread: + if thread_id is None: + query = query.where(Transcript.thread_id.is_(None)) + else: + query = query.where(Transcript.thread_id == thread_id) + return query + + async def cleanup_transcripts_older_than( + self, + before: datetime.datetime, + ) -> int: + """Delete Transcript rows created before the supplied timestamp.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.delete(Transcript).where(Transcript.created_at < before) + ) + await session.commit() + return result.rowcount or 0 + + async def _get_next_seq(self, conversation_id: str) -> int: + """Fallback next sequence number for stores that cannot expose autoincrement IDs.""" + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(sqlalchemy.func.max(Transcript.seq)) + .where(Transcript.conversation_id == conversation_id) + ) + max_seq = result.scalar() + return (max_seq or 0) + 1 + + def _row_to_dict( + self, + row: Transcript, + include_attachments: bool = False, + ) -> dict[str, typing.Any]: + """Convert a Transcript row to dict.""" + result = { + 'transcript_id': row.transcript_id, + 'event_id': row.event_id, + 'bot_id': row.bot_id, + 'workspace_id': row.workspace_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'role': row.role, + 'item_type': row.item_type, + 'content': row.content, + 'content_json': json.loads(row.content_json) if row.content_json else None, + 'seq': row.seq, + 'cursor': str(row.seq), + 'created_at': _datetime_to_epoch(row.created_at), + 'metadata': json.loads(row.metadata_json) if row.metadata_json else {}, + } + + if include_attachments and row.attachment_refs_json: + result['attachment_refs'] = json.loads(row.attachment_refs_json) + else: + result['attachment_refs'] = [] + + return result + + def _transcript_item_to_provider_message( + self, + item: dict[str, typing.Any], + ) -> provider_message.Message | None: + """Convert one Transcript API item into a provider Message.""" + if item.get('item_type') != 'message': + return None + + role = item.get('role') + if role not in {'user', 'assistant'}: + return None + + content_json = item.get('content_json') + if isinstance(content_json, dict): + message_data = dict(content_json) + message_data['role'] = role + try: + return provider_message.Message.model_validate(message_data) + except Exception: + pass + + content = item.get('content') + if content is None: + return None + return provider_message.Message(role=role, content=content) diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py new file mode 100644 index 000000000..ad8375cb8 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import quart + +from .. import group + + +@group.group_class('agents', '/api/v1/agents') +class AgentsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + sort_by = quart.request.args.get('sort_by', 'updated_at') + sort_order = quart.request.args.get('sort_order', 'DESC') + return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)}) + + json_data = await quart.request.json + try: + created = await self.ap.agent_service.create_agent(json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data=created) + + @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + return self.success(data=await self.ap.agent_service.get_agent_metadata()) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(agent_uuid: str) -> str: + if quart.request.method == 'GET': + agent = await self.ap.agent_service.get_agent(agent_uuid) + if agent is None: + return self.http_status(404, -1, 'agent not found') + return self.success(data={'agent': agent}) + + if quart.request.method == 'PUT': + json_data = await quart.request.json + try: + await self.ap.agent_service.update_agent(agent_uuid, json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success() + + await self.ap.agent_service.delete_agent(agent_uuid) + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py index 99c00944f..7a1b64922 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -3,7 +3,7 @@ All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that internal pipeline identifiers are never exposed to end-users. Each handler resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts -the bound pipeline_uuid for internal routing. +the message event route's Pipeline target for internal routing. """ import asyncio @@ -62,16 +62,17 @@ def _resolve_bot(self, bot_uuid: str): """Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``. Returns ``(None, None)`` when the bot does not exist, is not a - ``web_page_bot``, is disabled, or has no pipeline bound. + ``web_page_bot``, is disabled, or has no Pipeline target for messages. """ for bot in self.ap.platform_mgr.bots: + pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if ( bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable - and bot.bot_entity.use_pipeline_uuid + and pipeline_uuid ): - return bot, bot.bot_entity.use_pipeline_uuid + return bot, pipeline_uuid return None, None def _get_bot_config(self, bot_uuid: str) -> dict: diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 2e45add77..f45c89527 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -3,6 +3,7 @@ import quart from ... import group +from ......pipeline.extension_preferences import normalize_extension_preferences @group.group_class('pipelines', '/api/v1/pipelines') @@ -19,7 +20,10 @@ async def _() -> str: elif quart.request.method == 'POST': json_data = await quart.request.json - pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + try: + pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success(data={'uuid': pipeline_uuid}) @@ -41,7 +45,10 @@ async def _(pipeline_uuid: str) -> str: elif quart.request.method == 'PUT': json_data = await quart.request.json - await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + try: + await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success() elif quart.request.method == 'DELETE': @@ -73,24 +80,27 @@ async def _(pipeline_uuid: str) -> str: plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) - # Get available skills - available_skills = await self.ap.skill_service.list_skills() + # Skill listing depends on Box. Pipeline plugin/MCP binding + # must remain usable when Box is slow or unavailable. + try: + available_skills = await self.ap.skill_service.list_skills() + except Exception as exc: + self.ap.logger.warning('Unable to list skills for pipeline extensions: %s', exc) + available_skills = [] - extensions_prefs = pipeline.get('extensions_preferences', {}) + extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) return self.success( data={ - 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), - 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), - 'enable_all_skills': extensions_prefs.get('enable_all_skills', True), - 'bound_plugins': extensions_prefs.get('plugins', []), + 'enable_all_plugins': extensions_prefs['enable_all_plugins'], + 'enable_all_mcp_servers': extensions_prefs['enable_all_mcp_servers'], + 'enable_all_skills': extensions_prefs['enable_all_skills'], + 'bound_plugins': extensions_prefs['plugins'], 'available_plugins': plugins, - 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), + 'bound_mcp_servers': extensions_prefs['mcp_servers'], 'available_mcp_servers': mcp_servers, - 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []), - 'mcp_resource_agent_read_enabled': extensions_prefs.get( - 'mcp_resource_agent_read_enabled', True - ), - 'bound_skills': extensions_prefs.get('skills', []), + 'bound_mcp_resources': extensions_prefs['mcp_resources'], + 'mcp_resource_agent_read_enabled': extensions_prefs['mcp_resource_agent_read_enabled'], + 'bound_skills': extensions_prefs['skills'], 'available_skills': available_skills, } ) @@ -106,16 +116,41 @@ async def _(pipeline_uuid: str) -> str: bound_mcp_resources = json_data.get('bound_mcp_resources') mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled') - await self.ap.pipeline_service.update_pipeline_extensions( - pipeline_uuid, - bound_plugins, - bound_mcp_servers, - enable_all_plugins, - enable_all_mcp_servers, - bound_skills=bound_skills, - enable_all_skills=enable_all_skills, - bound_mcp_resources=bound_mcp_resources, - mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled, - ) + extension_flags = { + 'enable_all_plugins': enable_all_plugins, + 'enable_all_mcp_servers': enable_all_mcp_servers, + 'enable_all_skills': enable_all_skills, + } + for field, value in extension_flags.items(): + if field in json_data and not isinstance(value, bool): + return self.http_status( + 400, + -1, + f"Pipeline extension field '{field}' must be a boolean", + ) + + if 'mcp_resource_agent_read_enabled' in json_data and not isinstance( + mcp_resource_agent_read_enabled, bool + ): + return self.http_status( + 400, + -1, + "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean", + ) + + try: + await self.ap.pipeline_service.update_pipeline_extensions( + pipeline_uuid, + bound_plugins, + bound_mcp_servers, + enable_all_plugins, + enable_all_mcp_servers, + bound_skills=bound_skills, + enable_all_skills=enable_all_skills, + bound_mcp_resources=bound_mcp_resources, + mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled, + ) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index e3a13b789..61505b247 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -38,6 +38,49 @@ async def _(bot_uuid: str) -> str: logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) return self.success(data={'logs': logs, 'total_count': total_count}) + @self.route('//event-routes/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + return self.success(data=await self.ap.bot_service.list_event_route_statuses(bot_uuid)) + + async def _dry_run_event_route(bot_uuid: str) -> str: + json_data = await quart.request.json + if not isinstance(json_data, dict): + return self.http_status(400, -1, 'invalid request body') + + result = await self.ap.bot_service.dry_run_event_route( + bot_uuid=bot_uuid, + event_type=json_data.get('event_type'), + event_data=json_data.get('event_data', json_data.get('payload')), + context=json_data.get('context'), + event_bindings=json_data.get('event_bindings'), + ) + return self.success(data=result) + + self.route( + '//event-routes/dry-run', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + )(_dry_run_event_route) + # Backward-compatible alias for early local clients/tests created before + # the product route naming was settled. + self.route( + '//event_route/dry_run', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + )(_dry_run_event_route) + + @self.route('//event-routes/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + json_data = await quart.request.json + if not isinstance(json_data, dict): + return self.http_status(400, -1, 'invalid request body') + result = await self.ap.bot_service.dispatch_test_event_route( + bot_uuid=bot_uuid, + event_type=json_data.get('event_type'), + payload=json_data.get('event_data', json_data.get('payload')), + ) + return self.success(data=result) + @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index 128a0647d..b98062484 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -3,59 +3,61 @@ import quart from ... import group +from ......pipeline.extension_preferences import normalize_extension_preferences @group.group_class('tools', '/api/v1/tools') class ToolsRouterGroup(group.RouterGroup): + async def _get_scoped_tool_catalog(self) -> list[dict] | None: + pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') + bound_plugins: list[str] | None = None + bound_mcp_servers: list[str] | None = None + + if pipeline_uuid: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return None + + extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) + if not extensions_prefs['enable_all_plugins']: + bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']] + if not extensions_prefs['enable_all_mcp_servers']: + bound_mcp_servers = extensions_prefs['mcp_servers'] + + return await self.ap.tool_mgr.get_resolved_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + ) + async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """获取所有可用工具列表""" - pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') - bound_plugins: list[str] | None = None - bound_mcp_servers: list[str] | None = None - - if pipeline_uuid: - pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) - if pipeline is None: - return self.http_status(404, -1, 'pipeline not found') - - extensions_prefs = pipeline.get('extensions_preferences', {}) or {} - if not extensions_prefs.get('enable_all_plugins', True): - bound_plugins = [ - f'{plugin.get("author", "")}/{plugin.get("name", "")}' - for plugin in extensions_prefs.get('plugins', []) - if isinstance(plugin, dict) and plugin.get('name') - ] - if not extensions_prefs.get('enable_all_mcp_servers', True): - bound_mcp_servers = [ - server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str) - ] - - return self.success( - data={ - 'tools': await self.ap.tool_mgr.get_tool_catalog( - bound_plugins, - bound_mcp_servers, - include_skill_authoring=True, - ) - } - ) + catalog = await self._get_scoped_tool_catalog() + if catalog is None: + return self.http_status(404, -1, 'pipeline not found') + return self.success(data={'tools': catalog}) - @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(tool_name: str) -> str: """获取特定工具详情""" - tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True) + catalog = await self._get_scoped_tool_catalog() + if catalog is None: + return self.http_status(404, -1, 'pipeline not found') - for tool in tools: - if tool.name == tool_name: + for tool in catalog: + if tool.get('name') == tool_name: return self.success( data={ 'tool': { - 'name': tool.name, - 'description': tool.description, - 'human_desc': tool.human_desc, - 'parameters': tool.parameters, + 'name': tool['name'], + 'description': tool.get('description') or '', + 'human_desc': tool.get('human_desc') or '', + 'parameters': tool.get('parameters') or {}, + 'source': tool.get('source'), + 'source_name': tool.get('source_name'), + 'source_id': tool.get('source_id'), } } ) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 236a23582..7ca4340b9 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -105,8 +105,8 @@ async def _() -> str: """Save wizard progress to metadata table. Accepts JSON body with wizard state fields: - { "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null, - "bot_saved": bool, "selected_runner": str|null } + { "step": int, "selected_scenario": str|null, "selected_adapter": str|null, + "created_bot_uuid": str|null, "bot_saved": bool, "selected_runner": str|null } """ data = await quart.request.get_json(silent=True) or {} progress_json = json.dumps(data, ensure_ascii=False) diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py new file mode 100644 index 000000000..9f8accd08 --- /dev/null +++ b/src/langbot/pkg/api/http/service/agent.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import datetime +import uuid +import typing + +import sqlalchemy + +from ....core import app +from ....agent.runner.config_resolver import RunnerConfigResolver +from ....entity.persistence import agent as persistence_agent + + +AGENT_KIND_AGENT = 'agent' +AGENT_KIND_PIPELINE = 'pipeline' +PIPELINE_EVENT_PATTERNS = ['message.*'] +AGENT_DEFAULT_EVENT_PATTERNS = ['*'] + + +class AgentService: + """Unified processor facade for the peer Agent and Pipeline types.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_agent_metadata(self) -> dict[str, typing.Any]: + """Return metadata needed by Agent forms.""" + pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata() + ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None) + return { + 'runner_config': ai_metadata, + 'kinds': [ + { + 'name': AGENT_KIND_AGENT, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + }, + { + 'name': AGENT_KIND_PIPELINE, + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + }, + ], + } + + async def get_agents(self, sort_by: str = 'updated_at', sort_order: str = 'DESC') -> list[dict]: + agents = await self._get_agent_rows() + pipelines = await self.ap.pipeline_service.get_pipelines(sort_by='updated_at', sort_order='DESC') + + items = [self._agent_to_product_item(agent) for agent in agents] + items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines) + + reverse = sort_order == 'DESC' + sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at' + return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse) + + async def get_agent(self, agent_uuid: str) -> dict | None: + agent = await self._get_agent_row(agent_uuid) + if agent is not None: + return self._agent_to_product_item(agent, include_config=True) + + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is not None: + return self._pipeline_to_product_item(pipeline, include_config=True) + + return None + + async def create_agent(self, agent_data: dict) -> dict[str, str]: + kind = agent_data.get('kind') or AGENT_KIND_AGENT + if kind == AGENT_KIND_PIPELINE: + pipeline_uuid = await self.ap.pipeline_service.create_pipeline( + { + 'name': agent_data.get('name') or 'New Pipeline', + 'description': agent_data.get('description') or '', + 'emoji': agent_data.get('emoji') or '⚙️', + 'config': {}, + } + ) + return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE} + + if kind != AGENT_KIND_AGENT: + raise ValueError(f'Unsupported agent kind: {kind}') + + config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config() + config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config) + new_uuid = str(uuid.uuid4()) + values = { + 'uuid': new_uuid, + 'name': agent_data.get('name') or 'New Agent', + 'description': agent_data.get('description') or '', + 'emoji': agent_data.get('emoji') or '🤖', + 'kind': AGENT_KIND_AGENT, + 'component_ref': runner_id, + 'config': config, + 'enabled': agent_data.get('enabled', True), + 'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + } + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values)) + return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT} + + async def update_agent(self, agent_uuid: str, agent_data: dict) -> None: + existing_agent = await self._get_agent_row(agent_uuid) + if existing_agent is None: + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is None: + raise ValueError(f'Agent {agent_uuid} not found') + await self.ap.pipeline_service.update_pipeline(agent_uuid, agent_data) + return + + update_data = agent_data.copy() + for protected_field in ('uuid', 'kind', 'component_ref', 'created_at', 'updated_at', 'capability'): + update_data.pop(protected_field, None) + if 'config' in update_data: + config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config']) + update_data['config'] = config + else: + _, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config) + update_data['component_ref'] = runner_id + if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']: + update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_agent.Agent) + .where(persistence_agent.Agent.uuid == agent_uuid) + .values(**update_data) + ) + + async def delete_agent(self, agent_uuid: str) -> None: + existing_agent = await self._get_agent_row(agent_uuid) + if existing_agent is not None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return + + pipeline = await self.ap.pipeline_service.get_pipeline(agent_uuid) + if pipeline is None: + raise ValueError(f'Agent {agent_uuid} not found') + await self.ap.pipeline_service.delete_pipeline(agent_uuid) + + async def _get_agent_rows(self) -> list[persistence_agent.Agent]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_agent.Agent)) + return list(result.all()) + + async def _get_agent_row(self, agent_uuid: str) -> persistence_agent.Agent | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return result.first() + + async def _get_default_agent_config(self) -> dict[str, typing.Any]: + runners = [] + if getattr(self.ap, 'agent_runner_registry', None) is not None: + try: + runners = await self.ap.agent_runner_registry.list_runners(bound_plugins=None) + except Exception as e: + if getattr(self.ap, 'logger', None): + self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}') + + if not runners: + return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}} + + selected_runner = runners[0] + return { + 'runner': {'id': selected_runner.id, 'expire-time': 0}, + 'runner_config': { + selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema( + selected_runner.config_schema + ) + }, + } + + def _agent_to_product_item( + self, + agent: persistence_agent.Agent, + include_config: bool = False, + ) -> dict[str, typing.Any]: + item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent) + item['kind'] = AGENT_KIND_AGENT + item['capability'] = { + 'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + } + if not include_config: + item.pop('config', None) + return item + + @staticmethod + def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]: + item = pipeline.copy() + item['kind'] = AGENT_KIND_PIPELINE + item['component_ref'] = 'pipeline' + item['enabled'] = True + item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS + item['capability'] = { + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + } + if not include_config: + item.pop('config', None) + return item + + @staticmethod + def _parse_sort_time(value: typing.Any) -> datetime.datetime: + if isinstance(value, datetime.datetime): + return value + if isinstance(value, str): + try: + return datetime.datetime.fromisoformat(value) + except ValueError: + return datetime.datetime.min + return datetime.datetime.min diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 995267cf5..e023a71a4 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -5,6 +5,8 @@ import typing from ....core import app +from ....discover import engine +from ....entity.persistence import agent as persistence_agent from ....entity.persistence import bot as persistence_bot from ....entity.persistence import pipeline as persistence_pipeline @@ -13,10 +15,546 @@ class BotService: """Bot service""" ap: app.Application + FAILURE_ROUTE_NOT_FOUND = 'route_not_found' + FAILURE_PROCESSOR_DISABLED = 'processor_disabled' + FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found' + FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible' + FAILURE_INVALID_EVENT = 'invalid_event' + ROUTE_TRACE_KIND = 'event_route_trace' + + BOT_FIELDS = { + 'uuid', + 'name', + 'description', + 'adapter', + 'adapter_config', + 'enable', + 'event_bindings', + } def __init__(self, ap: app.Application) -> None: self.ap = ap + def _get_adapter_component(self, adapter_name: str) -> engine.Component | None: + """Return the discovered platform adapter component for an adapter name.""" + for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'): + if component.metadata.name == adapter_name: + return component + return None + + def _adapter_declares_webhook_url(self, adapter_name: str) -> bool: + """Whether the adapter manifest declares a generated webhook URL config item.""" + component = self._get_adapter_component(adapter_name) + if component is None: + return False + + for config_item in component.spec.get('config', []): + if config_item.get('type') == 'webhook-url': + return True + return False + + @staticmethod + def _is_message_event_pattern(event_pattern: str) -> bool: + return event_pattern == 'message.*' or event_pattern.startswith('message.') + + @staticmethod + def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool: + if supported_pattern == '*': + return True + if supported_pattern == binding_pattern: + return True + if binding_pattern == '*': + return False + if supported_pattern.endswith('.*'): + namespace = supported_pattern[:-2] + return binding_pattern == f'{namespace}.*' or binding_pattern.startswith(f'{namespace}.') + return False + + @classmethod + def _agent_supports_event_pattern(cls, supported_patterns: list[str] | None, event_pattern: str) -> bool: + patterns = supported_patterns or ['*'] + return any(cls._event_pattern_covers(pattern, event_pattern) for pattern in patterns) + + @staticmethod + def _format_diagnostic_step(step: dict[str, typing.Any]) -> str: + step_name = step.get('step') or 'diagnostic' + reason = step.get('reason') or step.get('failure_code') or 'No reason provided' + + if step_name == 'evaluate_binding': + route_number = step.get('binding_index') + if not isinstance(route_number, int): + route_number = step.get('order') + route_label = f'Route {int(route_number) + 1}' if isinstance(route_number, int) else 'Route' + event_pattern = step.get('event_pattern') or '*' + if step.get('selected'): + return f'{route_label} ({event_pattern}) selected: {reason}' + if step.get('matched'): + return f'{route_label} ({event_pattern}) matched: {reason}' + return f'{route_label} ({event_pattern}) skipped: {reason}' + + if step_name == 'validate_processor': + target_type = step.get('target_type') or 'processor' + target_uuid = step.get('target_uuid') or '' + suffix = f' {target_uuid}' if target_uuid else '' + return f'Validate {target_type}{suffix}: {reason}' + + return str(reason) + + @classmethod + def _format_diagnostic_steps(cls, diagnostic_details: list[dict[str, typing.Any]] | None) -> list[str]: + return [cls._format_diagnostic_step(step) for step in diagnostic_details or []] + + @classmethod + def _event_route_status_from_log(cls, log: typing.Any) -> dict[str, typing.Any] | None: + if hasattr(log, 'to_json'): + log_data = log.to_json() + elif isinstance(log, dict): + log_data = log + else: + log_data = { + 'seq_id': getattr(log, 'seq_id', None), + 'timestamp': getattr(log, 'timestamp', None), + 'level': getattr(getattr(log, 'level', None), 'value', getattr(log, 'level', None)), + 'text': getattr(log, 'text', None), + 'metadata': getattr(log, 'metadata', None), + } + + metadata = log_data.get('metadata') + if not isinstance(metadata, dict) or metadata.get('kind') != cls.ROUTE_TRACE_KIND: + return None + + return { + 'binding_id': metadata.get('binding_id'), + 'event_pattern': metadata.get('event_pattern'), + 'event_type': metadata.get('event_type'), + 'target_type': metadata.get('target_type'), + 'target_uuid': metadata.get('target_uuid') or '', + 'last_status': metadata.get('status'), + 'failure_code': metadata.get('failure_code'), + 'reason': metadata.get('reason') or log_data.get('text') or '', + 'run_id': metadata.get('run_id'), + 'timestamp': log_data.get('timestamp'), + 'seq_id': log_data.get('seq_id'), + 'level': log_data.get('level'), + 'message': log_data.get('text') or '', + } + + @staticmethod + def _target_kind(target_type: typing.Any, target_kind: str | None = None) -> str | None: + if target_kind: + return target_kind + if target_type == 'discard': + return 'discard' + if target_type in {'agent', 'pipeline'}: + return str(target_type) + return None + + @classmethod + def _diagnostic_result( + cls, + *, + matched: bool, + failure_code: str | None = None, + reason: str = '', + binding: dict[str, typing.Any] | None = None, + diagnostic_steps: list[dict[str, typing.Any]] | None = None, + target_name: str | None = None, + target_kind: str | None = None, + ) -> dict[str, typing.Any]: + binding = binding or {} + target_type = binding.get('target_type') + target_uuid = binding.get('target_uuid') or '' + matched_binding_index = binding.get('_dry_run_index') + if not isinstance(matched_binding_index, int): + matched_binding_index = binding.get('order') + if not isinstance(matched_binding_index, int): + matched_binding_index = None + target = None + if target_type: + target = { + 'target_type': target_type, + 'target_uuid': target_uuid or None, + 'target_name': target_name, + 'kind': cls._target_kind(target_type, target_kind), + } + return { + 'matched': matched, + 'binding_id': binding.get('id'), + 'matched_binding_id': binding.get('id'), + 'matched_binding_index': matched_binding_index, + 'event_pattern': binding.get('event_pattern'), + 'target_type': binding.get('target_type'), + 'target_uuid': target_uuid, + 'target': target, + 'reason': reason, + 'failure_code': failure_code, + 'diagnostic_steps': cls._format_diagnostic_steps(diagnostic_steps), + 'diagnostic_details': diagnostic_steps or [], + } + + @staticmethod + def _build_dry_run_event(event_type: str, event_data: typing.Any, context: typing.Any) -> dict[str, typing.Any]: + event: dict[str, typing.Any] = {} + if isinstance(event_data, dict): + event.update(event_data) + elif event_data is not None: + raise ValueError('event_data must be an object') + event['type'] = event_type + + if context is None: + return event + if not isinstance(context, dict): + raise ValueError('context must be an object') + event['context'] = context + return event + + @staticmethod + def _normalize_dry_run_bindings(bindings: typing.Any) -> list[dict[str, typing.Any]]: + if bindings is None: + return [] + if not isinstance(bindings, list): + raise ValueError('event_bindings must be an array') + + normalized: list[dict[str, typing.Any]] = [] + for index, raw_binding in enumerate(bindings): + if not isinstance(raw_binding, dict): + continue + event_pattern = str(raw_binding.get('event_pattern') or '').strip() + target_type = str(raw_binding.get('target_type') or '').strip() + if not event_pattern or not target_type: + continue + + try: + priority = int(raw_binding.get('priority') or 0) + except (TypeError, ValueError): + priority = 0 + + target_uuid = str(raw_binding.get('target_uuid') or '').strip() + if target_type == 'discard': + target_uuid = '' + + filters = raw_binding.get('filters') if isinstance(raw_binding.get('filters'), list) else [] + normalized.append( + { + 'id': raw_binding.get('id'), + 'event_pattern': event_pattern, + 'target_type': target_type, + 'target_uuid': target_uuid, + 'filters': filters, + 'priority': priority, + 'enabled': bool(raw_binding.get('enabled', True)), + # For draft bindings, current array order is the effective order + # that would be persisted on save. + 'order': index, + '_dry_run_index': index, + } + ) + return normalized + + @staticmethod + def _index_event_bindings(bindings: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]: + indexed: list[dict[str, typing.Any]] = [] + for index, binding in enumerate(bindings): + copied = binding.copy() + copied.setdefault('_dry_run_index', index) + indexed.append(copied) + return indexed + + async def _get_pipeline_entity(self, pipeline_uuid: str) -> persistence_pipeline.LegacyPipeline | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + return result.first() + + async def _get_agent_entity(self, agent_uuid: str) -> persistence_agent.Agent | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid) + ) + return result.first() + + async def dry_run_event_route( + self, + bot_uuid: str, + event_type: str, + event_data: dict[str, typing.Any] | None = None, + context: dict[str, typing.Any] | None = None, + event_bindings: list[dict[str, typing.Any]] | None = None, + ) -> dict[str, typing.Any]: + """Diagnose Bot event routing without dispatching to Agent, Pipeline, or platform actions.""" + from ....platform.botmgr import RuntimeBot + + event_type = str(event_type or '').strip() + if not event_type: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_INVALID_EVENT, + reason='event_type is required', + diagnostic_steps=[ + { + 'step': 'validate_event', + 'matched': False, + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': 'event_type is required', + } + ], + ) + + bot = await self.get_bot(bot_uuid, include_secret=False) + if bot is None: + raise Exception('Bot not found') + + try: + event = self._build_dry_run_event(event_type, event_data, context) + bindings = ( + self._normalize_dry_run_bindings(event_bindings) + if event_bindings is not None + else self._index_event_bindings( + RuntimeBot._get_event_bindings_from_value(bot.get('event_bindings') or []) + ) + ) + except ValueError as exc: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_INVALID_EVENT, + reason=str(exc), + diagnostic_steps=[ + { + 'step': 'validate_event', + 'matched': False, + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': str(exc), + } + ], + ) + + selected_binding, diagnostic_steps = RuntimeBot._evaluate_eba_event_bindings( + bindings, + event, + event_type, + ) + if selected_binding is None: + return self._diagnostic_result( + matched=False, + failure_code=self.FAILURE_ROUTE_NOT_FOUND, + reason='No enabled event binding matched event_type and filters', + diagnostic_steps=diagnostic_steps, + ) + + target_type = selected_binding.get('target_type') + target_uuid = str(selected_binding.get('target_uuid') or '') + + if target_type == 'discard': + return self._diagnostic_result( + matched=True, + binding=selected_binding, + reason='Event route matched discard target', + diagnostic_steps=diagnostic_steps, + ) + + if target_type == 'pipeline': + if not RuntimeBot._is_message_event_type(event_type): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason='Pipeline targets only support message events', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': 'Pipeline targets only support message events', + } + ], + ) + pipeline = await self._get_pipeline_entity(target_uuid) if target_uuid else None + if pipeline is None: + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_NOT_FOUND, + reason='Pipeline target not found', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_NOT_FOUND, + 'reason': 'Pipeline target not found', + } + ], + ) + return self._diagnostic_result( + matched=True, + binding=selected_binding, + target_name=getattr(pipeline, 'name', None), + reason='Event route matched pipeline target', + diagnostic_steps=diagnostic_steps, + ) + + if target_type == 'agent': + agent = await self._get_agent_entity(target_uuid) + if agent is None or getattr(agent, 'kind', 'agent') != 'agent': + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_NOT_FOUND, + reason='Agent target not found', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_NOT_FOUND, + 'reason': 'Agent target not found', + } + ], + ) + if not getattr(agent, 'enabled', True): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_DISABLED, + reason='Agent target is disabled', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_DISABLED, + 'reason': 'Agent target is disabled', + } + ], + ) + if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type): + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason='Agent target does not support this event type', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': 'Agent target does not support this event type', + } + ], + ) + return self._diagnostic_result( + matched=True, + binding=selected_binding, + target_name=getattr(agent, 'name', None), + target_kind=getattr(agent, 'kind', None), + reason='Event route matched agent target', + diagnostic_steps=diagnostic_steps, + ) + + return self._diagnostic_result( + matched=False, + binding=selected_binding, + failure_code=self.FAILURE_PROCESSOR_INCOMPATIBLE, + reason=f'Unsupported event binding target type: {target_type}', + diagnostic_steps=diagnostic_steps + + [ + { + 'step': 'validate_processor', + 'binding_id': selected_binding.get('id'), + 'target_type': target_type, + 'target_uuid': target_uuid, + 'matched': False, + 'failure_code': self.FAILURE_PROCESSOR_INCOMPATIBLE, + 'reason': f'Unsupported event binding target type: {target_type}', + } + ], + ) + + async def _normalize_event_bindings(self, bindings: list[dict] | None) -> list[dict]: + """Validate and normalize Bot event bindings.""" + if not bindings: + return [] + + normalized: list[dict] = [] + for index, raw_binding in enumerate(bindings): + if not isinstance(raw_binding, dict): + continue + + event_pattern = str(raw_binding.get('event_pattern') or '').strip() + target_type = str(raw_binding.get('target_type') or '').strip() + target_uuid = str(raw_binding.get('target_uuid') or '').strip() + if not event_pattern or not target_type: + continue + + if target_type == 'pipeline': + if not self._is_message_event_pattern(event_pattern): + raise ValueError('Pipeline can only be bound to message events') + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where( + persistence_pipeline.LegacyPipeline.uuid == target_uuid + ) + ) + if result.first() is None: + raise ValueError('Pipeline not found') + elif target_type == 'agent': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid) + ) + agent = result.first() + if agent is None: + raise ValueError('Agent not found') + if not self._agent_supports_event_pattern(agent.supported_event_patterns, event_pattern): + raise ValueError('Agent does not support this event pattern') + elif target_type == 'discard': + target_uuid = '' + else: + raise ValueError(f'Unsupported event binding target type: {target_type}') + + normalized.append( + { + 'id': raw_binding.get('id') or str(uuid.uuid4()), + 'event_pattern': event_pattern, + 'target_type': target_type, + 'target_uuid': target_uuid, + 'filters': raw_binding.get('filters') or [], + 'priority': int(raw_binding.get('priority') or 0), + 'enabled': bool(raw_binding.get('enabled', True)), + 'description': raw_binding.get('description') or '', + 'order': index, + } + ) + + return normalized + + async def _prepare_bot_data(self, bot_data: dict, *, include_uuid: bool) -> dict: + """Normalize Bot write payloads to the current event-routing model.""" + update_data = bot_data.copy() + if not include_uuid: + update_data.pop('uuid', None) + + update_data = {key: value for key, value in update_data.items() if key in self.BOT_FIELDS} + if 'event_bindings' in update_data: + update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings')) + return update_data + async def get_bots(self, include_secret: bool = True) -> list[dict]: """获取所有机器人""" result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) @@ -58,17 +596,10 @@ async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) if runtime_bot is not None: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id - # Webhook URL for unified webhook adapters (independent of bot running state) - if persistence_bot['adapter'] in [ - 'wecom', - 'wecombot', - 'officialaccount', - 'qqofficial', - 'slack', - 'wecomcs', - 'LINE', - 'lark', - ]: + # Webhook URL for adapters that declare a generated webhook config item. + # This is manifest-driven so EBA adapters do not need to be mirrored in a + # second hard-coded list. + if self._adapter_declares_webhook_url(persistence_bot['adapter']): webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') webhook_url = f'/bots/{bot_uuid}' @@ -97,46 +628,23 @@ async def create_bot(self, bot_data: dict) -> str: raise ValueError(f'Maximum number of bots ({max_bots}) reached') # TODO: 检查配置信息格式 + bot_data = await self._prepare_bot_data(bot_data, include_uuid=True) bot_data['uuid'] = str(uuid.uuid4()) - - # bind the most recently updated pipeline if any exist - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline) - .order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) - .limit(1) - ) - pipeline = result.first() - if pipeline is not None: - bot_data['use_pipeline_uuid'] = pipeline.uuid - bot_data['use_pipeline_name'] = pipeline.name + bot_data.setdefault('event_bindings', []) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) bot = await self.get_bot(bot_data['uuid']) - await self.ap.platform_mgr.load_bot(bot) + runtime_bot = await self.ap.platform_mgr.load_bot(bot) + if runtime_bot.enable: + await runtime_bot.run() return bot_data['uuid'] async def update_bot(self, bot_uuid: str, bot_data: dict) -> None: """Update bot""" - update_data = bot_data.copy() - - if 'uuid' in update_data: - del update_data['uuid'] - - # set use_pipeline_name - if 'use_pipeline_uuid' in update_data: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid'] - ) - ) - pipeline = result.first() - if pipeline is not None: - update_data['use_pipeline_name'] = pipeline.name - else: - raise Exception('Pipeline not found') + update_data = await self._prepare_bot_data(bot_data, include_uuid=False) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid) @@ -174,6 +682,123 @@ async def list_event_logs( return [log.to_json() for log in logs], total_count + async def list_event_route_statuses(self, bot_uuid: str) -> dict[str, typing.Any]: + """Return recent runtime status for Bot event routes from in-memory Bot logs.""" + from ....platform.botmgr import RuntimeBot + + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception('Bot not found') + + latest_by_binding: dict[str, dict[str, typing.Any]] = {} + unmatched_events: list[dict[str, typing.Any]] = [] + for log in getattr(runtime_bot.logger, 'logs', []): + status = self._event_route_status_from_log(log) + if status is None: + continue + binding_id = status.get('binding_id') + if binding_id: + latest_by_binding[str(binding_id)] = status + else: + unmatched_events.append(status) + + raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', []) + bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings) + routes: list[dict[str, typing.Any]] = [] + current_binding_ids: set[str] = set() + for index, binding in enumerate(bindings): + binding_id = binding.get('id') + if binding_id: + current_binding_ids.add(str(binding_id)) + route_status = { + 'binding_id': binding_id, + 'event_pattern': binding.get('event_pattern'), + 'event_type': None, + 'target_type': binding.get('target_type'), + 'target_uuid': binding.get('target_uuid') or '', + 'last_status': None, + 'failure_code': None, + 'reason': None, + 'run_id': None, + 'timestamp': None, + 'seq_id': None, + 'level': None, + 'message': '', + 'order': binding.get('order', index), + 'enabled': binding.get('enabled', True), + 'current': True, + } + if binding_id and str(binding_id) in latest_by_binding: + route_status.update(latest_by_binding[str(binding_id)]) + route_status['order'] = binding.get('order', index) + route_status['enabled'] = binding.get('enabled', True) + route_status['current'] = True + routes.append(route_status) + + stale_routes = [ + {**status, 'current': False} + for binding_id, status in latest_by_binding.items() + if binding_id not in current_binding_ids + ] + + return { + 'routes': routes, + 'unmatched_events': unmatched_events[-10:], + 'stale_routes': stale_routes, + } + + async def dispatch_test_event_route( + self, + bot_uuid: str, + event_type: str, + payload: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Dispatch a synthetic event through the saved Bot runtime route configuration.""" + event_type = str(event_type or '').strip() + if not event_type: + return { + 'dispatched': False, + 'event_type': '', + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': 'event_type is required', + 'suppressed_outputs': [], + 'route_status': { + 'routes': [], + 'unmatched_events': [], + 'stale_routes': [], + }, + } + if payload is not None and not isinstance(payload, dict): + return { + 'dispatched': False, + 'event_type': event_type, + 'failure_code': self.FAILURE_INVALID_EVENT, + 'reason': 'payload must be an object', + 'suppressed_outputs': [], + 'route_status': { + 'routes': [], + 'unmatched_events': [], + 'stale_routes': [], + }, + } + + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception('Bot not found') + + dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {}) + route_status = await self.list_event_route_statuses(bot_uuid) + return { + 'dispatched': bool(dispatch_result.get('dispatched')), + 'event_type': event_type, + 'status': dispatch_result.get('status'), + 'binding_id': dispatch_result.get('binding_id'), + 'failure_code': dispatch_result.get('failure_code'), + 'reason': dispatch_result.get('reason'), + 'suppressed_outputs': dispatch_result.get('suppressed_outputs', []), + 'route_status': route_status, + } + async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None: """Send message to a specific target via bot diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index 87298c084..ff01c8834 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -7,7 +7,6 @@ from ....core import app from ....entity.persistence import model as persistence_model -from ....entity.persistence import pipeline as persistence_pipeline from ....provider.modelmgr import requester as model_requester @@ -151,23 +150,9 @@ async def create_llm_model( self.ap.model_mgr.llm_models.append(runtime_llm_model) if auto_set_to_default_pipeline: - # set the default pipeline model to this model - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.is_default == True - ) - ) - pipeline = result.first() - if pipeline is not None: - model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {}) - if not model_config.get('primary', ''): - pipeline_config = pipeline.config - pipeline_config['ai']['local-agent']['model'] = { - 'primary': model_data['uuid'], - 'fallbacks': [], - } - pipeline_data = {'config': pipeline_config} - await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data) + default_config_service = getattr(self.ap, 'agent_runner_default_config_service', None) + if default_config_service is not None: + await default_config_service.auto_set_default_pipeline_llm_model(model_data['uuid']) return model_data['uuid'] diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 2a6451c8b..51523a727 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -3,9 +3,15 @@ import uuid import json import sqlalchemy +import typing from ....core import app +from ....agent.runner.config_resolver import RunnerConfigResolver from ....entity.persistence import pipeline as persistence_pipeline +from ....pipeline.extension_preferences import ( + normalize_extension_preferences, + validate_extension_preferences, +) default_stage_order = [ @@ -13,7 +19,6 @@ 'BanSessionCheckStage', # 封禁会话检查 'PreContentFilterStage', # 内容过滤前置阶段 'PreProcessor', # 预处理器 - 'ConversationMessageTruncator', # 会话消息截断器 'RequireRateLimitOccupancy', # 请求速率限制占用 'MessageProcessor', # 处理器 'ReleaseRateLimitOccupancy', # 释放速率限制占用 @@ -30,11 +35,100 @@ class PipelineService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _get_default_values_from_schema(self, config_schema: list[dict[str, typing.Any]]) -> dict[str, typing.Any]: + """Build runner config defaults from a DynamicForm schema.""" + defaults: dict[str, typing.Any] = {} + for item in config_schema: + name = item.get('name') + if not name: + continue + if 'default' in item: + defaults[name] = item['default'] + return defaults + + async def get_default_pipeline_config(self) -> dict[str, typing.Any]: + """Get the default pipeline config, rendering runner defaults from installed plugins.""" + from ....utils import paths as path_utils + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + agent_runner_registry = getattr(self.ap, 'agent_runner_registry', None) + if agent_runner_registry is None: + return config + + try: + runners = await agent_runner_registry.list_runners(bound_plugins=None) + except Exception as e: + logger = getattr(self.ap, 'logger', None) + if logger: + logger.warning(f'Failed to load plugin agent runners for default pipeline config: {e}') + return config + + if not runners: + return config + + selected_runner = runners[0] + ai_config = config.setdefault('ai', {}) + runner_config = ai_config.setdefault('runner', {}) + runner_config['id'] = selected_runner.id + runner_config.setdefault('expire-time', 0) + + ai_config['runner_config'] = { + selected_runner.id: self._get_default_values_from_schema(selected_runner.config_schema), + } + + return config + async def get_pipeline_metadata(self) -> list[dict]: + """Get pipeline metadata with dynamically loaded plugin runners from registry""" + import copy + + # Deep copy AI metadata to avoid modifying the original + ai_metadata = copy.deepcopy(self.ap.pipeline_config_meta_ai) + + # Find the runner stage + runner_stage = None + for stage in ai_metadata.get('stages', []): + if stage.get('name') == 'runner': + runner_stage = stage + break + + if runner_stage: + # Find the runner select config (now uses 'id' field) + for config_item in runner_stage.get('config', []): + if config_item.get('name') == 'id': + # Get plugin agent runners from registry + try: + ( + runner_options, + runner_stages, + ) = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline() + + # Replace options entirely with registry options + # Only installed/available runners should be shown + config_item['options'] = runner_options + + # Use the registry order as the default order. If no runner is available, leave + # the default unset so the UI can recommend installing an AgentRunner plugin. + if runner_options and 'default' not in config_item: + config_item['default'] = runner_options[0]['name'] + + # Add corresponding stage configuration for each runner + for stage_config in runner_stages: + # Avoid duplicate stages + existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])} + if stage_config['name'] not in existing_stage_names: + ai_metadata['stages'].append(stage_config) + + except Exception as e: + self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}') + return [ self.ap.pipeline_config_meta_trigger, self.ap.pipeline_config_meta_safety, - self.ap.pipeline_config_meta_ai, + ai_metadata, self.ap.pipeline_config_meta_output, ] @@ -74,7 +168,10 @@ async def get_pipeline(self, pipeline_uuid: str) -> dict | None: return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: - from ....utils import paths as path_utils + if 'extensions_preferences' in pipeline_data: + self._validate_extension_preferences(pipeline_data['extensions_preferences']) + if 'config' in pipeline_data: + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) @@ -89,9 +186,8 @@ async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> s pipeline_data['stages'] = default_stage_order.copy() pipeline_data['is_default'] = default - template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') - with open(template_path, 'r', encoding='utf-8') as f: - pipeline_data['config'] = json.load(f) + pipeline_data['config'] = await self.get_default_pipeline_config() + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default if 'extensions_preferences' not in pipeline_data: @@ -118,6 +214,10 @@ async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None pipeline_data = pipeline_data.copy() for protected_field in ('uuid', 'for_version', 'stages', 'is_default'): pipeline_data.pop(protected_field, None) + if 'config' in pipeline_data: + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) + if 'extensions_preferences' in pipeline_data: + self._validate_extension_preferences(pipeline_data['extensions_preferences']) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) @@ -127,19 +227,6 @@ async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None pipeline = await self.get_pipeline(pipeline_uuid) - if 'name' in pipeline_data: - from ....entity.persistence import bot as persistence_bot - - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid) - ) - - bots = result.all() - - for bot in bots: - bot_data = {'use_pipeline_name': pipeline_data['name']} - await self.ap.bot_service.update_bot(bot.uuid, bot_data) - await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) @@ -187,18 +274,7 @@ async def copy_pipeline(self, pipeline_uuid: str) -> str: 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), 'config': original_pipeline.config.copy() if original_pipeline.config else {}, 'is_default': False, - 'extensions_preferences': ( - original_pipeline.extensions_preferences.copy() - if original_pipeline.extensions_preferences - else { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - 'mcp_resources': [], - 'mcp_resource_agent_read_enabled': True, - } - ), + 'extensions_preferences': normalize_extension_preferences(original_pipeline.extensions_preferences), } # Insert the new pipeline @@ -225,6 +301,36 @@ async def update_pipeline_extensions( mcp_resource_agent_read_enabled: bool | None = None, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" + extension_updates: dict[str, typing.Any] = { + 'enable_all_plugins': enable_all_plugins, + 'enable_all_mcp_servers': enable_all_mcp_servers, + 'enable_all_skills': enable_all_skills, + 'plugins': bound_plugins, + } + if bound_mcp_servers is not None: + extension_updates['mcp_servers'] = bound_mcp_servers + if bound_skills is not None: + extension_updates['skills'] = bound_skills + if bound_mcp_resources is not None: + extension_updates['mcp_resources'] = bound_mcp_resources + RunnerConfigResolver.validate_mcp_resource_attachments( + bound_mcp_resources, + context='Pipeline extension', + field_name='bound_mcp_resources', + ) + if mcp_resource_agent_read_enabled is not None: + extension_updates['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled + self._validate_extension_preferences( + extension_updates, + context='Pipeline extension', + field_aliases={ + 'plugins': 'bound_plugins', + 'mcp_servers': 'bound_mcp_servers', + 'skills': 'bound_skills', + 'mcp_resources': 'bound_mcp_resources', + }, + ) + # Get current pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( @@ -237,7 +343,7 @@ async def update_pipeline_extensions( raise ValueError(f'Pipeline {pipeline_uuid} not found') # Update extensions_preferences - extensions_preferences = pipeline.extensions_preferences or {} + extensions_preferences = normalize_extension_preferences(pipeline.extensions_preferences) extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers extensions_preferences['enable_all_skills'] = enable_all_skills @@ -261,3 +367,22 @@ async def update_pipeline_extensions( await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) pipeline = await self.get_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) + + @staticmethod + def _validate_extension_preferences( + value: typing.Any, + *, + context: str = 'Pipeline extensions_preferences', + field_aliases: typing.Mapping[str, str] | None = None, + ) -> dict[str, typing.Any]: + validated = validate_extension_preferences( + value, + context=context, + field_aliases=field_aliases, + ) + RunnerConfigResolver.validate_mcp_resource_attachments( + validated.get('mcp_resources'), + context=context, + field_name='mcp_resources', + ) + return validated diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index 94b926975..cbb739a48 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -2,6 +2,7 @@ import io import inspect +import logging import os import posixpath import zipfile @@ -9,6 +10,8 @@ from urllib.parse import quote, unquote, urlparse import httpx +from langbot_plugin.box.errors import BoxError +from langbot_plugin.entities.io.errors import ActionCallError, ActionCallTimeoutError from ....core import app from ....skill.utils import parse_frontmatter @@ -33,6 +36,8 @@ 'codeload.github.com', } +logger = logging.getLogger(__name__) + class SkillService: """Filesystem-backed skill management service.""" @@ -81,12 +86,16 @@ def _serialize_skill(skill: dict) -> dict: async def list_skills(self) -> list[dict]: # When Box is unavailable, surface an empty list rather than raising — - # the skills page should render cleanly, and the UI separately renders - # a "Box disabled / unavailable" banner via useBoxStatus. + # the skills page and unrelated extension surfaces should render + # cleanly, and the UI separately renders Box status via useBoxStatus. box_service = self._box_service() if box_service is None: return [] - return [self._serialize_skill(skill) for skill in await box_service.list_skills()] + try: + return [self._serialize_skill(skill) for skill in await box_service.list_skills()] + except (ActionCallTimeoutError, ActionCallError, BoxError, TimeoutError, ConnectionError) as exc: + logger.warning('Box skill list unavailable; returning an empty skill list: %s', exc) + return [] async def get_skill(self, skill_name: str) -> Optional[dict]: box_service = self._box_service() diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 95630bbaf..e0e96d192 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -28,7 +28,7 @@ INSTRUCTIONS = """\ This MCP server manages a LangBot instance. LangBot is an LLM-native instant -messaging bot platform. Use these tools to inspect and manage bots, pipelines, +messaging bot platform. Use these tools to inspect and manage bots, agents, pipelines, models, knowledge bases, MCP servers, and skills. Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the @@ -113,6 +113,29 @@ async def delete_bot(bot_uuid: str) -> str: await ap.bot_service.delete_bot(bot_uuid) return _dump({'ok': True}) + @mcp.tool(description='List recent runtime route status for a bot event route table.') + async def list_bot_event_route_statuses(bot_uuid: str) -> str: + return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid)) + + @mcp.tool( + description=( + 'Dispatch a synthetic event through the saved bot event routes. ' + 'This validates routing without sending real outbound platform messages.' + ) + ) + async def test_bot_event_route( + bot_uuid: str, + event_type: str, + payload: dict | None = None, + ) -> str: + return _dump( + await ap.bot_service.dispatch_test_event_route( + bot_uuid=bot_uuid, + event_type=event_type, + payload=payload, + ) + ) + # ----- Pipelines ----------------------------------------------- # @mcp.tool(description='List all pipelines.') async def list_pipelines() -> str: @@ -141,6 +164,34 @@ async def delete_pipeline(pipeline_uuid: str) -> str: await ap.pipeline_service.delete_pipeline(pipeline_uuid) return _dump({'ok': True}) + # ----- Processors ---------------------------------------------- # + @mcp.tool(description='List product-level processors, including Agents and Pipelines.') + async def list_processors() -> str: + return _dump(await ap.agent_service.get_agents()) + + @mcp.tool(description='Get an Agent or Pipeline processor by UUID.') + async def get_processor(processor_uuid: str) -> str: + return _dump(await ap.agent_service.get_agent(processor_uuid)) + + @mcp.tool( + description=( + 'Create an Agent or Pipeline processor. Set `processor_data.kind` to ' + '`agent` or `pipeline`. Returns the new UUID and kind.' + ) + ) + async def create_processor(processor_data: dict) -> str: + return _dump(await ap.agent_service.create_agent(processor_data)) + + @mcp.tool(description='Update an Agent or Pipeline processor by UUID.') + async def update_processor(processor_uuid: str, processor_data: dict) -> str: + await ap.agent_service.update_agent(processor_uuid, processor_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete an Agent or Pipeline processor by UUID.') + async def delete_processor(processor_uuid: str) -> str: + await ap.agent_service.delete_agent(processor_uuid) + return _dump({'ok': True}) + # ----- Models -------------------------------------------------- # @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index ca30eb930..389c61990 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -4,6 +4,7 @@ import collections import datetime as _dt import enum +import hashlib import json import os from typing import TYPE_CHECKING @@ -13,6 +14,7 @@ from langbot_plugin.box.client import BoxRuntimeClient from .connector import BoxRuntimeConnector, _get_box_config from ..telemetry import features as telemetry_features +from ..utils import constants from langbot_plugin.box.errors import BoxError, BoxValidationError from langbot_plugin.box.models import ( BUILTIN_PROFILES, @@ -27,6 +29,8 @@ _UTC = _dt.timezone.utc _MAX_RECENT_ERRORS = 50 _MIB = 1024 * 1024 +_HOST_BOX_SCOPE_VARIABLE = '_host_box_scope' +_BOX_SESSION_ID_PREFIX = 'lb-box-' def _is_path_under(path: str, root: str) -> bool: @@ -224,39 +228,53 @@ async def execute_spec_payload( return self._serialize_result(result) def resolve_box_session_id(self, query: pipeline_query.Query) -> str: - """Resolve the Box session_id from the pipeline's template and query variables. - - When ``system.limitation.force_box_session_id_template`` is set to a - non-empty value, that template overrides whatever the pipeline - configured. This is the authoritative SaaS guard: it runs on every - ``exec`` call, so a tenant cannot escape a single shared sandbox even - by editing the pipeline config directly through the API (which only - gates the web UI). - """ - forced_template = self._forced_box_session_id_template() - if forced_template: - template = forced_template - else: - template = ( - (query.pipeline_config or {}) - .get('ai', {}) - .get('local-agent', {}) - .get('box-session-id-template', '{launcher_type}_{launcher_id}') - ) - variables = dict(query.variables or {}) + """Resolve a Host-owned Box session ID for the current conversation.""" + if query is None: + raise BoxValidationError('Box execution requires a Host session context.') + + variables = getattr(query, 'variables', None) + if isinstance(variables, dict) and _HOST_BOX_SCOPE_VARIABLE in variables: + private_scope = variables[_HOST_BOX_SCOPE_VARIABLE] + if not isinstance(private_scope, str) or not private_scope.strip(): + raise BoxValidationError('Box execution requires a Host conversation scope.') + return self._hash_box_session_scope(f'host:{private_scope}') + + session = getattr(query, 'session', None) launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + + if launcher_type is None: + launcher_type = getattr(session, 'launcher_type', None) + if launcher_id is None: + launcher_id = getattr(session, 'launcher_id', None) if hasattr(launcher_type, 'value'): launcher_type = launcher_type.value - launcher_id = getattr(query, 'launcher_id', None) - sender_id = getattr(query, 'sender_id', None) - query_id = getattr(query, 'query_id', None) - variables.setdefault('query_id', str(query_id or 'unknown')) - variables.setdefault('launcher_type', str(launcher_type or 'query')) - variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown')) - variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown')) - variables.setdefault('global', 'global') - return template.format_map(collections.defaultdict(lambda: 'unknown', variables)) + if launcher_type is None or launcher_id is None or not str(launcher_id).strip(): + raise BoxValidationError('Box execution requires a Host session context.') + + adapter = getattr(query, 'adapter', None) + adapter_identity = adapter.__class__.__name__ if adapter is not None else None + scope = json.dumps( + { + 'instance_id': str(constants.instance_id or '') or None, + 'workspace_id': None, + 'bot_id': getattr(query, 'bot_uuid', None), + 'platform_adapter': adapter_identity, + 'target_type': str(launcher_type), + 'target_id': str(launcher_id), + 'thread_id': None, + }, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + ) + return self._hash_box_session_scope(f'host:{scope}') + + @staticmethod + def _hash_box_session_scope(scope: str) -> str: + digest = hashlib.sha256(scope.encode('utf-8')).hexdigest() + return f'{_BOX_SESSION_ID_PREFIX}{digest}' def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]: """Build extra_mounts entries for all pipeline-bound skills. @@ -1122,20 +1140,6 @@ def _load_custom_image(self) -> str | None: raw = str(self._local_config().get('image', '') or '').strip() return raw or None - def _forced_box_session_id_template(self) -> str: - """Return the SaaS-forced sandbox-scope template, or '' when unset. - - Read from ``system.limitation.force_box_session_id_template``. A - non-empty value pins every pipeline to a single sandbox scope - (e.g. ``'{global}'``) and cannot be overridden per-pipeline. - """ - limitation = ( - (self.ap.instance_config.data or {}).get('system', {}).get('limitation', {}) - if getattr(self.ap, 'instance_config', None) is not None - else {} - ) - return str(limitation.get('force_box_session_id_template', '') or '').strip() - def _load_workspace_quota_mb(self) -> int | None: raw_value = self._local_config().get('workspace_quota_mb') if raw_value in (None, ''): @@ -1305,42 +1309,6 @@ def _record_error(self, exc: Exception, query: pipeline_query.Query): def get_recent_errors(self) -> list[dict]: return list(self._recent_errors) - def get_system_guidance(self, query_id=None) -> str: - """Return LLM system-prompt guidance for the exec tool. - - All execution-specific prompt text is kept here so that callers - (e.g. LocalAgentRunner) stay free of box domain knowledge. - - ``query_id`` is the current turn's pipeline query id. When provided, - the guidance ALWAYS advertises the per-query outbox path so the agent - knows how to deliver generated files back to the user — even on turns - where the user sent no inbound attachment (e.g. "generate a QR code"), - which is exactly when the inbound-attachment note never fires. Outbound - collection in the wrapper runs on every turn regardless of inbound - files, so without this the file would be produced and silently dropped. - """ - guidance = ( - 'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, ' - 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' - 'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec ' - 'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation ' - 'details, do not include the generated script in the final answer; return the result and a brief explanation only.' - ) - if self.default_workspace: - guidance += ( - ' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' - 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' - 'user for directory parameters unless they explicitly need a different directory.' - ) - if query_id is not None: - outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}' - guidance += ( - f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, ' - f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be ' - 'delivered to the user automatically; do not paste file contents or base64 into your reply.' - ) - return guidance - async def get_status(self) -> dict: if not self._available: return { diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index b0adb5594..6cbd970dd 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -4,6 +4,7 @@ import asyncio import traceback import os +from typing import TYPE_CHECKING from ..platform import botmgr as im_mgr from ..platform.webhook_pusher import WebhookPusher @@ -26,6 +27,7 @@ from ..api.http.service import model as model_service from ..api.http.service import provider as provider_service from ..api.http.service import pipeline as pipeline_service +from ..api.http.service import agent as agent_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service @@ -46,6 +48,9 @@ from ..survey import manager as survey_module from ..skill import manager as skill_mgr +if TYPE_CHECKING: + from ..agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService + class Application: """Runtime application object and context""" @@ -143,6 +148,8 @@ class Application: pipeline_service: pipeline_service.PipelineService = None + agent_service: agent_service.AgentService = None + bot_service: bot_service.BotService = None knowledge_service: knowledge_service.KnowledgeService = None @@ -165,6 +172,13 @@ class Application: maintenance_service: maintenance_service.MaintenanceService = None + # Agent runner subsystem + agent_runner_registry: AgentRunnerRegistry = None + + agent_runner_default_config_service: AgentRunnerDefaultConfigService = None + + agent_run_orchestrator: AgentRunOrchestrator = None + def __init__(self): pass diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index a8d53b7b3..7d8c89f29 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -23,6 +23,7 @@ from ...api.http.service import model as model_service from ...api.http.service import provider as provider_service from ...api.http.service import pipeline as pipeline_service +from ...api.http.service import agent as agent_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service @@ -39,6 +40,7 @@ from .. import taskmgr from ...telemetry import telemetry as telemetry_module from ...survey import manager as survey_module +from ...agent.runner import AgentRunnerRegistry, AgentRunOrchestrator, AgentRunnerDefaultConfigService @stage.stage_class('BuildAppStage') @@ -74,6 +76,9 @@ async def run(self, ap: app.Application): pipeline_service_inst = pipeline_service.PipelineService(ap) ap.pipeline_service = pipeline_service_inst + agent_service_inst = agent_service.AgentService(ap) + ap.agent_service = agent_service_inst + bot_service_inst = bot_service.BotService(ap) ap.bot_service = bot_service_inst @@ -194,5 +199,15 @@ async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeC await plugin_connector_inst.initialize() ap.plugin_connector = plugin_connector_inst + # Initialize agent runner subsystem + agent_runner_registry_inst = AgentRunnerRegistry(ap) + ap.agent_runner_registry = agent_runner_registry_inst + + agent_runner_default_config_service_inst = AgentRunnerDefaultConfigService(ap) + ap.agent_runner_default_config_service = agent_runner_default_config_service_inst + + agent_run_orchestrator_inst = AgentRunOrchestrator(ap, agent_runner_registry_inst) + ap.agent_run_orchestrator = agent_run_orchestrator_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 6fc890f10..03a8a0fd8 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -110,44 +110,6 @@ class LoadConfigStage(stage.BootingStage): async def run(self, ap: app.Application): """Load config file""" - # # ======= deprecated ======= - # if os.path.exists('data/config/command.json'): - # ap.command_cfg = await config.load_json_config( - # 'data/config/command.json', - # 'templates/legacy/command.json', - # completion=False, - # ) - - # if os.path.exists('data/config/pipeline.json'): - # ap.pipeline_cfg = await config.load_json_config( - # 'data/config/pipeline.json', - # 'templates/legacy/pipeline.json', - # completion=False, - # ) - - # if os.path.exists('data/config/platform.json'): - # ap.platform_cfg = await config.load_json_config( - # 'data/config/platform.json', - # 'templates/legacy/platform.json', - # completion=False, - # ) - - # if os.path.exists('data/config/provider.json'): - # ap.provider_cfg = await config.load_json_config( - # 'data/config/provider.json', - # 'templates/legacy/provider.json', - # completion=False, - # ) - - # if os.path.exists('data/config/system.json'): - # ap.system_cfg = await config.load_json_config( - # 'data/config/system.json', - # 'templates/legacy/system.json', - # completion=False, - # ) - - # # ======= deprecated ======= - ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) # Apply environment variable overrides to data/config.yaml diff --git a/src/langbot/pkg/discover/engine.py b/src/langbot/pkg/discover/engine.py index 713420d14..48923fe67 100644 --- a/src/langbot/pkg/discover/engine.py +++ b/src/langbot/pkg/discover/engine.py @@ -241,12 +241,14 @@ def recursive_load_component_manifests_in_dir(path: str, depth: int = 1): return for file in importutil.list_resource_files(path): - if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')): - comp = self.load_component_manifest(os.path.join(path, file), owner, no_save) + file_path = os.path.join(path, file) + is_dir = importutil.is_resource_dir(file_path) + if (not is_dir) and (file.endswith('.yaml') or file.endswith('.yml')): + comp = self.load_component_manifest(file_path, owner, no_save) if comp is not None: components.append(comp) - elif os.path.isdir(os.path.join(path, file)): - recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1) + elif is_dir: + recursive_load_component_manifests_in_dir(file_path, depth + 1) recursive_load_component_manifests_in_dir(path) return components diff --git a/src/langbot/pkg/entity/persistence/agent.py b/src/langbot/pkg/entity/persistence/agent.py new file mode 100644 index 000000000..577d60f95 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent.py @@ -0,0 +1,26 @@ +import sqlalchemy + +from .base import Base + + +class Agent(Base): + """Product-level Agent processor.""" + + __tablename__ = 'agents' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='') + emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🤖') + kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent') + component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) + supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*']) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/agent_run.py b/src/langbot/pkg/entity/persistence/agent_run.py new file mode 100644 index 000000000..e735ea692 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent_run.py @@ -0,0 +1,200 @@ +"""Agent run ledger persistence entities.""" + +from __future__ import annotations + +import datetime + +import sqlalchemy + +from .base import Base + + +class AgentRun(Base): + """AgentRun stores Host-owned execution lifecycle facts.""" + + __tablename__ = 'agent_run' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for pagination.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique AgentRunner run identifier.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Input event that triggered this run.""" + + agent_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Future Host-owned agent identifier.""" + + binding_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Binding that selected this runner.""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Runner descriptor ID.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation this run belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread this run belongs to.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace this run belongs to.""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID this run belongs to.""" + + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """Run lifecycle status.""" + + status_reason = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Human-readable terminal or current status reason.""" + + queue_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Host queue name this run is waiting in.""" + + priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + """Higher values are claimed before lower values within a queue.""" + + requested_runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Specific runtime requested by the producer, if any.""" + + claimed_by_runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Runtime that currently owns the claim lease.""" + + claim_token = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Opaque token required to renew or release the current claim.""" + + claim_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the current claim lease expires.""" + + dispatch_attempts = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + """Number of times this run has been claimed for dispatch.""" + + last_claimed_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When this run was last claimed.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When the run record was created.""" + + started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When execution started.""" + + finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When execution reached a terminal status.""" + + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + """When the run record was last updated.""" + + deadline_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """Execution deadline if one was assigned.""" + + cancel_requested_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When cancellation was requested.""" + + usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Final or latest aggregate token usage JSON.""" + + cost_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Host-calculated cost JSON, if available.""" + + authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Run-scoped authorization snapshot JSON.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + __table_args__ = ( + sqlalchemy.Index( + 'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status' + ), + sqlalchemy.Index('ix_agent_run_runner_status', 'runner_id', 'status'), + sqlalchemy.Index('ix_agent_run_queue_claim', 'queue_name', 'status', 'priority', 'id'), + ) + + +class AgentRuntime(Base): + """AgentRuntime stores Host-owned runtime heartbeat registry facts.""" + + __tablename__ = 'agent_runtime' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID.""" + + runtime_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique runtime or daemon identifier.""" + + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """Runtime lifecycle status.""" + + display_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Human-readable runtime display name.""" + + endpoint = sqlalchemy.Column(sqlalchemy.String(1024), nullable=True) + """Runtime endpoint, if it exposes one.""" + + version = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runtime version string.""" + + capabilities_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Runtime capabilities JSON.""" + + labels_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Runtime labels JSON.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + last_heartbeat_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the runtime last sent a heartbeat.""" + + heartbeat_deadline_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """When the runtime should be considered stale.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When the runtime record was created.""" + + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + """When the runtime record was last updated.""" + + +class AgentRunEvent(Base): + """AgentRunEvent stores one result event emitted by a run.""" + + __tablename__ = 'agent_run_event' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Run that produced this event.""" + + sequence = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + """Monotonic sequence inside the run.""" + + type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) + """Result event type.""" + + data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Result event payload JSON.""" + + usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Token usage JSON for this event, if provided.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this event was persisted.""" + + source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Source that appended the event.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata JSON.""" + + __table_args__ = ( + sqlalchemy.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), + sqlalchemy.Index('ix_agent_run_event_run_sequence', 'run_id', 'sequence'), + ) diff --git a/src/langbot/pkg/entity/persistence/agent_runner_state.py b/src/langbot/pkg/entity/persistence/agent_runner_state.py new file mode 100644 index 000000000..93f297f4b --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent_runner_state.py @@ -0,0 +1,88 @@ +"""Agent runner state persistence entity for host-owned state.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class AgentRunnerState(Base): + """AgentRunnerState stores host-owned state for AgentRunner protocol. + + State is: + - Host-owned: Managed by LangBot, not by plugin instances + - Scope-isolated: Separated by runner_id + binding_identity + scope + - Policy-enforced: Controlled by StatePolicy (enable_state, state_scopes) + + Scope key design: + - conversation: runner_id + binding_id + conversation_id [+ thread_id] + - actor: runner_id + binding_id + actor_type + actor_id + - subject: runner_id + binding_id + subject_type + subject_id + - runner: runner_id + binding_id + + This table is the production store for AgentRunner state. + """ + + __tablename__ = 'agent_runner_state' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + # Identity + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Runner descriptor ID (plugin:author/name/runner).""" + + binding_identity = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Binding identity for isolation (binding_id or scope_type:scope_id).""" + + scope = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + """State scope: 'conversation', 'actor', 'subject', or 'runner'.""" + + scope_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=False) + """Full scope key for unique lookup (includes all identity parts).""" + + state_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + """State key within scope (should use namespace prefix like external.*).""" + + value_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """State value as JSON string (size-limited by host).""" + + # Context fields for querying/filtering + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID if applicable.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace ID for multi-tenant.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation ID for conversation scope.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID for thread-scoped conversation state.""" + + actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Actor type for actor scope.""" + + actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Actor ID for actor scope.""" + + subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Subject type for subject scope.""" + + subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Subject ID for subject scope.""" + + # Lifecycle + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this state entry was created.""" + + updated_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + """When this state entry was last updated.""" + + # Unique constraint: scope_key + state_key + __table_args__ = ( + sqlalchemy.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key'), + sqlalchemy.Index('ix_agent_runner_state_runner_binding', 'runner_id', 'binding_identity'), + sqlalchemy.Index('ix_agent_runner_state_scope_key_lookup', 'scope_key'), + ) diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index 9043b7560..d8270b106 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -28,9 +28,7 @@ class Bot(Base): adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) - use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) - pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') + event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, diff --git a/src/langbot/pkg/entity/persistence/event_log.py b/src/langbot/pkg/entity/persistence/event_log.py new file mode 100644 index 000000000..d8203f8af --- /dev/null +++ b/src/langbot/pkg/entity/persistence/event_log.py @@ -0,0 +1,85 @@ +"""EventLog persistence entity for storing auditable event facts.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class EventLog(Base): + """EventLog stores auditable event records for AgentRunner. + + This is the fact source for events - messages, tool calls, system events, etc. + Large payloads are stored separately; this table stores references and + summaries. + """ + + __tablename__ = 'event_log' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique event identifier.""" + + event_type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) + """Event type (message.received, tool.call.started, etc.).""" + + event_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + """When the event occurred.""" + + source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) + """Event source (platform, webui, api, scheduler, system, pipeline_adapter).""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID that handled this event.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace ID for multi-tenant deployments.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Conversation ID this event belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID if platform supports threads.""" + + # Actor information + actor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Actor type (user, system, runner).""" + + actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Actor identifier.""" + + actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Actor display name.""" + + # Subject information + subject_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) + """Subject type (message, tool_call, attachment, etc.).""" + + subject_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Subject identifier.""" + + # Input information + input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Brief summary of input (truncated text, max 1000 chars).""" + + input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Full input JSON if reasonably sized (AgentInput as JSON string).""" + + # Raw event reference + raw_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Reference to raw event payload stored outside the inline event row.""" + + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Run ID that processed this event.""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runner ID that processed this event.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this record was created.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata as JSON string.""" diff --git a/src/langbot/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py index ac3b4602f..3b2cd0996 100644 --- a/src/langbot/pkg/entity/persistence/metadata.py +++ b/src/langbot/pkg/entity/persistence/metadata.py @@ -1,15 +1,6 @@ import sqlalchemy from .base import Base -from ...utils import constants - - -initial_metadata = [ - { - 'key': 'database_version', - 'value': str(constants.required_database_version), - }, -] class Metadata(Base): diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py new file mode 100644 index 000000000..5d66454e7 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/transcript.py @@ -0,0 +1,79 @@ +"""Transcript persistence entity for conversation history projection.""" +from __future__ import annotations + +import sqlalchemy +import datetime + +from .base import Base + + +class Transcript(Base): + """Transcript stores conversation-oriented message projection for history API. + + This is a projection of EventLog, optimized for agent history retrieval. + It includes message content and attachment refs, but not raw platform payloads. + """ + + __tablename__ = 'transcript' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + """Auto-increment ID for sequencing.""" + + transcript_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + """Unique transcript item identifier.""" + + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Reference to the source event in EventLog.""" + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Bot UUID this item belongs to.""" + + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Workspace this item belongs to.""" + + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + """Conversation this item belongs to.""" + + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Thread ID if platform supports threads.""" + + role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) + """Message role: 'user', 'assistant', 'system', or 'tool'.""" + + item_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='message') + """Item type: 'message', 'tool_call', 'tool_result', 'system'.""" + + # Content + content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Text content summary (may be truncated for large messages, max 4000 chars).""" + + content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Full structured content as JSON string (Message model dump).""" + + # Attachment references + attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Attachment references as JSON string.""" + + # Sequence for cursor-based pagination + seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + """Monotonic cursor sequence for pagination.""" + + # Context + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + """Run ID that generated this item (for assistant messages).""" + + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + """Runner ID that generated this item.""" + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + """When this item was created.""" + + metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + """Additional metadata as JSON string (sender_id, platform, etc.).""" + + # Indexes + __table_args__ = ( + sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'), + sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'), + sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'), + ) diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index 40543edd4..403c6ae51 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -13,6 +13,29 @@ from langbot.pkg.entity.persistence.base import Base +# Import all ORM models so they are registered with Base.metadata +# This is required for autogenerate to detect model changes +from langbot.pkg.entity.persistence import ( + agent, # noqa: F401 + agent_run, # noqa: F401 + agent_runner_state, # noqa: F401 + apikey, # noqa: F401 + bot, # noqa: F401 + bstorage, # noqa: F401 + event_log, # noqa: F401 + mcp, # noqa: F401 + metadata, # noqa: F401 + model, # noqa: F401 + monitoring, # noqa: F401 + pipeline, # noqa: F401 + plugin, # noqa: F401 + rag, # noqa: F401 + transcript, # noqa: F401 + user, # noqa: F401 + vector, # noqa: F401 + webhook, # noqa: F401 +) + target_metadata = Base.metadata diff --git a/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py index 929356e00..b72188c7c 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py +++ b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py @@ -1,7 +1,7 @@ -"""baseline: stamp existing schema (db version 25) +"""baseline: stamp the supported 4.x schema This is a no-op migration that marks the starting point for Alembic. -All tables already exist via create_all() + legacy DBMigration system. +Current tables already exist via SQLAlchemy metadata create_all(). Revision ID: 0001_baseline Revises: None @@ -15,8 +15,7 @@ def upgrade() -> None: - # No-op: existing schema is already at database_version=25 - # This revision serves as the Alembic baseline. + # No-op: this revision serves as the Alembic baseline. pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py index a13f2caa6..df461ce4a 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -5,6 +5,8 @@ Create Date: 2026-06-26 """ +import json + import sqlalchemy as sa from alembic import op @@ -14,19 +16,64 @@ depends_on = None +_BOT_ADMINS = sa.table( + 'bot_admins', + sa.column('bot_uuid', sa.String(255)), + sa.column('launcher_type', sa.String(64)), + sa.column('launcher_id', sa.String(255)), +) + + +def _upsert_admin(conn: sa.Connection, *, bot_uuid: str, launcher_type: str, launcher_id: str) -> None: + values = { + 'bot_uuid': bot_uuid, + 'launcher_type': launcher_type, + 'launcher_id': launcher_id, + } + conflict_columns = [ + _BOT_ADMINS.c.bot_uuid, + _BOT_ADMINS.c.launcher_type, + _BOT_ADMINS.c.launcher_id, + ] + + if conn.dialect.name == 'postgresql': + from sqlalchemy.dialects.postgresql import insert + + statement = insert(_BOT_ADMINS).values(**values).on_conflict_do_nothing(index_elements=conflict_columns) + elif conn.dialect.name == 'sqlite': + from sqlalchemy.dialects.sqlite import insert + + statement = insert(_BOT_ADMINS).values(**values).on_conflict_do_nothing(index_elements=conflict_columns) + else: + existing = conn.execute( + sa.select(sa.literal(1)) + .select_from(_BOT_ADMINS) + .where( + _BOT_ADMINS.c.bot_uuid == bot_uuid, + _BOT_ADMINS.c.launcher_type == launcher_type, + _BOT_ADMINS.c.launcher_id == launcher_id, + ) + .limit(1) + ).first() + if existing is not None: + return + statement = sa.insert(_BOT_ADMINS).values(**values) + + conn.execute(statement) + + def upgrade() -> None: conn = op.get_bind() - if 'bot_admins' in sa.inspect(conn).get_table_names(): - return - op.create_table( - 'bot_admins', - sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), - sa.Column('bot_uuid', sa.String(255), nullable=False), - sa.Column('launcher_type', sa.String(64), nullable=False), - sa.Column('launcher_id', sa.String(255), nullable=False), - sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), - sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), - ) + if 'bot_admins' not in sa.inspect(conn).get_table_names(): + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) # Migrate old config-based admins into the first bot (best-effort) inspector = sa.inspect(conn) @@ -48,28 +95,27 @@ def upgrade() -> None: if meta_row is None: return - import json - try: cfg = json.loads(meta_row[0]) - except Exception: + except (TypeError, json.JSONDecodeError): return admins = cfg.get('admins', []) + if not isinstance(admins, list): + return for entry in admins: + if not isinstance(entry, str): + continue parts = entry.split('_', 1) if len(parts) != 2: continue launcher_type, launcher_id = parts - try: - conn.execute( - sa.text( - 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)' - ), - {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, - ) - except Exception: - pass + _upsert_admin( + conn, + bot_uuid=first_bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) # Remove admins key from stored config if 'admins' in cfg: @@ -81,4 +127,5 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_table('bot_admins') + if 'bot_admins' in sa.inspect(op.get_bind()).get_table_names(): + op.drop_table('bot_admins') diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py b/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py new file mode 100644 index 000000000..9490624f5 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_merge_agent_mcp_heads.py @@ -0,0 +1,20 @@ +"""Merge agent runner and MCP migration heads. + +Revision ID: 0007_merge_agent_mcp_heads +Revises: 8d3a1f2c4b6e, 0006_normalize_mcp_remote_mode +Create Date: 2026-06-23 +""" + +# revision identifiers, used by Alembic. +revision = '0007_merge_agent_mcp_heads' +down_revision = ('8d3a1f2c4b6e', '0006_normalize_mcp_remote_mode') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py b/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py new file mode 100644 index 000000000..a855a65bb --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0008_agent_product_surface.py @@ -0,0 +1,66 @@ +"""Add Agent product surface tables. + +Revision ID: 0008_agent_product_surface +Revises: 0007_merge_agent_mcp_heads +Create Date: 2026-06-23 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '0008_agent_product_surface' +down_revision = '0007_merge_agent_mcp_heads' +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(column['name'] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, 'agents'): + op.create_table( + 'agents', + sa.Column('uuid', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=255), nullable=False, server_default=''), + sa.Column('emoji', sa.String(length=10), nullable=True), + sa.Column('kind', sa.String(length=50), nullable=False, server_default='agent'), + sa.Column('component_ref', sa.String(length=255), nullable=True), + sa.Column('config', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('supported_event_patterns', sa.JSON(), nullable=False, server_default='["*"]'), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint('uuid'), + sa.UniqueConstraint('uuid'), + ) + + if _table_exists(inspector, 'bots') and not _column_exists(inspector, 'bots', 'event_bindings'): + with op.batch_alter_table('bots') as batch_op: + batch_op.add_column(sa.Column('event_bindings', sa.JSON(), nullable=False, server_default='[]')) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, 'bots') and _column_exists(inspector, 'bots', 'event_bindings'): + with op.batch_alter_table('bots') as batch_op: + batch_op.drop_column('event_bindings') + + if _table_exists(inspector, 'agents'): + op.drop_table('agents') diff --git a/src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py b/src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py new file mode 100644 index 000000000..99208d2f3 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py @@ -0,0 +1,133 @@ +"""Migrate legacy Bot routes into Pipeline event bindings. + +Bindings keep referencing the original Pipeline UUIDs. This migration never +creates Agent rows or copies Pipeline runner configuration. + +Revision ID: 0009_migrate_event_bindings +Revises: 0008_agent_product_surface +Create Date: 2026-06-26 +""" + +import json +import uuid + +import sqlalchemy as sa +from alembic import op + +revision = '0009_migrate_event_bindings' +down_revision = '0008_agent_product_surface' +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _rule_to_filters(rule: dict) -> list[dict] | None: + """Convert one legacy Pipeline routing rule to an EBA event filter.""" + rule_type = rule.get('type') + operator = rule.get('operator', 'eq') + value = rule.get('value', '') + + if rule_type == 'launcher_type': + return [{'field': 'chat_type', 'operator': operator, 'value': value}] + if rule_type == 'launcher_id': + return [{'field': 'chat_id', 'operator': operator, 'value': value}] + if rule_type == 'message_content': + return [{'field': 'message_text', 'operator': operator, 'value': value}] + if rule_type == 'message_has_element': + element_operator = { + 'eq': 'contains', + 'neq': 'not_contains', + }.get(operator) + if element_operator is None: + # The legacy matcher treated every other operator as non-matching. + element_operator = 'unsupported_legacy_operator' + return [{'field': 'message_element_types', 'operator': element_operator, 'value': value}] + + return None + + +def upgrade() -> None: + if not _table_exists('bots') or not _column_exists('bots', 'event_bindings'): + return + if not _column_exists('bots', 'use_pipeline_uuid') or not _column_exists('bots', 'pipeline_routing_rules'): + return + + bind = op.get_bind() + rows = bind.execute( + sa.text('SELECT uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings FROM bots') + ).fetchall() + + for bot_uuid, use_pipeline_uuid, routing_rules_raw, event_bindings_raw in rows: + try: + existing = ( + json.loads(event_bindings_raw) if isinstance(event_bindings_raw, str) else (event_bindings_raw or []) + ) + except Exception: + existing = [] + + if existing: + continue # already has event_bindings — skip + + try: + routing_rules = ( + json.loads(routing_rules_raw) if isinstance(routing_rules_raw, str) else (routing_rules_raw or []) + ) + except Exception: + routing_rules = [] + + new_bindings: list[dict] = [] + base_priority = len(routing_rules) + + for i, rule in enumerate(routing_rules): + target_uuid = rule.get('pipeline_uuid', '') + if not target_uuid: + continue + filters = _rule_to_filters(rule) + if filters is None: + continue + new_bindings.append( + { + 'id': str(uuid.uuid4()), + 'event_pattern': 'message.*', + 'target_type': 'pipeline', + 'target_uuid': target_uuid, + 'filters': filters, + 'priority': base_priority - i, + 'enabled': True, + 'description': f'Migrated from routing rule ({rule.get("type")})', + 'order': i, + } + ) + + if use_pipeline_uuid: + new_bindings.append( + { + 'id': str(uuid.uuid4()), + 'event_pattern': 'message.*', + 'target_type': 'pipeline', + 'target_uuid': use_pipeline_uuid, + 'filters': [], + 'priority': 0, + 'enabled': True, + 'description': 'Migrated from default pipeline binding', + 'order': len(new_bindings), + } + ) + + if new_bindings: + bind.execute( + sa.text('UPDATE bots SET event_bindings = :b WHERE uuid = :u'), + {'b': json.dumps(new_bindings, ensure_ascii=False), 'u': bot_uuid}, + ) + + +def downgrade() -> None: + pass # not reversible diff --git a/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py b/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py new file mode 100644 index 000000000..ade946f9c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py @@ -0,0 +1,19 @@ +"""merge mcp resource and agent product migration heads + +Revision ID: 0010_merge_mcp_agent_heads +Revises: 0008_mcp_resource_prefs, 0009_migrate_event_bindings +Create Date: 2026-06-30 +""" + +revision = '0010_merge_mcp_agent_heads' +down_revision = ('0008_mcp_resource_prefs', '0009_migrate_event_bindings') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py b/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py new file mode 100644 index 000000000..b41fdae30 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py @@ -0,0 +1,55 @@ +"""drop legacy bot pipeline routing columns + +Revision ID: 0011_drop_legacy_bot_routing +Revises: 0010_merge_mcp_agent_heads +Create Date: 2026-07-01 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '0011_drop_legacy_bot_routing' +down_revision = '0010_merge_mcp_agent_heads' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def upgrade() -> None: + if not _table_exists('bots'): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns('bots')} + columns_to_drop = [ + column_name + for column_name in ('use_pipeline_name', 'use_pipeline_uuid', 'pipeline_routing_rules') + if column_name in existing_columns + ] + if not columns_to_drop: + return + + with op.batch_alter_table('bots', schema=None) as batch_op: + for column_name in columns_to_drop: + batch_op.drop_column(column_name) + + +def downgrade() -> None: + if not _table_exists('bots'): + return + + with op.batch_alter_table('bots', schema=None) as batch_op: + if not _column_exists('bots', 'use_pipeline_name'): + batch_op.add_column(sa.Column('use_pipeline_name', sa.String(255), nullable=True)) + if not _column_exists('bots', 'use_pipeline_uuid'): + batch_op.add_column(sa.Column('use_pipeline_uuid', sa.String(255), nullable=True)) + if not _column_exists('bots', 'pipeline_routing_rules'): + batch_op.add_column(sa.Column('pipeline_routing_rules', sa.JSON(), nullable=False, server_default='[]')) diff --git a/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py b/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py new file mode 100644 index 000000000..60916bf90 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py @@ -0,0 +1,67 @@ +"""add monitoring tool calls + +Revision ID: 0012_monitoring_tool_calls +Revises: 0011_drop_legacy_bot_routing +Create Date: 2026-07-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0012_monitoring_tool_calls' +down_revision = '0011_drop_legacy_bot_routing' +branch_labels = None +depends_on = None + + +_INDEXES = { + 'ix_monitoring_tool_calls_timestamp': ['timestamp'], + 'ix_monitoring_tool_calls_bot_id': ['bot_id'], + 'ix_monitoring_tool_calls_pipeline_id': ['pipeline_id'], + 'ix_monitoring_tool_calls_session_id': ['session_id'], + 'ix_monitoring_tool_calls_message_id': ['message_id'], +} + + +def _table_exists() -> bool: + return 'monitoring_tool_calls' in sa.inspect(op.get_bind()).get_table_names() + + +def _index_names() -> set[str]: + if not _table_exists(): + return set() + return {index['name'] for index in sa.inspect(op.get_bind()).get_indexes('monitoring_tool_calls')} + + +def upgrade() -> None: + if not _table_exists(): + op.create_table( + 'monitoring_tool_calls', + sa.Column('id', sa.String(255), primary_key=True), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('tool_name', sa.String(255), nullable=False), + sa.Column('tool_source', sa.String(50), nullable=False), + sa.Column('duration', sa.Integer(), nullable=False), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=False), + sa.Column('bot_name', sa.String(255), nullable=False), + sa.Column('pipeline_id', sa.String(255), nullable=False), + sa.Column('pipeline_name', sa.String(255), nullable=False), + sa.Column('session_id', sa.String(255), nullable=True), + sa.Column('message_id', sa.String(255), nullable=True), + sa.Column('arguments', sa.Text(), nullable=True), + sa.Column('result', sa.Text(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + ) + + existing_indexes = _index_names() + for index_name, columns in _INDEXES.items(): + if index_name not in existing_indexes: + op.create_index(index_name, 'monitoring_tool_calls', columns, unique=False) + + +def downgrade() -> None: + if _table_exists(): + op.drop_table('monitoring_tool_calls') diff --git a/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py new file mode 100644 index 000000000..7d7c3746a --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py @@ -0,0 +1,148 @@ +"""add_event_log_and_transcript_tables + +Revision ID: 58846a8d7a81 +Revises: 0005_add_llm_context_length +Create Date: 2026-05-23 15:41:47.030841 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = '58846a8d7a81' +down_revision = '0005_add_llm_context_length' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _column_exists(table_name: str, column_name: str) -> bool: + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + # Create event_log table + if not _table_exists('event_log'): + op.create_table( + 'event_log', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('event_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_type', sa.String(100), nullable=False), + sa.Column('event_time', sa.DateTime(), nullable=True), + sa.Column('source', sa.String(50), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('conversation_id', sa.String(255), nullable=True), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('actor_type', sa.String(50), nullable=True), + sa.Column('actor_id', sa.String(255), nullable=True), + sa.Column('actor_name', sa.String(255), nullable=True), + sa.Column('subject_type', sa.String(50), nullable=True), + sa.Column('subject_id', sa.String(255), nullable=True), + sa.Column('input_summary', sa.Text(), nullable=True), + sa.Column('input_json', sa.Text(), nullable=True), + sa.Column('raw_ref', sa.String(255), nullable=True), + sa.Column('run_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + + # Create indexes for event_log + _create_index_if_missing('event_log', 'ix_event_log_event_id', ['event_id'], unique=True) + _create_index_if_missing('event_log', 'ix_event_log_event_type', ['event_type']) + _create_index_if_missing('event_log', 'ix_event_log_bot_id', ['bot_id']) + _create_index_if_missing('event_log', 'ix_event_log_conversation_id', ['conversation_id']) + _create_index_if_missing('event_log', 'ix_event_log_run_id', ['run_id']) + + # Create transcript table + if not _table_exists('transcript'): + op.create_table( + 'transcript', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('transcript_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_id', sa.String(255), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('conversation_id', sa.String(255), nullable=False), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('role', sa.String(50), nullable=False), + sa.Column('item_type', sa.String(50), nullable=False, server_default='message'), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('content_json', sa.Text(), nullable=True), + sa.Column('attachment_refs_json', sa.Text(), nullable=True), + sa.Column('seq', sa.Integer(), nullable=False), + sa.Column('run_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + else: + _add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True)) + _add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True)) + + # Create indexes for transcript + _create_index_if_missing('transcript', 'ix_transcript_transcript_id', ['transcript_id'], unique=True) + _create_index_if_missing('transcript', 'ix_transcript_event_id', ['event_id']) + _create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_id', ['conversation_id']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_seq', ['conversation_id', 'seq']) + _create_index_if_missing('transcript', 'ix_transcript_conversation_created', ['conversation_id', 'created_at']) + _create_index_if_missing( + 'transcript', + 'ix_transcript_scope_seq', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'], + ) + _create_index_if_missing('transcript', 'ix_transcript_run_id', ['run_id']) + + +def downgrade() -> None: + # Drop transcript table + _drop_index_if_exists('transcript', 'ix_transcript_run_id') + _drop_index_if_exists('transcript', 'ix_transcript_scope_seq') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_created') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_seq') + _drop_index_if_exists('transcript', 'ix_transcript_conversation_id') + _drop_index_if_exists('transcript', 'ix_transcript_bot_id') + _drop_index_if_exists('transcript', 'ix_transcript_event_id') + _drop_index_if_exists('transcript', 'ix_transcript_transcript_id') + + if _table_exists('transcript'): + op.drop_table('transcript') + + # Drop event_log table + _drop_index_if_exists('event_log', 'ix_event_log_run_id') + _drop_index_if_exists('event_log', 'ix_event_log_conversation_id') + _drop_index_if_exists('event_log', 'ix_event_log_bot_id') + _drop_index_if_exists('event_log', 'ix_event_log_event_type') + _drop_index_if_exists('event_log', 'ix_event_log_event_id') + + if _table_exists('event_log'): + op.drop_table('event_log') diff --git a/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py b/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py new file mode 100644 index 000000000..1682acdda --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/6dfd3dd7f0c7_add_agent_runner_state_table_for_host_.py @@ -0,0 +1,94 @@ +# Alembic script.py.mako — template for auto-generated revisions +"""add agent_runner_state table for host-owned persistent state + +Revision ID: 6dfd3dd7f0c7 +Revises: 58846a8d7a81 +Create Date: 2026-05-23 19:49:08.529110 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '6dfd3dd7f0c7' +down_revision = '58846a8d7a81' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + if not _table_exists('agent_runner_state'): + op.create_table('agent_runner_state', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('runner_id', sa.String(length=255), nullable=False), + sa.Column('binding_identity', sa.String(length=255), nullable=False), + sa.Column('scope', sa.String(length=50), nullable=False), + sa.Column('scope_key', sa.String(length=512), nullable=False), + sa.Column('state_key', sa.String(length=255), nullable=False), + sa.Column('value_json', sa.Text(), nullable=True), + sa.Column('bot_id', sa.String(length=255), nullable=True), + sa.Column('workspace_id', sa.String(length=255), nullable=True), + sa.Column('conversation_id', sa.String(length=255), nullable=True), + sa.Column('thread_id', sa.String(length=255), nullable=True), + sa.Column('actor_type', sa.String(length=50), nullable=True), + sa.Column('actor_id', sa.String(length=255), nullable=True), + sa.Column('subject_type', sa.String(length=50), nullable=True), + sa.Column('subject_id', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('scope_key', 'state_key', name='uq_agent_runner_state_scope_key_state_key') + ) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_actor_id', ['actor_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_binding_identity', ['binding_identity']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_bot_id', ['bot_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_conversation_id', ['conversation_id']) + _create_index_if_missing( + 'agent_runner_state', + 'ix_agent_runner_state_runner_binding', + ['runner_id', 'binding_identity'], + ) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_runner_id', ['runner_id']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope', ['scope']) + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_runner_binding') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_conversation_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_bot_id') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_binding_identity') + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_actor_id') + + if _table_exists('agent_runner_state'): + op.drop_table('agent_runner_state') + # ### end Alembic commands ### diff --git a/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py new file mode 100644 index 000000000..99da93c0e --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py @@ -0,0 +1,78 @@ +"""add transcript scope columns + +Revision ID: 7b2c1d9e4f30 +Revises: 6dfd3dd7f0c7 +Create Date: 2026-06-12 +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7b2c1d9e4f30' +down_revision = '6dfd3dd7f0c7' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + if not set(columns).issubset(existing_columns): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + _add_column_if_missing('transcript', sa.Column('bot_id', sa.String(255), nullable=True)) + _add_column_if_missing('transcript', sa.Column('workspace_id', sa.String(255), nullable=True)) + _create_index_if_missing('transcript', 'ix_transcript_bot_id', ['bot_id']) + _create_index_if_missing( + 'transcript', + 'ix_transcript_scope_seq', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'], + ) + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key') + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup', ['scope_key']) + + +def downgrade() -> None: + _drop_index_if_exists('agent_runner_state', 'ix_agent_runner_state_scope_key_lookup') + _create_index_if_missing('agent_runner_state', 'ix_agent_runner_state_scope_key', ['scope_key']) + _drop_index_if_exists('transcript', 'ix_transcript_scope_seq') + _drop_index_if_exists('transcript', 'ix_transcript_bot_id') + if not _table_exists('transcript'): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns('transcript')} + with op.batch_alter_table('transcript', schema=None) as batch_op: + if 'workspace_id' in existing_columns: + batch_op.drop_column('workspace_id') + if 'bot_id' in existing_columns: + batch_op.drop_column('bot_id') diff --git a/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py new file mode 100644 index 000000000..88773c1b1 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py @@ -0,0 +1,202 @@ +"""add agent run ledger + +Revision ID: 8d3a1f2c4b6e +Revises: 7b2c1d9e4f30 +Create Date: 2026-06-15 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '8d3a1f2c4b6e' +down_revision = '7b2c1d9e4f30' +branch_labels = None +depends_on = None + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _index_exists(table_name: str, index_name: str) -> bool: + return index_name in {index['name'] for index in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _add_column_if_missing(table_name: str, column: sa.Column) -> None: + if not _table_exists(table_name) or _column_exists(table_name, column.name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.add_column(column) + + +def _create_index_if_missing(table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _table_exists(table_name) or _index_exists(table_name, index_name): + return + existing_columns = {column['name'] for column in sa.inspect(op.get_bind()).get_columns(table_name)} + if not set(columns).issubset(existing_columns): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.create_index(index_name, columns, unique=unique) + + +def _drop_index_if_exists(table_name: str, index_name: str) -> None: + if not _table_exists(table_name) or not _index_exists(table_name, index_name): + return + with op.batch_alter_table(table_name, schema=None) as batch_op: + batch_op.drop_index(index_name) + + +def upgrade() -> None: + if not _table_exists('agent_run'): + op.create_table( + 'agent_run', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('run_id', sa.String(255), nullable=False, unique=True), + sa.Column('event_id', sa.String(255), nullable=True), + sa.Column('agent_id', sa.String(255), nullable=True), + sa.Column('binding_id', sa.String(255), nullable=True), + sa.Column('runner_id', sa.String(255), nullable=False), + sa.Column('conversation_id', sa.String(255), nullable=True), + sa.Column('thread_id', sa.String(255), nullable=True), + sa.Column('workspace_id', sa.String(255), nullable=True), + sa.Column('bot_id', sa.String(255), nullable=True), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('status_reason', sa.Text(), nullable=True), + sa.Column('queue_name', sa.String(255), nullable=True), + sa.Column('priority', sa.Integer(), nullable=False, server_default='0'), + sa.Column('requested_runtime_id', sa.String(255), nullable=True), + sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True), + sa.Column('claim_token', sa.String(255), nullable=True), + sa.Column('claim_lease_expires_at', sa.DateTime(), nullable=True), + sa.Column('dispatch_attempts', sa.Integer(), nullable=False, server_default='0'), + sa.Column('last_claimed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('deadline_at', sa.DateTime(), nullable=True), + sa.Column('cancel_requested_at', sa.DateTime(), nullable=True), + sa.Column('usage_json', sa.Text(), nullable=True), + sa.Column('cost_json', sa.Text(), nullable=True), + sa.Column('authorization_json', sa.Text(), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + ) + else: + _add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True)) + _add_column_if_missing( + 'agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0') + ) + _add_column_if_missing('agent_run', sa.Column('requested_runtime_id', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True)) + _add_column_if_missing('agent_run', sa.Column('claim_lease_expires_at', sa.DateTime(), nullable=True)) + _add_column_if_missing( + 'agent_run', sa.Column('dispatch_attempts', sa.Integer(), nullable=False, server_default='0') + ) + _add_column_if_missing('agent_run', sa.Column('last_claimed_at', sa.DateTime(), nullable=True)) + + _create_index_if_missing('agent_run', 'ix_agent_run_run_id', ['run_id'], unique=True) + _create_index_if_missing('agent_run', 'ix_agent_run_event_id', ['event_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_binding_id', ['binding_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_runner_id', ['runner_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_conversation_id', ['conversation_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_bot_id', ['bot_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_status', ['status']) + _create_index_if_missing('agent_run', 'ix_agent_run_queue_name', ['queue_name']) + _create_index_if_missing('agent_run', 'ix_agent_run_requested_runtime_id', ['requested_runtime_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_claimed_by_runtime_id', ['claimed_by_runtime_id']) + _create_index_if_missing('agent_run', 'ix_agent_run_claim_token', ['claim_token']) + _create_index_if_missing('agent_run', 'ix_agent_run_claim_lease_expires_at', ['claim_lease_expires_at']) + _create_index_if_missing( + 'agent_run', + 'ix_agent_run_scope_status', + ['bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status'], + ) + _create_index_if_missing('agent_run', 'ix_agent_run_runner_status', ['runner_id', 'status']) + _create_index_if_missing('agent_run', 'ix_agent_run_queue_claim', ['queue_name', 'status', 'priority', 'id']) + + if not _table_exists('agent_run_event'): + op.create_table( + 'agent_run_event', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('run_id', sa.String(255), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('type', sa.String(100), nullable=False), + sa.Column('data_json', sa.Text(), nullable=True), + sa.Column('usage_json', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('source', sa.String(50), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + sa.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), + ) + + _create_index_if_missing('agent_run_event', 'ix_agent_run_event_run_id', ['run_id']) + _create_index_if_missing('agent_run_event', 'ix_agent_run_event_type', ['type']) + _create_index_if_missing( + 'agent_run_event', + 'ix_agent_run_event_run_sequence', + ['run_id', 'sequence'], + ) + + if not _table_exists('agent_runtime'): + op.create_table( + 'agent_runtime', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('runtime_id', sa.String(255), nullable=False, unique=True), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('display_name', sa.String(255), nullable=True), + sa.Column('endpoint', sa.String(1024), nullable=True), + sa.Column('version', sa.String(255), nullable=True), + sa.Column('capabilities_json', sa.Text(), nullable=True), + sa.Column('labels_json', sa.Text(), nullable=True), + sa.Column('metadata_json', sa.Text(), nullable=True), + sa.Column('last_heartbeat_at', sa.DateTime(), nullable=True), + sa.Column('heartbeat_deadline_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('(CURRENT_TIMESTAMP)')), + ) + + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_runtime_id', ['runtime_id'], unique=True) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_status', ['status']) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_last_heartbeat_at', ['last_heartbeat_at']) + _create_index_if_missing('agent_runtime', 'ix_agent_runtime_heartbeat_deadline_at', ['heartbeat_deadline_at']) + + +def downgrade() -> None: + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_heartbeat_deadline_at') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_last_heartbeat_at') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_status') + _drop_index_if_exists('agent_runtime', 'ix_agent_runtime_runtime_id') + if _table_exists('agent_runtime'): + op.drop_table('agent_runtime') + + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_run_sequence') + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_type') + _drop_index_if_exists('agent_run_event', 'ix_agent_run_event_run_id') + if _table_exists('agent_run_event'): + op.drop_table('agent_run_event') + + _drop_index_if_exists('agent_run', 'ix_agent_run_queue_claim') + _drop_index_if_exists('agent_run', 'ix_agent_run_claim_lease_expires_at') + _drop_index_if_exists('agent_run', 'ix_agent_run_claim_token') + _drop_index_if_exists('agent_run', 'ix_agent_run_claimed_by_runtime_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_requested_runtime_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_queue_name') + _drop_index_if_exists('agent_run', 'ix_agent_run_runner_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_scope_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_status') + _drop_index_if_exists('agent_run', 'ix_agent_run_bot_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_conversation_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_runner_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_binding_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_event_id') + _drop_index_if_exists('agent_run', 'ix_agent_run_run_id') + if _table_exists('agent_run'): + op.drop_table('agent_run') diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 7ad7b9683..32144e3fb 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -7,15 +7,14 @@ import sqlalchemy.ext.asyncio as sqlalchemy_asyncio import sqlalchemy -from . import database, migration -from ..entity.persistence import base, metadata, model as persistence_model +from . import database +from ..entity.persistence import base, model as persistence_model from ..entity import persistence from ..core import app -from ..utils import constants, importutil -from . import databases, migrations +from ..utils import importutil +from . import databases importutil.import_modules_in_pkg(databases) -importutil.import_modules_in_pkg(migrations) importutil.import_modules_in_pkg(persistence) @@ -43,40 +42,6 @@ async def initialize(self): break await self.create_tables() - - # run migrations - database_version = await self.execute_async( - sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == 'database_version') - ) - - database_version = int(database_version.fetchone()[1]) - required_database_version = constants.required_database_version - - if database_version < required_database_version: - migrations = migration.preregistered_db_migrations - migrations.sort(key=lambda x: x.number) - - last_migration_number = database_version - - for migration_cls in migrations: - migration_instance = migration_cls(self.ap) - - if ( - migration_instance.number > database_version - and migration_instance.number <= required_database_version - ): - await migration_instance.upgrade() - await self.execute_async( - sqlalchemy.update(metadata.Metadata) - .where(metadata.Metadata.key == 'database_version') - .values({'value': str(migration_instance.number)}) - ) - last_migration_number = migration_instance.number - self.ap.logger.info(f'Migration {migration_instance.number} completed.') - - self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') - - # Run Alembic migrations (new migration system) await self._run_alembic_migrations() await self.write_space_model_providers() @@ -88,19 +53,6 @@ async def create_tables(self): await conn.commit() - # ======= write initial data ======= - - # write initial metadata - self.ap.logger.info('Creating initial metadata...') - for item in metadata.initial_metadata: - # check if the item exists - result = await self.execute_async( - sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == item['key']) - ) - row = result.first() - if row is None: - await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) - async def write_space_model_providers(self): space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get( 'models_gateway_api_url', 'https://api.langbot.cloud/v1' @@ -139,7 +91,7 @@ async def write_space_model_providers(self): # ================================= async def _run_alembic_migrations(self): - """Run Alembic-based migrations after legacy migrations complete.""" + """Run Alembic migrations for supported 4.x databases.""" from . import alembic_runner engine = self.get_db_engine() diff --git a/src/langbot/pkg/persistence/migration.py b/src/langbot/pkg/persistence/migration.py deleted file mode 100644 index 294e30ca2..000000000 --- a/src/langbot/pkg/persistence/migration.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import typing -import abc - -from ..core import app - - -preregistered_db_migrations: list[typing.Type[DBMigration]] = [] - - -def migration_class(number: int): - """Migration class decorator""" - - def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]: - cls.number = number - preregistered_db_migrations.append(cls) - return cls - - return wrapper - - -class DBMigration(abc.ABC): - """Database migration""" - - number: int - """Migration number""" - - def __init__(self, ap: app.Application): - self.ap = ap - - @abc.abstractmethod - async def upgrade(self): - """Upgrade""" - pass - - @abc.abstractmethod - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/README.md b/src/langbot/pkg/persistence/migrations/README.md deleted file mode 100644 index 8f62f1d26..000000000 --- a/src/langbot/pkg/persistence/migrations/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Legacy migrations (DEPRECATED — do not add new files here) - -This directory holds the **legacy 3.x database migration system** -(`DBMigration` subclasses in `dbmXXX_*.py`, registered via -`@migration.migration_class(N)` and run from `pkg/persistence/mgr.py`). - -**This system is frozen. Do not add new `dbmXXX_*.py` migrations.** - -The chain is capped at version 25 (`required_database_version = 25` in -`pkg/utils/constants.py`). These files exist only to upgrade pre-existing -3.x databases up to the Alembic baseline (`0001_baseline`). Removing them -would break in-place upgrades from old installations, so they are kept -read-only. - -## All new schema changes use Alembic - -Migrations now live in `pkg/persistence/alembic/versions/`. To create one: - -```bash -uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change" -``` - -(requires `data/config.yaml` to exist). Review and edit the generated -script before committing — Alembic migrations run automatically on startup -and must be idempotent and guard against missing tables (the test suite -runs them against empty databases). - -### Rules for Alembic revision ids - -- Keep the revision id **≤ 32 characters** — PostgreSQL stores - `alembic_version.version_num` as `varchar(32)` and will raise - `StringDataRightTruncationError` on overflow. -- Guard every `op` call against a missing table / missing column - (`inspector.get_table_names()` / `inspector.get_columns()`); fresh - installs create the schema via `create_all()` and stamp the baseline, - so migrations may run against tables that already match or do not exist. diff --git a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py deleted file mode 100644 index 55e63fff4..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py +++ /dev/null @@ -1,252 +0,0 @@ -from .. import migration -from copy import deepcopy -import uuid -import os -import sqlalchemy -import shutil - -from ...config import manager as config_manager -from ...entity.persistence import ( - model as persistence_model, - pipeline as persistence_pipeline, - bot as persistence_bot, -) - - -@migration.migration_class(1) -class DBMigrateV3Config(migration.DBMigration): - """Migrate v3 config to v4 database""" - - async def upgrade(self): - """Upgrade""" - """ - Migrate all config files under data/config. - After migration, all previous config files are saved under data/legacy/config. - After migration, all config files under data/metadata/ are saved under data/legacy/metadata. - """ - - if self.ap.provider_cfg is None: - return - - # ======= Migrate model ======= - # Only migrate the currently selected model - model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o') - - model_requester = 'openai-chat-completions' - model_requester_config = {} - model_api_keys = ['sk-proj-1234567890'] - model_abilities = [] - model_extra_args = {} - - if os.path.exists('data/metadata/llm-models.json'): - _llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False) - - for item in _llm_model_meta.data.get('list', []): - if item.get('name') == model_name: - if 'model_name' in item: - model_name = item['model_name'] - if 'requester' in item: - model_requester = item['requester'] - if 'token_mgr' in item: - _token_mgr = item['token_mgr'] - - if _token_mgr in self.ap.provider_cfg.data.get('keys', {}): - model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr] - - if 'tool_call_supported' in item and item['tool_call_supported']: - model_abilities.append('func_call') - - if 'vision_supported' in item and item['vision_supported']: - model_abilities.append('vision') - - if ( - model_requester in self.ap.provider_cfg.data.get('requester', {}) - and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester] - ): - model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args'] - - if model_requester in self.ap.provider_cfg.data.get('requester', {}): - model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester] - model_requester_config = { - 'base_url': model_requester_config['base-url'], - 'timeout': model_requester_config['timeout'], - } - - break - - model_uuid = str(uuid.uuid4()) - - llm_model_data = { - 'uuid': model_uuid, - 'name': model_name, - 'description': '由 LangBot v3 迁移而来', - 'requester': model_requester, - 'requester_config': model_requester_config, - 'api_keys': model_api_keys, - 'abilities': model_abilities, - 'extra_args': model_extra_args, - } - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data) - ) - - # ======= Migrate pipeline config ======= - # Modify to default pipeline - default_pipeline = [ - self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) - for pipeline in ( - await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.is_default == True - ) - ) - ).all() - ][0] - - pipeline_uuid = str(uuid.uuid4()) - pipeline_name = 'ChatPipeline' - - if default_pipeline: - pipeline_name = default_pipeline['name'] - pipeline_uuid = default_pipeline['uuid'] - - pipeline_config = default_pipeline['config'] - - # ai - pipeline_config['ai']['runner'] = { - 'runner': self.ap.provider_cfg.data['runner'], - } - pipeline_config['ai']['local-agent']['model'] = model_uuid - pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][ - 'max-round' - ] - - pipeline_config['ai']['local-agent']['prompt'] = [ - { - 'role': 'system', - 'content': self.ap.provider_cfg.data['prompt']['default'], - } - ] - pipeline_config['ai']['dify-service-api'] = { - 'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'], - 'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'], - 'api-key': self.ap.provider_cfg.data['dify-service-api'][ - self.ap.provider_cfg.data['dify-service-api']['app-type'] - ]['api-key'], - 'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'], - 'timeout': self.ap.provider_cfg.data['dify-service-api'][ - self.ap.provider_cfg.data['dify-service-api']['app-type'] - ]['timeout'], - } - pipeline_config['ai']['dashscope-app-api'] = { - 'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'], - 'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'], - 'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][ - self.ap.provider_cfg.data['dashscope-app-api']['app-type'] - ]['references_quote'], - } - - # trigger - pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default'] - pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control'] - pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules'] - - # safety - pipeline_config['safety']['content-filter'] = { - 'scope': 'all', - 'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'], - } - pipeline_config['safety']['rate-limit'] = { - 'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'], - 'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'], - 'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'], - } - - # output - pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process'] - pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay'] - pipeline_config['output']['misc'] = { - 'hide-exception': self.ap.platform_cfg.data['hide-exception-info'], - 'quote-origin': self.ap.platform_cfg.data['quote-origin'], - 'at-sender': self.ap.platform_cfg.data['at-sender'], - 'track-function-calls': self.ap.platform_cfg.data['track-function-calls'], - } - - default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]' - default_pipeline['config'] = pipeline_config - default_pipeline.pop('created_at') - default_pipeline.pop('updated_at') - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.update(persistence_pipeline.LegacyPipeline) - .values(default_pipeline) - .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid']) - ) - - # ======= Migrate bot ======= - # Only migrate enabled bots - for adapter in self.ap.platform_cfg.data.get('platform-adapters', []): - if not adapter.get('enable'): - continue - - args = deepcopy(adapter) - args.pop('adapter') - args.pop('enable') - - bot_data = { - 'uuid': str(uuid.uuid4()), - 'name': adapter.get('adapter'), - 'description': '由 LangBot v3 迁移而来', - 'adapter': adapter.get('adapter'), - 'adapter_config': args, - 'enable': True, - 'use_pipeline_uuid': pipeline_uuid, - 'use_pipeline_name': pipeline_name, - } - - await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data)) - - # ======= Migrate system settings ======= - self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions'] - self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] - self.ap.instance_config.data['command'] = { - 'prefix': self.ap.command_cfg.data['command-prefix'], - 'enable': self.ap.command_cfg.data['command-enable'] - if 'command-enable' in self.ap.command_cfg.data - else True, - 'privilege': self.ap.command_cfg.data['privilege'], - } - self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency'] - self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][ - 'default' - ] - self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp'] - self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies'] - await self.ap.instance_config.dump_config() - - # ======= move files ======= - # Migrate all config files under data/config - all_legacy_dir_name = [ - 'config', - # 'metadata', - 'prompts', - 'scenario', - ] - - def move_legacy_files(dir_name: str): - if not os.path.exists(f'data/legacy/{dir_name}'): - os.makedirs(f'data/legacy/{dir_name}') - - if os.path.exists(f'data/{dir_name}'): - for file in os.listdir(f'data/{dir_name}'): - if file.endswith('.json'): - shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}') - - os.rmdir(f'data/{dir_name}') - - for dir_name in all_legacy_dir_name: - move_legacy_files(dir_name) - - async def downgrade(self): - """Downgrade""" diff --git a/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py b/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py deleted file mode 100644 index 471e28cdc..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py +++ /dev/null @@ -1,55 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(2) -class DBMigrateCombineQuoteMsgConfig(migration.DBMigration): - """Combine quote message config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'trigger' exists - if 'trigger' not in config: - config['trigger'] = {} - - # Ensure 'misc' exists in 'trigger' - if 'misc' not in config['trigger']: - config['trigger']['misc'] = {} - - # Add 'combine-quote-message' if not exists - if 'combine-quote-message' not in config['trigger']['misc']: - config['trigger']['misc']['combine-quote-message'] = False - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py b/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py deleted file mode 100644 index 602562cf8..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py +++ /dev/null @@ -1,62 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(3) -class DBMigrateN8nConfig(migration.DBMigration): - """N8n config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'ai' exists - if 'ai' not in config: - config['ai'] = {} - - # Add 'n8n-service-api' if not exists - if 'n8n-service-api' not in config['ai']: - config['ai']['n8n-service-api'] = { - 'webhook-url': 'http://your-n8n-webhook-url', - 'auth-type': 'none', - 'basic-username': '', - 'basic-password': '', - 'jwt-secret': '', - 'jwt-algorithm': 'HS256', - 'header-name': '', - 'header-value': '', - 'timeout': 120, - 'output-key': 'response', - } - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py b/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py deleted file mode 100644 index d422102cd..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py +++ /dev/null @@ -1,53 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(4) -class DBMigrateRAGKBUUID(migration.DBMigration): - """RAG知识库UUID""" - - async def upgrade(self): - """升级""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'ai' not in config: - config['ai'] = {} - if 'local-agent' not in config['ai']: - config['ai']['local-agent'] = {} - - # Add 'knowledge-base' if not exists - if 'knowledge-base' not in config['ai']['local-agent']: - config['ai']['local-agent']['knowledge-base'] = '' - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py b/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py deleted file mode 100644 index 7e71bb0e9..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py +++ /dev/null @@ -1,53 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(5) -class DBMigratePipelineRemoveCotConfig(migration.DBMigration): - """Pipeline remove cot config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'output' not in config: - config['output'] = {} - if 'misc' not in config['output']: - config['output']['misc'] = {} - - # Add 'remove-think' if not exists - if 'remove-think' not in config['output']['misc']: - config['output']['misc']['remove-think'] = False - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py b/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py deleted file mode 100644 index d25e987aa..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py +++ /dev/null @@ -1,58 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(6) -class DBMigrateLangflowApiConfig(migration.DBMigration): - """Langflow API config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'ai' exists - if 'ai' not in config: - config['ai'] = {} - - # Add 'langflow-api' if not exists - if 'langflow-api' not in config['ai']: - config['ai']['langflow-api'] = { - 'base-url': 'http://localhost:7860', - 'api-key': 'your-api-key', - 'flow-id': 'your-flow-id', - 'input-type': 'chat', - 'output-type': 'chat', - 'tweaks': '{}', - } - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py b/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py deleted file mode 100644 index 26c38c577..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py +++ /dev/null @@ -1,44 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(7) -class DBMigratePluginInstallSource(migration.DBMigration): - """插件安装来源""" - - async def upgrade(self): - """升级""" - # 查询表结构获取所有列名(异步执行 SQL) - - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_settings';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(plugin_settings);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # 检查并添加 install_source 列 - if 'install_source' not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'" - ) - ) - - # 检查并添加 install_info 列 - if 'install_info' not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'") - ) - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py b/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py deleted file mode 100644 index 23da3568f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py +++ /dev/null @@ -1,22 +0,0 @@ -from .. import migration - - -@migration.migration_class(8) -class DBMigratePluginConfig(migration.DBMigration): - """插件配置""" - - async def upgrade(self): - """升级""" - - if 'plugin' not in self.ap.instance_config.data: - self.ap.instance_config.data['plugin'] = { - 'runtime_ws_url': 'ws://langbot_plugin_runtime:5400/control/ws', - 'enable_marketplace': True, - 'cloud_service_url': 'https://space.langbot.app', - } - - await self.ap.instance_config.dump_config() - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py deleted file mode 100644 index 927da4bf0..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py +++ /dev/null @@ -1,20 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(9) -class DBMigratePipelineExtensionPreferences(migration.DBMigration): - """Pipeline extension preferences""" - - async def upgrade(self): - """Upgrade""" - - sql_text = sqlalchemy.text( - "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" - ) - await self.ap.persistence_mgr.execute_async(sql_text) - - async def downgrade(self): - """Downgrade""" - sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') - await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py deleted file mode 100644 index fcbe149df..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py +++ /dev/null @@ -1,105 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(10) -class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration): - """Pipeline support multiple knowledge base binding""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Convert knowledge-base from string to array - if 'ai' in config and 'local-agent' in config['ai']: - current_kb = config['ai']['local-agent'].get('knowledge-base', '') - - # If it's already a list, skip - if isinstance(current_kb, list): - continue - - # Convert string to list - if current_kb and current_kb != '__none__': - config['ai']['local-agent']['knowledge-bases'] = [current_kb] - else: - config['ai']['local-agent']['knowledge-bases'] = [] - - # Remove old field - if 'knowledge-base' in config['ai']['local-agent']: - del config['ai']['local-agent']['knowledge-base'] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Convert knowledge-bases from array back to string - if 'ai' in config and 'local-agent' in config['ai']: - current_kbs = config['ai']['local-agent'].get('knowledge-bases', []) - - # If it's already a string, skip - if isinstance(current_kbs, str): - continue - - # Convert list to string (take first one or empty) - if current_kbs and len(current_kbs) > 0: - config['ai']['local-agent']['knowledge-base'] = current_kbs[0] - else: - config['ai']['local-agent']['knowledge-base'] = '' - - # Remove new field - if 'knowledge-bases' in config['ai']['local-agent']: - del config['ai']['local-agent']['knowledge-bases'] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) diff --git a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py deleted file mode 100644 index 98070d4f2..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py +++ /dev/null @@ -1,55 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(11) -class DBMigrateDifyApiConfig(migration.DBMigration): - """Dify base prompt config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'ai' not in config: - config['ai'] = {} - if 'dify-service-api' not in config['ai']: - config['ai']['dify-service-api'] = {} - - # Add 'base-prompt' if not exists - if 'base-prompt' not in config['ai']['dify-service-api']: - config['ai']['dify-service-api']['base-prompt'] = ( - 'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.', - ) - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py b/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py deleted file mode 100644 index 6b1aca2d0..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py +++ /dev/null @@ -1,73 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(12) -class DBMigratePipelineExtensionsEnableAll(migration.DBMigration): - """Pipeline extensions enable all""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - extensions_preferences = ( - json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - ) - - # Ensure extensions_preferences is a dict - if extensions_preferences is None: - extensions_preferences = {} - - # Add 'enable_all_plugins' if not exists - if 'enable_all_plugins' not in extensions_preferences: - if 'plugins' in extensions_preferences: - extensions_preferences['enable_all_plugins'] = False - else: - extensions_preferences['enable_all_plugins'] = True - extensions_preferences['plugins'] = [] - - # Add 'enable_all_mcp_servers' if not exists - if 'enable_all_mcp_servers' not in extensions_preferences: - if 'mcp_servers' in extensions_preferences: - extensions_preferences['enable_all_mcp_servers'] = False - else: - extensions_preferences['enable_all_mcp_servers'] = True - extensions_preferences['mcp_servers'] = [] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - { - 'extensions_preferences': json.dumps(extensions_preferences), - 'for_version': current_version, - 'uuid': uuid, - }, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid' - ), - { - 'extensions_preferences': json.dumps(extensions_preferences), - 'for_version': current_version, - 'uuid': uuid, - }, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py b/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py deleted file mode 100644 index 19f37e834..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py +++ /dev/null @@ -1,49 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(13) -class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration): - """Add updated_at field to knowledge_bases table""" - - async def upgrade(self): - """Upgrade""" - # Get all column names from the table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Check and add updated_at column - if 'updated_at' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP' - ) - ) - else: - # SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE - # Add column without default first - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME') - ) - - # Set initial updated_at values to created_at for existing records - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL') - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py b/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py deleted file mode 100644 index ea93e9304..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py +++ /dev/null @@ -1,94 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(14) -class DBMigrateSpaceAccountSupport(migration.DBMigration): - """Add Space account support fields to users table""" - - async def upgrade(self): - """Upgrade""" - # Get all column names from the users table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';") - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Add account_type column - if 'account_type' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") - ) - - # Add space_account_uuid column - if 'space_account_uuid' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') - ) - - # Add space_access_token column - if 'space_access_token' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') - ) - - # Add space_refresh_token column - if 'space_refresh_token' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') - ) - - # Add space_access_token_expires_at column - if 'space_access_token_expires_at' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP') - ) - - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME') - ) - - # Add space_api_key column - if 'space_api_key' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py b/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py deleted file mode 100644 index 363ff6663..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py +++ /dev/null @@ -1,15 +0,0 @@ -from .. import migration - - -# this is a deprecated migration -@migration.migration_class(15) -class DBMigrateModelSourceTracking(migration.DBMigration): - """Add source tracking fields to models tables for Space integration""" - - async def upgrade(self): - """Upgrade""" - pass - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py b/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py deleted file mode 100644 index 150e5209f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py +++ /dev/null @@ -1,305 +0,0 @@ -import uuid as uuid_lib - -import sqlalchemy -from .. import migration - - -@migration.migration_class(16) -class DBMigrateModelProviderRefactor(migration.DBMigration): - """Refactor model structure: create providers from existing models and update references""" - - async def upgrade(self): - """Upgrade""" - # Step 1: Create model_providers table if not exists - await self._create_providers_table() - - # Step 2: Migrate existing models to use providers - await self._migrate_llm_models() - await self._migrate_embedding_models() - - # Step 3: Remove deprecated columns - await self._cleanup_columns() - - async def _create_providers_table(self): - """Create model_providers table""" - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - CREATE TABLE IF NOT EXISTS model_providers ( - uuid VARCHAR(255) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - requester VARCHAR(255) NOT NULL, - base_url VARCHAR(512) NOT NULL, - api_keys JSONB NOT NULL DEFAULT '[]', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - """) - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - CREATE TABLE IF NOT EXISTS model_providers ( - uuid VARCHAR(255) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - requester VARCHAR(255) NOT NULL, - base_url VARCHAR(512) NOT NULL, - api_keys JSON NOT NULL DEFAULT '[]', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - """) - ) - - async def _migrate_llm_models(self): - """Migrate LLM models to use providers""" - llm_columns = await self._get_columns('llm_models') - - # Add provider_uuid column if not exists - if 'provider_uuid' not in llm_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)') - ) - - # Add prefered_ranking column if not exists - if 'prefered_ranking' not in llm_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') - ) - - # Only migrate if old columns exist - if 'requester' not in llm_columns: - return - - # Get all LLM models with old structure - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models') - ) - models = result.fetchall() - - # Create providers and update models - provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid - - for model in models: - model_uuid, model_name, requester, requester_config, api_keys = model - - # Extract base_url from requester_config - base_url = '' - if requester_config: - if isinstance(requester_config, str): - import json - - requester_config = json.loads(requester_config) - base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') - - # Parse api_keys if it's a string - if isinstance(api_keys, str): - import json - - try: - api_keys = json.loads(api_keys) - except Exception: - api_keys = [] - if not api_keys: - api_keys = [] - - # Create cache key - api_keys_str = str(sorted(api_keys)) if api_keys else '[]' - cache_key = (requester, base_url, api_keys_str) - - if cache_key in provider_cache: - provider_uuid = provider_cache[cache_key] - else: - # Create new provider - provider_uuid = str(uuid_lib.uuid4()) - provider_name = f'{requester}' - if base_url: - # Extract domain for name - try: - from urllib.parse import urlparse - - parsed = urlparse(base_url) - provider_name = parsed.netloc or requester - except Exception: - pass - - import json - - api_keys_json = json.dumps(api_keys) if api_keys else '[]' - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) - VALUES (:uuid, :name, :requester, :base_url, :api_keys) - """), - { - 'uuid': provider_uuid, - 'name': provider_name, - 'requester': requester, - 'base_url': base_url, - 'api_keys': api_keys_json, - }, - ) - provider_cache[cache_key] = provider_uuid - - # Update model with provider_uuid - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), - {'provider_uuid': provider_uuid, 'uuid': model_uuid}, - ) - - async def _migrate_embedding_models(self): - """Migrate embedding models to use providers""" - embedding_columns = await self._get_columns('embedding_models') - - # Add provider_uuid column if not exists - if 'provider_uuid' not in embedding_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)') - ) - - # Add prefered_ranking column if not exists - if 'prefered_ranking' not in embedding_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') - ) - - # Only migrate if old columns exist - if 'requester' not in embedding_columns: - return - - # Get all embedding models with old structure - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models') - ) - models = result.fetchall() - - # Get existing providers - provider_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers') - ) - existing_providers = provider_result.fetchall() - - provider_cache = {} - for p in existing_providers: - p_uuid, p_requester, p_base_url, p_api_keys = p - api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]' - provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid - - for model in models: - model_uuid, model_name, requester, requester_config, api_keys = model - - base_url = '' - if requester_config: - if isinstance(requester_config, str): - import json - - requester_config = json.loads(requester_config) - base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') - - # Parse api_keys if it's a string - if isinstance(api_keys, str): - import json - - try: - api_keys = json.loads(api_keys) - except Exception: - api_keys = [] - if not api_keys: - api_keys = [] - - api_keys_str = str(sorted(api_keys)) if api_keys else '[]' - cache_key = (requester, base_url, api_keys_str) - - if cache_key in provider_cache: - provider_uuid = provider_cache[cache_key] - else: - provider_uuid = str(uuid_lib.uuid4()) - provider_name = f'{requester}' - if base_url: - try: - from urllib.parse import urlparse - - parsed = urlparse(base_url) - provider_name = parsed.netloc or requester - except Exception: - pass - - import json - - api_keys_json = json.dumps(api_keys) if api_keys else '[]' - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) - VALUES (:uuid, :name, :requester, :base_url, :api_keys) - """), - { - 'uuid': provider_uuid, - 'name': provider_name, - 'requester': requester, - 'base_url': base_url, - 'api_keys': api_keys_json, - }, - ) - provider_cache[cache_key] = provider_uuid - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), - {'provider_uuid': provider_uuid, 'uuid': model_uuid}, - ) - - async def _cleanup_columns(self): - """Remove deprecated columns from model tables""" - - llm_columns = await self._get_columns('llm_models') - deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id'] - for col in deprecated_llm_cols: - if col in llm_columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}') - ) - - embedding_columns = await self._get_columns('embedding_models') - deprecated_embedding_cols = [ - 'requester', - 'requester_config', - 'api_keys', - 'description', - 'source', - 'space_model_id', - ] - for col in deprecated_embedding_cols: - if col in embedding_columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}') - ) - - async def _get_columns(self, table_name: str) -> list: - """Get column names for a table""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" - ) - ) - all_result = result.fetchall() - return [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - all_result = result.fetchall() - return [row[1] for row in all_result] - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py b/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py deleted file mode 100644 index ba6841556..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py +++ /dev/null @@ -1,25 +0,0 @@ -from .. import migration - - -@migration.migration_class(17) -class MoveCloudServiceUrl(migration.DBMigration): - """迁移云服务 URL 配置""" - - async def upgrade(self): - """升级""" - if 'space' not in self.ap.instance_config.data: - self.ap.instance_config.data['space'] = { - 'url': 'https://space.langbot.app', - 'models_gateway_api_url': 'https://api.langbot.cloud/v1', - 'oauth_authorize_url': 'https://space.langbot.app/auth/authorize', - 'disable_models_service': False, - } - - if 'plugin' in self.ap.instance_config.data: - self.ap.instance_config.data['plugin'].pop('cloud_service_url', None) - - await self.ap.instance_config.dump_config() - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py b/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py deleted file mode 100644 index f543a30b1..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py +++ /dev/null @@ -1,58 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(18) -class DBMigrateAddEmojiSupport(migration.DBMigration): - """Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables""" - - async def upgrade(self): - """Upgrade""" - # Add emoji field to knowledge_bases - await self._add_emoji_to_table('knowledge_bases', '📚') - - # Add emoji field to external_knowledge_bases - await self._add_emoji_to_table('external_knowledge_bases', '🔗') - - # Add emoji field to legacy_pipelines - await self._add_emoji_to_table('legacy_pipelines', '⚙️') - - async def _add_emoji_to_table(self, table_name: str, default_emoji: str): - """Add emoji column to specified table if it doesn't exist""" - # Get all column names from the table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Check and add emoji column - if 'emoji' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'") - ) - else: - # SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE - # Add column without default first - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)') - ) - - # Set default emoji value for existing records - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL") - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py deleted file mode 100644 index b1372aa71..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py +++ /dev/null @@ -1,24 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(19) -class DBMigrateMonitoringMessageRole(migration.DBMigration): - """Add role column to monitoring_messages table""" - - async def upgrade(self): - """Upgrade""" - try: - sql_text = sqlalchemy.text("ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'") - await self.ap.persistence_mgr.execute_async(sql_text) - except Exception: - # Column may already exist - pass - - async def downgrade(self): - """Downgrade""" - try: - sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role') - await self.ap.persistence_mgr.execute_async(sql_text) - except Exception: - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py deleted file mode 100644 index 616cb91a6..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py +++ /dev/null @@ -1,161 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(20) -class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration): - """Migrate to unified Knowledge Engine plugin architecture. - - Changes: - - Backup existing knowledge_bases data to knowledge_bases_backup - - Clear knowledge_bases table and add new plugin architecture columns - - Drop old columns (PostgreSQL only; SQLite leaves them unmapped) - - Preserve external_knowledge_bases table as-is for future migration - - Set rag_plugin_migration_needed flag in metadata if old data exists - """ - - async def upgrade(self): - """Upgrade""" - has_internal_data = await self._backup_knowledge_bases() - has_external_data = await self._check_external_knowledge_bases() - await self._clear_knowledge_bases() - await self._add_columns_to_knowledge_bases() - await self._drop_old_columns() - if has_internal_data or has_external_data: - await self._set_migration_flag() - - async def _get_table_columns(self, table_name: str) -> list[str]: - """Get column names from a table (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' - ).bindparams(table_name=table_name) - ) - return [row[0] for row in result.fetchall()] - else: - # SQLite PRAGMA does not support bind parameters; validate identifier. - if not table_name.isidentifier(): - raise ValueError(f'Invalid table name: {table_name}') - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - return [row[1] for row in result.fetchall()] - - async def _table_exists(self, table_name: str) -> bool: - """Check if a table exists.""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' - ).bindparams(table_name=table_name) - ) - return result.scalar() - else: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( - table_name=table_name - ) - ) - return result.first() is not None - - async def _backup_knowledge_bases(self) -> bool: - """Backup knowledge_bases data. Returns True if data was backed up.""" - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;')) - count = result.scalar() - if count == 0: - return False - - # Drop backup table if it already exists (from a previous failed migration) - if await self._table_exists('knowledge_bases_backup'): - await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;')) - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;') - ) - self.ap.logger.info( - 'Backed up %d knowledge base(s) to knowledge_bases_backup table.', - count, - ) - return True - - async def _check_external_knowledge_bases(self) -> bool: - """Check if external_knowledge_bases table exists and has data. - - The table is preserved as-is (not dropped) for future migration. - """ - if not await self._table_exists('external_knowledge_bases'): - return False - - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') - ) - count = result.scalar() - if count > 0: - self.ap.logger.info( - 'Found %d external knowledge base(s) in external_knowledge_bases table. ' - 'Table preserved for future migration.', - count, - ) - return count > 0 - - async def _clear_knowledge_bases(self): - """Clear all rows from knowledge_bases table (preserve table structure).""" - await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;')) - - async def _add_columns_to_knowledge_bases(self): - """Add new RAG plugin architecture columns to knowledge_bases table.""" - columns = await self._get_table_columns('knowledge_bases') - - new_columns = { - 'knowledge_engine_plugin_id': 'VARCHAR', - 'collection_id': 'VARCHAR', - 'creation_settings': 'TEXT', # JSON stored as TEXT for SQLite compatibility - 'retrieval_settings': 'TEXT', - } - - for col_name, col_type in new_columns.items(): - if col_name not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') - ) - - async def _drop_old_columns(self): - """Drop embedding_model_uuid and top_k columns (PostgreSQL only). - - SQLite does not support DROP COLUMN in older versions, so we leave the - columns in place — the SQLAlchemy entity simply won't map them. - """ - if self.ap.persistence_mgr.db.name != 'postgresql': - return - - columns = await self._get_table_columns('knowledge_bases') - - if 'embedding_model_uuid' in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;') - ) - - if 'top_k' in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') - ) - - async def _set_migration_flag(self): - """Set rag_plugin_migration_needed flag in metadata table.""" - # Check if the key already exists - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';") - ) - row = result.first() - if row is not None: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';") - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');") - ) - self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.') - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py deleted file mode 100644 index 59c1e357f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py +++ /dev/null @@ -1,74 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(21) -class DBMigrateMergeExceptionHandling(migration.DBMigration): - """Merge hide-exception and block-failed-request-output into a single exception-handling select option, - and add failure-hint field. - - Conversion logic: - - block-failed-request-output=true -> exception-handling: hide - - hide-exception=true -> exception-handling: show-hint - - hide-exception=false -> exception-handling: show-error - """ - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'output' not in config: - config['output'] = {} - if 'misc' not in config['output']: - config['output']['misc'] = {} - - misc = config['output']['misc'] - - # Determine new exception-handling value from legacy fields - hide_exception = misc.get('hide-exception', True) - block_failed = misc.get('block-failed-request-output', False) - - if block_failed: - exception_handling = 'hide' - elif hide_exception: - exception_handling = 'show-hint' - else: - exception_handling = 'show-error' - - misc['exception-handling'] = exception_handling - - # Add failure-hint with default value - misc['failure-hint'] = 'Request failed.' - - # Remove legacy fields - misc.pop('hide-exception', None) - - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py b/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py deleted file mode 100644 index b66adc73b..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py +++ /dev/null @@ -1,73 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(22) -class DBMigrateMonitoringUserId(migration.DBMigration): - """Add user_id and user_name columns to monitoring_sessions table - - This migration adds the missing user_id column and also ensures user_name - column exists (in case migration 21 failed or was skipped). - """ - - async def _table_exists(self, table_name: str) -> bool: - """Check if a table exists (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' - ).bindparams(table_name=table_name) - ) - return bool(result.scalar()) - else: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( - table_name=table_name - ) - ) - return result.first() is not None - - async def _get_table_columns(self, table_name: str) -> list[str]: - """Get column names from a table (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' - ).bindparams(table_name=table_name) - ) - return [row[0] for row in result.fetchall()] - else: - if not table_name.isidentifier(): - raise ValueError(f'Invalid table name: {table_name}') - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - return [row[1] for row in result.fetchall()] - - async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str): - """Add a column to a table if it does not already exist.""" - columns = await self._get_table_columns(table_name) - if column_name in columns: - self.ap.logger.debug('%s column already exists in %s.', column_name, table_name) - return - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};') - ) - self.ap.logger.info('Added %s column to %s table.', column_name, table_name) - - async def upgrade(self): - # Check if monitoring_sessions table exists - if not await self._table_exists('monitoring_sessions'): - self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.') - return - - # Add user_id column to monitoring_sessions table - await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)') - - # Add user_name column to monitoring_sessions table (in case migration 21 failed) - await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)') - - # Add user_name column to monitoring_messages table (in case migration 21 failed) - if await self._table_exists('monitoring_messages'): - await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)') - - async def downgrade(self): - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py b/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py deleted file mode 100644 index 11ab3bb4e..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py +++ /dev/null @@ -1,102 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(23) -class DBMigrateModelFallbackConfig(migration.DBMigration): - """Convert model field from plain UUID string to object with primary/fallbacks""" - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'ai' not in config or 'local-agent' not in config['ai']: - continue - - local_agent = config['ai']['local-agent'] - changed = False - - # Convert model from string to object - model_value = local_agent.get('model', '') - if isinstance(model_value, str): - local_agent['model'] = { - 'primary': model_value, - 'fallbacks': [], - } - changed = True - - # Remove leftover fallback-models field if present - if 'fallback-models' in local_agent: - del local_agent['fallback-models'] - changed = True - - if not changed: - continue - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'ai' not in config or 'local-agent' not in config['ai']: - continue - - local_agent = config['ai']['local-agent'] - - # Convert model from object back to string - model_value = local_agent.get('model', '') - if isinstance(model_value, dict): - local_agent['model'] = model_value.get('primary', '') - else: - continue - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) diff --git a/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py b/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py deleted file mode 100644 index a5b833bcc..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py +++ /dev/null @@ -1,49 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(24) -class DBMigrateWecomBotWebSocketMode(migration.DBMigration): - """Add enable-webhook field to existing wecombot adapter configs. - - Existing wecombot bots were all using webhook mode, so we set - enable-webhook=true to preserve their behavior after the new - WebSocket long connection mode is introduced as default. - """ - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'") - ) - bots = result.fetchall() - - for bot_row in bots: - bot_uuid = bot_row[0] - adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1] - - if 'enable-webhook' in adapter_config: - continue - - # Determine mode based on existing config: if webhook fields are present, keep webhook mode - has_webhook_config = bool( - adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid') - ) - adapter_config['enable-webhook'] = has_webhook_config - - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'), - {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'), - {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py b/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py deleted file mode 100644 index 816bf2860..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py +++ /dev/null @@ -1,15 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(25) -class DBMigrateBotPipelineRoutingRules(migration.DBMigration): - """Add pipeline_routing_rules column to bots table""" - - async def upgrade(self): - sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'") - await self.ap.persistence_mgr.execute_async(sql_text) - - async def downgrade(self): - sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules') - await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py deleted file mode 100644 index 8bd71b10c..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py +++ /dev/null @@ -1,17 +0,0 @@ -from langbot.pkg.entity.persistence import monitoring as persistence_monitoring -from .. import migration - - -@migration.migration_class(26) -class DBMigrateMonitoringToolCalls(migration.DBMigration): - """Add monitoring_tool_calls table""" - - async def upgrade(self): - """Upgrade""" - async with self.ap.persistence_mgr.get_db_engine().begin() as conn: - await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True) - - async def downgrade(self): - """Downgrade""" - async with self.ap.persistence_mgr.get_db_engine().begin() as conn: - await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True) diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py index 09d18a582..15af3d21a 100644 --- a/src/langbot/pkg/pipeline/controller.py +++ b/src/langbot/pkg/pipeline/controller.py @@ -21,11 +21,45 @@ def __init__(self, ap: app.Application): self.ap = ap self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline']) + async def _try_claim_steering_before_session_slot( + self, + query: pipeline_query.Query, + ) -> bool: + """Claim steering while the normal per-session slot is still busy. + + Follow-up input must be claimed before it waits behind the session + semaphore; otherwise the active run can finish before the query reaches + ChatMessageHandler.try_claim_steering_from_query. + """ + try: + pipeline_uuid = query.pipeline_uuid + if not pipeline_uuid: + return False + + pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) + if not pipeline: + return False + + session = await self.ap.sess_mgr.get_session(query) + query.session = session + query.pipeline_config = pipeline.pipeline_entity.config + query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins + query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers + + return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query) + except Exception as exc: + self.ap.logger.warning( + f'Failed to claim query {query.query_id} as steering input: {exc}', + exc_info=True, + ) + return False + async def consumer(self): """事件处理循环""" try: while True: selected_query: pipeline_query.Query = None + claimed_steering_query: pipeline_query.Query = None # 取请求 async with self.ap.query_pool: @@ -36,6 +70,13 @@ async def consumer(self): # Debug logging removed from tight loop to prevent excessive log generation # that can cause memory overflow in high-traffic scenarios + if session._semaphore.locked(): + if await self._try_claim_steering_before_session_slot(query): + claimed_steering_query = query + self.ap.logger.debug(f'Claimed query {query.query_id} as steering before session slot') + break + continue + if not session._semaphore.locked(): selected_query = query await session._semaphore.acquire() @@ -44,7 +85,12 @@ async def consumer(self): break - if selected_query: # 找到了 + if claimed_steering_query: + queries.remove(claimed_steering_query) + self.ap.query_pool.cached_queries.pop(claimed_steering_query.query_id, None) + self.ap.query_pool.condition.notify_all() + continue + elif selected_query: # 找到了 queries.remove(selected_query) else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 await self.ap.query_pool.condition.wait() diff --git a/src/langbot/pkg/pipeline/extension_preferences.py b/src/langbot/pkg/pipeline/extension_preferences.py new file mode 100644 index 000000000..42689b1e4 --- /dev/null +++ b/src/langbot/pkg/pipeline/extension_preferences.py @@ -0,0 +1,156 @@ +"""Validation and fail-closed reads for Pipeline extension preferences.""" + +from __future__ import annotations + +import typing + + +_BOOLEAN_FIELDS = ( + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + 'mcp_resource_agent_read_enabled', +) + + +def _valid_plugin_binding(value: typing.Any) -> bool: + return ( + isinstance(value, dict) + and isinstance(value.get('author'), str) + and bool(value['author']) + and isinstance(value.get('name'), str) + and bool(value['name']) + ) + + +def _valid_name(value: typing.Any) -> bool: + return isinstance(value, str) and bool(value) + + +def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]: + """Return a safe runtime view of persisted extension preferences. + + Missing fields in a valid object retain the established defaults. A + malformed root is treated as an empty allowlist with every extension + capability disabled. + """ + if not isinstance(value, dict): + return { + 'enable_all_plugins': False, + 'enable_all_mcp_servers': False, + 'enable_all_skills': False, + 'mcp_resource_agent_read_enabled': False, + 'plugins': [], + 'mcp_servers': [], + 'skills': [], + 'mcp_resources': [], + } + + normalized = dict(value) + normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True + normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True + normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True + normalized['mcp_resource_agent_read_enabled'] = ( + value.get('mcp_resource_agent_read_enabled', True) is True + ) + + plugins = value.get('plugins', []) + plugins_are_valid = isinstance(plugins, list) and all( + _valid_plugin_binding(plugin) for plugin in plugins + ) + normalized['plugins'] = list(plugins) if plugins_are_valid else [] + if not plugins_are_valid: + normalized['enable_all_plugins'] = False + + mcp_servers = value.get('mcp_servers', []) + mcp_servers_are_valid = isinstance(mcp_servers, list) and all( + _valid_name(server) for server in mcp_servers + ) + normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else [] + if not mcp_servers_are_valid: + normalized['enable_all_mcp_servers'] = False + + skills = value.get('skills', []) + skills_are_valid = isinstance(skills, list) and all(_valid_name(skill) for skill in skills) + normalized['skills'] = list(skills) if skills_are_valid else [] + if not skills_are_valid: + normalized['enable_all_skills'] = False + + mcp_resources = value.get('mcp_resources', []) + mcp_resources_are_valid = isinstance(mcp_resources, list) and all( + isinstance(resource, dict) for resource in mcp_resources + ) + normalized['mcp_resources'] = list(mcp_resources) if mcp_resources_are_valid else [] + if not mcp_resources_are_valid: + normalized['mcp_resource_agent_read_enabled'] = False + return normalized + + +def validate_extension_preferences( + value: typing.Any, + *, + context: str = 'Pipeline extensions_preferences', + field_aliases: typing.Mapping[str, str] | None = None, +) -> dict[str, typing.Any]: + """Reject extension preferences that cannot be consumed unambiguously.""" + if not isinstance(value, dict): + raise ValueError(f'{context} must be an object') + + for field_name in _BOOLEAN_FIELDS: + if field_name in value and not isinstance(value[field_name], bool): + raise ValueError(f"{context} field '{field_name}' must be a boolean") + + aliases = field_aliases or {} + _validate_list_field( + value, + 'plugins', + aliases.get('plugins', 'plugins'), + _valid_plugin_binding, + 'a plugin object with non-empty author and name', + context, + ) + _validate_list_field( + value, + 'mcp_servers', + aliases.get('mcp_servers', 'mcp_servers'), + _valid_name, + 'a non-empty string', + context, + ) + _validate_list_field( + value, + 'skills', + aliases.get('skills', 'skills'), + _valid_name, + 'a non-empty string', + context, + ) + _validate_list_field( + value, + 'mcp_resources', + aliases.get('mcp_resources', 'mcp_resources'), + lambda item: isinstance(item, dict), + 'an object', + context, + ) + return value + + +def _validate_list_field( + value: dict[str, typing.Any], + field_name: str, + field_label: str, + item_validator: typing.Callable[[typing.Any], bool], + item_description: str, + context: str, +) -> None: + if field_name not in value: + return + items = value[field_name] + if not isinstance(items, list): + raise ValueError(f"{context} field '{field_label}' must be a list") + for index, item in enumerate(items): + if not item_validator(item): + raise ValueError( + f"{context} field '{field_label}[{index}]' must be {item_description}" + ) diff --git a/src/langbot/pkg/pipeline/msgtrun/__init__.py b/src/langbot/pkg/pipeline/msgtrun/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/pipeline/msgtrun/msgtrun.py b/src/langbot/pkg/pipeline/msgtrun/msgtrun.py deleted file mode 100644 index 00a9bfbf2..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/msgtrun.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from .. import stage, entities -from . import truncator -from ...utils import importutil -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from . import truncators - -importutil.import_modules_in_pkg(truncators) - - -@stage.stage_class('ConversationMessageTruncator') -class ConversationMessageTruncator(stage.PipelineStage): - """Conversation message truncator - - Used to truncate the conversation message chain to adapt to the LLM message length limit. - """ - - trun: truncator.Truncator - - async def initialize(self, pipeline_config: dict): - use_method = 'round' - - for trun in truncator.preregistered_truncators: - if trun.name == use_method: - self.trun = trun(self.ap) - break - else: - raise ValueError(f'Unknown truncator: {use_method}') - - async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: - """处理""" - query = await self.trun.truncate(query) - - return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/msgtrun/truncator.py b/src/langbot/pkg/pipeline/msgtrun/truncator.py deleted file mode 100644 index 180982d3c..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/truncator.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import typing -import abc - -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - -preregistered_truncators: list[typing.Type[Truncator]] = [] - - -def truncator_class( - name: str, -) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: - """截断器类装饰器 - - Args: - name (str): 截断器名称 - - Returns: - typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器 - """ - - def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]: - assert issubclass(cls, Truncator) - - cls.name = name - - preregistered_truncators.append(cls) - - return cls - - return decorator - - -class Truncator(abc.ABC): - """消息截断器基类""" - - name: str - - ap: app.Application - - def __init__(self, ap: app.Application): - self.ap = ap - - async def initialize(self): - pass - - @abc.abstractmethod - async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: - """截断 - - 一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。 - 请勿操作其他字段。 - """ - pass diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py b/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py deleted file mode 100644 index 400706b67..000000000 --- a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from .. import truncator -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - - -@truncator.truncator_class('round') -class RoundTruncator(truncator.Truncator): - """Truncate the conversation message chain to adapt to the LLM message length limit.""" - - async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: - """截断""" - max_round = query.pipeline_config['ai']['local-agent']['max-round'] - - temp_messages = [] - - current_round = 0 - - # Traverse from back to front - for msg in query.messages[::-1]: - if current_round < max_round: - temp_messages.append(msg) - if msg.role == 'user': - current_round += 1 - else: - break - - query.messages = temp_messages[::-1] - - return query diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index adf44b524..ebc26cc66 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -14,6 +14,8 @@ import langbot_plugin.api.entities.events as events from ..utils import importutil from .config_coercion import coerce_pipeline_config +from ..agent.runner.config_resolver import RunnerConfigResolver +from .extension_preferences import normalize_extension_preferences import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -28,7 +30,6 @@ wrapper, preproc, ratelimit, - msgtrun, ) importutil.import_modules_in_pkgs( @@ -42,7 +43,6 @@ wrapper, preproc, ratelimit, - msgtrun, ] ) @@ -93,32 +93,43 @@ def __init__( self.stage_containers = stage_containers # Extract bound plugins and MCP servers from extensions_preferences - extensions_prefs = pipeline_entity.extensions_preferences or {} - self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) - self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) - local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {}) - self.mcp_resource_attachments = local_agent_config.get( + extensions_prefs = normalize_extension_preferences(pipeline_entity.extensions_preferences) + self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) is True + self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) is True + pipeline_config = pipeline_entity.config or {} + runner_config: dict[str, typing.Any] = {} + runner_id = ( + RunnerConfigResolver.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None + ) + if runner_id: + resolved_runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + if isinstance(resolved_runner_config, dict): + runner_config = resolved_runner_config + + self.mcp_resource_attachments = runner_config.get( 'mcp-resources', extensions_prefs.get('mcp_resources', []), ) - self.mcp_resource_agent_read_enabled = local_agent_config.get( - 'mcp-resource-agent-read-enabled', - extensions_prefs.get('mcp_resource_agent_read_enabled', True), + self.mcp_resource_agent_read_enabled = ( + runner_config.get( + 'mcp-resource-agent-read-enabled', + extensions_prefs.get('mcp_resource_agent_read_enabled', True), + ) + is True ) if self.enable_all_plugins: # None indicates to use all available plugins self.bound_plugins = None else: - plugin_list = extensions_prefs.get('plugins', []) - self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else [] + plugin_list = extensions_prefs['plugins'] + self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if self.enable_all_mcp_servers: # None indicates to use all available MCP servers self.bound_mcp_servers = None else: - mcp_server_list = extensions_prefs.get('mcp_servers', []) - self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] + self.bound_mcp_servers = extensions_prefs['mcp_servers'] async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config @@ -289,8 +300,10 @@ async def process_query(self, query: pipeline_query.Query): # Get runner name from pipeline config runner_name = None - if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']: - runner_name = query.pipeline_config['ai']['runner'].get('runner') + if query.pipeline_config: + from ..agent.runner.config_resolver import RunnerConfigResolver + + runner_name = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) # Record query start and store message_id message_id = '' @@ -449,6 +462,9 @@ async def load_pipeline( # initialize stage containers according to pipeline_entity.stages stage_containers: list[StageInstContainer] = [] for stage_name in pipeline_entity.stages: + if stage_name not in self.stage_dict: + self.ap.logger.warning(f'Pipeline stage {stage_name} is not registered; skipping') + continue stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap))) for stage_container in stage_containers: diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index b14d0a827..9d5106083 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import typing from .. import stage, entities from langbot_plugin.api.entities.builtin.provider import message as provider_message @@ -9,6 +10,18 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.events as platform_events +from ...agent.runner.descriptor import AgentRunnerDescriptor +from ...agent.runner.config_resolver import RunnerConfigResolver +from ...agent.runner import config_schema +from ...agent.runner.resource_policy import ResourcePolicyProjector +from ...provider.tools.toolmgr import ToolManager +from ..extension_preferences import normalize_extension_preferences + + +DEFAULT_PROMPT_CONFIG = [ + {'role': 'system', 'content': 'You are a helpful assistant.'}, +] + @stage.stage_class('PreProcessor') class PreProcessor(stage.PipelineStage): @@ -25,20 +38,151 @@ class PreProcessor(stage.PipelineStage): - use_funcs """ - @staticmethod - def _filter_selected_tools( - tools: list, - local_agent_config: dict, - ) -> list: - if local_agent_config.get('enable-all-tools', True) is not False: - return tools + async def _get_runner_descriptor( + self, + runner_id: str | None, + bound_plugins: list[str] | None, + ) -> AgentRunnerDescriptor | None: + if not runner_id: + return None + + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + + try: + return await registry.get(runner_id, bound_plugins) + except Exception as e: + self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}') + return None + + async def _resolve_llm_model( + self, + primary_uuid: str, + ) -> typing.Any | None: + if primary_uuid in config_schema.NONE_SENTINELS: + return None + try: + return await self.ap.model_mgr.get_model_by_uuid(primary_uuid) + except ValueError: + self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') + return None + + async def _resolve_fallback_models(self, fallback_uuids: list[str]) -> list[str]: + valid_fallbacks = [] + for fallback_uuid in fallback_uuids: + if fallback_uuid in config_schema.NONE_SENTINELS: + continue + try: + await self.ap.model_mgr.get_model_by_uuid(fallback_uuid) + valid_fallbacks.append(fallback_uuid) + except ValueError: + self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping') + return valid_fallbacks + + async def _resolve_host_tools( + self, + query: pipeline_query.Query, + runner_config: dict[str, typing.Any], + bound_plugins: list[str] | None, + bound_mcp_servers: list[str] | None, + include_mcp_resource_tools: bool, + ) -> list[typing.Any]: + catalog = await self.ap.tool_mgr.get_resolved_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + policy = ResourcePolicyProjector.from_runner_config( + runner_config, + resolved_tool_names=ResourcePolicyProjector.extract_tool_names(catalog), + ) + catalog = ResourcePolicyProjector.filter_tools(catalog, policy) + ToolManager.bind_query_tool_sources(query, catalog) + return ToolManager.tools_from_catalog(catalog) + + def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool: + if descriptor is None: + return True + return descriptor.capabilities.multimodal_input + + def _model_supports_vision(self, llm_model: typing.Any | None) -> bool: + if not llm_model: + return False + abilities = getattr(getattr(llm_model, 'model_entity', None), 'abilities', []) + return 'vision' in (abilities or []) + + def _should_keep_image_inputs( + self, + descriptor: AgentRunnerDescriptor | None, + uses_host_models: bool, + llm_model: typing.Any | None, + ) -> bool: + if not self._runner_accepts_multimodal_input(descriptor): + return False + if uses_host_models: + return self._model_supports_vision(llm_model) + return True + + def _strip_images_from_history(self, query: pipeline_query.Query) -> None: + for msg in query.messages: + if isinstance(msg.content, list): + msg.content = [elem for elem in msg.content if elem.type != 'image_url'] + + def _has_declared_db_engine(self) -> bool: + persistence_mgr = getattr(self.ap, 'persistence_mgr', None) + if persistence_mgr is None: + return False + if 'get_db_engine' in getattr(persistence_mgr, '__dict__', {}): + return True + return hasattr(type(persistence_mgr), 'get_db_engine') + + async def _load_agent_runner_history_messages( + self, + runner_id: str | None, + conversation_uuid: str | None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + ) -> list[provider_message.Message] | None: + if not runner_id or not conversation_uuid or not self._has_declared_db_engine(): + return None + + try: + from ...agent.runner.transcript_store import TranscriptStore + + store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) + messages = await store.get_legacy_provider_messages( + str(conversation_uuid), + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + strict_thread=True, + ) + except Exception as e: + self.ap.logger.warning(f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}') + return None - selected_tools = local_agent_config.get('tools', []) - if not isinstance(selected_tools, list): - return [] + return messages or None - selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} - return [tool for tool in tools if tool.name in selected_tool_names] + async def _resolve_history_messages( + self, + runner_id: str | None, + conversation: typing.Any, + bot_id: str | None = None, + workspace_id: str | None = None, + ) -> list[provider_message.Message]: + transcript_messages = await self._load_agent_runner_history_messages( + runner_id, + getattr(conversation, 'uuid', None), + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=getattr(conversation, 'thread_id', None), + ) + if transcript_messages is not None: + return transcript_messages + return conversation.messages.copy() async def process( self, @@ -46,50 +190,37 @@ async def process( stage_inst_name: str, ) -> entities.StageProcessResult: """Process""" - selected_runner = query.pipeline_config['ai']['runner']['runner'] - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - include_skill_authoring = ( - selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None + # Resolve runner ID from the current ai.runner.id shape. + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) + + # Get runner config from ai.runner_config[runner_id]. + runner_config = ( + RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} ) + query.variables = query.variables or {} + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) is True + descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) session = await self.ap.sess_mgr.get_session(query) - # When not local-agent, llm_model is None + uses_host_models = config_schema.uses_host_models(descriptor) + uses_host_tools = config_schema.uses_host_tools(descriptor) llm_model = None - if selected_runner == 'local-agent': - # Read model config — new format is { primary: str, fallbacks: [str] }, - # but handle legacy plain string for backward compatibility - model_config = local_agent_config.get('model', {}) - if isinstance(model_config, str): - # Legacy format: plain UUID string - primary_uuid = model_config - fallback_uuids = [] - else: - primary_uuid = model_config.get('primary', '') - fallback_uuids = model_config.get('fallbacks', []) - - if primary_uuid: - try: - llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid) - except ValueError: - self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') - - # Resolve fallback model UUIDs - if fallback_uuids: - valid_fallbacks = [] - for fb_uuid in fallback_uuids: - try: - await self.ap.model_mgr.get_model_by_uuid(fb_uuid) - valid_fallbacks.append(fb_uuid) - except ValueError: - self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') - if valid_fallbacks: - query.variables['_fallback_model_uuids'] = valid_fallbacks + if uses_host_models: + primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config) + llm_model = await self._resolve_llm_model(primary_uuid) + valid_fallbacks = await self._resolve_fallback_models(fallback_uuids) + if valid_fallbacks: + query.variables['_fallback_model_uuids'] = valid_fallbacks + + prompt_config = config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG) conversation = await self.ap.sess_mgr.get_conversation( query, session, - query.pipeline_config['ai']['local-agent']['prompt'], + prompt_config, query.pipeline_uuid, query.bot_uuid, ) @@ -98,7 +229,7 @@ async def process( # been idle for longer than the configured conversation expire time. # The idle window is measured from the last preprocess/update time, not # from the conversation creation time. - conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None) + conversation_expire_time = RunnerConfigResolver.get_expire_time(query.pipeline_config) now = datetime.datetime.now() if conversation_expire_time is not None and conversation_expire_time > 0: last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None) @@ -115,28 +246,28 @@ async def process( # time instead of the first message/creation time. conversation.update_time = now - # 设置query + # Attach resolved session state to the query. query.session = session query.prompt = conversation.prompt.copy() - query.messages = conversation.messages.copy() + query.messages = await self._resolve_history_messages( + runner_id, + conversation, + bot_id=query.bot_uuid, + ) - if selected_runner == 'local-agent': + if uses_host_models: query.use_funcs = [] if llm_model: query.use_llm_model_uuid = llm_model.model_entity.uuid - if 'func_call' in (llm_model.model_entity.abilities or []): - # Get bound plugins and MCP servers for filtering tools - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) - all_tools = await self.ap.tool_mgr.get_all_tools( + if uses_host_tools and 'func_call' in (llm_model.model_entity.abilities or []): + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, bound_plugins, bound_mcp_servers, - include_skill_authoring=include_skill_authoring, - include_mcp_resource_tools=include_mcp_resource_tools, + include_mcp_resource_tools, ) - query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') @@ -144,17 +275,26 @@ async def process( # If primary model doesn't support func_call but fallback models exist, # load tools anyway since fallback models may support them - if not query.use_funcs and query.variables.get('_fallback_model_uuids'): - bound_plugins = query.variables.get('_pipeline_bound_plugins', None) - bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) - all_tools = await self.ap.tool_mgr.get_all_tools( + if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'): + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, bound_plugins, bound_mcp_servers, - include_skill_authoring=include_skill_authoring, - include_mcp_resource_tools=include_mcp_resource_tools, + include_mcp_resource_tools, ) - query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) + elif uses_host_tools: + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, + bound_plugins, + bound_mcp_servers, + include_mcp_resource_tools, + ) + + self.ap.logger.debug(f'Bound plugins: {bound_plugins}') + self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') + self.ap.logger.debug(f'Use funcs: {query.use_funcs}') sender_name = '' @@ -179,32 +319,28 @@ async def process( } query.variables.update(variables) - # Check if this model supports vision, if not, remove all images - # TODO this checking should be performed in runner, and in this stage, the image should be reserved - if selected_runner == 'local-agent' and llm_model and 'vision' not in (llm_model.model_entity.abilities or []): - for msg in query.messages: - if isinstance(msg.content, list): - for me in msg.content: - if me.type == 'image_url': - msg.content.remove(me) + keep_image_inputs = self._should_keep_image_inputs(descriptor, uses_host_models, llm_model) + if not keep_image_inputs: + self._strip_images_from_history(query) content_list: list[provider_message.ContentElement] = [] plain_text = '' - quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message') + quote_msg = query.pipeline_config['trigger'].get('misc', {}).get('combine-quote-message', False) for me in query.message_chain: if isinstance(me, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(me.text)) plain_text += me.text elif isinstance(me, platform_message.Image): - if selected_runner != 'local-agent' or ( - llm_model and 'vision' in (llm_model.model_entity.abilities or []) - ): + if keep_image_inputs: if me.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) + else: + content_list.append(provider_message.ContentElement.from_text('[Image]')) + plain_text += '[Image]' elif isinstance(me, platform_message.Voice): - # 转成文件链接,让下游 runner 上传到目标模型 + # Convert voice input into file content for downstream model upload. if me.base64: content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk')) elif me.url: @@ -219,11 +355,12 @@ async def process( if isinstance(msg, platform_message.Plain): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): - if selected_runner != 'local-agent' or ( - llm_model and 'vision' in (llm_model.model_entity.abilities or []) - ): + if keep_image_inputs: if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) + else: + content_list.append(provider_message.ContentElement.from_text('[Image]')) + plain_text += '[Image]' elif isinstance(msg, platform_message.File): if msg.base64: content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name)) @@ -241,16 +378,14 @@ async def process( query.user_message = provider_message.Message(role='user', content=content_list) - # Extract knowledge base UUIDs into query variables so plugins can modify them - # during PromptPreProcessing before the runner performs retrieval. - kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) - if not kb_uuids: - old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - kb_uuids = [old_kb_uuid] - query.variables['_knowledge_base_uuids'] = list(kb_uuids) + # Extract configured KB UUIDs into query variables so PromptPreProcessing + # plugins can still adjust the authorized retrieval set before run_agent. + query.variables['_knowledge_base_uuids'] = config_schema.extract_knowledge_base_uuids( + descriptor, + runner_config, + ) - # =========== 触发事件 PromptPreProcessing + # Emit PromptPreProcessing before the runner receives the query. event = events.PromptPreProcessing( session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}', @@ -266,67 +401,16 @@ async def process( query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt - # =========== Skill awareness for the local-agent runner =========== - # The actual activation goes through the ``activate`` Tool Call so the - # LLM doesn't see full SKILL.md instructions until it commits to a - # skill (Claude Code's progressive disclosure). But the LLM still has - # to KNOW which skills exist to make that choice, so we: - # 1. resolve the pipeline's bound skills and stash them in - # ``query.variables['_pipeline_bound_skills']`` for downstream - # visibility checks (skill loader, native exec workdir); - # 2. inject a short ``Available Skills`` index (name + description - # only) into the system prompt. The contributor's original PR - # relied on this injection; without it the LLM never discovers - # the skills are there and just calls native tools instead. - if selected_runner == 'local-agent' and self.ap.skill_mgr: + if getattr(self.ap, 'skill_mgr', None) is not None: pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) - extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {}) - enable_all_skills = extensions_prefs.get('enable_all_skills', True) + extensions_prefs = normalize_extension_preferences((pipeline_data or {}).get('extensions_preferences')) + enable_all_skills = extensions_prefs['enable_all_skills'] if enable_all_skills: bound_skills = None # None = all loaded skills are visible else: - bound_skills = extensions_prefs.get('skills', []) + bound_skills = extensions_prefs['skills'] query.variables['_pipeline_bound_skills'] = bound_skills - skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( - bound_skills=bound_skills, - ) - if skill_addition: - # Append to the first system message; create one if the - # prompt has none. Handles both plain-string and - # content-element (list) message bodies. - if query.prompt.messages and query.prompt.messages[0].role == 'system': - head = query.prompt.messages[0] - if isinstance(head.content, str): - head.content = head.content + skill_addition - elif isinstance(head.content, list): - appended = False - for ce in head.content: - if getattr(ce, 'type', None) == 'text': - ce.text = (ce.text or '') + skill_addition - appended = True - break - if not appended: - head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) - else: - query.prompt.messages.insert( - 0, - provider_message.Message(role='system', content=skill_addition.strip()), - ) - self.ap.logger.debug( - f'Skill index injected into system prompt: ' - f'pipeline={query.pipeline_uuid} ' - f'bound_skills={bound_skills or "all"} ' - f'loaded_skills={len(self.ap.skill_mgr.skills)}' - ) - else: - self.ap.logger.debug( - f'No skills available for prompt injection: ' - f'pipeline={query.pipeline_uuid} ' - f'loaded_skills={len(self.ap.skill_mgr.skills)} ' - f'bound_skills={bound_skills}' - ) - return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 4e5c14ea4..51ea2aa04 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -10,30 +10,36 @@ from .. import handler from ... import entities from ... import plugin_diagnostics -from ....provider import runner as runner_module import langbot_plugin.api.entities.events as events -from ....utils import importutil, constants, runner as runner_utils +from ....agent.runner.config_resolver import RunnerConfigResolver +from ....agent.runner import config_schema +from ....utils import constants, runner as runner_utils from ....telemetry import features as telemetry_features -from ....provider import runners import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message -importutil.import_modules_in_pkg(runners) +DEFAULT_PROMPT_CONFIG = [ + {'role': 'system', 'content': 'You are a helpful assistant.'}, +] class ChatMessageHandler(handler.MessageHandler): + """Chat message handler using AgentRunOrchestrator. + + This handler delegates all runner execution to the agent_run_orchestrator, + which resolves runner ID, builds context, invokes plugin runtime, + and normalizes results. + """ + async def handle( self, query: pipeline_query.Query, ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: - """处理""" - # 调API - # 生成器 - - # 触发插件事件 + """Handle chat message by delegating to AgentRunOrchestrator.""" + # Trigger plugin event event_class = ( events.PersonNormalMessageReceived if query.launcher_type == provider_session.LauncherTypes.PERSON @@ -54,7 +60,7 @@ async def handle( bound_plugins = query.variables.get('_pipeline_bound_plugins', None) event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) - is_create_card = False # 判断下是否需要创建流式卡片 + is_create_card = False # Track if streaming card was created if event_ctx.is_prevented_default(): if event_ctx.event.reply_message_chain is not None: @@ -87,40 +93,51 @@ async def handle( text_length = 0 try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - try: - for r in runner_module.preregistered_runners: - if r.name == query.pipeline_config['ai']['runner']['runner']: - runner = r(self.ap, query.pipeline_config) - break - else: - raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') # Mark start time for telemetry start_ts = time.time() - if is_stream: - resp_message_id = uuid.uuid4() - chunk_count = 0 # Track streaming chunks to reduce excessive logging + try_claim_steering = getattr( + self.ap.agent_run_orchestrator, + 'try_claim_steering_from_query', + None, + ) + if try_claim_steering and await try_claim_steering(query): + yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) + return - async for result in runner.run(query): - result.resp_message_id = str(resp_message_id) + try: + is_stream = await query.adapter.is_stream_output_supported() + except AttributeError: + is_stream = False + + # Create a single resp_message_id for the entire streaming response + resp_message_id = uuid.uuid4() + chunk_count = 0 + + # Use AgentRunOrchestrator to run the agent + # This replaces direct runner lookup and PluginAgentRunnerWrapper + async for result in self.ap.agent_run_orchestrator.run_from_query(query): + result.resp_message_id = str(resp_message_id) + + # For streaming mode, pop previous response before adding new chunk + # This allows incremental card updates + if is_stream: if query.resp_messages: query.resp_messages.pop() if query.resp_message_chain: query.resp_message_chain.pop() - # 此时连接外部 AI 服务正常,创建卡片 - if not is_create_card: # 只有不是第一次才创建卡片 + + # Create streaming card on first result (connection established) + if not is_create_card: await query.adapter.create_message_card(str(resp_message_id), query.message_event) is_create_card = True - query.resp_messages.append(result) + query.resp_messages.append(result) + + if is_stream: chunk_count += 1 - # Only log every 10th chunk to reduce excessive logging during streaming - # This prevents memory overflow from thousands of log entries per conversation - # First chunk uses INFO level to confirm connection establishment + # Only log every 10th chunk to reduce excessive logging during streaming. + # First chunk uses INFO level to confirm connection establishment. if chunk_count == 1: summary = self.format_result_log(result) if summary is not None: @@ -131,46 +148,59 @@ async def handle( self.ap.logger.debug( f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}' ) + else: + summary = self.format_result_log(result) + if summary is not None: + self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}') - if result.content is not None: - text_length += len(result.content) + if result.content is not None: + text_length += len(result.content) - yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - # Log final summary after streaming completes + # Log final summary after streaming completes + if is_stream: self.ap.logger.info( f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars' ) - else: - async for result in runner.run(query): - query.resp_messages.append(result) - - summary = self.format_result_log(result) - if summary is not None: - self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}') - - if result.content is not None: - text_length += len(result.content) - - yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - - query.session.using_conversation.messages.append(query.user_message) + # Keep a conversation object available for downstream legacy + # readers, but do not mirror AgentRunner history into + # conversation.messages. TranscriptStore is the canonical + # history source for this path. + await self._ensure_conversation_for_history(query) - query.session.using_conversation.messages.extend(query.resp_messages) except Exception as e: + # Import orchestrator errors for specific handling + from ....agent.runner.errors import ( + RunnerNotFoundError, + RunnerNotAuthorizedError, + RunnerExecutionError, + ) + error_info = f'{traceback.format_exc()}' self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') - traceback.print_exc() - exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') + # Handle specific runner errors with appropriate messages + if isinstance(e, RunnerNotFoundError): + user_notice = f'Agent runner not found: {e.runner_id}' + elif isinstance(e, RunnerNotAuthorizedError): + user_notice = 'Agent runner not authorized for this pipeline' + elif isinstance(e, RunnerExecutionError): + if e.retryable: + user_notice = 'Agent runner temporarily unavailable. Please try again.' + else: + user_notice = 'Agent runner execution failed.' + else: + # Use existing exception handling + exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') - if exception_handling == 'show-error': - user_notice = f'{e}' - elif exception_handling == 'show-hint': - user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') - else: # hide - user_notice = None + if exception_handling == 'show-error': + user_notice = f'{e}' + elif exception_handling == 'show-hint': + user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + else: # hide + user_notice = None yield entities.StageProcessResult( result_type=entities.ResultType.INTERRUPT, @@ -180,7 +210,7 @@ async def handle( debug_notice=traceback.format_exc(), ) finally: - # Telemetry reporting: collect minimal per-query execution info and send asynchronously + # Telemetry reporting try: end_ts = time.time() duration_ms = None @@ -188,16 +218,14 @@ async def handle( duration_ms = int((end_ts - start_ts) * 1000) adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None - runner_name = ( - query.pipeline_config.get('ai', {}).get('runner', {}).get('runner') - if query.pipeline_config - else None - ) - # Model name if using localagent + # Use orchestrator to resolve runner ID for telemetry + runner_name = self.ap.agent_run_orchestrator.resolve_runner_id_for_telemetry(query) + + # Model name if available model_name = None try: - if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None): + if getattr(query, 'use_llm_model_uuid', None): m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) if m and getattr(m, 'model_entity', None): model_name = getattr(m.model_entity, 'name', None) @@ -207,7 +235,7 @@ async def handle( pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None) runner_category = runner_utils.get_runner_category_from_runner( - runner_name, runner, query.pipeline_config + runner_name, None, query.pipeline_config ) # Feature usage collected during query processing (tool calls, @@ -231,7 +259,6 @@ async def handle( 'timestamp': datetime.utcnow().isoformat(), } - # Send telemetry asynchronously and do not block pipeline via app's telemetry manager await self.ap.telemetry.start_send_task(payload) # Trigger survey events on successful non-WebSocket responses @@ -241,5 +268,72 @@ async def handle( # Counts toward the bot_response_success_100 milestone event await self.ap.survey.record_bot_response_success() except Exception as ex: - # Ensure telemetry issues do not affect normal flow self.ap.logger.warning(f'Failed to send telemetry: {ex}') + + async def _ensure_conversation_for_history( + self, + query: pipeline_query.Query, + ) -> provider_session.Conversation: + session = getattr(query, 'session', None) + conversation = getattr(session, 'using_conversation', None) + if conversation is not None: + return conversation + + if session is None or getattr(self.ap, 'sess_mgr', None) is None: + raise RuntimeError('Conversation is not available for history update') + + prompt_config = await self._build_history_prompt_config(query) + conversation = await self.ap.sess_mgr.get_conversation( + query, + session, + prompt_config, + query.pipeline_uuid, + query.bot_uuid, + ) + if conversation is None: + raise RuntimeError('Conversation manager did not return a conversation') + + if getattr(session, 'using_conversation', None) is None: + session.using_conversation = conversation + return conversation + + async def _build_history_prompt_config( + self, + query: pipeline_query.Query, + ) -> list[dict[str, typing.Any]]: + prompt_messages = getattr(getattr(query, 'prompt', None), 'messages', None) + if prompt_messages: + prompt_config = [] + for message in prompt_messages: + if hasattr(message, 'model_dump'): + prompt_config.append(message.model_dump(mode='python')) + elif isinstance(message, dict): + prompt_config.append(message) + if prompt_config: + return prompt_config + + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) + runner_config = ( + RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + ) + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) + return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG) + + async def _get_runner_descriptor( + self, + runner_id: str | None, + bound_plugins: list[str] | None, + ) -> typing.Any | None: + if not runner_id: + return None + + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return None + + try: + return await registry.get(runner_id, bound_plugins) + except Exception as e: + self.ap.logger.debug(f'Unable to load AgentRunner descriptor for {runner_id}: {e}') + return None diff --git a/src/langbot/pkg/persistence/migrations/__init__.py b/src/langbot/pkg/platform/adapters/__init__.py similarity index 100% rename from src/langbot/pkg/persistence/migrations/__init__.py rename to src/langbot/pkg/platform/adapters/__init__.py diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/__init__.py b/src/langbot/pkg/platform/adapters/aiocqhttp/__init__.py new file mode 100644 index 000000000..3c14f6312 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter + +__all__ = ['AiocqhttpAdapter'] diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py new file mode 100644 index 000000000..7344c4b4b --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import asyncio +import traceback +import typing + +import aiocqhttp +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.aiocqhttp.api_impl import AiocqhttpAPIMixin +from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter +from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter +from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True) + + message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() + event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + + config: dict + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + run_config = dict(config) + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + run_config['shutdown_trigger'] = shutdown_trigger_placeholder + access_token = run_config.pop('access-token', '') or None + bot = aiocqhttp.CQHttp(access_token=access_token) + + super().__init__( + config=run_config, + logger=logger, + bot=bot, + bot_account_id='', + listeners={}, + ) + self._register_native_handlers() + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.deleted', + 'group.member_joined', + 'group.member_left', + 'group.member_banned', + 'friend.request_received', + 'friend.added', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'bot.muted', + 'bot.unmuted', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'delete_message', + 'forward_message', + 'get_message', + 'get_group_info', + 'get_group_list', + 'get_group_member_list', + 'get_group_member_info', + 'set_group_name', + 'get_user_info', + 'get_friend_list', + 'approve_friend_request', + 'approve_group_invite', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api', + ] + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def run_async(self): + await self.bot._server_app.run_task(**self.config) + + async def kill(self) -> bool: + return False + + def _register_native_handlers(self): + @self.bot.on_message() + async def on_message(event: aiocqhttp.Event): + await self._handle_native_event(event) + + @self.bot.on_notice() + async def on_notice(event: aiocqhttp.Event): + await self._handle_native_event(event) + + @self.bot.on_request() + async def on_request(event: aiocqhttp.Event): + await self._handle_native_event(event) + + @self.bot.on_websocket_connection + async def on_websocket_connection(event: aiocqhttp.Event): + self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id) + await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}') + await self._dispatch_native_event(event) + + async def _handle_native_event(self, event: aiocqhttp.Event): + self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id) + if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id: + return + try: + if getattr(event, 'type', None) == 'message' and ( + platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners + ): + legacy_event = await self.event_converter.target2legacy(event, self.bot) + if legacy_event: + callback = self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, self) + await self._dispatch_native_event(event) + except Exception: + await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}') + + async def _dispatch_native_event(self, event: aiocqhttp.Event): + eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id) + if eba_event: + await self._dispatch_eba_event(eba_event) + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/api_impl.py b/src/langbot/pkg/platform/adapters/aiocqhttp/api_impl.py new file mode 100644 index 000000000..f4c315909 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/api_impl.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import typing + +import aiocqhttp + +from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter +from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class AiocqhttpAPIMixin: + bot: aiocqhttp.CQHttp + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + forward = message.get_first(platform_message.Forward) + if forward and target_type == 'group': + raw = await self._send_forward_message(int(target_id), typing.cast(platform_message.Forward, forward)) + return platform_events.MessageResult(message_id=raw.get('message_id'), raw=raw) + + aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message) + if target_type == 'group': + raw = await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg) + elif target_type in ('person', 'private'): + raw = await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg) + else: + raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}') + return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + assert isinstance(message_source.source_platform_object, aiocqhttp.Event) + aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message) + if quote_origin: + source_id = getattr(message_source, 'message_id', None) or message_source.message_chain.message_id + aiocq_msg = aiocqhttp.MessageSegment.reply(source_id) + aiocq_msg + raw = await self.bot.send(message_source.source_platform_object, aiocq_msg) + return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {}) + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + await self.bot.delete_msg(message_id=int(message_id)) + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raw_message = await self.bot.get_msg(message_id=int(message_id)) + target_message = aiocqhttp.Message(raw_message.get('message', [])) + if to_chat_type == 'group': + raw = await self.bot.send_group_msg(group_id=int(to_chat_id), message=target_message) + elif to_chat_type in ('person', 'private'): + raw = await self.bot.send_private_msg(user_id=int(to_chat_id), message=target_message) + else: + raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}') + return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {}) + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + raw = await self.bot.get_msg(message_id=int(message_id)) + message_type = raw.get('message_type') or chat_type + event = aiocqhttp.Event.from_payload( + { + 'post_type': 'message', + 'message_type': 'group' if message_type == 'group' else 'private', + 'sub_type': raw.get('sub_type', 'normal'), + 'time': raw.get('time', 0), + 'self_id': self.bot_account_id or 0, + 'message_id': raw.get('message_id', message_id), + 'user_id': raw.get('sender', {}).get('user_id') or raw.get('user_id') or chat_id, + 'group_id': raw.get('group_id') or (chat_id if message_type == 'group' else None), + 'message': raw.get('message', []), + 'raw_message': raw.get('raw_message', ''), + 'sender': raw.get('sender', {}), + } + ) + return await AiocqhttpEventConverter.message_to_eba(event, self.bot) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + raw = await self.bot.get_group_info(group_id=int(group_id)) + return platform_entities.UserGroup( + id=raw.get('group_id', group_id), + name=raw.get('group_name', ''), + member_count=raw.get('member_count'), + ) + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + raw_list = await self.bot.get_group_list() + return [ + platform_entities.UserGroup( + id=item.get('group_id', ''), + name=item.get('group_name', ''), + member_count=item.get('member_count'), + ) + for item in raw_list + ] + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raw_list = await self.bot.get_group_member_list(group_id=int(group_id)) + return [self._member_to_entity(item, group_id) for item in raw_list] + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True) + return self._member_to_entity(raw, group_id) + + async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None: + await self.bot.set_group_name(group_id=int(group_id), group_name=name) + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration)) + + async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None: + await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0) + + async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None: + await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False) + + async def leave_group(self, group_id: typing.Union[int, str]) -> None: + await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True) + return platform_entities.User( + id=raw.get('user_id', user_id), + nickname=raw.get('nickname', ''), + avatar_url=raw.get('avatar_url'), + ) + + async def get_friend_list(self) -> list[platform_entities.User]: + raw_list = await self.bot.get_friend_list() + return [ + platform_entities.User( + id=item.get('user_id', ''), + nickname=item.get('nickname', ''), + remark=item.get('remark'), + ) + for item in raw_list + ] + + async def approve_friend_request( + self, + request_id: typing.Union[int, str], + approve: bool = True, + remark: typing.Optional[str] = None, + ) -> None: + await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '') + + async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None: + await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='') + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + @staticmethod + def _member_to_entity(raw: dict, group_id: typing.Union[int, str]) -> platform_entities.UserGroupMember: + role = platform_entities.MemberRole.MEMBER + if raw.get('role') == 'owner': + role = platform_entities.MemberRole.OWNER + elif raw.get('role') == 'admin': + role = platform_entities.MemberRole.ADMIN + return platform_entities.UserGroupMember( + user=platform_entities.User( + id=raw.get('user_id', ''), + nickname=raw.get('nickname', ''), + remark=raw.get('card') or raw.get('remark'), + ), + group_id=group_id, + role=role, + display_name=raw.get('card') or raw.get('nickname'), + joined_at=float(raw['join_time']) if raw.get('join_time') else None, + title=raw.get('title'), + ) + + async def _send_forward_message(self, group_id: int, forward: platform_message.Forward) -> dict: + messages = [] + for node in forward.node_list: + if not node.message_chain: + continue + content, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain) + if not content: + continue + messages.append( + { + 'type': 'node', + 'data': { + 'user_id': str(node.sender_id or self.bot_account_id or '10000'), + 'nickname': node.sender_name or 'LangBot', + 'content': list(content), + }, + } + ) + if not messages: + return {} + try: + return await self.bot.call_action( + 'send_forward_msg', group_id=group_id, user_id=str(self.bot_account_id), messages=messages + ) + except Exception: + return await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages) diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py new file mode 100644 index 000000000..507be3d4a --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import typing + +import aiocqhttp + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None): + return getattr(event, 'source_platform_object', None) + + @staticmethod + async def target2yiri( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + bot_user_id: int | str | None = None, + ) -> platform_events.Event | None: + event_type = getattr(event, 'type', None) + if event_type == 'message': + return await AiocqhttpEventConverter.message_to_eba(event, bot) + if event_type == 'notice': + return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id) + if event_type == 'request': + return AiocqhttpEventConverter.request_to_eba(event) + if event_type == 'meta_event': + return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}') + return None + + @staticmethod + async def target2legacy( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot) + if eba_event: + return eba_event.to_legacy_event() + return None + + @staticmethod + async def message_to_eba( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_events.MessageReceivedEvent: + message_chain = await AiocqhttpMessageConverter.target2yiri( + getattr(event, 'message', []), + getattr(event, 'message_id', -1), + getattr(event, 'time', None), + bot, + ) + message_type = getattr(event, 'message_type', getattr(event, 'detail_type', 'private')) + group = None + chat_type = platform_entities.ChatType.PRIVATE + chat_id = getattr(event, 'user_id', '') + if message_type == 'group': + chat_type = platform_entities.ChatType.GROUP + chat_id = getattr(event, 'group_id', '') + group = AiocqhttpEventConverter.group_from_event(event) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name='aiocqhttp', + message_id=getattr(event, 'message_id', ''), + message_chain=message_chain, + sender=AiocqhttpEventConverter.user_from_sender(event), + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + + @staticmethod + def notice_to_eba( + event: aiocqhttp.Event, + bot_user_id: int | str | None = None, + ) -> platform_events.EBAEvent: + notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', '')) + if notice_type in ('group_recall', 'friend_recall'): + return platform_events.MessageDeletedEvent( + type='message.deleted', + adapter_name='aiocqhttp', + message_id=getattr(event, 'message_id', ''), + operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)), + chat_type=platform_entities.ChatType.GROUP + if notice_type == 'group_recall' + else platform_entities.ChatType.PRIVATE, + chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')), + group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + if notice_type == 'group_increase': + group = AiocqhttpEventConverter.group_from_event(event) + user = AiocqhttpEventConverter.user(getattr(event, 'user_id', '')) + inviter_id = getattr(event, 'operator_id', None) + if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='aiocqhttp', + group=group, + inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + return platform_events.MemberJoinedEvent( + type='group.member_joined', + adapter_name='aiocqhttp', + group=group, + member=user, + inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, + join_type=getattr(event, 'sub_type', None) or 'direct', + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + if notice_type == 'group_decrease': + group = AiocqhttpEventConverter.group_from_event(event) + operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) + if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name='aiocqhttp', + group=group, + operator=operator, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + return platform_events.MemberLeftEvent( + type='group.member_left', + adapter_name='aiocqhttp', + group=group, + member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), + is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'), + operator=operator, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + if notice_type == 'group_ban': + group = AiocqhttpEventConverter.group_from_event(event) + duration = int(getattr(event, 'duration', 0) or 0) + operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) + if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): + event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent + kwargs: dict[str, typing.Any] = { + 'type': 'bot.muted' if duration > 0 else 'bot.unmuted', + 'adapter_name': 'aiocqhttp', + 'group': group, + 'operator': operator, + 'timestamp': float(getattr(event, 'time', 0) or 0), + 'source_platform_object': event, + } + if duration > 0: + kwargs['duration'] = duration + return event_cls(**kwargs) + if duration > 0: + return platform_events.MemberBannedEvent( + type='group.member_banned', + adapter_name='aiocqhttp', + group=group, + member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), + operator=operator, + duration=duration, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + if notice_type == 'friend_add': + return platform_events.FriendAddedEvent( + type='friend.added', + adapter_name='aiocqhttp', + user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}') + + @staticmethod + def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent: + request_type = getattr(event, 'request_type', getattr(event, 'detail_type', '')) + if request_type == 'friend': + return platform_events.FriendRequestReceivedEvent( + type='friend.request_received', + adapter_name='aiocqhttp', + request_id=getattr(event, 'flag', ''), + user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), + message=getattr(event, 'comment', None), + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite': + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='aiocqhttp', + group=AiocqhttpEventConverter.group_from_event(event), + inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), + request_id=getattr(event, 'flag', ''), + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}') + + @staticmethod + def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User: + sender = getattr(event, 'sender', {}) or {} + nickname = sender.get('card') or sender.get('nickname') or '' + return platform_entities.User( + id=sender.get('user_id', getattr(event, 'user_id', '')), + nickname=nickname, + remark=sender.get('remark'), + ) + + @staticmethod + def user(user_id: typing.Union[int, str, None], nickname: str = '') -> platform_entities.User | None: + if user_id is None or user_id == '': + return None + return platform_entities.User(id=user_id, nickname=nickname) + + @staticmethod + def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup: + return platform_entities.UserGroup( + id=getattr(event, 'group_id', ''), + name=getattr(event, 'group_name', '') or '', + member_count=getattr(event, 'member_count', None), + ) + + @staticmethod + def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name='aiocqhttp', + action=action, + data={key: value for key, value in dict(event).items() if key not in {'message'}}, + timestamp=float(getattr(event, 'time', 0) or 0), + source_platform_object=event, + ) + + @staticmethod + def _is_bot_user(user_id: typing.Any, bot_user_id: typing.Any, event: aiocqhttp.Event) -> bool: + candidate = bot_user_id or getattr(event, 'self_id', None) + return candidate is not None and user_id is not None and str(user_id) == str(candidate) diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml new file mode 100644 index 000000000..e31733c90 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml @@ -0,0 +1,131 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: aiocqhttp-eba + label: + en_US: OneBot v11 + zh_Hans: OneBot v11 + zh_Hant: OneBot v11 + description: + en_US: OneBot v11 adapter for QQ-compatible protocol endpoints with event-driven orchestration support + zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端,支持事件驱动编排 + zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端,支援事件驅動編排 + icon: onebot.svg + +spec: + categories: + - protocol + help_links: + zh: https://link.langbot.app/zh/platforms/aiocqhttp + en: https://link.langbot.app/en/platforms/aiocqhttp + ja: https://link.langbot.app/ja/platforms/aiocqhttp + config: + - name: host + label: + en_US: Host + zh_Hans: 主机 + zh_Hant: 主機 + description: + en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0 + zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0 + zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0 + type: string + required: true + default: 0.0.0.0 + - name: port + label: + en_US: Port + zh_Hans: 端口 + zh_Hant: 連接埠 + description: + en_US: Reverse WebSocket listen port + zh_Hans: 反向 WebSocket 监听端口 + zh_Hant: 反向 WebSocket 監聽連接埠 + type: integer + required: true + default: 2280 + - name: access-token + label: + en_US: Access Token + zh_Hans: 访问令牌 + zh_Hant: 存取令牌 + description: + en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured + zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填 + zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填 + type: string + required: false + default: "" + + supported_events: + - message.received + - message.deleted + - group.member_joined + - group.member_left + - group.member_banned + - friend.request_received + - friend.added + - bot.invited_to_group + - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - delete_message + - forward_message + - get_message + - get_group_info + - get_group_list + - get_group_member_list + - get_group_member_info + - set_group_name + - get_user_info + - get_friend_list + - approve_friend_request + - approve_group_invite + - mute_member + - unmute_member + - kick_member + - leave_group + - call_platform_api + + platform_specific_apis: + - action: get_login_info + description: { en_US: "Get current bot account information", zh_Hans: "获取当前机器人账号信息" } + - action: get_status + description: { en_US: "Get endpoint status", zh_Hans: "获取协议端状态" } + - action: get_version_info + description: { en_US: "Get endpoint version information", zh_Hans: "获取协议端版本信息" } + - action: get_group_honor_info + description: { en_US: "Get group honor information", zh_Hans: "获取群荣誉信息" } + - action: set_group_card + description: { en_US: "Set a member group card", zh_Hans: "设置群名片" } + - action: set_group_special_title + description: { en_US: "Set a member special title", zh_Hans: "设置群专属头衔" } + - action: set_group_admin + description: { en_US: "Set group administrator status", zh_Hans: "设置群管理员" } + - action: set_group_whole_ban + description: { en_US: "Enable or disable whole-group mute", zh_Hans: "设置全员禁言" } + - action: send_group_forward_msg + description: { en_US: "Send a merged forward message", zh_Hans: "发送合并转发消息" } + - action: get_forward_msg + description: { en_US: "Get merged forward message content", zh_Hans: "获取合并转发消息内容" } + - action: get_record + description: { en_US: "Get voice file", zh_Hans: "获取语音文件" } + - action: get_image + description: { en_US: "Get image file", zh_Hans: "获取图片文件" } + - action: can_send_image + description: { en_US: "Check whether images can be sent", zh_Hans: "检查是否可以发送图片" } + - action: can_send_record + description: { en_US: "Check whether voice messages can be sent", zh_Hans: "检查是否可以发送语音" } + +execution: + python: + path: ./adapter.py + attr: AiocqhttpAdapter diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py new file mode 100644 index 000000000..0682d5cec --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import datetime +import typing + +import aiocqhttp + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +FACE_NAMES = { + '14': '微笑', + '21': '可爱', + '23': '傲慢', + '24': '饥饿', + '25': '困', + '26': '惊恐', + '27': '流汗', + '28': '憨笑', + '29': '悠闲', + '30': '奋斗', + '32': '疑问', + '33': '嘘', + '34': '晕', + '38': '敲打', + '39': '再见', + '42': '爱情', + '43': '跳跳', + '49': '拥抱', + '53': '蛋糕', + '63': '玫瑰', + '66': '爱心', + '74': '太阳', + '75': '月亮', + '76': '赞', + '78': '握手', + '79': '胜利', + '85': '飞吻', + '89': '西瓜', + '96': '冷汗', + '97': '擦汗', + '98': '抠鼻', + '99': '鼓掌', + '100': '糗大了', + '101': '坏笑', + '102': '左哼哼', + '103': '右哼哼', + '104': '哈欠', + '106': '委屈', + '111': '可怜', + '120': '拳头', + '122': '爱你', + '123': 'NO', + '124': 'OK', + '129': '挥手', + '144': '喝彩', + '147': '棒棒糖', + '171': '茶', + '173': '泪奔', + '174': '无奈', + '175': '卖萌', + '179': 'doge', + '180': '惊喜', + '182': '笑哭', + '201': '点赞', + '203': '托脸', + '212': '托腮', + '264': '捂脸', + '271': '吃瓜', + '285': '摸鱼', +} + + +class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + ) -> tuple[aiocqhttp.Message, typing.Union[int, str, None], datetime.datetime | None]: + target = aiocqhttp.Message() + source_id: typing.Union[int, str, None] = None + source_time: datetime.datetime | None = None + + for component in message_chain: + if isinstance(component, platform_message.Source): + source_id = component.id + source_time = component.time + elif isinstance(component, platform_message.Plain): + target.append(aiocqhttp.MessageSegment.text(component.text)) + elif isinstance(component, platform_message.At): + target.append(aiocqhttp.MessageSegment.at(component.target)) + elif isinstance(component, platform_message.AtAll): + target.append(aiocqhttp.MessageSegment.at('all')) + elif isinstance(component, platform_message.Image): + file_arg = AiocqhttpMessageConverter._file_arg(component) + if file_arg: + target.append(aiocqhttp.MessageSegment.image(file_arg)) + elif isinstance(component, platform_message.Voice): + file_arg = AiocqhttpMessageConverter._file_arg(component) + if file_arg: + target.append(aiocqhttp.MessageSegment.record(file_arg)) + elif isinstance(component, platform_message.File): + file_arg = component.url or component.path or component.base64 or component.id + target.append( + aiocqhttp.MessageSegment( + type_='file', + data={ + 'file': file_arg, + 'name': component.name or 'file', + }, + ) + ) + elif isinstance(component, platform_message.Face): + if component.face_type == 'rps': + target.append(aiocqhttp.MessageSegment.rps()) + elif component.face_type == 'dice': + target.append(aiocqhttp.MessageSegment.dice()) + else: + target.append(aiocqhttp.MessageSegment.face(component.face_id)) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + node_message, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain) + target.extend(node_message) + elif isinstance(component, platform_message.Quote) and component.id is not None: + target.append(aiocqhttp.MessageSegment.reply(component.id)) + else: + target.append(aiocqhttp.MessageSegment.text(str(component))) + + return target, source_id, source_time + + @staticmethod + async def target2yiri( + message: typing.Any, + message_id: typing.Union[int, str] = -1, + timestamp: float | None = None, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_message.MessageChain: + target = aiocqhttp.Message(message) + message_time = datetime.datetime.fromtimestamp(timestamp) if timestamp else datetime.datetime.now() + components: list[platform_message.MessageComponent] = [ + platform_message.Source(id=message_id, time=message_time), + ] + + for segment in target: + if segment.type == 'text': + components.append(platform_message.Plain(text=segment.data.get('text', ''))) + elif segment.type == 'at': + qq = str(segment.data.get('qq', '')) + components.append(platform_message.AtAll() if qq == 'all' else platform_message.At(target=qq)) + elif segment.type == 'image': + if segment.data.get('emoji_package_id'): + components.append( + platform_message.Face( + face_id=int(segment.data.get('emoji_package_id') or 0), + face_name=segment.data.get('summary', ''), + ) + ) + else: + components.append( + platform_message.Image( + image_id=str(segment.data.get('file', '')), + url=segment.data.get('url') or segment.data.get('file') or '', + ) + ) + elif segment.type == 'record': + components.append( + platform_message.Voice( + voice_id=str(segment.data.get('file', '')), + url=segment.data.get('url') or segment.data.get('file') or '', + ) + ) + elif segment.type == 'file': + components.append( + platform_message.File( + id=str(segment.data.get('file_id') or segment.data.get('file') or ''), + name=segment.data.get('name') or segment.data.get('file') or '', + size=int(segment.data.get('size') or segment.data.get('file_size') or 0), + url=segment.data.get('url') or segment.data.get('file_url') or '', + ) + ) + elif segment.type == 'reply': + quote = await AiocqhttpMessageConverter._quote_from_reply_segment(segment, bot) + components.append(quote) + elif segment.type == 'face': + face_id = str(segment.data.get('id', 0)) + face_name = '' + raw = segment.data.get('raw') + if isinstance(raw, dict): + face_name = str(raw.get('faceText') or '') + components.append( + platform_message.Face( + face_id=int(face_id or 0), + face_name=face_name.replace('/', '') or FACE_NAMES.get(face_id, ''), + ) + ) + elif segment.type == 'rps': + components.append( + platform_message.Face( + face_type='rps', + face_id=int(segment.data.get('result') or 0), + face_name='猜拳', + ) + ) + elif segment.type == 'dice': + components.append( + platform_message.Face( + face_type='dice', + face_id=int(segment.data.get('result') or 0), + face_name='骰子', + ) + ) + else: + components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}')) + + return platform_message.MessageChain(components) + + @staticmethod + def _file_arg(component: platform_message.Image | platform_message.Voice) -> str: + if component.base64: + _, _, payload = component.base64.partition(',') + return f'base64://{payload or component.base64}' + if component.url: + return component.url + if component.path: + return str(component.path) + return '' + + @staticmethod + async def _quote_from_reply_segment( + segment: aiocqhttp.MessageSegment, + bot: aiocqhttp.CQHttp | None, + ) -> platform_message.Quote: + reply_id = segment.data.get('id') + origin = platform_message.MessageChain([]) + sender_id = None + group_id = None + target_id = None + if bot is not None and reply_id is not None: + try: + message_data = await bot.get_msg(message_id=int(reply_id)) + sender_id = message_data.get('sender', {}).get('user_id') or message_data.get('user_id') + group_id = message_data.get('group_id') + target_id = group_id or sender_id + origin = await AiocqhttpMessageConverter.target2yiri( + message_data.get('message', []), + message_data.get('message_id', reply_id), + message_data.get('time'), + bot=None, + ) + except Exception: + origin = platform_message.MessageChain([]) + return platform_message.Quote( + id=reply_id, + group_id=group_id, + sender_id=sender_id, + target_id=target_id, + origin=origin, + ) diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/onebot.svg b/src/langbot/pkg/platform/adapters/aiocqhttp/onebot.svg new file mode 100644 index 000000000..c685bb9be --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/onebot.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/platform_api.py b/src/langbot/pkg/platform/adapters/aiocqhttp/platform_api.py new file mode 100644 index 000000000..76b014665 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/platform_api.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import typing + +import aiocqhttp + + +async def _call(bot: aiocqhttp.CQHttp, action: str, params: dict[str, typing.Any]) -> dict: + result = await bot.call_action(action, **params) + return result or {} + + +async def get_login_info(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_login_info', params) + + +async def get_status(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_status', params) + + +async def get_version_info(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_version_info', params) + + +async def get_group_honor_info(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_group_honor_info', params) + + +async def set_group_card(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'set_group_card', params) + + +async def set_group_special_title(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'set_group_special_title', params) + + +async def set_group_admin(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'set_group_admin', params) + + +async def set_group_whole_ban(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'set_group_whole_ban', params) + + +async def send_group_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'send_group_forward_msg', params) + + +async def get_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_forward_msg', params) + + +async def get_record(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_record', params) + + +async def get_image(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'get_image', params) + + +async def can_send_image(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'can_send_image', params) + + +async def can_send_record(bot: aiocqhttp.CQHttp, params: dict) -> dict: + return await _call(bot, 'can_send_record', params) + + +PLATFORM_API_MAP = { + 'get_login_info': get_login_info, + 'get_status': get_status, + 'get_version_info': get_version_info, + 'get_group_honor_info': get_group_honor_info, + 'set_group_card': set_group_card, + 'set_group_special_title': set_group_special_title, + 'set_group_admin': set_group_admin, + 'set_group_whole_ban': set_group_whole_ban, + 'send_group_forward_msg': send_group_forward_msg, + 'get_forward_msg': get_forward_msg, + 'get_record': get_record, + 'get_image': get_image, + 'can_send_image': can_send_image, + 'can_send_record': can_send_record, +} diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/types.py b/src/langbot/pkg/platform/adapters/aiocqhttp/types.py new file mode 100644 index 000000000..e2fe9ccaf --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/types.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import typing + +import aiocqhttp + + +TargetMessage = typing.Union[str, list, dict, aiocqhttp.Message] +OneBotResponse = dict[str, typing.Any] | None diff --git a/src/langbot/pkg/platform/adapters/dingtalk/__init__.py b/src/langbot/pkg/platform/adapters/dingtalk/__init__.py new file mode 100644 index 000000000..3d51bd227 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/__init__.py @@ -0,0 +1 @@ +"""DingTalk EBA platform adapter.""" diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py new file mode 100644 index 000000000..8993a696f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import traceback +import typing + +import dingtalk_stream +import pydantic + +from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin +from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter +from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter +from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler): + def __init__(self, adapter: 'DingTalkAdapter'): + super().__init__() + self.adapter = adapter + + async def process(self, message: dingtalk_stream.CallbackMessage): + callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {}) + event = DingTalkEvent.from_payload( + { + 'conversation_type': 'CardCallback', + 'Type': 'card_callback', + 'CardCallback': { + 'extension': callback.extension, + 'corp_id': callback.corp_id, + 'user_id': callback.user_id, + 'content': callback.content, + 'space_id': callback.space_id, + 'card_instance_id': callback.card_instance_id, + }, + } + ) + if event is not None: + await self.adapter._handle_native_event(event) + return dingtalk_stream.AckMessage.STATUS_OK, 'OK' + + +class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: DingTalkClient = pydantic.Field(exclude=True) + + message_converter: DingTalkMessageConverter = DingTalkMessageConverter() + event_converter: DingTalkEventConverter = DingTalkEventConverter() + + config: dict + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + card_instance_id_dict: dict = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + _group_cache: dict[str, platform_entities.UserGroup] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code'] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') + + bot = DingTalkClient( + client_id=config['client_id'], + client_secret=config['client_secret'], + robot_name=config['robot_name'], + robot_code=config['robot_code'], + markdown_card=config.get('markdown_card', True), + logger=logger, + ) + super().__init__( + config=config, + logger=logger, + card_instance_id_dict={}, + bot_account_id=config['robot_name'], + bot=bot, + listeners={}, + _message_cache={}, + _user_cache={}, + _group_cache={}, + ) + self._register_native_handlers() + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'feedback.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_group_info', + 'get_group_list', + 'get_group_member_info', + 'get_user_info', + 'get_friend_list', + 'get_file_url', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + markdown_enabled = self.config.get('markdown_card', False) + content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + if target_type in ('person', 'private'): + raw = await self.bot.send_proactive_message_to_one(target_id, content) + elif target_type == 'group': + raw = await self.bot.send_proactive_message_to_group(target_id, content) + else: + raise ValueError(f'Unsupported dingtalk target_type: {target_type}') + return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + assert isinstance(message_source.source_platform_object, DingTalkEvent) + incoming_message = message_source.source_platform_object.incoming_message + markdown_enabled = self.config.get('markdown_card', False) + content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + raw = await self.bot.send_message(content, incoming_message, at) + return platform_events.MessageResult( + message_id=getattr(incoming_message, 'message_id', None), + raw=raw if isinstance(raw, dict) else {'result': raw}, + ) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + message_id = bot_message.resp_message_id + msg_seq = bot_message.msg_sequence + if (msg_seq - 1) % 8 != 0 and not is_final: + return + + markdown_enabled = self.config.get('markdown_card', False) + content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + card_instance, card_instance_id = self.card_instance_id_dict[message_id] + if not content and bot_message.content: + content = bot_message.content + if content: + await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) + if is_final and bot_message.tool_calls is None: + self.card_instance_id_dict.pop(message_id) + + async def create_message_card(self, message_id, event): + card_template_id = self.config['card_template_id'] + incoming_message = event.source_platform_object.incoming_message + card_auto_layout = self.config.get('card_auto_layout', False) + card_instance, card_instance_id = await self.bot.create_and_card( + card_template_id, + incoming_message, + card_auto_layout=card_auto_layout, + ) + self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) + return True + + async def is_stream_output_supported(self) -> bool: + return bool(self.config.get('enable-stream-reply', False)) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def run_async(self): + await self.logger.info('DingTalk EBA adapter starting') + await self.bot.start() + + async def kill(self) -> bool: + await self.bot.stop() + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + def _register_native_handlers(self): + async def on_message(event: DingTalkEvent): + await self._handle_native_event(event) + + self.bot.on_message('FriendMessage')(on_message) + self.bot.on_message('GroupMessage')(on_message) + self.bot.client.register_callback_handler( + dingtalk_stream.CallbackHandler.TOPIC_CARD_CALLBACK, + DingTalkCardCallbackHandler(self), + ) + + async def _handle_native_event(self, event: DingTalkEvent): + try: + await self.logger.debug( + 'DingTalk EBA event received: ' + f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}' + ) + if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name']) + if legacy_event: + callback = self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event, self.config['robot_name']) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group diff --git a/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py b/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py new file mode 100644 index 000000000..6db28ef4d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/api_impl.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import typing + +from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class DingTalkAPIMixin: + bot: DingTalkClient + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='') + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + return list(self._group_cache.values()) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raise NotSupportedError('get_group_member_list') + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + user = self._user_cache.get(str(user_id)) + if user is None: + raise NotSupportedError('get_group_member_info:user_not_cached') + return platform_entities.UserGroupMember( + user=user, + group_id=group_id, + role=platform_entities.MemberRole.MEMBER, + display_name=user.nickname, + ) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='') + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + return await self.bot.get_file_url(file_id) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg b/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg new file mode 100644 index 000000000..b60653b7a --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/dingtalk.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py new file mode 100644 index 000000000..51e7db193 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import typing + +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter +from langbot.pkg.platform.adapters.dingtalk.types import ADAPTER_NAME +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event): + return getattr(event, 'source_platform_object', None) + + @staticmethod + async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None: + if event.conversation in {'FriendMessage', 'GroupMessage'}: + return await DingTalkEventConverter.message_to_eba(event, bot_name) + if event.conversation == 'CardCallback': + feedback = DingTalkEventConverter.card_callback_to_feedback(event) + if feedback is not None: + return feedback + return DingTalkEventConverter.platform_specific(event, 'card.callback') + return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}') + + @staticmethod + async def target2legacy( + event: DingTalkEvent, + bot_name: str, + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name) + if eba_event: + return eba_event.to_legacy_event() + return None + + @staticmethod + async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent: + incoming_message = event.incoming_message + message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name) + sender = DingTalkEventConverter.user_from_event(event) + chat_type = platform_entities.ChatType.PRIVATE + chat_id = getattr(incoming_message, 'sender_staff_id', '') + group = None + if event.conversation == 'GroupMessage': + chat_type = platform_entities.ChatType.GROUP + chat_id = getattr(incoming_message, 'conversation_id', '') + group = DingTalkEventConverter.group_from_event(event) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=getattr(incoming_message, 'message_id', ''), + message_chain=message_chain, + sender=sender, + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=DingTalkEventConverter._timestamp(incoming_message), + source_platform_object=event, + ) + + @staticmethod + def card_callback_to_feedback(event: DingTalkEvent) -> platform_events.FeedbackReceivedEvent | None: + callback = event.get('CardCallback') or {} + content = callback.get('content') or {} + extension = callback.get('extension') or {} + action = str( + content.get('action') or content.get('actionKey') or content.get('value') or extension.get('action') or '' + ).lower() + feedback_type = DingTalkEventConverter._feedback_type( + action or content.get('feedback_type') or content.get('feedbackType') + ) + if feedback_type is None: + return None + feedback_id = str( + content.get('feedback_id') + or content.get('feedbackId') + or callback.get('card_instance_id') + or callback.get('user_id') + or '' + ) + return platform_events.FeedbackReceivedEvent( + type='feedback.received', + adapter_name=ADAPTER_NAME, + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=content.get('feedback_content') or content.get('feedbackContent') or content.get('reason'), + inaccurate_reasons=content.get('inaccurate_reasons') or content.get('inaccurateReasons'), + user_id=callback.get('user_id') or None, + session_id=callback.get('space_id') or None, + message_id=content.get('message_id') or content.get('messageId'), + stream_id=content.get('stream_id') or content.get('streamId'), + timestamp=0.0, + source_platform_object=event, + ) + + @staticmethod + def _feedback_type(value: typing.Any) -> int | None: + if isinstance(value, int) and value in {1, 2, 3}: + return value + normalized = str(value or '').lower() + if normalized in {'1', 'like', 'liked', 'thumb_up', 'thumbup', 'up', 'good'}: + return 1 + if normalized in {'2', 'dislike', 'disliked', 'thumb_down', 'thumbdown', 'down', 'bad'}: + return 2 + if normalized in {'3', 'cancel', 'remove', 'removed', 'clear'}: + return 3 + return None + + @staticmethod + def user_from_event(event: DingTalkEvent) -> platform_entities.User: + incoming_message = event.incoming_message + return platform_entities.User( + id=getattr(incoming_message, 'sender_staff_id', ''), + nickname=getattr(incoming_message, 'sender_nick', '') or '', + ) + + @staticmethod + def group_from_event(event: DingTalkEvent) -> platform_entities.UserGroup: + incoming_message = event.incoming_message + return platform_entities.UserGroup( + id=getattr(incoming_message, 'conversation_id', ''), + name=getattr(incoming_message, 'conversation_title', '') or '', + ) + + @staticmethod + def platform_specific(event: DingTalkEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data={ + key: value for key, value in dict(event).items() if key not in {'IncomingMessage', 'Picture', 'Audio'} + }, + timestamp=DingTalkEventConverter._timestamp(event.incoming_message), + source_platform_object=event, + ) + + @staticmethod + def _timestamp(incoming_message: typing.Any) -> float: + value = getattr(incoming_message, 'create_at', None) + if isinstance(value, (int, float)): + timestamp = float(value) + return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp + if hasattr(value, 'timestamp'): + return float(value.timestamp()) + return 0.0 diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml new file mode 100644 index 000000000..c8dc5f117 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -0,0 +1,127 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: dingtalk-eba + label: + en_US: DingTalk + zh_Hans: 钉钉 + zh_Hant: 釘釘 + description: + en_US: DingTalk adapter with event-driven orchestration support + zh_Hans: 钉钉适配器,支持事件驱动编排 + zh_Hant: 釘釘適配器,支援事件驅動編排 + icon: dingtalk.svg + +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/dingtalk + en: https://link.langbot.app/en/platforms/dingtalk + ja: https://link.langbot.app/ja/platforms/dingtalk + config: + - name: client_id + label: + en_US: Client ID + zh_Hans: 客户端ID + zh_Hant: 用戶端ID + type: string + required: true + default: "" + - name: client_secret + label: + en_US: Client Secret + zh_Hans: 客户端密钥 + zh_Hant: 用戶端密鑰 + type: string + required: true + default: "" + - name: robot_code + label: + en_US: Robot Code + zh_Hans: 机器人代码 + zh_Hant: 機器人代碼 + type: string + required: true + default: "" + - name: robot_name + label: + en_US: Robot Name + zh_Hans: 机器人名称 + zh_Hant: 機器人名稱 + type: string + required: true + default: "" + - name: markdown_card + label: + en_US: Markdown Card + zh_Hans: 是否使用 Markdown 卡片 + zh_Hant: 是否使用 Markdown 卡片 + type: boolean + required: false + default: true + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用钉钉卡片流式回复模式 + zh_Hant: 啟用釘釘卡片串流回覆模式 + description: + en_US: If enabled, the bot will use DingTalk card streaming replies. + zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容 + zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容 + type: boolean + required: true + default: false + - name: card_auto_layout + label: + en_US: Card Auto Layout + zh_Hans: 卡片宽屏自动布局 + zh_Hant: 卡片寬螢幕自動佈局 + type: boolean + required: false + default: false + - name: card_template_id + label: + en_US: Card Template ID + zh_Hans: 卡片模板ID + zh_Hant: 卡片範本ID + type: string + required: true + default: "填写你的卡片template_id" + + supported_events: + - message.received + - feedback.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_group_info + - get_group_list + - get_group_member_info + - get_user_info + - get_friend_list + - get_file_url + - call_platform_api + + platform_specific_apis: + - action: check_access_token + description: { en_US: "Check whether the current DingTalk access token is usable", zh_Hans: "检查当前钉钉 access token 是否可用" } + - action: refresh_access_token + description: { en_US: "Refresh the DingTalk access token", zh_Hans: "刷新钉钉 access token" } + - action: get_file_url + description: { en_US: "Resolve a DingTalk download code to a file URL", zh_Hans: "将钉钉 downloadCode 解析为文件 URL" } + - action: get_audio_base64 + description: { en_US: "Download DingTalk audio as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉语音并转为 base64" } + - action: download_image_base64 + description: { en_US: "Download DingTalk image as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉图片并转为 base64" } + +execution: + python: + path: ./adapter.py + attr: DingTalkAdapter diff --git a/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py new file mode 100644 index 000000000..92bd44d6b --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import datetime +import typing + +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + def _format_image_as_markdown(msg: platform_message.Image) -> str: + if msg.url: + return f'\n![image]({msg.url})\n' + if msg.base64: + if msg.base64.startswith('data:'): + return f'\n![image]({msg.base64})\n' + return f'\n![image](data:image/png;base64,{msg.base64})\n' + return '' + + @staticmethod + def _component_text_fallback(component: platform_message.MessageComponent) -> str: + if isinstance(component, platform_message.At): + return f'@{component.display or component.target}' + if isinstance(component, platform_message.AtAll): + return '@所有人' + if isinstance(component, platform_message.File): + if component.url: + return f'\n[{component.name or "file"}]({component.url})\n' + return f'\n[File]{component.name or component.id or "file"}\n' + if isinstance(component, platform_message.Voice): + return component.url or '[Voice]' + if isinstance(component, platform_message.Face): + return str(component) + if isinstance(component, platform_message.Unknown): + return component.text + return str(component) + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + markdown_enabled: bool = True, + ) -> tuple[str, bool]: + content = '' + at = False + for msg in message_chain: + if isinstance(msg, platform_message.Source): + continue + if isinstance(msg, platform_message.Plain): + content += msg.text + elif isinstance(msg, platform_message.At): + at = True + content += DingTalkMessageConverter._component_text_fallback(msg) + elif isinstance(msg, platform_message.AtAll): + content += DingTalkMessageConverter._component_text_fallback(msg) + elif isinstance(msg, platform_message.Image): + if markdown_enabled: + content += DingTalkMessageConverter._format_image_as_markdown(msg) + else: + content += '[Image]' + elif isinstance(msg, platform_message.File): + content += DingTalkMessageConverter._component_text_fallback(msg) + elif isinstance(msg, platform_message.Voice): + content += DingTalkMessageConverter._component_text_fallback(msg) + elif isinstance(msg, platform_message.Quote): + if msg.id is not None: + content += f'[引用消息 {msg.id}] ' + if msg.origin: + quote_content, quote_at = await DingTalkMessageConverter.yiri2target(msg.origin, markdown_enabled) + content += quote_content + at = at or quote_at + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + sender = node.sender_name or node.sender_id or '' + if sender: + content += f'\n[{sender}] ' + if node.message_chain: + forwarded_content, forwarded_at = await DingTalkMessageConverter.yiri2target( + node.message_chain, markdown_enabled + ) + content += forwarded_content + at = at or forwarded_at + else: + content += DingTalkMessageConverter._component_text_fallback(msg) + return content, at + + @staticmethod + async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain: + incoming_message = event.incoming_message + components: list[platform_message.MessageComponent] = [ + platform_message.Source( + id=getattr(incoming_message, 'message_id', ''), + time=DingTalkMessageConverter._message_time(incoming_message), + ) + ] + + for at_user in getattr(incoming_message, 'at_users', []) or []: + if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None): + components.append(platform_message.At(target=bot_name, display=bot_name)) + + rich_content = event.rich_content + if rich_content: + for element in rich_content.get('Elements') or []: + if element.get('Type') == 'text': + text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name) + if text.strip(): + components.append(platform_message.Plain(text=text)) + elif element.get('Type') == 'image' and element.get('Picture'): + components.append(platform_message.Image(base64=element['Picture'])) + else: + if event.content and event.type != 'audio': + components.append( + platform_message.Plain( + text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name), + ) + ) + if event.picture: + components.append(platform_message.Image(base64=event.picture)) + + if event.file: + components.append(platform_message.File(url=event.file, name=event.name or 'file')) + if event.audio: + if event.content and event.type == 'audio': + components.append(platform_message.Plain(text=event.content)) + else: + components.append(platform_message.Voice(base64=event.audio)) + + quote = DingTalkMessageConverter._quote_component(event) + if quote: + components.append(quote) + + return platform_message.MessageChain(components) + + @staticmethod + def _quote_component(event: DingTalkEvent) -> platform_message.Quote | None: + quote_info = event.quoted_message + if not quote_info: + return None + origin_components: list[platform_message.MessageComponent] = [] + msg_type = quote_info.get('msg_type', '') + if msg_type == 'file' and quote_info.get('file_url'): + origin_components.append( + platform_message.File(url=quote_info['file_url'], name=quote_info.get('file_name', 'file')) + ) + elif msg_type == 'picture' and quote_info.get('picture'): + origin_components.append(platform_message.Image(base64=quote_info['picture'])) + elif msg_type == 'audio' and quote_info.get('audio'): + origin_components.append(platform_message.Voice(base64=quote_info['audio'])) + elif quote_info.get('content'): + origin_components.append(platform_message.Plain(text=str(quote_info['content']))) + + incoming_message = event.incoming_message + return platform_message.Quote( + id=quote_info.get('message_id') or None, + group_id=getattr(incoming_message, 'conversation_id', None), + sender_id=quote_info.get('sender_id') or None, + target_id=getattr(incoming_message, 'conversation_id', None) + or getattr(incoming_message, 'sender_staff_id', None), + origin=platform_message.MessageChain(origin_components), + ) + + @staticmethod + def _strip_bot_mention(text: str, bot_name: str) -> str: + return text.replace('@' + bot_name, '') + + @staticmethod + def _message_time(incoming_message: typing.Any) -> datetime.datetime: + value = getattr(incoming_message, 'create_at', None) + if isinstance(value, datetime.datetime): + return value + if isinstance(value, (int, float)): + timestamp = float(value) + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000 + return datetime.datetime.fromtimestamp(timestamp) + return datetime.datetime.now() diff --git a/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py b/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py new file mode 100644 index 000000000..6491e5fb8 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/platform_api.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import typing + +from langbot.libs.dingtalk_api.api import DingTalkClient + + +async def check_access_token(bot: DingTalkClient, params: dict) -> dict: + return {'valid': await bot.check_access_token()} + + +async def refresh_access_token(bot: DingTalkClient, params: dict) -> dict: + await bot.get_access_token() + return {'ok': bool(bot.access_token)} + + +async def get_file_url(bot: DingTalkClient, params: dict) -> dict: + download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id') + if not download_code: + raise ValueError('download_code is required') + return {'url': await bot.get_file_url(str(download_code))} + + +async def get_audio_base64(bot: DingTalkClient, params: dict) -> dict: + download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id') + if not download_code: + raise ValueError('download_code is required') + return {'base64': await bot.get_audio_url(str(download_code))} + + +async def download_image_base64(bot: DingTalkClient, params: dict) -> dict: + download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id') + if not download_code: + raise ValueError('download_code is required') + return {'base64': await bot.download_image(str(download_code))} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[DingTalkClient, dict], typing.Awaitable[dict]]] = { + 'check_access_token': check_access_token, + 'refresh_access_token': refresh_access_token, + 'get_file_url': get_file_url, + 'get_audio_base64': get_audio_base64, + 'download_image_base64': download_image_base64, +} diff --git a/src/langbot/pkg/platform/adapters/dingtalk/types.py b/src/langbot/pkg/platform/adapters/dingtalk/types.py new file mode 100644 index 000000000..c12b72acc --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'dingtalk-eba' diff --git a/src/langbot/pkg/platform/adapters/discord/__init__.py b/src/langbot/pkg/platform/adapters/discord/__init__.py new file mode 100644 index 000000000..233ca2393 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter + +__all__ = ['DiscordAdapter'] diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py new file mode 100644 index 000000000..6295145f0 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import os +import traceback +import typing + +import discord +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin +from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: discord.Client = pydantic.Field(exclude=True) + + message_converter: DiscordMessageConverter = DiscordMessageConverter() + event_converter: DiscordEventConverter = DiscordEventConverter() + + config: dict + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + adapter_self = self + + class LangBotDiscordClient(discord.Client): + async def on_ready(self: discord.Client): + adapter_self.bot_account_id = str(self.user.id) if self.user else '' + await adapter_self.logger.info(f'Discord adapter running as {self.user}') + + async def on_message(self: discord.Client, message: discord.Message): + if self.user and message.author.id == self.user.id: + return + if message.author.bot: + return + try: + if ( + platform_events.FriendMessage in adapter_self.listeners + or platform_events.GroupMessage in adapter_self.listeners + ): + legacy_event = await adapter_self.event_converter.target2legacy(message) + callback = adapter_self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, adapter_self) + + eba_event = await adapter_self.event_converter.target2yiri( + message, self.user.id if self.user else None + ) + if eba_event: + await adapter_self._dispatch_eba_event(eba_event) + except Exception: + await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}') + + async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message): + await adapter_self._dispatch_gateway_tuple( + 'message_edit', (before, after), self.user.id if self.user else None + ) + + async def on_message_delete(self: discord.Client, message: discord.Message): + await adapter_self._dispatch_gateway_tuple( + 'message_delete', message, self.user.id if self.user else None + ) + + async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent): + await adapter_self._dispatch_gateway_tuple( + 'raw_message_delete', + payload, + self.user.id if self.user else None, + ) + + async def on_reaction_add( + self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member + ): + if self.user and user.id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'reaction_add', (reaction, user), self.user.id if self.user else None + ) + + async def on_reaction_remove( + self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member + ): + if self.user and user.id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'reaction_remove', (reaction, user), self.user.id if self.user else None + ) + + async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent): + if self.user and payload.user_id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'raw_reaction_add', + payload, + self.user.id if self.user else None, + ) + + async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent): + if self.user and payload.user_id == self.user.id: + return + await adapter_self._dispatch_gateway_tuple( + 'raw_reaction_remove', + payload, + self.user.id if self.user else None, + ) + + async def on_member_join(self: discord.Client, member: discord.Member): + await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None) + + async def on_member_remove(self: discord.Client, member: discord.Member): + await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None) + + async def on_guild_join(self: discord.Client, guild: discord.Guild): + await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None) + + async def on_guild_remove(self: discord.Client, guild: discord.Guild): + await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None) + + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + intents.reactions = True + + args = {} + if os.getenv('http_proxy'): + args['proxy'] = os.getenv('http_proxy') + bot = LangBotDiscordClient(intents=intents, **args) + + super().__init__( + config=config, + logger=logger, + bot_account_id=config.get('client_id', ''), + listeners={}, + bot=bot, + ) + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.edited', + 'message.deleted', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'edit_message', + 'delete_message', + 'forward_message', + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'get_user_info', + 'get_file_url', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api', + ] + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + content, files = await self.message_converter.yiri2target(message) + channel = await self._get_channel(target_id) + kwargs = {'content': content} + if files: + kwargs['files'] = files + sent = await channel.send(**kwargs) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + assert isinstance(message_source.source_platform_object, discord.Message) + content, files = await self.message_converter.yiri2target(message) + kwargs = {'content': content} + if files: + kwargs['files'] = files + if quote_origin: + kwargs['reference'] = message_source.source_platform_object + kwargs['mention_author'] = any(isinstance(component, platform_message.At) for component in message.root) + sent = await message_source.source_platform_object.channel.send(**kwargs) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None): + try: + event = await self.event_converter.target2yiri((kind, payload), bot_user_id) + if event: + await self._dispatch_eba_event(event) + except Exception: + await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type, None) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + async def run_async(self): + await self.bot.start(self.config['token'], reconnect=True) + + async def kill(self) -> bool: + await self.bot.close() + return True diff --git a/src/langbot/pkg/platform/adapters/discord/api_impl.py b/src/langbot/pkg/platform/adapters/discord/api_impl.py new file mode 100644 index 000000000..7a308ec9e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/api_impl.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import datetime +import typing + +import discord + +from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DiscordAPIMixin: + bot: discord.Client + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + channel = await self._get_channel(chat_id) + message = await channel.fetch_message(int(message_id)) + content, files = await DiscordMessageConverter.yiri2target(new_content) + if files: + await message.edit(content=content, attachments=[]) + await channel.send(content=content, files=files) + return + await message.edit(content=content) + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + channel = await self._get_channel(chat_id) + message = await channel.fetch_message(int(message_id)) + await message.delete() + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + from_channel = await self._get_channel(from_chat_id) + to_channel = await self._get_channel(to_chat_id) + message = await from_channel.fetch_message(int(message_id)) + files = [await attachment.to_file() for attachment in message.attachments] + sent = await to_channel.send(content=message.content, files=files) + return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id}) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + guild = await self._get_guild(group_id) + return DiscordEventConverter.group_from_guild(guild) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + guild = await self._get_guild(group_id) + members = guild.members or [member async for member in guild.fetch_members(limit=None)] + return [self._member_to_entity(member) for member in members] + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + return self._member_to_entity(member) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id)) + return DiscordEventConverter.user_from_author(user) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + return file_id + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + until = None + if duration > 0: + until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration) + await member.timeout(until, reason='LangBot EBA mute_member') + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + await member.timeout(None, reason='LangBot EBA unmute_member') + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + guild = await self._get_guild(group_id) + member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) + await member.kick(reason='LangBot EBA kick_member') + + async def leave_group(self, group_id: typing.Union[int, str]) -> None: + guild = await self._get_guild(group_id) + await guild.leave() + + async def _get_channel(self, channel_id: typing.Union[int, str]) -> discord.abc.Messageable: + channel = self.bot.get_channel(int(channel_id)) + if channel is None: + channel = await self.bot.fetch_channel(int(channel_id)) + return channel + + async def _get_guild(self, guild_id: typing.Union[int, str]) -> discord.Guild: + guild = self.bot.get_guild(int(guild_id)) + if guild is None: + guild = await self.bot.fetch_guild(int(guild_id)) + return guild + + @staticmethod + def _member_to_entity(member: discord.Member) -> platform_entities.UserGroupMember: + role = platform_entities.MemberRole.MEMBER + if member.guild.owner_id == member.id: + role = platform_entities.MemberRole.OWNER + elif member.guild_permissions.administrator or member.guild_permissions.manage_guild: + role = platform_entities.MemberRole.ADMIN + return platform_entities.UserGroupMember( + user=DiscordEventConverter.user_from_author(member), + group_id=member.guild.id, + role=role, + display_name=member.display_name, + joined_at=member.joined_at.timestamp() if member.joined_at else None, + title=member.top_role.name if member.top_role else None, + ) diff --git a/src/langbot/pkg/platform/adapters/discord/discord.svg b/src/langbot/pkg/platform/adapters/discord/discord.svg new file mode 100644 index 000000000..177a0591f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/discord.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/adapters/discord/event_converter.py b/src/langbot/pkg/platform/adapters/discord/event_converter.py new file mode 100644 index 000000000..045488a4e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/event_converter.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import typing + +import discord + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> discord.Message: + raise NotImplementedError + + @staticmethod + async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None: + if isinstance(event, discord.Message): + return await DiscordEventConverter.message_to_eba(event) + if isinstance(event, tuple) and len(event) == 2: + kind, payload = event + if kind == 'message_edit': + before, after = payload + return await DiscordEventConverter.message_edit_to_eba(before, after) + if kind == 'message_delete': + return await DiscordEventConverter.message_delete_to_eba(payload) + if kind == 'raw_message_delete': + return DiscordEventConverter.raw_message_delete_to_eba(payload) + if kind == 'reaction_add': + reaction, user = payload + return DiscordEventConverter.reaction_to_eba(reaction, user, True) + if kind == 'reaction_remove': + reaction, user = payload + return DiscordEventConverter.reaction_to_eba(reaction, user, False) + if kind == 'raw_reaction_add': + return DiscordEventConverter.raw_reaction_to_eba(payload, True) + if kind == 'raw_reaction_remove': + return DiscordEventConverter.raw_reaction_to_eba(payload, False) + if kind == 'member_join': + return DiscordEventConverter.member_join_to_eba(payload, bot_user_id) + if kind == 'member_remove': + return DiscordEventConverter.member_left_to_eba(payload, bot_user_id) + if kind == 'guild_join': + return DiscordEventConverter.guild_join_to_eba(payload) + if kind == 'guild_remove': + return DiscordEventConverter.guild_remove_to_eba(payload) + return None + + @staticmethod + async def message_to_eba(message: discord.Message) -> platform_events.MessageReceivedEvent: + message_chain = await DiscordMessageConverter.target2yiri(message) + group = DiscordEventConverter.group_from_message(message) + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name='discord', + message_id=message.id, + message_chain=message_chain, + sender=DiscordEventConverter.user_from_author(message.author), + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=group, + timestamp=message.created_at.timestamp(), + source_platform_object=message, + ) + + @staticmethod + async def message_edit_to_eba( + before: discord.Message, after: discord.Message + ) -> platform_events.MessageEditedEvent: + return platform_events.MessageEditedEvent( + type='message.edited', + adapter_name='discord', + message_id=after.id, + new_content=await DiscordMessageConverter.target2yiri(after), + editor=DiscordEventConverter.user_from_author(after.author), + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(after.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=after.channel.id, + group=DiscordEventConverter.group_from_message(after), + timestamp=after.edited_at.timestamp() if after.edited_at else after.created_at.timestamp(), + source_platform_object=after, + ) + + @staticmethod + async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent: + return platform_events.MessageDeletedEvent( + type='message.deleted', + adapter_name='discord', + message_id=message.id, + operator=None, + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=DiscordEventConverter.group_from_message(message), + timestamp=message.created_at.timestamp() if message.created_at else 0.0, + source_platform_object=message, + ) + + @staticmethod + def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent: + return platform_events.MessageDeletedEvent( + type='message.deleted', + adapter_name='discord', + message_id=payload.message_id, + operator=None, + chat_type=platform_entities.ChatType.PRIVATE + if payload.guild_id is None + else platform_entities.ChatType.GROUP, + chat_id=payload.channel_id, + group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None, + source_platform_object=payload, + ) + + @staticmethod + def reaction_to_eba( + reaction: discord.Reaction, + user: discord.User | discord.Member, + is_add: bool, + ) -> platform_events.MessageReactionEvent: + message = reaction.message + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name='discord', + message_id=message.id, + user=DiscordEventConverter.user_from_author(user), + reaction=str(reaction.emoji), + is_add=is_add, + chat_type=platform_entities.ChatType.PRIVATE + if isinstance(message.channel, discord.DMChannel) + else platform_entities.ChatType.GROUP, + chat_id=message.channel.id, + group=DiscordEventConverter.group_from_message(message), + source_platform_object=reaction, + ) + + @staticmethod + def raw_reaction_to_eba( + payload: discord.RawReactionActionEvent, + is_add: bool, + ) -> platform_events.MessageReactionEvent: + member = getattr(payload, 'member', None) + user = member or getattr(payload, 'user', None) + if user is None: + user = platform_entities.User(id=payload.user_id) + else: + user = DiscordEventConverter.user_from_author(user) + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name='discord', + message_id=payload.message_id, + user=user, + reaction=str(payload.emoji), + is_add=is_add, + chat_type=platform_entities.ChatType.PRIVATE + if payload.guild_id is None + else platform_entities.ChatType.GROUP, + chat_id=payload.channel_id, + group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None, + source_platform_object=payload, + ) + + @staticmethod + def member_join_to_eba( + member: discord.Member, + bot_user_id: int | None, + ) -> platform_events.BotInvitedToGroupEvent | platform_events.MemberJoinedEvent: + group = DiscordEventConverter.group_from_guild(member.guild) + user = DiscordEventConverter.user_from_author(member) + if bot_user_id is not None and member.id == bot_user_id: + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='discord', + group=group, + inviter=None, + timestamp=member.joined_at.timestamp() if member.joined_at else 0.0, + source_platform_object=member, + ) + return platform_events.MemberJoinedEvent( + type='group.member_joined', + adapter_name='discord', + group=group, + member=user, + inviter=None, + join_type='direct', + timestamp=member.joined_at.timestamp() if member.joined_at else 0.0, + source_platform_object=member, + ) + + @staticmethod + def member_left_to_eba( + member: discord.Member, + bot_user_id: int | None, + ) -> platform_events.BotRemovedFromGroupEvent | platform_events.MemberLeftEvent: + group = DiscordEventConverter.group_from_guild(member.guild) + user = DiscordEventConverter.user_from_author(member) + if bot_user_id is not None and member.id == bot_user_id: + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name='discord', + group=group, + operator=None, + source_platform_object=member, + ) + return platform_events.MemberLeftEvent( + type='group.member_left', + adapter_name='discord', + group=group, + member=user, + is_kicked=False, + operator=None, + source_platform_object=member, + ) + + @staticmethod + def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent: + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name='discord', + group=DiscordEventConverter.group_from_guild(guild), + inviter=None, + source_platform_object=guild, + ) + + @staticmethod + def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent: + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name='discord', + group=DiscordEventConverter.group_from_guild(guild), + operator=None, + source_platform_object=guild, + ) + + @staticmethod + async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage: + message_chain = await DiscordMessageConverter.target2yiri(message) + if isinstance(message.channel, discord.DMChannel): + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=message.author.id, + nickname=message.author.name, + remark=str(message.channel.id), + ), + message_chain=message_chain, + time=message.created_at.timestamp(), + source_platform_object=message, + ) + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=message.author.id, + member_name=message.author.display_name, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=message.channel.id, + name=message.channel.name, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=message.created_at.timestamp(), + source_platform_object=message, + ) + + @staticmethod + def user_from_author(author: discord.User | discord.Member) -> platform_entities.User: + return platform_entities.User( + id=author.id, + nickname=getattr(author, 'display_name', None) or author.name, + avatar_url=str(author.display_avatar.url) if getattr(author, 'display_avatar', None) else None, + is_bot=author.bot, + username=author.name, + ) + + @staticmethod + def group_from_message(message: discord.Message) -> platform_entities.UserGroup | None: + guild = getattr(message, 'guild', None) + if guild is None: + return None + return DiscordEventConverter.group_from_guild(guild) + + @staticmethod + def group_from_guild(guild: discord.Guild) -> platform_entities.UserGroup: + return platform_entities.UserGroup( + id=guild.id, + name=guild.name, + member_count=guild.member_count, + avatar_url=str(guild.icon.url) if guild.icon else None, + owner_id=guild.owner_id, + ) diff --git a/src/langbot/pkg/platform/adapters/discord/manifest.yaml b/src/langbot/pkg/platform/adapters/discord/manifest.yaml new file mode 100644 index 000000000..3dc285a12 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/manifest.yaml @@ -0,0 +1,89 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: discord-eba + label: + en_US: Discord + zh_Hans: Discord + description: + en_US: Discord adapter with event-driven orchestration support + zh_Hans: Discord 适配器,支持事件驱动编排 + icon: discord.svg + +spec: + categories: + - popular + - global + config: + - name: client_id + label: + en_US: Client ID + zh_Hans: 客户端 ID + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + type: string + required: true + default: "" + + supported_events: + - message.received + - message.edited + - message.deleted + - message.reaction + - group.member_joined + - group.member_left + - bot.invited_to_group + - bot.removed_from_group + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - edit_message + - delete_message + - forward_message + - get_group_info + - get_group_member_list + - get_group_member_info + - get_user_info + - get_file_url + - mute_member + - unmute_member + - kick_member + - leave_group + - call_platform_api + + platform_specific_apis: + - action: get_channel + description: { en_US: "Get channel information", zh_Hans: "获取频道信息" } + - action: get_guild + description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" } + - action: get_guild_channels + description: { en_US: "Get guild channels", zh_Hans: "获取服务器频道列表" } + - action: get_guild_roles + description: { en_US: "Get guild roles", zh_Hans: "获取服务器角色列表" } + - action: create_invite + description: { en_US: "Create channel invite", zh_Hans: "创建频道邀请链接" } + - action: pin_message + description: { en_US: "Pin a message", zh_Hans: "置顶消息" } + - action: unpin_message + description: { en_US: "Unpin a message", zh_Hans: "取消置顶消息" } + - action: add_reaction + description: { en_US: "Add a reaction", zh_Hans: "添加表情回应" } + - action: remove_reaction + description: { en_US: "Remove a reaction", zh_Hans: "移除表情回应" } + - action: typing + description: { en_US: "Send typing indicator", zh_Hans: "发送正在输入状态" } + +execution: + python: + path: ./adapter.py + attr: DiscordAdapter diff --git a/src/langbot/pkg/platform/adapters/discord/message_converter.py b/src/langbot/pkg/platform/adapters/discord/message_converter.py new file mode 100644 index 000000000..d62a56788 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/message_converter.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import base64 +import datetime +import io +import os +import re +import uuid + +import discord + +from langbot.pkg.utils import httpclient +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DiscordMessageConverter: + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + ) -> tuple[str, list[discord.File]]: + text_parts: list[str] = [] + files: list[discord.File] = [] + + for element in list(message_chain): + if isinstance(element, platform_message.At): + text_parts.append(f'<@{element.target}>') + elif isinstance(element, platform_message.AtAll): + text_parts.append('@everyone') + elif isinstance(element, platform_message.Plain): + text_parts.append(element.text) + elif isinstance(element, platform_message.Image): + file_bytes, filename = await DiscordMessageConverter._load_image(element) + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.Voice): + file_bytes, filename = await DiscordMessageConverter._load_voice(element) + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.File): + file_bytes = await DiscordMessageConverter._load_file(element) + if file_bytes: + filename = element.name or f'{uuid.uuid4()}.bin' + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(element, platform_message.Forward): + for node in element.node_list: + node_text, node_files = await DiscordMessageConverter.yiri2target(node.message_chain) + text_parts.append(node_text) + files.extend(node_files) + + return ''.join(text_parts), files + + @staticmethod + async def target2yiri(message: discord.Message) -> platform_message.MessageChain: + message_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp())) + elements: list[platform_message.MessageComponent] = [platform_message.Source(id=message.id, time=message_time)] + elements.extend(DiscordMessageConverter._text_components(message.content)) + + for attachment in message.attachments: + if DiscordMessageConverter._is_image_attachment(attachment): + elements.append(platform_message.Image(url=attachment.url)) + else: + elements.append( + platform_message.File( + name=attachment.filename, + size=attachment.size or 0, + url=attachment.url, + ) + ) + + return platform_message.MessageChain(elements) + + @staticmethod + def _text_components(text: str) -> list[platform_message.MessageComponent]: + if not text: + return [] + + pattern = re.compile(r'(@everyone|@here|<@!?(\d+)>)') + components: list[platform_message.MessageComponent] = [] + last = 0 + for match in pattern.finditer(text): + if match.start() > last: + components.append(platform_message.Plain(text=text[last : match.start()])) + if match.group(1) in ('@everyone', '@here'): + components.append(platform_message.AtAll()) + else: + components.append(platform_message.At(target=match.group(2))) + last = match.end() + if last < len(text): + components.append(platform_message.Plain(text=text[last:])) + return components + + @staticmethod + async def _load_image(element: platform_message.Image) -> tuple[bytes | None, str]: + filename = f'{uuid.uuid4()}.png' + if element.base64: + header, _, payload = element.base64.partition(',') + data = payload or header + if 'jpeg' in header or 'jpg' in header: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in header: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in header: + filename = f'{uuid.uuid4()}.webp' + return base64.b64decode(data), filename + if element.url: + data, content_type = await DiscordMessageConverter._download(element.url) + if 'jpeg' in content_type or 'jpg' in content_type: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in content_type: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in content_type: + filename = f'{uuid.uuid4()}.webp' + return data, filename + if element.path: + path = os.path.abspath(element.path.replace('\x00', '')) + if not os.path.exists(path): + return None, filename + with open(path, 'rb') as fp: + data = fp.read() + ext = os.path.splitext(path)[1] + if ext: + filename = f'{uuid.uuid4()}{ext}' + return data, filename + return None, filename + + @staticmethod + async def _load_voice(element: platform_message.Voice) -> tuple[bytes | None, str]: + filename = f'{uuid.uuid4()}.mp3' + if element.base64: + header, _, payload = element.base64.partition(',') + data = payload or header + for ext in ('wav', 'mp3', 'ogg', 'm4a', 'aac', 'flac', 'opus', 'webm'): + if ext in header: + filename = f'{uuid.uuid4()}.{ext}' + break + return base64.b64decode(data), filename + if element.url: + data, _ = await DiscordMessageConverter._download(element.url) + return data, filename + return None, filename + + @staticmethod + async def _load_file(element: platform_message.File) -> bytes | None: + if element.base64: + return base64.b64decode(element.base64.split(',')[-1]) + if element.url: + data, _ = await DiscordMessageConverter._download(element.url) + return data + return None + + @staticmethod + async def _download(url: str) -> tuple[bytes, str]: + session = httpclient.get_session(trust_env=True) + async with session.get(url) as response: + return await response.read(), response.headers.get('Content-Type', '') + + @staticmethod + def _is_image_attachment(attachment: discord.Attachment) -> bool: + content_type = attachment.content_type or '' + return content_type.startswith('image/') or attachment.filename.lower().endswith( + ('.png', '.jpg', '.jpeg', '.gif', '.webp') + ) diff --git a/src/langbot/pkg/platform/adapters/discord/platform_api.py b/src/langbot/pkg/platform/adapters/discord/platform_api.py new file mode 100644 index 000000000..4c39e8f3c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/platform_api.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import typing + +import discord + + +async def get_channel(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + return { + 'id': channel.id, + 'name': getattr(channel, 'name', ''), + 'type': str(channel.type), + 'guild_id': getattr(getattr(channel, 'guild', None), 'id', None), + } + + +async def get_guild(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + return {'id': guild.id, 'name': guild.name, 'member_count': guild.member_count, 'owner_id': guild.owner_id} + + +async def get_guild_channels(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + channels = guild.channels or await guild.fetch_channels() + return {'channels': [{'id': channel.id, 'name': channel.name, 'type': str(channel.type)} for channel in channels]} + + +async def get_guild_roles(bot: discord.Client, params: dict) -> dict: + guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id'])) + return {'roles': [{'id': role.id, 'name': role.name, 'position': role.position} for role in guild.roles]} + + +async def create_invite(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + invite = await channel.create_invite( + max_age=params.get('max_age', 0), + max_uses=params.get('max_uses', 0), + unique=params.get('unique', True), + reason=params.get('reason', 'LangBot EBA create_invite'), + ) + return {'url': invite.url, 'code': invite.code} + + +async def pin_message(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.pin(reason=params.get('reason', 'LangBot EBA pin_message')) + return {'ok': True} + + +async def unpin_message(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.unpin(reason=params.get('reason', 'LangBot EBA unpin_message')) + return {'ok': True} + + +async def add_reaction(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + await message.add_reaction(params['emoji']) + return {'ok': True} + + +async def remove_reaction(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + message = await channel.fetch_message(int(params['message_id'])) + user = ( + bot.user + if 'user_id' not in params + else bot.get_user(int(params['user_id'])) or await bot.fetch_user(int(params['user_id'])) + ) + await message.remove_reaction(params['emoji'], user) + return {'ok': True} + + +async def send_typing(bot: discord.Client, params: dict) -> dict: + channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) + async with channel.typing(): + return {'ok': True} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[discord.Client, dict], typing.Awaitable[dict]]] = { + 'get_channel': get_channel, + 'get_guild': get_guild, + 'get_guild_channels': get_guild_channels, + 'get_guild_roles': get_guild_roles, + 'create_invite': create_invite, + 'pin_message': pin_message, + 'unpin_message': unpin_message, + 'add_reaction': add_reaction, + 'remove_reaction': remove_reaction, + 'typing': send_typing, +} diff --git a/src/langbot/pkg/platform/adapters/discord/types.py b/src/langbot/pkg/platform/adapters/discord/types.py new file mode 100644 index 000000000..bdb66079f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/types.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import typing + +import pydantic + + +class DiscordAdapterConfig(pydantic.BaseModel): + client_id: str + token: str + guild_id: typing.Optional[str] = None + debug_channel_id: typing.Optional[str] = None diff --git a/src/langbot/pkg/platform/adapters/discord/voice.py b/src/langbot/pkg/platform/adapters/discord/voice.py new file mode 100644 index 000000000..0cf83e6fd --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/voice.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +# Voice support is still implemented by the legacy Discord source adapter. The +# EBA adapter exposes text, guild, member, moderation, and platform-specific APIs +# first; voice-specific EBA actions will move here when that surface is migrated. diff --git a/src/langbot/pkg/platform/adapters/kook/__init__.py b/src/langbot/pkg/platform/adapters/kook/__init__.py new file mode 100644 index 000000000..b740b9955 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.kook.adapter import KookAdapter + +__all__ = ['KookAdapter'] diff --git a/src/langbot/pkg/platform/adapters/kook/adapter.py b/src/langbot/pkg/platform/adapters/kook/adapter.py new file mode 100644 index 000000000..00eb3fc8a --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/adapter.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import json +import traceback +import typing +import zlib + +import aiohttp +import pydantic +import websockets + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.kook.api_impl import KookAPIMixin +from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter +from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter +from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP +from langbot.pkg.platform.adapters.kook.errors import NotSupportedError +from langbot.pkg.utils import httpclient +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +BasePlatformAdapter = getattr( + abstract_platform_adapter, + 'AbstractPlatformAdapter', + abstract_platform_adapter.AbstractMessagePlatformAdapter, +) + + +class KookAdapter(KookAPIMixin, BasePlatformAdapter): + message_converter: KookMessageConverter = KookMessageConverter() + event_converter: KookEventConverter = KookEventConverter() + + config: dict + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + ws: typing.Any = pydantic.Field(exclude=True, default=None) + ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) + heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) + running: bool = pydantic.Field(exclude=True, default=False) + session_id: str = pydantic.Field(exclude=True, default='') + current_sn: int = pydantic.Field(exclude=True, default=0) + gateway_url: str = pydantic.Field(exclude=True, default='') + http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None) + + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + _group_cache: dict[str, platform_entities.UserGroup] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + if not config.get('token'): + raise Exception('KOOK adapter requires "token" in config') + + super().__init__( + config=config, + logger=logger, + bot_account_id='', + listeners={}, + running=False, + session_id='', + current_sn=0, + gateway_url='', + http_session=None, + _message_cache={}, + _user_cache={}, + _group_cache={}, + **kwargs, + ) + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_group_info', + 'get_group_list', + 'get_group_member_info', + 'get_user_info', + 'get_friend_list', + 'upload_file', + 'get_file_url', + 'delete_message', + 'forward_message', + 'call_platform_api', + ] + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def run_async(self): + self.running = True + self.http_session = httpclient.get_session() + await self.logger.info('KOOK EBA adapter starting') + + try: + bot_info = await self._get_bot_user_info() + self.bot_account_id = str(bot_info.get('id') or '') + except Exception as e: + await self.logger.error(f'Failed to get KOOK bot user info: {e}') + + self.ws_task = asyncio.create_task(self._websocket_loop()) + try: + await self.ws_task + finally: + self.running = False + + async def kill(self) -> bool: + self.running = False + for task in (self.heartbeat_task, self.ws_task): + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if self.ws: + await self.ws.close() + await self.logger.info('KOOK EBA adapter stopped') + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + async def _handle_hello(self, data: dict): + self.session_id = str(data.get('session_id') or '') + await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}') + + async def _handle_event(self, data: dict, sn: int): + self.current_sn = max(self.current_sn, sn) + + event_type = int(data.get('type', 0) or 0) + channel_type = data.get('channel_type') + author_id = str(data.get('author_id') or '') + is_message_event = event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'} + + if is_message_event and self.bot_account_id and author_id == self.bot_account_id: + return + + try: + if is_message_event and ( + platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners + ): + legacy_event = await self.event_converter.target2legacy(data, self.bot_account_id) + callback = self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, self) + + eba_event = await self.event_converter.target2yiri(data, self.bot_account_id) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group + + async def _websocket_loop(self): + retry_count = 0 + max_retries = int(self.config.get('max_retries', 3)) + + while self.running and retry_count < max_retries: + try: + if not self.gateway_url: + self.gateway_url = await self._get_gateway_url() + + async with websockets.connect(self.gateway_url) as ws: + self.ws = ws + await self.logger.info('Connected to KOOK WebSocket') + self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0) + hello_data = json.loads(self._decode_ws_message(hello_msg)) + if hello_data.get('s') != 1: + raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}') + await self._handle_hello(hello_data.get('d') or {}) + retry_count = 0 + + async for message in ws: + msg_data = json.loads(self._decode_ws_message(message)) + signal = msg_data.get('s') + if signal == 0: + await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0)) + elif signal == 5: + break + except websockets.exceptions.ConnectionClosed: + retry_count += 1 + await self.logger.warning('KOOK WebSocket connection closed, reconnecting') + await asyncio.sleep(min(2**retry_count, 30)) + except asyncio.CancelledError: + raise + except Exception: + retry_count += 1 + await self.logger.error(f'KOOK WebSocket error: {traceback.format_exc()}') + await asyncio.sleep(min(2**retry_count, 30)) + finally: + if self.heartbeat_task: + self.heartbeat_task.cancel() + try: + await self.heartbeat_task + except asyncio.CancelledError: + pass + self.ws = None + + if retry_count >= max_retries: + await self.logger.error(f'Failed to connect to KOOK after {max_retries} retries') + + async def _heartbeat_loop(self): + try: + while self.running and self.ws: + await asyncio.sleep(30) + if self.ws: + await self.ws.send(json.dumps({'s': 2, 'sn': self.current_sn})) + except asyncio.CancelledError: + pass + except Exception as e: + await self.logger.error(f'KOOK heartbeat error: {e}') + + async def _get_gateway_url(self) -> str: + raw = await self._request('GET', '/gateway/index', params={'compress': 1}) + return str(raw['data']['url']) + + async def _get_bot_user_info(self) -> dict: + raw = await self._request('GET', '/user/me') + return raw.get('data') or {} + + async def _request( + self, + method: str, + endpoint: str, + *, + params: dict | None = None, + json: dict | None = None, + data: dict | None = None, + filename: str | None = None, + ) -> dict: + session = self.http_session or httpclient.get_session() + self.http_session = session + url = f'https://www.kookapp.cn/api/v3{endpoint}' + headers = {'Authorization': f'Bot {self.config["token"]}'} + + request_kwargs: dict[str, typing.Any] = {'params': params, 'headers': headers} + if json is not None: + request_kwargs['json'] = json + if data is not None and filename is not None: + form = aiohttp.FormData() + form.add_field('file', data['file'], filename=filename) + request_kwargs['data'] = form + elif data is not None: + request_kwargs['data'] = data + + async with session.request(method, url, **request_kwargs) as response: + payload = await response.json(content_type=None) + if response.status != 200: + raise Exception(f'KOOK API HTTP {response.status}: {payload}') + if payload.get('code') != 0: + raise Exception(f'KOOK API error {payload.get("code")}: {payload.get("message")}') + return payload + + @staticmethod + def _decode_ws_message(message) -> str: + if isinstance(message, bytes): + try: + return zlib.decompress(message).decode('utf-8') + except Exception: + return message.decode('utf-8') + return str(message) diff --git a/src/langbot/pkg/platform/adapters/kook/api_impl.py b/src/langbot/pkg/platform/adapters/kook/api_impl.py new file mode 100644 index 000000000..1d48b36d8 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/api_impl.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import typing + +from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot.pkg.platform.adapters.kook.errors import NotSupportedError + + +class KookAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + content, msg_type = await KookMessageConverter.yiri2target(message) + endpoint = '/message/create' if target_type.lower() in {'group', 'channel'} else '/direct-message/create' + raw = await self._request( + 'POST', + endpoint, + json={ + 'target_id': str(target_id), + 'content': content, + 'type': msg_type, + }, + ) + data = raw.get('data') or {} + return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + content, msg_type = await KookMessageConverter.yiri2target(message) + kook_event = message_source.source_platform_object or {} + channel_type = kook_event.get('channel_type') + msg_id = kook_event.get('msg_id') + + if channel_type == 'GROUP': + endpoint = '/message/create' + payload = { + 'target_id': str(kook_event.get('target_id') or message_source.chat_id), + 'content': content, + 'type': msg_type, + } + else: + endpoint = '/direct-message/create' + extra = kook_event.get('extra') or {} + payload = { + 'content': content, + 'type': msg_type, + } + if extra.get('code'): + payload['chat_code'] = extra['code'] + else: + payload['target_id'] = str(kook_event.get('author_id') or message_source.chat_id) + + if msg_id: + payload['reply_msg_id'] = msg_id + if quote_origin: + payload['quote'] = msg_id + + raw = await self._request('POST', endpoint, json=payload) + data = raw.get('data') or {} + return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw) + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + cached = self._group_cache.get(str(group_id)) + if cached: + return cached + raw = await self._request('GET', '/channel/view', params={'target_id': str(group_id)}) + data = raw.get('data') or {} + return platform_entities.UserGroup( + id=str(data.get('id') or group_id), + name=str(data.get('name') or ''), + member_count=data.get('user_count'), + ) + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + return list(self._group_cache.values()) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raise NotSupportedError('get_group_member_list') + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + user = self._user_cache.get(str(user_id)) + if user is None: + raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)}) + data = raw.get('data') or {} + user = platform_entities.User( + id=str(data.get('id') or user_id), + nickname=str(data.get('nickname') or data.get('username') or ''), + username=data.get('username'), + avatar_url=data.get('avatar'), + is_bot=bool(data.get('bot', False)), + ) + return platform_entities.UserGroupMember( + user=user, + group_id=str(group_id), + role=platform_entities.MemberRole.MEMBER, + display_name=user.nickname, + ) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + cached = self._user_cache.get(str(user_id)) + if cached: + return cached + raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)}) + data = raw.get('data') or {} + return platform_entities.User( + id=str(data.get('id') or user_id), + nickname=str(data.get('nickname') or data.get('username') or ''), + username=data.get('username'), + avatar_url=data.get('avatar'), + is_bot=bool(data.get('bot', False)), + ) + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + data = {'file': file_data} + raw = await self._request('POST', '/asset/create', data=data, filename=filename) + result = raw.get('data') or {} + return str(result.get('url') or result.get('id') or '') + + async def get_file_url(self, file_id: str) -> str: + return file_id + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete' + await self._request('POST', endpoint, json={'msg_id': str(message_id)}) + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + cached = self._message_cache.get(str(message_id)) + if cached is None: + raise NotSupportedError('forward_message:message_not_cached') + return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain) + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + raise NotSupportedError('mute_member') + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('unmute_member') + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('kick_member') + + async def leave_group(self, group_id: typing.Union[int, str]) -> None: + raise NotSupportedError('leave_group') diff --git a/src/langbot/pkg/platform/adapters/kook/errors.py b/src/langbot/pkg/platform/adapters/kook/errors.py new file mode 100644 index 000000000..ad800e7d1 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/errors.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +try: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +except ModuleNotFoundError: + + class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name + + +__all__ = ['NotSupportedError'] diff --git a/src/langbot/pkg/platform/adapters/kook/event_converter.py b/src/langbot/pkg/platform/adapters/kook/event_converter.py new file mode 100644 index 000000000..5f29defae --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/event_converter.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import time + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class KookEventConverter(abstract_platform_adapter.AbstractEventConverter): + MESSAGE_TYPES = {1, 2, 4, 8, 9, 10} + + @staticmethod + async def yiri2target(event: platform_events.Event): + raise NotImplementedError + + @staticmethod + async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None: + event_type = int(kook_event.get('type', 0) or 0) + channel_type = kook_event.get('channel_type') + if event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}: + return await KookEventConverter.message_to_eba(kook_event, bot_account_id) + + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name='kook', + action=str(kook_event.get('type') or 'gateway_event'), + data=KookEventConverter._compact_data(kook_event), + timestamp=KookEventConverter._timestamp(kook_event), + source_platform_object=kook_event, + ) + + @staticmethod + async def message_to_eba(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageReceivedEvent: + channel_type = kook_event.get('channel_type') + author = KookEventConverter._author(kook_event) + chat_type = platform_entities.ChatType.PRIVATE if channel_type == 'PERSON' else platform_entities.ChatType.GROUP + chat_id = KookEventConverter._chat_id(kook_event) + group = None + if chat_type == platform_entities.ChatType.GROUP: + group = KookEventConverter._group(kook_event) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name='kook', + message_id=str(kook_event.get('msg_id') or ''), + message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id), + sender=author, + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=KookEventConverter._timestamp(kook_event), + source_platform_object=kook_event, + ) + + @staticmethod + async def target2legacy( + kook_event: dict, bot_account_id: str = '' + ) -> platform_events.FriendMessage | platform_events.GroupMessage: + eba_event = await KookEventConverter.message_to_eba(kook_event, bot_account_id) + return eba_event.to_legacy_event() + + @staticmethod + def _author(kook_event: dict) -> platform_entities.User: + extra = kook_event.get('extra') or {} + author = extra.get('author') or {} + user_id = str(kook_event.get('author_id') or author.get('id') or '') + return platform_entities.User( + id=user_id, + nickname=str(author.get('nickname') or author.get('username') or user_id), + username=author.get('username'), + avatar_url=author.get('avatar'), + is_bot=bool(author.get('bot', False)), + remark=user_id, + ) + + @staticmethod + def _group(kook_event: dict) -> platform_entities.UserGroup: + extra = kook_event.get('extra') or {} + return platform_entities.UserGroup( + id=str(kook_event.get('target_id') or ''), + name=str(extra.get('channel_name') or kook_event.get('target_id') or ''), + description=extra.get('guild_name'), + owner_id=extra.get('guild_id'), + ) + + @staticmethod + def _chat_id(kook_event: dict) -> str: + if kook_event.get('channel_type') == 'PERSON': + extra = kook_event.get('extra') or {} + return str(extra.get('code') or kook_event.get('author_id') or kook_event.get('target_id') or '') + return str(kook_event.get('target_id') or '') + + @staticmethod + def _timestamp(kook_event: dict) -> float: + raw_timestamp = kook_event.get('msg_timestamp') or time.time() + timestamp = float(raw_timestamp) + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000.0 + return timestamp + + @staticmethod + def _compact_data(kook_event: dict) -> dict: + return { + 'type': kook_event.get('type'), + 'channel_type': kook_event.get('channel_type'), + 'target_id': kook_event.get('target_id'), + 'author_id': kook_event.get('author_id'), + 'msg_id': kook_event.get('msg_id'), + } diff --git a/src/langbot/pkg/platform/adapters/kook/kook.png b/src/langbot/pkg/platform/adapters/kook/kook.png new file mode 100644 index 000000000..ba6ea15d8 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/kook/kook.png differ diff --git a/src/langbot/pkg/platform/adapters/kook/manifest.yaml b/src/langbot/pkg/platform/adapters/kook/manifest.yaml new file mode 100644 index 000000000..79eeebc52 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/manifest.yaml @@ -0,0 +1,79 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: kook-eba + label: + en_US: KOOK + zh_Hans: KOOK + zh_Hant: KOOK + description: + en_US: KOOK adapter with event-driven orchestration support, supporting channel and direct messages. + zh_Hans: KOOK 适配器,支持事件驱动编排、频道消息和私聊消息。 + zh_Hant: KOOK 適配器,支援事件驅動編排、頻道訊息和私聊訊息。 + icon: kook.png + docs: + zh: https://link.langbot.app/zh/platforms/kook + en: https://link.langbot.app/en/platforms/kook + ja: https://link.langbot.app/ja/platforms/kook + +spec: + categories: + - global + config: + - name: token + label: + en_US: Bot Token + zh_Hans: Bot Token + zh_Hant: Bot Token + type: string + required: true + default: "" + - name: enable-stream-reply + label: + en_US: Enable stream reply + zh_Hans: 启用流式回复 + zh_Hant: 啟用串流回覆 + type: boolean + required: true + default: false + + supported_events: + - message.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_group_info + - get_group_list + - get_group_member_info + - get_user_info + - get_friend_list + - upload_file + - get_file_url + - delete_message + - forward_message + - call_platform_api + + platform_specific_apis: + - action: get_current_user + description: { en_US: "Get current bot user", zh_Hans: "获取当前机器人用户" } + - action: get_user + description: { en_US: "Get user information", zh_Hans: "获取用户信息" } + - action: get_channel + description: { en_US: "Get channel information", zh_Hans: "获取频道信息" } + - action: get_guild + description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" } + - action: get_gateway + description: { en_US: "Get WebSocket gateway URL", zh_Hans: "获取 WebSocket 网关地址" } + - action: send_direct_message + description: { en_US: "Send a direct KOOK message", zh_Hans: "发送 KOOK 私聊消息" } + +execution: + python: + path: ./adapter.py + attr: KookAdapter diff --git a/src/langbot/pkg/platform/adapters/kook/message_converter.py b/src/langbot/pkg/platform/adapters/kook/message_converter.py new file mode 100644 index 000000000..b494b75d1 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/message_converter.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import datetime +import re + +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +MENTION_PATTERN = re.compile(r'(\(met\)(?P[^()]+)\(met\)|\(rol\)(?P[^()]+)\(rol\))') + + +class KookMessageConverter: + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]: + content_parts: list[str] = [] + message_type = 1 + + for component in message_chain: + if isinstance(component, platform_message.Source): + continue + if isinstance(component, platform_message.Plain): + content_parts.append(component.text) + elif isinstance(component, platform_message.At): + if component.target: + content_parts.append(f'(met){component.target}(met)') + elif isinstance(component, platform_message.AtAll): + content_parts.append('(met)all(met)') + elif isinstance(component, platform_message.Image): + if component.url: + content_parts.append(component.url) + message_type = 2 + elif component.image_id: + content_parts.append(component.image_id) + message_type = 2 + elif isinstance(component, platform_message.File): + if component.url: + content_parts.append(component.url) + message_type = 4 + elif isinstance(component, platform_message.Voice): + if component.url: + content_parts.append(component.url) + message_type = 8 + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain) + content_parts.append(forward_content) + + return ''.join(content_parts), message_type + + @staticmethod + async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain: + components: list[platform_message.MessageComponent] = [] + + msg_id = kook_message.get('msg_id') or kook_message.get('id') or '' + timestamp = KookMessageConverter._timestamp(kook_message.get('msg_timestamp')) + if msg_id: + components.append(platform_message.Source(id=str(msg_id), time=timestamp)) + + msg_type = int(kook_message.get('type', 1) or 1) + content = str(kook_message.get('content') or '') + extra = kook_message.get('extra') or {} + + if msg_type in (1, 9): + components.extend(KookMessageConverter._parse_text_components(content, extra, bot_account_id)) + elif msg_type == 2: + if content: + components.append(platform_message.Image(url=content)) + elif msg_type == 4: + attachments = extra.get('attachments') or {} + components.append( + platform_message.File( + id=str(attachments.get('id') or ''), + name=str(attachments.get('name') or 'file'), + size=int(attachments.get('size') or 0), + url=content, + ) + ) + elif msg_type == 8: + attachments = extra.get('attachments') or {} + components.append(platform_message.Voice(url=content, length=int(attachments.get('duration') or 0))) + elif msg_type == 10: + components.append(platform_message.Unknown(text=content or '[KOOK card message]')) + else: + components.append(platform_message.Unknown(text=content or f'Unsupported KOOK message type: {msg_type}')) + + if len(components) == 1 and isinstance(components[0], platform_message.Source): + components.append(platform_message.Plain(text='')) + + return platform_message.MessageChain(components) + + @staticmethod + def _parse_text_components( + content: str, + extra: dict, + bot_account_id: str, + ) -> list[platform_message.MessageComponent]: + components: list[platform_message.MessageComponent] = [] + mention_all = bool(extra.get('mention_all', False)) + mentions = {str(item) for item in extra.get('mention', [])} + mention_roles = {str(item) for item in extra.get('mention_roles', [])} + + last = 0 + for match in MENTION_PATTERN.finditer(content): + if match.start() > last: + components.append(platform_message.Plain(text=content[last : match.start()])) + met = match.group('met') + role = match.group('role') + if met == 'all': + components.append(platform_message.AtAll()) + elif met: + components.append(platform_message.At(target=met)) + mentions.discard(str(met)) + elif role: + mention_roles.discard(str(role)) + if bot_account_id: + components.append(platform_message.At(target=bot_account_id)) + last = match.end() + + if last < len(content): + components.append(platform_message.Plain(text=content[last:])) + + if mention_all and not any(isinstance(item, platform_message.AtAll) for item in components): + components.insert(0, platform_message.AtAll()) + for mention_id in sorted(mentions): + components.insert(0, platform_message.At(target=mention_id)) + if mention_roles and bot_account_id: + components.insert(0, platform_message.At(target=bot_account_id)) + + return components + + @staticmethod + def _timestamp(raw_timestamp) -> datetime.datetime: + if raw_timestamp is None: + return datetime.datetime.now() + timestamp = float(raw_timestamp) + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000.0 + return datetime.datetime.fromtimestamp(timestamp) diff --git a/src/langbot/pkg/platform/adapters/kook/platform_api.py b/src/langbot/pkg/platform/adapters/kook/platform_api.py new file mode 100644 index 000000000..af1b0625e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/platform_api.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import typing +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + + +async def get_current_user(adapter, params: dict) -> dict: + return await adapter._request('GET', '/user/me') + + +async def get_user(adapter, params: dict) -> dict: + return await adapter._request('GET', '/user/view', params={'user_id': params['user_id']}) + + +async def get_channel(adapter, params: dict) -> dict: + return await adapter._request('GET', '/channel/view', params={'target_id': params['target_id']}) + + +async def get_guild(adapter, params: dict) -> dict: + return await adapter._request('GET', '/guild/view', params={'guild_id': params['guild_id']}) + + +async def get_gateway(adapter, params: dict) -> dict: + raw = await adapter._request('GET', '/gateway/index', params={'compress': int(params.get('compress', 1))}) + data = raw.get('data') + if isinstance(data, dict) and data.get('url'): + data = {**data, 'url': _redact_url_token(str(data['url']))} + raw = {**raw, 'data': data} + return raw + + +async def send_direct_message(adapter, params: dict) -> dict: + payload = { + 'content': params['content'], + 'type': params.get('type', 1), + } + if params.get('chat_code'): + payload['chat_code'] = params['chat_code'] + else: + payload['target_id'] = params['target_id'] + return await adapter._request('POST', '/direct-message/create', json=payload) + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'get_current_user': get_current_user, + 'get_user': get_user, + 'get_channel': get_channel, + 'get_guild': get_guild, + 'get_gateway': get_gateway, + 'send_direct_message': send_direct_message, +} + + +def _redact_url_token(url: str) -> str: + parts = urlsplit(url) + query = urlencode( + [(key, '' if key.lower() == 'token' else value) for key, value in parse_qsl(parts.query)], + doseq=True, + ) + return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment)) diff --git a/src/langbot/pkg/platform/adapters/kook/types.py b/src/langbot/pkg/platform/adapters/kook/types.py new file mode 100644 index 000000000..ec4371149 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/types.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from enum import Enum + + +class KookChannelType(str, Enum): + GROUP = 'GROUP' + PERSON = 'PERSON' + + +class KookMessageType(int, Enum): + TEXT = 1 + IMAGE = 2 + FILE = 4 + AUDIO = 8 + KMARKDOWN = 9 + CARD = 10 diff --git a/src/langbot/pkg/platform/adapters/lark/__init__.py b/src/langbot/pkg/platform/adapters/lark/__init__.py new file mode 100644 index 000000000..1b39cde87 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/__init__.py @@ -0,0 +1 @@ +"""Lark/Feishu EBA platform adapter.""" diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py new file mode 100644 index 000000000..3f2767e48 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import time +import traceback +import typing +import uuid + +from Crypto.Cipher import AES +import lark_oapi +from lark_oapi.api.auth.v3 import ( + CreateAppAccessTokenRequest, + CreateAppAccessTokenRequestBody, + CreateAppAccessTokenResponse, + CreateTenantAccessTokenRequest, + CreateTenantAccessTokenRequestBody, + CreateTenantAccessTokenResponse, + ResendAppTicketRequest, + ResendAppTicketRequestBody, + ResendAppTicketResponse, +) +from lark_oapi.api.cardkit.v1 import ( + ContentCardElementRequest, + ContentCardElementRequestBody, + ContentCardElementResponse, + CreateCardRequest, + CreateCardRequestBody, + CreateCardResponse, +) +from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + CreateMessageResponse, + EventMessage, + EventSender, + P2ImMessageReceiveV1, + P2ImMessageReceiveV1Data, + ReplyMessageRequest, + ReplyMessageRequestBody, + ReplyMessageResponse, +) +import lark_oapi.ws.exception +import pydantic +import quart + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin +from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter +from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter +from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class AESCipher: + def __init__(self, key: str): + self.key = hashlib.sha256(self.str_to_bytes(key)).digest() + + @staticmethod + def str_to_bytes(data): + if isinstance(data, str): + return data.encode('utf8') + return data + + @staticmethod + def _unpad(value: bytes) -> bytes: + return value[: -value[len(value) - 1]] + + def decrypt_string(self, encrypted: str) -> str: + encrypted_bytes = base64.b64decode(encrypted) + iv = encrypted_bytes[: AES.block_size] + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return self._unpad(cipher.decrypt(encrypted_bytes[AES.block_size :])).decode('utf8') + + +class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: lark_oapi.ws.Client = pydantic.Field(exclude=True) + api_client: lark_oapi.Client = pydantic.Field(exclude=True) + quart_app: quart.Quart = pydantic.Field(exclude=True) + cipher: AESCipher = pydantic.Field(exclude=True) + + config: dict + lark_tenant_key: str = pydantic.Field(exclude=True, default='') + app_ticket: str | None = None + app_access_token: str | None = None + app_access_token_expire_at: int | None = None + tenant_access_tokens: dict[str, dict[str, typing.Any]] = pydantic.Field(default_factory=dict) + bot_uuid: str | None = None + event_loop: asyncio.AbstractEventLoop | None = pydantic.Field(exclude=True, default=None) + + message_converter: LarkMessageConverter = LarkMessageConverter() + event_converter: LarkEventConverter = LarkEventConverter() + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = pydantic.Field(default_factory=dict) + card_id_dict: dict[str, str] = pydantic.Field(default_factory=dict) + pending_monitoring_msg: dict[str, str] = pydantic.Field(default_factory=dict) + reply_to_monitoring_msg: dict[str, tuple[str, float]] = pydantic.Field(default_factory=dict) + _message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict) + _user_cache: dict[str, platform_entities.User] = pydantic.PrivateAttr(default_factory=dict) + _group_cache: dict[str, platform_entities.UserGroup] = pydantic.PrivateAttr(default_factory=dict) + _monitoring_mapping_ttl: int = 600 + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + required_keys = ['app_id', 'app_secret', 'bot_name'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise ValueError(f'Lark missing required config: {", ".join(missing_keys)}') + + api_client = self.build_api_client(config) + event_handler = self._build_event_handler() + bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) + cipher = AESCipher(config.get('encrypt-key', '')) + + super().__init__( + config=config, + logger=logger, + lark_tenant_key=config.get('lark_tenant_key', ''), + bot_account_id=config['bot_name'], + bot=bot, + api_client=api_client, + quart_app=quart.Quart(__name__), + cipher=cipher, + listeners={}, + card_id_dict={}, + pending_monitoring_msg={}, + reply_to_monitoring_msg={}, + event_loop=None, + **kwargs, + ) + self._message_cache = {} + self._user_cache = {} + self._group_cache = {} + self.request_app_ticket() + + def _build_event_handler(self): + async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): + await self._handle_message_event(event) + + def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): + self._submit_coro(on_message(event)) + + def sync_on_card_action(event): + return self._handle_card_action_sync(event) + + return ( + lark_oapi.EventDispatcherHandler.builder('', '') + .register_p2_im_message_receive_v1(sync_on_message) + .register_p2_card_action_trigger(sync_on_card_action) + .build() + ) + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'bot.invited_to_group', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_group_info', + 'get_group_member_info', + 'get_user_info', + 'get_file_url', + 'call_platform_api', + ] + + def build_api_client(self, config: dict) -> lark_oapi.Client: + builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']) + if config.get('app_type', 'self') == 'isv': + builder = builder.app_type(lark_oapi.AppType.ISV) + return builder.build() + + def request_app_ticket(self): + if self.config.get('app_type', 'self') != 'isv': + return + request = ( + ResendAppTicketRequest.builder() + .request_body( + ResendAppTicketRequestBody.builder() + .app_id(self.config['app_id']) + .app_secret(self.config['app_secret']) + .build() + ) + .build() + ) + response: ResendAppTicketResponse = self.api_client.auth.v3.app_ticket.resend(request) + if not response.success(): + raise RuntimeError(f'Lark app_ticket resend failed: {response.code} {response.msg}') + + def request_app_access_token(self): + if self.config.get('app_type', 'self') != 'isv': + return + request = ( + CreateAppAccessTokenRequest.builder() + .request_body( + CreateAppAccessTokenRequestBody.builder() + .app_id(self.config['app_id']) + .app_secret(self.config['app_secret']) + .app_ticket(self.app_ticket) + .build() + ) + .build() + ) + response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request) + if not response.success(): + raise RuntimeError(f'Lark app_access_token failed: {response.code} {response.msg}') + content = json.loads(response.raw.content) + self.app_access_token = content['app_access_token'] + self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300 + + def get_app_access_token(self): + if self.config.get('app_type', 'self') != 'isv': + return None + if ( + self.app_access_token is None + or self.app_access_token_expire_at is None + or int(time.time()) >= self.app_access_token_expire_at + ): + self.request_app_access_token() + return self.app_access_token + + def request_tenant_access_token(self, tenant_key: str): + if self.config.get('app_type', 'self') != 'isv': + return + request = ( + CreateTenantAccessTokenRequest.builder() + .request_body( + CreateTenantAccessTokenRequestBody.builder() + .app_access_token(self.get_app_access_token()) + .tenant_key(tenant_key) + .build() + ) + .build() + ) + response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request) + if not response.success(): + raise RuntimeError(f'Lark tenant_access_token failed: {response.code} {response.msg}') + content = json.loads(response.raw.content) + self.tenant_access_tokens[tenant_key] = { + 'token': content['tenant_access_token'], + 'expire_at': int(time.time()) + content['expire'] - 300, + } + + def get_tenant_access_token(self, tenant_key: str | None): + if self.config.get('app_type', 'self') != 'isv' or not tenant_key: + return None + cached = self.tenant_access_tokens.get(tenant_key) + if cached is None or int(time.time()) >= cached['expire_at']: + self.request_tenant_access_token(tenant_key) + return self.tenant_access_tokens.get(tenant_key, {}).get('token') + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + receive_id_type = 'chat_id' if target_type == 'group' else 'open_id' + message_ids: list[str] = [] + + for msg_type, content in self._outbound_payloads(text_elements, media_items): + request = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(str(target_id)) + .content(json.dumps(content, ensure_ascii=False)) + .msg_type(msg_type) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + response: CreateMessageResponse = await self.api_client.im.v1.message.acreate(request) + if not response.success(): + raise RuntimeError(f'Lark send_message failed: {response.code} {response.msg}') + message_ids.append(getattr(response.data, 'message_id', '')) + + return platform_events.MessageResult( + message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids} + ) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + tenant_key = self._tenant_key_from_source(message_source) + message_ids: list[str] = [] + + for msg_type, content in self._outbound_payloads(text_elements, media_items): + request = ( + ReplyMessageRequest.builder() + .message_id(self._message_id_from_source(message_source)) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(content, ensure_ascii=False)) + .msg_type(msg_type) + .reply_in_thread(False) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( + request, self.request_option(tenant_key) + ) + if not response.success(): + raise RuntimeError(f'Lark reply_message failed: {response.code} {response.msg}') + message_ids.append(getattr(response.data, 'message_id', '')) + + return platform_events.MessageResult( + message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids} + ) + + def _outbound_payloads(self, text_elements: list[list[dict]], media_items: list[dict]) -> list[tuple[str, dict]]: + payloads: list[tuple[str, dict]] = [] + if text_elements: + needs_post = any(ele.get('tag') == 'at' for paragraph in text_elements for ele in paragraph) + if needs_post: + payloads.append(('post', {'zh_Hans': {'title': '', 'content': text_elements}})) + else: + parts = [] + for paragraph in text_elements: + text = ''.join(ele.get('text', '') for ele in paragraph) + if text: + parts.append(text) + payloads.append(('text', {'text': '\n\n'.join(parts)})) + for media in media_items: + payloads.append((media['msg_type'], media['content'])) + return payloads + + async def is_stream_output_supported(self) -> bool: + return bool(self.config.get('enable-stream-reply', False)) + + async def on_monitoring_message_created(self, query, monitoring_message_id: str): + user_msg_id = getattr(query.message_event, 'message_id', None) + if user_msg_id: + self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_id + + async def create_message_card(self, message_id, event) -> bool: + card_id = await self.create_card_id(message_id) + content = {'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}} + request = ( + ReplyMessageRequest.builder() + .message_id(self._message_id_from_source(event)) + .request_body( + ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build() + ) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( + request, self.request_option(self._tenant_key_from_source(event)) + ) + if not response.success(): + raise RuntimeError(f'Lark create_message_card failed: {response.code} {response.msg}') + return True + + async def create_card_id(self, message_id) -> str: + card_data = { + 'schema': '2.0', + 'config': {'update_multi': True, 'streaming_mode': True}, + 'body': { + 'direction': 'vertical', + 'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}], + }, + } + request = ( + CreateCardRequest.builder() + .request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build()) + .build() + ) + response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request) + if not response.success(): + raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}') + self.card_id_dict[str(message_id)] = response.data.card_id + return response.data.card_id + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + if bot_message.msg_sequence % 8 != 0 and not is_final: + return + text_elements, _ = await self.message_converter.yiri2target(message, self.api_client) + content = '\n\n'.join( + ''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'}) + for paragraph in text_elements + ) + request = ( + ContentCardElementRequest.builder() + .card_id(self.card_id_dict[bot_message.resp_message_id]) + .element_id('streaming_txt') + .request_body( + ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build() + ) + .build() + ) + response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content( + request, self.request_option(self._tenant_key_from_source(message_source)) + ) + if not response.success(): + raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}') + if is_final and bot_message.tool_calls is None: + self.card_id_dict.pop(bot_message.resp_message_id, None) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + if self.listeners.get(event_type) is callback: + self.listeners.pop(event_type, None) + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None: + source_event = getattr(event.source_platform_object, 'event', None) + message = getattr(source_event, 'message', None) if source_event else None + thread_id = getattr(message, 'thread_id', None) + if thread_id and isinstance(event, platform_events.MessageReceivedEvent) and event.group: + return f'{event.group.id}_{thread_id}' + return None + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + try: + data = await request.json + if 'encrypt' in data: + data = json.loads(self.cipher.decrypt_string(data['encrypt'])) + event_type = self.get_event_type(data) + if event_type == 'url_verification': + return {'challenge': data.get('challenge')} + if event_type == 'app_ticket': + self.app_ticket = self._webhook_event(data).get('app_ticket') + return {'code': 200, 'message': 'ok'} + if event_type == 'im.message.receive_v1': + p2v1 = P2ImMessageReceiveV1() + p2v1.header = self._webhook_header(data) + event_data = P2ImMessageReceiveV1Data() + raw_event = self._webhook_event(data) + event_data.message = EventMessage(raw_event['message']) + event_data.sender = EventSender(raw_event['sender']) + p2v1.event = event_data + p2v1.schema = data.get('schema', '2.0') + await self._handle_message_event(p2v1) + return {'code': 200, 'message': 'ok'} + if event_type == 'im.chat.member.bot.added_v1': + raw_event = self._webhook_event(data) + header = self._webhook_header(data) + chat_id = raw_event.get('chat_id', '') + await self._send_bot_added_welcome(chat_id, getattr(header, 'tenant_key', None)) + await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id)) + return {'code': 200, 'message': 'ok'} + if event_type == 'card.action.trigger': + feedback_event = self._feedback_event_from_webhook(data) + if feedback_event and platform_events.FeedbackEvent in self.listeners: + await self.listeners[platform_events.FeedbackEvent](feedback_event, self) + return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + await self._dispatch_eba_event(LarkEventConverter.platform_specific(data, event_type, data)) + return {'code': 200, 'message': 'ok'} + except Exception: + await self.logger.error(f'Error in lark webhook: {traceback.format_exc()}') + return {'code': 500, 'message': 'error'} + + def get_event_type(self, data: dict) -> str: + schema = data.get('schema', '1.0') + if schema == '2.0': + return data.get('header', {}).get('event_type', '') + if 'event' in data: + return data['event'].get('type', '') + return data.get('type', '') + + def _webhook_event(self, data: dict) -> dict: + return data.get('event', {}) + + def _webhook_header(self, data: dict): + return type('LarkWebhookHeader', (), data.get('header', {}))() + + async def run_async(self): + self.event_loop = asyncio.get_running_loop() + if not self.config.get('enable-webhook', False): + try: + await self.bot._connect() + except lark_oapi.ws.exception.ClientException: + raise + except Exception: + await self.bot._disconnect() + if self.bot._auto_reconnect: + await self.bot._reconnect() + else: + raise + else: + while True: + await asyncio.sleep(1) + + async def kill(self) -> bool: + self.bot._auto_reconnect = False + await self.bot._disconnect() + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1): + try: + if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event, self.api_client) + if legacy_event and type(legacy_event) in self.listeners: + await self.listeners[type(legacy_event)](legacy_event, self) + eba_event = await self.event_converter.target2yiri(event, self.api_client) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in lark message event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.Event): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group + + def _handle_card_action_sync(self, event): + feedback_event = self._feedback_event_from_callback(event) + if feedback_event and platform_events.FeedbackEvent in self.listeners: + self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + + def _submit_coro(self, coro): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = self.event_loop + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe(coro, loop) + return + coro.close() + raise + else: + loop.create_task(coro) + + def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None: + value = getattr(getattr(event.event, 'action', None), 'value', {}) or {} + return self._feedback_event( + raw=event, + feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())), + feedback_value=value.get('feedback', ''), + user_id=getattr(getattr(event.event, 'operator', None), 'open_id', None), + chat_id=getattr(getattr(event.event, 'context', None), 'open_chat_id', None), + message_id=getattr(getattr(event.event, 'context', None), 'open_message_id', None), + ) + + def _feedback_event_from_webhook(self, data: dict) -> platform_events.FeedbackEvent | None: + event = data.get('event', {}) + value = event.get('action', {}).get('value', {}) or {} + operator = event.get('operator', {}) + context = event.get('context', {}) + return self._feedback_event( + raw=data, + feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())), + feedback_value=value.get('feedback', ''), + user_id=operator.get('open_id') or operator.get('user_id'), + chat_id=context.get('open_chat_id'), + message_id=context.get('open_message_id'), + ) + + def _feedback_event( + self, + raw, + feedback_id: str, + feedback_value: str, + user_id: str | None, + chat_id: str | None, + message_id: str | None, + ) -> platform_events.FeedbackEvent | None: + if feedback_value == '有帮助': + feedback_type = 1 + elif feedback_value == '无帮助': + feedback_type = 2 + else: + return None + return platform_events.FeedbackEvent( + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=feedback_value, + user_id=user_id, + session_id=f'group_{chat_id}' if chat_id else (f'person_{user_id}' if user_id else None), + message_id=message_id, + stream_id=self.reply_to_monitoring_msg.get(message_id, (None, 0))[0] if message_id else None, + source_platform_object=raw, + ) + + async def _send_bot_added_welcome(self, chat_id: str, tenant_key: str | None): + welcome = self.config.get('bot_added_welcome', '') + if not welcome or not chat_id: + return + content = {'zh_Hans': {'title': '', 'content': [[{'tag': 'md', 'text': welcome}]]}} + request = ( + CreateMessageRequest.builder() + .receive_id_type('chat_id') + .request_body( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .content(json.dumps(content, ensure_ascii=False)) + .msg_type('post') + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + response: CreateMessageResponse = await self.api_client.im.v1.message.acreate( + request, self.request_option(tenant_key) + ) + if not response.success(): + await self.logger.warning(f'Lark bot_added_welcome failed: {response.code} {response.msg}') + + def _tenant_key_from_source(self, event: platform_events.Event) -> str | None: + source = getattr(event, 'source_platform_object', None) + header = getattr(source, 'header', None) + return getattr(header, 'tenant_key', None) + + def _message_id_from_source(self, event: platform_events.Event) -> str: + message_id = getattr(event, 'message_id', None) + if message_id: + return str(message_id) + source = getattr(event, 'source_platform_object', None) + source_event = getattr(source, 'event', None) + message = getattr(source_event, 'message', None) if source_event else None + message_id = getattr(message, 'message_id', None) + if message_id: + return str(message_id) + raise RuntimeError('Lark message source does not contain message_id') diff --git a/src/langbot/pkg/platform/adapters/lark/api_impl.py b/src/langbot/pkg/platform/adapters/lark/api_impl.py new file mode 100644 index 000000000..9bcf11af7 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/api_impl.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import typing + +from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest +from lark_oapi.core.model import RequestOption + +from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter +from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class LarkAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + cached = self._message_cache.get(str(message_id)) + if cached: + return cached + request = GetMessageRequest.builder().message_id(str(message_id)).build() + response = await self.api_client.im.v1.message.aget(request, self.request_option(None)) + if not response.success(): + raise NotSupportedError(f'get_message:{message_id}') + items = getattr(response.data, 'items', None) or [] + if not items: + raise NotSupportedError(f'get_message:{message_id}') + event_message = LarkEventConverter._build_event_message_from_message_item(items[0]) + if event_message is None: + raise NotSupportedError(f'get_message:{message_id}') + message_chain = await LarkMessageConverter.target2yiri(event_message, self.api_client) + event = platform_events.MessageReceivedEvent( + type='message.received', + adapter_name='lark-eba', + message_id=str(message_id), + message_chain=message_chain, + sender=platform_entities.User(id=''), + chat_type=platform_entities.ChatType.GROUP if chat_type == 'group' else platform_entities.ChatType.PRIVATE, + chat_id=chat_id, + group=platform_entities.UserGroup(id=chat_id, name='') if chat_type == 'group' else None, + timestamp=0, + source_platform_object=items[0], + ) + self._message_cache[str(message_id)] = event + return event + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + cached = self._group_cache.get(str(group_id)) + if cached: + return cached + request = GetChatRequest.builder().chat_id(str(group_id)).build() + response = await self.api_client.im.v1.chat.aget(request, self.request_option(None)) + if not response.success(): + raise NotSupportedError(f'get_group_info:{group_id}') + data = response.data + group = platform_entities.UserGroup( + id=getattr(data, 'chat_id', group_id), + name=getattr(data, 'name', '') or '', + description=getattr(data, 'description', None), + avatar_url=getattr(data, 'avatar', None), + owner_id=getattr(data, 'owner_id', None), + ) + self._group_cache[str(group.id)] = group + return group + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + user = self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id) + return platform_entities.UserGroupMember(user=user, group_id=group_id, role=platform_entities.MemberRole.MEMBER) + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + cached = self._user_cache.get(str(user_id)) + if cached: + return cached + return platform_entities.User(id=user_id) + + async def get_file_url(self, file_id: str) -> str: + if str(file_id).startswith('file://'): + return str(file_id) + raise NotSupportedError('get_file_url requires a file:// path or platform-specific resource download params') + + def request_option(self, tenant_key: str | None) -> RequestOption: + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + return ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) diff --git a/src/langbot/pkg/platform/adapters/lark/event_converter.py b/src/langbot/pkg/platform/adapters/lark/event_converter.py new file mode 100644 index 000000000..d76a03ab8 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/event_converter.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import time +import typing + +import lark_oapi +from lark_oapi.api.im.v1 import EventMessage, GetMessageRequest, Message + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter +from langbot.pkg.platform.adapters.lark.types import ADAPTER_NAME +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): + _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {} + _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096 + _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400 + + @staticmethod + async def yiri2target(event: platform_events.Event): + return getattr(event, 'source_platform_object', None) + + @staticmethod + async def target2yiri( + event: lark_oapi.im.v1.P2ImMessageReceiveV1, + api_client: lark_oapi.Client, + ) -> platform_events.Event | None: + return await LarkEventConverter.message_to_eba(event, api_client) + + @staticmethod + async def target2legacy( + event: lark_oapi.im.v1.P2ImMessageReceiveV1, + api_client: lark_oapi.Client, + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await LarkEventConverter.message_to_eba(event, api_client) + if eba_event: + return eba_event.to_legacy_event() + return None + + @staticmethod + async def message_to_eba( + event: lark_oapi.im.v1.P2ImMessageReceiveV1, + api_client: lark_oapi.Client, + ) -> platform_events.MessageReceivedEvent: + message = event.event.message + message_chain = await LarkMessageConverter.target2yiri(message, api_client) + await LarkEventConverter._append_quote_content(message, message_chain, api_client) + + sender = LarkEventConverter.user_from_event(event) + chat_type = platform_entities.ChatType.PRIVATE + chat_id = LarkEventConverter.sender_id(event) + group = None + if getattr(message, 'chat_type', '') == 'group': + chat_type = platform_entities.ChatType.GROUP + chat_id = getattr(message, 'chat_id', '') or chat_id + group = platform_entities.UserGroup(id=chat_id, name='') + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=getattr(message, 'message_id', ''), + message_chain=message_chain, + sender=sender, + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=LarkEventConverter._timestamp(getattr(message, 'create_time', None)), + source_platform_object=event, + ) + + @staticmethod + def user_from_event(event: lark_oapi.im.v1.P2ImMessageReceiveV1) -> platform_entities.User: + sender_id = getattr(getattr(event.event.sender, 'sender_id', None), 'open_id', '') or '' + union_id = getattr(getattr(event.event.sender, 'sender_id', None), 'union_id', '') or '' + return platform_entities.User(id=sender_id, nickname=union_id) + + @staticmethod + def sender_id(event: lark_oapi.im.v1.P2ImMessageReceiveV1) -> str: + return getattr(getattr(event.event.sender, 'sender_id', None), 'open_id', '') or '' + + @staticmethod + def bot_invited_to_group( + raw_event: typing.Any, + chat_id: str, + operator_id: str | None = None, + ) -> platform_events.BotInvitedToGroupEvent: + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name=ADAPTER_NAME, + group=platform_entities.UserGroup(id=chat_id, name=''), + inviter=platform_entities.User(id=operator_id) if operator_id else None, + timestamp=time.time(), + source_platform_object=raw_event, + ) + + @staticmethod + def platform_specific( + raw_event: typing.Any, action: str, data: dict | None = None + ) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=data or {}, + timestamp=time.time(), + source_platform_object=raw_event, + ) + + @classmethod + def _prune_processed_thread_quote_cache(cls, now: float | None = None) -> None: + if now is None: + now = time.time() + expire_before = now - cls._processed_thread_quote_cache_ttl_seconds + while cls._processed_thread_quote_cache: + oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items())) + if oldest_ts >= expire_before: + break + cls._processed_thread_quote_cache.pop(oldest_key, None) + while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size: + cls._processed_thread_quote_cache.pop(next(iter(cls._processed_thread_quote_cache)), None) + + @classmethod + def _extract_quote_message_id(cls, message: EventMessage) -> str | None: + parent_id = getattr(message, 'parent_id', None) + if not parent_id or parent_id == getattr(message, 'message_id', None): + return None + thread_id = getattr(message, 'thread_id', None) + if thread_id: + cls._prune_processed_thread_quote_cache() + if thread_id in cls._processed_thread_quote_cache: + return None + cls._processed_thread_quote_cache[thread_id] = time.time() + return parent_id + + @staticmethod + async def _append_quote_content( + message: EventMessage, + message_chain: platform_message.MessageChain, + api_client: lark_oapi.Client, + ) -> None: + quote_message_id = LarkEventConverter._extract_quote_message_id(message) + if not quote_message_id: + return + quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client) + if not quote_chain: + return + origin = platform_message.MessageChain( + [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)] + ) + message_chain.append( + platform_message.Quote( + id=quote_message_id, + group_id=getattr(message, 'chat_id', None), + target_id=getattr(message, 'chat_id', None), + origin=origin, + ) + ) + + @staticmethod + async def _fetch_quoted_message( + quote_message_id: str, + api_client: lark_oapi.Client, + ) -> platform_message.MessageChain | None: + request = GetMessageRequest.builder().message_id(quote_message_id).build() + response = await api_client.im.v1.message.aget(request) + if not response.success() or not getattr(response.data, 'items', None): + return None + event_message = LarkEventConverter._build_event_message_from_message_item(response.data.items[0]) + if event_message is None: + return None + return await LarkMessageConverter.target2yiri(event_message, api_client) + + @staticmethod + def _build_event_message_from_message_item(message_item: Message) -> EventMessage | None: + body = getattr(message_item, 'body', None) + content = getattr(body, 'content', None) if body else None + if not content: + return None + event_data = { + 'message_id': message_item.message_id, + 'message_type': message_item.msg_type, + 'content': content, + 'create_time': message_item.create_time, + 'mentions': getattr(message_item, 'mentions', []) or [], + } + for key in ('parent_id', 'root_id', 'thread_id', 'chat_id'): + value = getattr(message_item, key, None) + if value: + event_data[key] = value + return EventMessage(event_data) + + @staticmethod + def _timestamp(value: typing.Any) -> float: + if isinstance(value, (int, float, str)): + try: + timestamp = float(value) + return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp + except ValueError: + pass + if hasattr(value, 'timestamp'): + return float(value.timestamp()) + return 0.0 diff --git a/src/langbot/pkg/platform/adapters/lark/lark.svg b/src/langbot/pkg/platform/adapters/lark/lark.svg new file mode 100644 index 000000000..bf3c202ab --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/lark.svg @@ -0,0 +1 @@ + diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml new file mode 100644 index 000000000..9afca06e4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -0,0 +1,185 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: lark-eba + label: + en_US: Lark / Feishu + zh_Hans: 飞书 + zh_Hant: 飛書 + ja_JP: Lark + description: + en_US: Lark/Feishu adapter with event-driven orchestration support, supporting self-built/store apps and WebSocket/Webhook modes. + zh_Hans: 飞书适配器,支持事件驱动编排、自建/商店应用和长连接/Webhook 两种通信模式。 + zh_Hant: 飛書適配器,支援事件驅動編排、自建/商店應用和長連線/Webhook 兩種通訊模式。 + ja_JP: イベント駆動の編成に対応した Lark アダプター。カスタム/ストアアプリと WebSocket/Webhook モードをサポートします。 + icon: lark.svg + +spec: + categories: + - popular + - china + - global + help_links: + zh: https://link.langbot.app/zh/platforms/lark + en: https://link.langbot.app/en/platforms/lark + ja: https://link.langbot.app/ja/platforms/lark + config: + - name: app_id + label: + en_US: App ID + zh_Hans: 应用ID + zh_Hant: 應用ID + ja_JP: アプリ ID + type: string + required: true + default: "" + - name: app_secret + label: + en_US: App Secret + zh_Hans: 应用密钥 + zh_Hant: 應用密鑰 + ja_JP: アプリシークレット + type: string + required: true + default: "" + - name: bot_name + label: + en_US: Bot Name + zh_Hans: 机器人名称 + zh_Hant: 機器人名稱 + ja_JP: ボット名 + description: + en_US: Must match the Lark bot name so group mentions can be recognized. + zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常识别 @。 + zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常識別 @。 + ja_JP: グループメンションを認識するには Lark のボット名と一致する必要があります。 + type: string + required: true + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用 Webhook 模式 + zh_Hant: 啟用 Webhook 模式 + ja_JP: Webhook モードを有効化 + description: + en_US: Enable request URL callback mode. Disable it to use WebSocket long connection mode. + zh_Hans: 启用 Request URL 回调模式。关闭时使用 WebSocket 长连接模式。 + zh_Hant: 啟用 Request URL 回調模式。關閉時使用 WebSocket 長連線模式。 + ja_JP: Request URL コールバックモードを有効化します。無効時は WebSocket 長期接続を使用します。 + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + ja_JP: Webhook コールバック URL + description: + en_US: Copy this URL to the Lark app event subscription request URL. + zh_Hans: 复制此地址并粘贴到飞书应用事件订阅的 Request URL 中。 + zh_Hant: 複製此地址並貼到飛書應用事件訂閱的 Request URL 中。 + ja_JP: この URL を Lark アプリのイベント購読 Request URL に貼り付けてください。 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + - name: encrypt-key + label: + en_US: Encrypt Key + zh_Hans: 加密密钥 + zh_Hant: 加密密鑰 + ja_JP: 暗号化キー + type: string + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用飞书流式回复模式 + zh_Hant: 啟用飛書串流回覆模式 + ja_JP: ストリーミング返信モードを有効化 + description: + en_US: If enabled, replies are rendered through an updating Lark card. + zh_Hans: 如果启用,将使用可更新的飞书卡片进行流式回复。 + zh_Hant: 如果啟用,將使用可更新的飛書卡片進行串流回覆。 + ja_JP: 有効にすると、更新可能な Lark カードでストリーミング返信します。 + type: boolean + required: true + default: false + - name: app_type + label: + en_US: App Type + zh_Hans: 应用类型 + zh_Hant: 應用類型 + ja_JP: アプリタイプ + type: select + options: + - name: self + label: + en_US: Self-built Application + zh_Hans: 自建应用 + zh_Hant: 自建應用 + ja_JP: カスタムアプリ + - name: isv + label: + en_US: Store Application + zh_Hans: 商店应用 + zh_Hant: 商店應用 + ja_JP: ストアアプリ + required: false + default: self + - name: bot_added_welcome + label: + en_US: Bot Welcome Message + zh_Hans: 机器人进群欢迎语 + zh_Hant: 機器人進群歡迎語 + ja_JP: ボット参加時のウェルカムメッセージ + type: text + required: false + default: "" + + supported_events: + - message.received + - bot.invited_to_group + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_group_info + - get_group_member_info + - get_user_info + - get_file_url + - call_platform_api + + platform_specific_apis: + - action: check_tenant_access_token + description: { en_US: "Check whether the tenant access token can be obtained", zh_Hans: "检查 tenant access token 是否可获取" } + - action: refresh_app_access_token + description: { en_US: "Refresh store-app app access token", zh_Hans: "刷新商店应用 app access token" } + - action: refresh_tenant_access_token + description: { en_US: "Refresh store-app tenant access token", zh_Hans: "刷新商店应用 tenant access token" } + - action: get_chat + description: { en_US: "Get Lark chat metadata", zh_Hans: "获取飞书会话信息" } + - action: get_message + description: { en_US: "Get a Lark message", zh_Hans: "获取飞书消息" } + - action: get_message_resource + description: { en_US: "Download message image/file resource", zh_Hans: "下载消息图片/文件资源" } + +execution: + python: + path: ./adapter.py + attr: LarkAdapter diff --git a/src/langbot/pkg/platform/adapters/lark/message_converter.py b/src/langbot/pkg/platform/adapters/lark/message_converter.py new file mode 100644 index 000000000..63dc64f05 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/message_converter.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import base64 +import datetime +import json +import mimetypes +import os +import re +import tempfile +import traceback + +import lark_oapi +from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, + EventMessage, + GetMessageResourceRequest, + GetMessageResourceResponse, +) + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.utils import httpclient +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> str | None: + image_bytes = await LarkMessageConverter._get_component_bytes(msg) + if image_bytes is None: + return None + + temp_file_path = '' + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(image_bytes) + temp_file.flush() + temp_file_path = temp_file.name + + request = ( + CreateImageRequest.builder() + .request_body( + CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build() + ) + .build() + ) + response = await api_client.im.v1.image.acreate(request) + if not response.success(): + return None + return response.data.image_key + except Exception: + traceback.print_exc() + return None + finally: + if temp_file_path: + try: + os.unlink(temp_file_path) + except FileNotFoundError: + pass + + @staticmethod + async def upload_file_to_lark( + file_bytes: bytes, + api_client: lark_oapi.Client, + file_type: str, + file_name: str = 'file', + duration: int | None = None, + ) -> str | None: + temp_file_path = '' + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(file_bytes) + temp_file.flush() + temp_file_path = temp_file.name + + body_builder = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(open(temp_file_path, 'rb')) + ) + if duration is not None: + body_builder = body_builder.duration(duration) + + request = CreateFileRequest.builder().request_body(body_builder.build()).build() + response = await api_client.im.v1.file.acreate(request) + if not response.success(): + return None + return response.data.file_key + except Exception: + traceback.print_exc() + return None + finally: + if temp_file_path: + try: + os.unlink(temp_file_path) + except FileNotFoundError: + pass + + @staticmethod + async def _get_component_bytes( + msg: platform_message.Image | platform_message.Voice | platform_message.File, + ) -> bytes | None: + if getattr(msg, 'base64', None): + try: + base64_data = msg.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + return base64.b64decode(base64_data) + except Exception: + return None + if getattr(msg, 'url', None): + try: + if str(msg.url).startswith('file://'): + with open(str(msg.url)[7:], 'rb') as f: + return f.read() + session = httpclient.get_session() + async with session.get(msg.url) as response: + if response.status == 200: + return await response.read() + except Exception: + return None + if getattr(msg, 'path', None): + try: + with open(msg.path, 'rb') as f: + return f.read() + except Exception: + return None + return None + + @staticmethod + def _lark_file_type(file_name: str) -> str: + ext = os.path.splitext(file_name)[1].lstrip('.').lower() + return { + 'opus': 'opus', + 'mp4': 'mp4', + 'pdf': 'pdf', + 'doc': 'doc', + 'docx': 'doc', + 'xls': 'xls', + 'xlsx': 'xls', + 'ppt': 'ppt', + 'pptx': 'ppt', + }.get(ext, 'stream') + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + api_client: lark_oapi.Client, + ) -> tuple[list[list[dict]], list[dict]]: + message_elements: list[list[dict]] = [] + media_items: list[dict] = [] + pending_paragraph: list[dict] = [] + markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + async def process_text_with_images(text: str) -> tuple[str, list[str]]: + matches = list(markdown_image_pattern.finditer(text)) + if not matches: + return text, [] + cleaned_text = text + extracted_urls: list[str] = [] + for match in reversed(matches): + extracted_urls.insert(0, match.group(2)) + cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :] + cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text).strip() + return cleaned_text, extracted_urls + + for msg in message_chain: + if isinstance(msg, platform_message.Source): + continue + if isinstance(msg, platform_message.Plain): + cleaned_text, extracted_urls = await process_text_with_images(msg.text) + if cleaned_text: + segments = re.split(r'\n\s*\n', cleaned_text) + for i, segment in enumerate(segments): + segment = segment.strip() + if not segment: + continue + if i > 0 and pending_paragraph: + message_elements.append(pending_paragraph) + pending_paragraph = [] + pending_paragraph.append({'tag': 'md', 'text': segment}) + for url in extracted_urls: + image_key = await LarkMessageConverter.upload_image_to_lark( + platform_message.Image(url=url), api_client + ) + if image_key: + media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) + elif isinstance(msg, platform_message.At): + pending_paragraph.append({'tag': 'at', 'user_id': str(msg.target), 'style': []}) + elif isinstance(msg, platform_message.AtAll): + pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []}) + elif isinstance(msg, platform_message.Image): + image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client) + if image_key: + media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) + elif isinstance(msg, platform_message.Voice): + data = await LarkMessageConverter._get_component_bytes(msg) + if data: + duration = int(msg.length * 1000) if msg.length else None + file_key = await LarkMessageConverter.upload_file_to_lark( + data, api_client, file_type='opus', file_name='voice.opus', duration=duration + ) + if file_key: + media_items.append({'msg_type': 'audio', 'content': {'file_key': file_key}}) + elif isinstance(msg, platform_message.File): + data = await LarkMessageConverter._get_component_bytes(msg) + if data: + file_name = msg.name or 'file' + file_key = await LarkMessageConverter.upload_file_to_lark( + data, + api_client, + file_type=LarkMessageConverter._lark_file_type(file_name), + file_name=file_name, + ) + if file_key: + media_items.append({'msg_type': 'file', 'content': {'file_key': file_key}}) + elif isinstance(msg, platform_message.Quote): + if msg.id: + pending_paragraph.append({'tag': 'md', 'text': f'[引用消息 {msg.id}] '}) + if msg.origin: + sub_elements, sub_media = await LarkMessageConverter.yiri2target(msg.origin, api_client) + message_elements.extend(sub_elements) + media_items.extend(sub_media) + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + if node.sender_name or node.sender_id: + pending_paragraph.append({'tag': 'md', 'text': f'\n[{node.sender_name or node.sender_id}] '}) + sub_elements, sub_media = await LarkMessageConverter.yiri2target(node.message_chain, api_client) + message_elements.extend(sub_elements) + media_items.extend(sub_media) + + if pending_paragraph: + message_elements.append(pending_paragraph) + + return message_elements, media_items + + @staticmethod + async def target2yiri( + message: EventMessage, + api_client: lark_oapi.Client, + ) -> platform_message.MessageChain: + message_content = json.loads(message.content or '{}') + create_time = LarkMessageConverter._message_time(message) + components: list[platform_message.MessageComponent] = [ + platform_message.Source(id=message.message_id, time=create_time) + ] + + normalized = LarkMessageConverter._normalize_inbound_content(message, message_content) + for ele in normalized: + tag = ele.get('tag') + if tag in {'text', 'md'}: + text = ele.get('text') or '' + if text: + components.append(platform_message.Plain(text=text)) + elif tag == 'at': + user_id = ele.get('user_id') or ele.get('user_name') or '' + display = ele.get('user_name') or user_id + if user_id == 'all': + components.append(platform_message.AtAll()) + else: + components.append(platform_message.At(target=user_id, display=display)) + elif tag == 'img': + image_key = ele.get('image_key') or '' + image = await LarkMessageConverter._download_resource( + api_client, message.message_id, image_key, 'image' + ) + components.append(platform_message.Image(image_id=image_key, **image)) + elif tag == 'audio': + file_key = ele.get('file_key') or '' + audio = await LarkMessageConverter._download_resource(api_client, message.message_id, file_key, 'file') + components.append( + platform_message.Voice( + voice_id=file_key, + length=(ele.get('duration', 0) // 1000) if ele.get('duration') else None, + **audio, + ) + ) + elif tag == 'file': + file_key = ele.get('file_key') or '' + file_name = ele.get('file_name') or 'file' + file_data = await LarkMessageConverter._download_resource( + api_client, message.message_id, file_key, 'file' + ) + components.append( + platform_message.File( + id=file_key, + name=file_name, + size=file_data.pop('size', 0), + **file_data, + ) + ) + + return platform_message.MessageChain(components) + + @staticmethod + def _normalize_inbound_content(message: EventMessage, content: dict) -> list[dict]: + if message.message_type == 'text': + text = content.get('text', '') + return LarkMessageConverter._split_text_mentions(text, getattr(message, 'mentions', []) or []) + if message.message_type == 'post': + post_content = content.get('content', []) + flattened: list[dict] = [] + for ele in post_content: + if isinstance(ele, dict): + flattened.append(ele) + elif isinstance(ele, list): + flattened.extend(item for item in ele if isinstance(item, dict)) + return flattened + if message.message_type == 'image': + return [{'tag': 'img', 'image_key': content.get('image_key', ''), 'style': []}] + if message.message_type == 'file': + return [ + { + 'tag': 'file', + 'file_key': content.get('file_key', ''), + 'file_name': content.get('file_name', 'file'), + } + ] + if message.message_type == 'audio': + return [ + { + 'tag': 'audio', + 'file_key': content.get('file_key', ''), + 'duration': content.get('duration', 0), + } + ] + return [{'tag': 'text', 'text': json.dumps(content, ensure_ascii=False), 'style': []}] + + @staticmethod + def _split_text_mentions(text: str, mentions: list) -> list[dict]: + if not text: + return [] + mention_by_key = {getattr(m, 'key', ''): m for m in mentions} + pattern = re.compile(r'@_user_\d+') + result: list[dict] = [] + pos = 0 + for match in pattern.finditer(text): + if match.start() > pos: + result.append({'tag': 'text', 'text': text[pos : match.start()], 'style': []}) + mention = mention_by_key.get(match.group(0)) + if mention: + result.append( + { + 'tag': 'at', + 'user_id': getattr(mention, 'id', None) + or getattr(mention, 'open_id', None) + or getattr(mention, 'user_id', None) + or getattr(mention, 'key', match.group(0)), + 'user_name': getattr(mention, 'name', ''), + 'style': [], + } + ) + else: + result.append({'tag': 'text', 'text': match.group(0), 'style': []}) + pos = match.end() + if pos < len(text): + result.append({'tag': 'text', 'text': text[pos:], 'style': []}) + return result + + @staticmethod + async def _download_resource( + api_client: lark_oapi.Client, + message_id: str, + file_key: str, + resource_type: str, + ) -> dict: + if not file_key: + return {} + request = ( + GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build() + ) + response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) + if not response.success(): + return {} + data = response.file.read() + content_type = response.raw.headers.get('content-type', 'application/octet-stream') + base64_data = base64.b64encode(data).decode() + ext = mimetypes.guess_extension(content_type.split(';')[0].strip()) or '.bin' + temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}') + with open(temp_path, 'wb') as f: + f.write(data) + return { + 'url': f'file://{temp_path}', + 'path': temp_path, + 'base64': f'data:{content_type};base64,{base64_data}', + 'size': len(data), + } + + @staticmethod + def _message_time(message: EventMessage) -> datetime.datetime: + value = getattr(message, 'create_time', None) + if isinstance(value, datetime.datetime): + return value + if isinstance(value, (int, float, str)): + try: + timestamp = float(value) + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000 + return datetime.datetime.fromtimestamp(timestamp) + except ValueError: + pass + return datetime.datetime.now() diff --git a/src/langbot/pkg/platform/adapters/lark/platform_api.py b/src/langbot/pkg/platform/adapters/lark/platform_api.py new file mode 100644 index 000000000..e739caff4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/platform_api.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json + +from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest, GetMessageResourceRequest + + +async def check_tenant_access_token(adapter, params: dict) -> dict: + tenant_key = params.get('tenant_key') or getattr(adapter, 'lark_tenant_key', None) + token = adapter.get_tenant_access_token(tenant_key) + return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'} + + +async def refresh_app_access_token(adapter, params: dict) -> dict: + adapter.app_access_token = None + adapter.app_access_token_expire_at = None + token = adapter.get_app_access_token() + return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'} + + +async def refresh_tenant_access_token(adapter, params: dict) -> dict: + tenant_key = params.get('tenant_key') or getattr(adapter, 'lark_tenant_key', None) + if tenant_key: + adapter.tenant_access_tokens.pop(tenant_key, None) + token = adapter.get_tenant_access_token(tenant_key) + return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'} + + +async def get_chat(adapter, params: dict) -> dict: + request = GetChatRequest.builder().chat_id(params['chat_id']).build() + response = await adapter.api_client.im.v1.chat.aget(request, adapter.request_option(params.get('tenant_key'))) + return _response_to_dict(response) + + +async def get_message(adapter, params: dict) -> dict: + request = GetMessageRequest.builder().message_id(params['message_id']).build() + response = await adapter.api_client.im.v1.message.aget(request, adapter.request_option(params.get('tenant_key'))) + return _response_to_dict(response) + + +async def get_message_resource(adapter, params: dict) -> dict: + request = ( + GetMessageResourceRequest.builder() + .message_id(params['message_id']) + .file_key(params['file_key']) + .type(params.get('type', 'file')) + .build() + ) + response = await adapter.api_client.im.v1.message_resource.aget( + request, adapter.request_option(params.get('tenant_key')) + ) + if not response.success(): + return _response_to_dict(response) + content_type = response.raw.headers.get('content-type', 'application/octet-stream') + data = response.file.read() + return {'ok': True, 'content_type': content_type, 'size': len(data)} + + +def _response_to_dict(response) -> dict: + if not response.success(): + return {'ok': False, 'code': response.code, 'msg': response.msg, 'log_id': response.get_log_id()} + data = getattr(response, 'data', None) + if hasattr(data, 'to_json'): + data = data.to_json() + return {'ok': True, 'data': _jsonable(data)} + + +def _jsonable(value): + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, bytes): + return {'bytes': len(value)} + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, str): + return value + try: + return json.loads(value) + except Exception: + pass + raw = getattr(value, '__dict__', None) + if raw: + return {key: _jsonable(item) for key, item in raw.items() if not key.startswith('_')} + return str(value) + + +PLATFORM_API_MAP = { + 'check_tenant_access_token': check_tenant_access_token, + 'refresh_app_access_token': refresh_app_access_token, + 'refresh_tenant_access_token': refresh_tenant_access_token, + 'get_chat': get_chat, + 'get_message': get_message, + 'get_message_resource': get_message_resource, +} diff --git a/src/langbot/pkg/platform/adapters/lark/types.py b/src/langbot/pkg/platform/adapters/lark/types.py new file mode 100644 index 000000000..bce1e8764 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'lark-eba' diff --git a/src/langbot/pkg/platform/adapters/officialaccount/__init__.py b/src/langbot/pkg/platform/adapters/officialaccount/__init__.py new file mode 100644 index 000000000..d2ae23cf3 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.officialaccount.adapter import OfficialAccountAdapter + +__all__ = ['OfficialAccountAdapter'] diff --git a/src/langbot/pkg/platform/adapters/officialaccount/adapter.py b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py new file mode 100644 index 000000000..42350dd9f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import asyncio +import traceback +import typing + +import pydantic + +from langbot.libs.official_account_api.api import OAClient, OAClientForLongerResponse +from langbot.libs.official_account_api.oaevent import OAEvent +from langbot.pkg.platform.adapters.officialaccount.api_impl import OfficialAccountAPIMixin +from langbot.pkg.platform.adapters.officialaccount.event_converter import OfficialAccountEventConverter +from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError +from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter +from langbot.pkg.platform.adapters.officialaccount.platform_api import PLATFORM_API_MAP +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: typing.Any = pydantic.Field(exclude=True) + + message_converter: OfficialAccountMessageConverter = OfficialAccountMessageConverter() + event_converter: OfficialAccountEventConverter = OfficialAccountEventConverter() + + config: dict + bot_uuid: str | None = None + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise Exception(f'OfficialAccount EBA adapter missing config: {missing_keys}') + + mode = config['Mode'] + common_kwargs = { + 'token': config['token'], + 'EncodingAESKey': config['EncodingAESKey'], + 'Appsecret': config['AppSecret'], + 'AppID': config['AppID'], + 'logger': logger, + 'unified_mode': True, + 'api_base_url': config.get('api_base_url', 'https://api.weixin.qq.com'), + } + if mode == 'drop': + bot = OAClient(**common_kwargs) + elif mode == 'passive': + bot = OAClientForLongerResponse( + **common_kwargs, + LoadingMessage=config.get('LoadingMessage', ''), + ) + else: + raise KeyError('OfficialAccount Mode must be "drop" or "passive"') + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config.get('AppID', ''), + bot_uuid=None, + listeners={}, + _message_cache={}, + _user_cache={}, + ) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + raise NotSupportedError('send_message:official_account_requires_inbound_webhook_reply') + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + source = await OfficialAccountEventConverter.yiri2target(message_source) + if not isinstance(source, OAEvent): + raise ValueError('OfficialAccount reply_message requires an OAEvent source object') + content = await OfficialAccountMessageConverter.yiri2target(message) + if self.config.get('Mode') == 'passive': + await self.bot.set_message(source.user_id, source.message_id, content) + else: + await self.bot.set_message(source.message_id, content) + return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True}) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + params = dict(params or {}) + params.setdefault('mode', self.config.get('Mode')) + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await self.logger.info('OfficialAccount EBA adapter running in unified webhook mode') + await keep_alive() + + async def kill(self) -> bool: + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + def _register_native_handlers(self): + for msg_type in ('text', 'image', 'voice', 'event'): + self.bot.on_message(msg_type)(self._handle_native_event) + + async def _handle_native_event(self, event: OAEvent): + self.bot_account_id = event.receiver_id or self.bot_account_id + try: + if platform_events.FriendMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event) + if legacy_event and platform_events.FriendMessage in self.listeners: + await self.listeners[platform_events.FriendMessage](legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if isinstance(event, platform_events.MessageReceivedEvent): + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender diff --git a/src/langbot/pkg/platform/adapters/officialaccount/api_impl.py b/src/langbot/pkg/platform/adapters/officialaccount/api_impl.py new file mode 100644 index 000000000..377986c64 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/api_impl.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import typing + +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError + + +class OfficialAccountAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + user = self._user_cache.get(str(user_id)) + if user is None: + raise NotSupportedError('get_user_info:not_cached') + return user + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raise NotSupportedError('forward_message') + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + raise NotSupportedError('get_group_info') + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + raise NotSupportedError('get_group_list') + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raise NotSupportedError('get_group_member_list') + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + raise NotSupportedError('get_group_member_info') diff --git a/src/langbot/pkg/platform/adapters/officialaccount/errors.py b/src/langbot/pkg/platform/adapters/officialaccount/errors.py new file mode 100644 index 000000000..b56f459ae --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/errors.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +try: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +except ModuleNotFoundError: + + class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name diff --git a/src/langbot/pkg/platform/adapters/officialaccount/event_converter.py b/src/langbot/pkg/platform/adapters/officialaccount/event_converter.py new file mode 100644 index 000000000..5523a55f8 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/event_converter.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import time +import typing + +from langbot.libs.official_account_api.oaevent import OAEvent +from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter +from langbot.pkg.platform.adapters.officialaccount.types import ADAPTER_NAME +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> typing.Any: + return getattr(event, 'source_platform_object', None) + + async def target2legacy(self, event: OAEvent) -> platform_events.FriendMessage | None: + eba_event = await self.target2yiri(event) + if not isinstance(eba_event, platform_events.MessageReceivedEvent): + return None + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=eba_event.sender.id, + nickname=eba_event.sender.nickname, + remark='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + + async def target2yiri(self, event: OAEvent) -> platform_events.Event | None: + if event.type in {'text', 'image', 'voice'}: + return await self.message_to_eba(event) + return self.platform_specific(event, f'officialaccount.{event.detail_type or event.type or "unknown"}') + + async def message_to_eba(self, event: OAEvent) -> platform_events.MessageReceivedEvent: + sender_id = event.user_id or '' + timestamp = float(event.timestamp or time.time()) + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.message_id or f'{sender_id}:{int(timestamp)}', + message_chain=await OfficialAccountMessageConverter.target2yiri(event), + sender=platform_entities.User( + id=sender_id, + nickname=sender_id, + ), + chat_type=platform_entities.ChatType.PRIVATE, + chat_id=sender_id, + timestamp=timestamp, + source_platform_object=event, + ) + + @staticmethod + def platform_specific(event: OAEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=float(event.timestamp or time.time()), + source_platform_object=event, + ) diff --git a/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml new file mode 100644 index 000000000..80b3ecd31 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml @@ -0,0 +1,123 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: officialaccount-eba + label: + en_US: Official Account + zh_Hans: 微信公众号 + zh_Hant: 微信公眾號 + description: + en_US: WeChat Official Account adapter with event-driven orchestration support + zh_Hans: 微信公众号适配器,支持事件驱动编排,通过统一 Webhook 接收公众号消息 + zh_Hant: 微信公眾號適配器,支援事件驅動編排,透過統一 Webhook 接收公眾號訊息 + icon: officialaccount.png + +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/officialaccount + en: https://link.langbot.app/en/platforms/officialaccount + ja: https://link.langbot.app/ja/platforms/officialaccount + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your Official Account webhook configuration. + zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中。 + zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中。 + type: webhook-url + required: false + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + type: string + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 消息加解密密钥 + zh_Hant: 訊息加解密密鑰 + type: string + required: true + default: "" + - name: AppID + label: + en_US: App ID + zh_Hans: 应用 ID + zh_Hant: 應用 ID + type: string + required: true + default: "" + - name: AppSecret + label: + en_US: App Secret + zh_Hans: 应用密钥 + zh_Hant: 應用密鑰 + type: string + required: true + default: "" + - name: Mode + label: + en_US: Mode + zh_Hans: 接入模式 + zh_Hant: 接入模式 + description: + en_US: "drop replies within the current callback; passive returns a loading message first and queues the real reply for the user's next message." + zh_Hans: "drop 会在当前回调内等待回复;passive 会先返回加载提示,并将真实回复排队到用户下一条消息。" + zh_Hant: "drop 會在目前回調內等待回覆;passive 會先回傳載入提示,並將真實回覆排隊到使用者下一則訊息。" + type: string + required: true + default: "drop" + - name: LoadingMessage + label: + en_US: Loading Message + zh_Hans: 加载消息 + zh_Hant: 載入訊息 + type: string + required: false + default: "AI正在思考中,请发送任意内容获取回复。" + - name: api_base_url + label: + en_US: API Base URL + zh_Hans: API 基础 URL + zh_Hant: API 基礎 URL + description: + en_US: Optional Official Account API base URL, useful when routing through a reverse proxy. + zh_Hans: 可选,若通过反向代理访问微信公众号 API,可修改此项。 + zh_Hant: 可選,若透過反向代理存取微信公眾號 API,可修改此項。 + type: string + required: false + default: "https://api.weixin.qq.com" + + supported_events: + - message.received + - platform.specific + + supported_apis: + required: + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - call_platform_api + + platform_specific_apis: + - action: get_mode + description: { en_US: "Return the configured Official Account reply mode", zh_Hans: "返回当前微信公众号回复模式" } + - action: get_cached_response_status + description: { en_US: "Inspect cached passive/drop reply state for diagnostics", zh_Hans: "查看被动回复缓存状态,用于诊断" } + +execution: + python: + path: ./adapter.py + attr: OfficialAccountAdapter diff --git a/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py b/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py new file mode 100644 index 000000000..4def1937b --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import datetime + +from langbot.libs.official_account_api.oaevent import OAEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> str: + content_parts: list[str] = [] + for component in message_chain: + if isinstance(component, platform_message.Source): + continue + if isinstance(component, platform_message.Plain): + content_parts.append(component.text) + elif isinstance(component, platform_message.At): + content_parts.append(f'@{component.display or component.target}') + elif isinstance(component, platform_message.AtAll): + content_parts.append('@all') + elif isinstance(component, platform_message.Image): + content_parts.append('[Image]') + elif isinstance(component, platform_message.Voice): + content_parts.append('[Voice]') + elif isinstance(component, platform_message.File): + content_parts.append(f'[File: {component.name or component.id or component.url or "file"}]') + elif isinstance(component, platform_message.Quote): + if component.id is not None: + content_parts.append(f'[Quote {component.id}]') + if component.origin: + content_parts.append(await OfficialAccountMessageConverter.yiri2target(component.origin)) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + content_parts.append(await OfficialAccountMessageConverter.yiri2target(node.message_chain)) + else: + content_parts.append(str(component)) + return '\n'.join(part for part in content_parts if part) + + @staticmethod + async def target2yiri(event: OAEvent) -> platform_message.MessageChain: + timestamp = event.timestamp or int(datetime.datetime.now().timestamp()) + components: list[platform_message.MessageComponent] = [ + platform_message.Source( + id=event.message_id or f'{event.user_id}:{timestamp}', + time=datetime.datetime.fromtimestamp(timestamp), + ) + ] + + if event.type == 'text' and event.message: + components.append(platform_message.Plain(text=event.message)) + elif event.type == 'image': + image_kwargs = {} + if event.picurl: + image_kwargs['url'] = event.picurl + if event.media_id: + image_kwargs['image_id'] = event.media_id + if image_kwargs: + components.append(platform_message.Image(**image_kwargs)) + elif event.type == 'voice': + if event.media_id: + components.append(platform_message.Voice(voice_id=event.media_id)) + else: + components.append(platform_message.Unknown(text='[officialaccount voice message without media id]')) + elif event.type == 'event': + components.append(platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]')) + else: + components.append(platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]')) + + return platform_message.MessageChain(components) diff --git a/src/langbot/pkg/platform/adapters/officialaccount/officialaccount.png b/src/langbot/pkg/platform/adapters/officialaccount/officialaccount.png new file mode 100644 index 000000000..24746e1d6 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/officialaccount/officialaccount.png differ diff --git a/src/langbot/pkg/platform/adapters/officialaccount/platform_api.py b/src/langbot/pkg/platform/adapters/officialaccount/platform_api.py new file mode 100644 index 000000000..80864a88c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/platform_api.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import typing + + +async def get_mode(bot, params: dict) -> dict: + return { + 'mode': params.get('mode') or ('passive' if hasattr(bot, 'msg_queue') else 'drop'), + 'longer_response': hasattr(bot, 'msg_queue'), + } + + +async def get_cached_response_status(bot, params: dict) -> dict: + message_id = params.get('message_id') or params.get('msg_id') + user_id = params.get('user_id') or params.get('from_user') + if hasattr(bot, 'generated_content'): + return {'pending': str(message_id) in {str(key) for key in bot.generated_content}} + if hasattr(bot, 'msg_queue'): + queue = bot.msg_queue.get(str(user_id), []) if user_id is not None else [] + return {'queued': len(queue)} + return {'pending': False} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'get_mode': get_mode, + 'get_cached_response_status': get_cached_response_status, +} diff --git a/src/langbot/pkg/platform/adapters/officialaccount/types.py b/src/langbot/pkg/platform/adapters/officialaccount/types.py new file mode 100644 index 000000000..d120f6b13 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/officialaccount/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'officialaccount-eba' diff --git a/src/langbot/pkg/platform/adapters/qqofficial/__init__.py b/src/langbot/pkg/platform/adapters/qqofficial/__init__.py new file mode 100644 index 000000000..d87bfc1dc --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/__init__.py @@ -0,0 +1,6 @@ +"""QQ Official API EBA platform adapter.""" + +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter + +__all__ = ['QQOfficialAdapter'] + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py new file mode 100644 index 000000000..47831a389 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import asyncio +import time +import traceback +import typing + +import pydantic + +from langbot.libs.qq_official_api.api import QQOfficialClient +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin +from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError +from langbot.pkg.platform.adapters.qqofficial.event_converter import ( + BOT_INVITED_EVENT_TYPES, + BOT_REMOVED_EVENT_TYPES, + MEMBER_JOINED_EVENT_TYPES, + MEMBER_LEFT_EVENT_TYPES, + MESSAGE_EVENT_TYPES, + REACTION_ADD_EVENT_TYPES, + REACTION_REMOVE_EVENT_TYPES, + QQOfficialEventConverter, +) +from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter +from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: typing.Any = pydantic.Field(exclude=True) + + message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() + event_converter: QQOfficialEventConverter = QQOfficialEventConverter() + + config: dict + bot_uuid: str | None = None + enable_webhook: bool = False + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + _group_cache: dict[str, platform_entities.UserGroup] = {} + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} + _stream_ctx: dict[str, dict] = {} + _stream_ctx_ts: dict[str, float] = {} + _fallback_text: dict[str, str] = {} + _fallback_text_ts: dict[str, float] = {} + _ws_task: asyncio.Task | None = None + + _STREAM_CTX_TTL = 300 + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = ['appid', 'secret', 'token'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise Exception(f'QQOfficial EBA adapter missing config: {missing_keys}') + + enable_webhook = config.get('enable-webhook', config.get('enable_webhook', False)) + bot = QQOfficialClient( + app_id=config['appid'], + secret=config['secret'], + token=config['token'], + logger=logger, + unified_mode=enable_webhook, + ) + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config['appid'], + bot_uuid=None, + enable_webhook=enable_webhook, + listeners={}, + _message_cache={}, + _user_cache={}, + _group_cache={}, + _member_cache={}, + _stream_ctx={}, + _stream_ctx_ts={}, + _fallback_text={}, + _fallback_text_ts={}, + _ws_task=None, + ) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + raw = await self._send_content_list( + str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message) + ) + return platform_events.MessageResult(raw={'results': raw}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + source = await QQOfficialEventConverter.yiri2target(message_source) + if not isinstance(source, QQOfficialEvent): + raise ValueError('QQOfficial reply_message requires a QQOfficialEvent source object') + target_type, target_id = self._reply_target(source) + raw = await self._send_content_list( + target_type, + target_id, + await QQOfficialMessageConverter.yiri2target(message), + msg_id=source.d_id, + ) + return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw}) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self, dict(params or {})) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + if self.enable_webhook: + await self.logger.info('QQ Official EBA adapter running in unified webhook mode') + while True: + await asyncio.sleep(1) + else: + await self._run_websocket() + + async def kill(self) -> bool: + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + self._ws_task = None + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + async def is_stream_output_supported(self) -> bool: + return bool(self.config.get('enable-stream-reply') or self.config.get('enable_stream_reply')) + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + source = event.source_platform_object + if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE': + return False + self._stream_ctx[message_id] = { + 'user_openid': source.user_openid, + 'msg_id': source.d_id, + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + self._stream_ctx_ts[message_id] = time.time() + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: dict, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + await self._cleanup_stale_streams() + chunk_text = '\n\n'.join( + component.text for component in message if isinstance(component, platform_message.Plain) + ) + message_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + if not message_id or message_id not in self._stream_ctx: + if chunk_text: + self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text + self._fallback_text_ts[message_id] = time.time() + if is_final: + full_text = self._fallback_text.pop(message_id, '') + if full_text: + await self.reply_message( + message_source, + platform_message.MessageChain([platform_message.Plain(text=full_text)]), + quote_origin, + ) + return + + ctx = self._stream_ctx[message_id] + if chunk_text: + ctx['accumulated_text'] += chunk_text + if not ctx['session_started']: + if not ctx['accumulated_text']: + return + ctx['session_started'] = True + + content_to_send = ctx['accumulated_text'][ctx['sent_length'] :] + if not content_to_send and not is_final: + return + now = time.time() + if not is_final and (now - ctx['last_update_ts']) < 0.5: + return + ctx['last_update_ts'] = now + + resp = await self.bot.send_stream_msg( + user_openid=ctx['user_openid'], + content=content_to_send, + event_id=ctx['msg_id'], + msg_id=ctx['msg_id'], + msg_seq=ctx['msg_seq'], + index=ctx['index'], + stream_msg_id=ctx['stream_msg_id'], + input_state=10 if is_final else 1, + ) + if isinstance(resp, dict) and resp.get('id'): + ctx['stream_msg_id'] = resp['id'] + ctx['sent_length'] = len(ctx['accumulated_text']) + ctx['index'] += 1 + if is_final: + self._stream_ctx.pop(message_id, None) + self._stream_ctx_ts.pop(message_id, None) + + def _register_native_handlers(self): + for event_type in ( + MESSAGE_EVENT_TYPES + | REACTION_ADD_EVENT_TYPES + | REACTION_REMOVE_EVENT_TYPES + | MEMBER_JOINED_EVENT_TYPES + | MEMBER_LEFT_EVENT_TYPES + | BOT_INVITED_EVENT_TYPES + | BOT_REMOVED_EVENT_TYPES + ): + self.bot.on_message(event_type)(self._handle_native_event) + + async def _handle_native_event(self, event: QQOfficialEvent): + self.bot_account_id = self.config.get('appid', self.bot_account_id) + try: + if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event) + if legacy_event and type(legacy_event) in self.listeners: + await self.listeners[type(legacy_event)](legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group + self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember( + user=event.sender, + group_id=event.group.id, + role=platform_entities.MemberRole.MEMBER, + display_name=event.sender.nickname, + ) + + async def _run_websocket(self): + await self.logger.info('QQ Official EBA adapter starting in WebSocket mode') + + async def on_ready(): + await self.logger.info('QQ Official WebSocket connected and ready') + + async def on_event(event_type: str, event_data: dict): + if not isinstance(event_data, dict): + await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}') + return + if event_type not in MESSAGE_EVENT_TYPES: + await self._handle_native_event(QQOfficialEvent({'t': event_type, **event_data})) + return + payload = {'t': event_type, 'd': event_data} + message_data = await self.bot.get_message(payload) + if message_data: + await self.bot._handle_message(QQOfficialEvent.from_payload(message_data)) + + async def on_error(error: Exception): + await self.logger.error(f'QQ Official WebSocket error: {error}') + + self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error)) + try: + await self._ws_task + except asyncio.CancelledError: + pass + + @staticmethod + def _reply_target(event: QQOfficialEvent) -> tuple[str, str]: + if event.t == 'C2C_MESSAGE_CREATE': + return 'person', event.user_openid + if event.t == 'GROUP_AT_MESSAGE_CREATE': + return 'group', event.group_openid + if event.t == 'AT_MESSAGE_CREATE': + return 'channel', event.channel_id + if event.t == 'DIRECT_MESSAGE_CREATE': + return 'channel_private', event.guild_id + raise NotSupportedError(f'reply_message:{event.t or "unknown_event"}') + + async def _send_content_list( + self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None + ) -> list[dict]: + target_type = self._normalize_target_type(target_type) + results: list[dict] = [] + for content in content_list: + content_type = content.get('type', 'text') + if target_type == 'channel': + if content_type == 'text': + raw = await self.bot.send_channle_group_text_msg(target_id, content.get('content', ''), msg_id) + results.append({'type': content_type, 'raw': raw}) + continue + if target_type == 'channel_private': + if content_type == 'text': + raw = await self.bot.send_channle_private_text_msg(target_id, content.get('content', ''), msg_id) + results.append({'type': content_type, 'raw': raw}) + continue + if content_type == 'text': + if target_type == 'c2c': + raw = await self.bot.send_private_text_msg(target_id, content.get('content', ''), msg_id) + elif target_type == 'group': + raw = await self.bot.send_group_text_msg(target_id, content.get('content', ''), msg_id) + else: + raise NotSupportedError(f'send_message:{target_type}') + results.append({'type': content_type, 'raw': raw}) + elif content_type == 'image': + raw = await self.bot.send_image_msg( + target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id + ) + results.append({'type': content_type, 'raw': raw}) + elif content_type == 'voice': + raw = await self.bot.send_voice_msg( + target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id + ) + results.append({'type': content_type, 'raw': raw}) + elif content_type == 'file': + raw = await self.bot.send_file_msg( + target_type, + target_id, + file_url=content.get('url'), + file_data=content.get('base64'), + file_name=content.get('name', 'file'), + msg_id=msg_id, + ) + results.append({'type': content_type, 'raw': raw}) + return results + + @staticmethod + def _normalize_target_type(target_type: str) -> str: + if target_type in {'person', 'private', 'friend', 'c2c'}: + return 'c2c' + if target_type in {'group', 'group_openid'}: + return 'group' + if target_type in {'channel', 'guild'}: + return 'channel' + if target_type in {'channel_private', 'direct', 'dm'}: + return 'channel_private' + return target_type + + async def _cleanup_stale_streams(self): + now = time.time() + for message_id in [key for key, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]: + self._stream_ctx.pop(message_id, None) + self._stream_ctx_ts.pop(message_id, None) + for message_id in [key for key, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]: + self._fallback_text.pop(message_id, None) + self._fallback_text_ts.pop(message_id, None) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py b/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py new file mode 100644 index 000000000..d71847672 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import typing + +from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class QQOfficialAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + user = self._user_cache.get(str(user_id)) + if user is None: + raise NotSupportedError('get_user_info:not_cached') + return user + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + group = self._group_cache.get(str(group_id)) + if group is None: + raise NotSupportedError('get_group_info:not_cached') + return group + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + member = self._member_cache.get((str(group_id), str(user_id))) + if member is None: + raise NotSupportedError('get_group_member_info:not_cached') + return member + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raise NotSupportedError('forward_message') + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0): + raise NotSupportedError('mute_member') + + async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]): + raise NotSupportedError('unmute_member') + + async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]): + raise NotSupportedError('kick_member') + + async def leave_group(self, group_id: typing.Union[int, str]): + raise NotSupportedError('leave_group') + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/errors.py b/src/langbot/pkg/platform/adapters/qqofficial/errors.py new file mode 100644 index 000000000..72483b096 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/errors.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +try: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +except ModuleNotFoundError: + + class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py new file mode 100644 index 000000000..66b550418 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import datetime +import time +import typing + +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter +from langbot.pkg.platform.adapters.qqofficial.types import ADAPTER_NAME +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +MESSAGE_EVENT_TYPES = { + 'C2C_MESSAGE_CREATE', + 'DIRECT_MESSAGE_CREATE', + 'GROUP_AT_MESSAGE_CREATE', + 'AT_MESSAGE_CREATE', +} +REACTION_ADD_EVENT_TYPES = {'MESSAGE_REACTION_ADD'} +REACTION_REMOVE_EVENT_TYPES = {'MESSAGE_REACTION_REMOVE'} +MEMBER_JOINED_EVENT_TYPES = {'GUILD_MEMBER_ADD', 'GROUP_MEMBER_ADD'} +MEMBER_LEFT_EVENT_TYPES = {'GUILD_MEMBER_REMOVE', 'GROUP_MEMBER_REMOVE'} +BOT_INVITED_EVENT_TYPES = {'GUILD_CREATE', 'GROUP_ADD_ROBOT'} +BOT_REMOVED_EVENT_TYPES = {'GUILD_DELETE', 'GROUP_DEL_ROBOT'} + + +class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> typing.Any: + return getattr(event, 'source_platform_object', None) + + async def target2legacy( + self, event: QQOfficialEvent + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await self.target2yiri(event) + if not isinstance(eba_event, platform_events.MessageReceivedEvent): + return None + if eba_event.chat_type == platform_entities.ChatType.PRIVATE: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=eba_event.sender.id, + nickname=eba_event.sender.nickname, + remark='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=eba_event.sender.id, + member_name=eba_event.sender.nickname, + permission='MEMBER', + group=platform_entities.Group( + id=eba_event.group.id if eba_event.group else eba_event.chat_id, + name=eba_event.group.name if eba_event.group else '', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + + async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event: + if event.t in MESSAGE_EVENT_TYPES: + return await self.message_to_eba(event) + if event.t in REACTION_ADD_EVENT_TYPES | REACTION_REMOVE_EVENT_TYPES: + return self.reaction_to_eba(event, event.t in REACTION_ADD_EVENT_TYPES) + if event.t in MEMBER_JOINED_EVENT_TYPES: + return self.member_joined_to_eba(event) + if event.t in MEMBER_LEFT_EVENT_TYPES: + return self.member_left_to_eba(event) + if event.t in BOT_INVITED_EVENT_TYPES: + return self.bot_invited_to_eba(event) + if event.t in BOT_REMOVED_EVENT_TYPES: + return self.bot_removed_to_eba(event) + return self.platform_specific(event, f'qqofficial.{event.t or "unknown"}') + + async def message_to_eba(self, event: QQOfficialEvent) -> platform_events.MessageReceivedEvent: + timestamp = _timestamp_value(event.timestamp) + sender = platform_entities.User( + id=self._sender_id(event), + nickname=event.username or self._sender_id(event), + ) + chat_type = platform_entities.ChatType.PRIVATE + chat_id = self._private_chat_id(event) + group = None + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + chat_type = platform_entities.ChatType.GROUP + chat_id = event.channel_id if event.t == 'AT_MESSAGE_CREATE' else event.group_openid + chat_id = chat_id or event.group_openid or event.channel_id or '' + group = platform_entities.UserGroup(id=str(chat_id), name=str(chat_id)) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.d_id or event.id or '', + message_chain=await QQOfficialMessageConverter.target2yiri(event), + sender=sender, + chat_type=chat_type, + chat_id=chat_id or '', + group=group, + timestamp=timestamp, + source_platform_object=event, + ) + + def reaction_to_eba(self, event: QQOfficialEvent, is_add: bool) -> platform_events.MessageReactionEvent: + chat_type, chat_id, group = self._chat_from_event(event) + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name=ADAPTER_NAME, + message_id=_event_value(event, 'message_id', 'msg_id', 'target_id', 'id'), + user=platform_entities.User( + id=_event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id'), + nickname=_event_value(event, 'username', 'nick', 'nickname', 'user_openid', 'openid'), + ), + reaction=_event_value(event, 'emoji', 'emoji_id', 'reaction', 'reaction_id', 'type'), + is_add=is_add, + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def member_joined_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberJoinedEvent: + group = self._group_from_event(event) + member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id') + inviter_id = _event_value(event, 'inviter_openid', 'operator_openid', 'op_user_id') + return platform_events.MemberJoinedEvent( + type='group.member_joined', + adapter_name=ADAPTER_NAME, + group=group, + member=member, + inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def member_left_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberLeftEvent: + group = self._group_from_event(event) + member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id') + operator_id = _event_value(event, 'operator_openid', 'op_user_id', 'user_openid') + return platform_events.MemberLeftEvent( + type='group.member_left', + adapter_name=ADAPTER_NAME, + group=group, + member=member, + operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None, + is_kicked=event.t in {'GROUP_MEMBER_REMOVE', 'GUILD_MEMBER_REMOVE'}, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def bot_invited_to_eba(self, event: QQOfficialEvent) -> platform_events.BotInvitedToGroupEvent: + group = self._group_from_event(event) + inviter_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid') + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name=ADAPTER_NAME, + group=group, + inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None, + request_id=_event_value(event, 'event_id', 'id'), + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def bot_removed_to_eba(self, event: QQOfficialEvent) -> platform_events.BotRemovedFromGroupEvent: + group = self._group_from_event(event) + operator_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid') + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name=ADAPTER_NAME, + group=group, + operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def _chat_from_event( + self, event: QQOfficialEvent + ) -> tuple[platform_entities.ChatType, str, platform_entities.UserGroup | None]: + group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id') + if group_id: + return platform_entities.ChatType.GROUP, group_id, self._group_from_event(event) + private_id = _event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id') + return platform_entities.ChatType.PRIVATE, private_id, None + + @staticmethod + def _group_from_event(event: QQOfficialEvent) -> platform_entities.UserGroup: + group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id') + group_name = _event_value(event, 'group_name', 'guild_name', 'channel_name', 'name') or group_id + return platform_entities.UserGroup(id=group_id, name=group_name) + + @staticmethod + def _user_from_event(event: QQOfficialEvent, *keys: str) -> platform_entities.User: + user_id = _event_value(event, *keys) + nickname = _event_value(event, 'username', 'nick', 'nickname') or user_id + return platform_entities.User(id=user_id, nickname=nickname) + + @staticmethod + def _sender_id(event: QQOfficialEvent) -> str: + member_openid = event.member_openid or event.get('member_openid', '') + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + return member_openid or event.user_openid or event.d_author_id or '' + return event.user_openid or member_openid or event.d_author_id or event.guild_id or event.group_openid or '' + + @staticmethod + def _private_chat_id(event: QQOfficialEvent) -> str: + if event.t == 'DIRECT_MESSAGE_CREATE': + return event.guild_id or event.user_openid or '' + return event.user_openid or event.guild_id or '' + + @staticmethod + def platform_specific(event: QQOfficialEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + +def _event_value(event: QQOfficialEvent, *keys: str) -> str: + nested = event.get('d') if isinstance(event.get('d'), dict) else {} + for key in keys: + value = event.get(key) + if value in (None, '', {}): + value = nested.get(key) + if value not in (None, '', {}): + return str(value) + return '' + + +def _timestamp_value(value: str) -> float: + if not value: + return time.time() + try: + return float(datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z').timestamp()) + except (TypeError, ValueError): + return time.time() diff --git a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml new file mode 100644 index 000000000..66fdd67b4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml @@ -0,0 +1,124 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: qqofficial-eba + label: + en_US: QQ Official API + zh_Hans: QQ 官方 API + zh_Hant: QQ 官方 API + description: + en_US: QQ Official API adapter with event-driven orchestration support, using Webhook or WebSocket mode. + zh_Hans: QQ 官方 API 适配器,支持事件驱动编排、Webhook 和 WebSocket 两种连接模式。 + zh_Hant: QQ 官方 API 適配器,支援事件驅動編排、Webhook 和 WebSocket 兩種連線模式。 + icon: qqofficial.svg + +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/qqofficial + en: https://link.langbot.app/en/platforms/qqofficial + ja: https://link.langbot.app/ja/platforms/qqofficial + config: + - name: appid + label: + en_US: App ID + zh_Hans: 应用 ID + zh_Hant: 應用 ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 密钥 + zh_Hant: 密鑰 + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + type: string + required: true + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用 Webhook 模式 + zh_Hant: 啟用 Webhook 模式 + description: + en_US: If enabled, the bot receives messages through LangBot's unified webhook endpoint. Otherwise it uses the QQ WebSocket gateway. + zh_Hans: 启用后,机器人通过 LangBot 统一 Webhook 接收消息;否则使用 QQ WebSocket 网关。 + zh_Hant: 啟用後,機器人透過 LangBot 統一 Webhook 接收訊息;否則使用 QQ WebSocket 閘道。 + type: boolean + required: true + default: false + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用流式回复模式 + zh_Hant: 啟用串流回覆模式 + description: + en_US: If enabled, the adapter uses QQ Official streaming replies for C2C private messages. + zh_Hans: 启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。 + zh_Hant: 啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。 + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your QQ Official API webhook configuration. + zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中。 + zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中。 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + + supported_events: + - message.received + - message.reaction + - group.member_joined + - group.member_left + - bot.invited_to_group + - bot.removed_from_group + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - get_group_info + - get_group_member_list + - get_group_member_info + - call_platform_api + + platform_specific_apis: + - action: check_access_token + description: { en_US: "Check whether the cached QQ Official access token is usable", zh_Hans: "检查当前缓存的 QQ 官方 access token 是否可用" } + - action: refresh_access_token + description: { en_US: "Force refresh the QQ Official access token", zh_Hans: "强制刷新 QQ 官方 access token" } + - action: get_gateway_url + description: { en_US: "Return the QQ Official WebSocket gateway URL", zh_Hans: "获取 QQ 官方 WebSocket 网关地址" } + - action: get_mode + description: { en_US: "Return adapter receive and stream-reply mode", zh_Hans: "返回适配器接收模式和流式回复模式" } + +execution: + python: + path: ./adapter.py + attr: QQOfficialAdapter diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py new file mode 100644 index 000000000..16b43ab90 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import datetime +import re + +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from langbot.pkg.utils import image +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +def _is_base64_data(value: str) -> bool: + if not value: + return False + if value.startswith('data:'): + return True + if value.startswith(('http://', 'https://', '/', './', '../')): + return False + return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value)) + + +class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + content_list: list[dict] = [] + for component in message_chain: + if isinstance(component, platform_message.Source): + continue + if isinstance(component, platform_message.Plain): + content_list.append({'type': 'text', 'content': component.text}) + elif isinstance(component, platform_message.At): + content_list.append({'type': 'text', 'content': f'@{component.display or component.target}'}) + elif isinstance(component, platform_message.AtAll): + content_list.append({'type': 'text', 'content': '@all'}) + elif isinstance(component, platform_message.Image): + content_list.append(QQOfficialMessageConverter._media_payload(component, 'image')) + elif isinstance(component, platform_message.Voice): + content_list.append(QQOfficialMessageConverter._media_payload(component, 'voice')) + elif isinstance(component, platform_message.File): + payload = QQOfficialMessageConverter._media_payload(component, 'file') + payload['name'] = component.name or component.id or 'file' + content_list.append(payload) + elif isinstance(component, platform_message.Quote): + if component.id is not None: + content_list.append({'type': 'text', 'content': f'[Quote {component.id}]'}) + if component.origin: + content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin)) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + content_list.extend(await QQOfficialMessageConverter.yiri2target(node.message_chain)) + else: + text = str(component) + if text: + content_list.append({'type': 'text', 'content': text}) + return content_list + + @staticmethod + def _media_payload(component, content_type: str) -> dict: + url = getattr(component, 'url', '') or getattr(component, 'path', '') or None + b64 = getattr(component, 'base64', '') or None + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + return {'type': content_type, 'url': url, 'base64': b64} + + @staticmethod + async def target2yiri(event: QQOfficialEvent) -> platform_message.MessageChain: + components: list[platform_message.MessageComponent] = [ + platform_message.Source(id=event.d_id or event.id or '', time=_parse_timestamp(event.timestamp)), + ] + + if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + components.append(platform_message.At(target='justbot')) + + if event.attachments: + try: + base64_url = await image.get_qq_official_image_base64( + pic_url=event.attachments, + content_type=event.content_type, + ) + components.append(platform_message.Image(base64=base64_url)) + except Exception: + components.append(platform_message.Image(url=event.attachments)) + + if event.content: + components.append(platform_message.Plain(text=event.content)) + + if len(components) == 1 or ( + len(components) == 2 and isinstance(components[1], platform_message.At) + ): + components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]')) + + return platform_message.MessageChain(components) + + +def _parse_timestamp(value: str) -> datetime.datetime: + if not value: + return datetime.datetime.now() + try: + return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z') + except (TypeError, ValueError): + return datetime.datetime.now() + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py b/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py new file mode 100644 index 000000000..b205cc774 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import typing + + +async def check_access_token(adapter, params: dict) -> dict: + ok = await adapter.bot.check_access_token() + return {'ok': bool(ok), 'expires_at': getattr(adapter.bot, 'access_token_expiry_time', None)} + + +async def refresh_access_token(adapter, params: dict) -> dict: + adapter.bot.access_token = '' + adapter.bot.access_token_expiry_time = None + await adapter.bot.get_access_token() + return {'ok': bool(adapter.bot.access_token), 'expires_at': adapter.bot.access_token_expiry_time} + + +async def get_gateway_url(adapter, params: dict) -> dict: + url = await adapter.bot.get_gateway_url() + return {'url': url} + + +async def get_mode(adapter, params: dict) -> dict: + return { + 'webhook': bool(adapter.enable_webhook), + 'stream_reply': bool(adapter.config.get('enable-stream-reply') or adapter.config.get('enable_stream_reply')), + 'bot_account_id': adapter.bot_account_id, + } + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'check_access_token': check_access_token, + 'refresh_access_token': refresh_access_token, + 'get_gateway_url': get_gateway_url, + 'get_mode': get_mode, +} + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/qqofficial.svg b/src/langbot/pkg/platform/adapters/qqofficial/qqofficial.svg new file mode 100644 index 000000000..8b956b9e1 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/qqofficial.svg @@ -0,0 +1,2 @@ + + diff --git a/src/langbot/pkg/platform/adapters/qqofficial/types.py b/src/langbot/pkg/platform/adapters/qqofficial/types.py new file mode 100644 index 000000000..e5fb8f895 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/types.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pydantic + +ADAPTER_NAME = 'qqofficial-eba' + + +class QQOfficialAdapterConfig(pydantic.BaseModel): + appid: str + secret: str + token: str + enable_webhook: bool = False + enable_stream_reply: bool = False + diff --git a/src/langbot/pkg/platform/adapters/slack/__init__.py b/src/langbot/pkg/platform/adapters/slack/__init__.py new file mode 100644 index 000000000..237988069 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.slack.adapter import SlackAdapter + +__all__ = ['SlackAdapter'] diff --git a/src/langbot/pkg/platform/adapters/slack/adapter.py b/src/langbot/pkg/platform/adapters/slack/adapter.py new file mode 100644 index 000000000..e751a5a6f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/adapter.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import asyncio +import traceback +import typing + +import pydantic + +from langbot.libs.slack_api.api import SlackClient +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.api_impl import SlackAPIMixin +from langbot.pkg.platform.adapters.slack.errors import NotSupportedError +from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter +from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter +from langbot.pkg.platform.adapters.slack.platform_api import PLATFORM_API_MAP +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: typing.Any = pydantic.Field(exclude=True) + + message_converter: SlackMessageConverter = SlackMessageConverter() + event_converter: SlackEventConverter = SlackEventConverter() + + config: dict + bot_uuid: str | None = None + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + _group_cache: dict[str, platform_entities.UserGroup] = {} + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = ['bot_token', 'signing_secret'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise Exception(f'Slack EBA adapter missing config: {missing_keys}') + + bot = SlackClient( + bot_token=config['bot_token'], + signing_secret=config['signing_secret'], + logger=logger, + unified_mode=True, + ) + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config.get('bot_user_id', ''), + bot_uuid=None, + listeners={}, + _message_cache={}, + _user_cache={}, + _group_cache={}, + _member_cache={}, + ) + self.event_converter = SlackEventConverter(config['bot_token']) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'get_group_info', + 'get_group_list', + 'get_group_member_list', + 'get_group_member_info', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + content = await SlackMessageConverter.yiri2target(message) + raw = await self._send_text(str(target_type), str(target_id), content) + return platform_events.MessageResult(raw=raw) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + source = await SlackEventConverter.yiri2target(message_source) + if not isinstance(source, SlackEvent): + raise ValueError('Slack reply_message requires a SlackEvent source object') + target_type = 'channel' if source.type == 'channel' else 'person' + target_id = source.channel_id if source.type == 'channel' else source.user_id + raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message)) + return platform_events.MessageResult(message_id=source.message_id, raw=raw) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self, dict(params or {})) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + await self.logger.info('Slack EBA adapter running in unified webhook mode') + while True: + await asyncio.sleep(1) + + async def kill(self) -> bool: + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + def _register_native_handlers(self): + for msg_type in ('im', 'channel'): + self.bot.on_message(msg_type)(self._handle_native_event) + + async def _handle_native_event(self, event: SlackEvent): + try: + if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event) + if legacy_event and type(legacy_event) in self.listeners: + await self.listeners[type(legacy_event)](legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in slack native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group + self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember( + user=event.sender, + group_id=event.group.id, + role=platform_entities.MemberRole.MEMBER, + display_name=event.sender.nickname, + ) + + async def _send_text(self, target_type: str, target_id: str, content: str) -> dict: + target_type = self._normalize_target_type(target_type) + if target_type == 'person': + raw = await self.bot.send_message_to_one(content, target_id) + elif target_type == 'channel': + raw = await self.bot.send_message_to_channel(content, target_id) + else: + raise NotSupportedError(f'send_message:{target_type}') + return {'target_type': target_type, 'target_id': target_id, 'raw': raw} + + @staticmethod + def _normalize_target_type(target_type: str) -> str: + if target_type in {'person', 'private', 'friend', 'im', 'dm'}: + return 'person' + if target_type in {'group', 'channel'}: + return 'channel' + return target_type diff --git a/src/langbot/pkg/platform/adapters/slack/api_impl.py b/src/langbot/pkg/platform/adapters/slack/api_impl.py new file mode 100644 index 000000000..46bc382a9 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/api_impl.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import typing + +from langbot.pkg.platform.adapters.slack.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class SlackAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + user = self._user_cache.get(str(user_id)) + if user is None: + raise NotSupportedError('get_user_info:not_cached') + return user + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + group = self._group_cache.get(str(group_id)) + if group is None: + raise NotSupportedError('get_group_info:not_cached') + return group + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + return list(self._group_cache.values()) + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + member = self._member_cache.get((str(group_id), str(user_id))) + if member is None: + raise NotSupportedError('get_group_member_info:not_cached') + return member + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raise NotSupportedError('forward_message') + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') diff --git a/src/langbot/pkg/platform/adapters/slack/errors.py b/src/langbot/pkg/platform/adapters/slack/errors.py new file mode 100644 index 000000000..b56f459ae --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/errors.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +try: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +except ModuleNotFoundError: + + class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name diff --git a/src/langbot/pkg/platform/adapters/slack/event_converter.py b/src/langbot/pkg/platform/adapters/slack/event_converter.py new file mode 100644 index 000000000..531f4082b --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/event_converter.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import time +import typing + +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter +from langbot.pkg.platform.adapters.slack.types import ADAPTER_NAME +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self, bot_token: str = ''): + self.bot_token = bot_token + + @staticmethod + async def yiri2target(event: platform_events.Event) -> typing.Any: + return getattr(event, 'source_platform_object', None) + + async def target2legacy(self, event: SlackEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await self.target2yiri(event) + if not isinstance(eba_event, platform_events.MessageReceivedEvent): + return None + if eba_event.chat_type == platform_entities.ChatType.PRIVATE: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=eba_event.sender.id, + nickname=eba_event.sender.nickname, + remark='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=eba_event.sender.id, + member_name=eba_event.sender.nickname, + permission='MEMBER', + group=platform_entities.Group( + id=eba_event.group.id if eba_event.group else eba_event.chat_id, + name=eba_event.group.name if eba_event.group else '', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + + async def target2yiri(self, event: SlackEvent) -> platform_events.Event: + if event.type in {'im', 'channel'}: + return await self.message_to_eba(event) + return self.platform_specific(event, f'slack.{event.type or "unknown"}') + + async def message_to_eba(self, event: SlackEvent) -> platform_events.MessageReceivedEvent: + sender_id = event.user_id or '' + sender = platform_entities.User( + id=sender_id, + nickname=event.sender_name or sender_id, + ) + chat_type = platform_entities.ChatType.PRIVATE + chat_id = sender_id + group = None + if event.type == 'channel': + chat_type = platform_entities.ChatType.GROUP + chat_id = event.channel_id or '' + group = platform_entities.UserGroup(id=str(chat_id), name=str(chat_id)) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.message_id or event.get('event', {}).get('event_ts') or '', + message_chain=await SlackMessageConverter.target2yiri(event, self.bot_token), + sender=sender, + chat_type=chat_type, + chat_id=chat_id or '', + group=group, + timestamp=_timestamp_value(event), + source_platform_object=event, + ) + + @staticmethod + def platform_specific(event: SlackEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=_timestamp_value(event), + source_platform_object=event, + ) + + +def _timestamp_value(event: SlackEvent) -> float: + raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts') + try: + return float(raw_ts) + except (TypeError, ValueError): + return time.time() diff --git a/src/langbot/pkg/platform/adapters/slack/manifest.yaml b/src/langbot/pkg/platform/adapters/slack/manifest.yaml new file mode 100644 index 000000000..4e72eae7e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/manifest.yaml @@ -0,0 +1,81 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: slack-eba + label: + en_US: Slack + zh_Hans: Slack + zh_Hant: Slack + description: + en_US: Slack adapter with event-driven orchestration support, using LangBot's unified webhook endpoint. + zh_Hans: Slack 适配器,支持事件驱动编排,通过 LangBot 统一 Webhook 接收 Slack 事件订阅消息。 + zh_Hant: Slack 適配器,支援事件驅動編排,透過 LangBot 統一 Webhook 接收 Slack 事件訂閱訊息。 + icon: slack.png + +spec: + categories: + - popular + - global + help_links: + zh: https://link.langbot.app/zh/platforms/slack + en: https://link.langbot.app/en/platforms/slack + ja: https://link.langbot.app/ja/platforms/slack + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your Slack app's event subscription configuration. + zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中。 + zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中。 + type: webhook-url + required: false + default: "" + - name: bot_token + label: + en_US: Bot Token + zh_Hans: 机器人令牌 + zh_Hant: 機器人令牌 + type: string + required: true + default: "" + - name: signing_secret + label: + en_US: Signing Secret + zh_Hans: 签名密钥 + zh_Hant: 簽名密鑰 + type: string + required: true + default: "" + + supported_events: + - message.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - get_group_info + - get_group_list + - get_group_member_list + - get_group_member_info + - call_platform_api + + platform_specific_apis: + - action: get_mode + description: { en_US: "Return adapter webhook mode", zh_Hans: "返回适配器 Webhook 模式" } + - action: auth_test + description: { en_US: "Call Slack auth.test with the configured bot token", zh_Hans: "使用配置的机器人令牌调用 Slack auth.test" } + +execution: + python: + path: ./adapter.py + attr: SlackAdapter diff --git a/src/langbot/pkg/platform/adapters/slack/message_converter.py b/src/langbot/pkg/platform/adapters/slack/message_converter.py new file mode 100644 index 000000000..3702c5e59 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/message_converter.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import datetime + +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.utils import image +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> str: + parts: list[str] = [] + for component in message_chain: + if isinstance(component, platform_message.Source): + continue + if isinstance(component, platform_message.Plain): + parts.append(component.text) + elif isinstance(component, platform_message.At): + parts.append(f'<@{component.target}>') + elif isinstance(component, platform_message.AtAll): + parts.append('') + elif isinstance(component, platform_message.Image): + parts.append(component.url or '[Image]') + elif isinstance(component, platform_message.Voice): + parts.append(component.url or '[Voice]') + elif isinstance(component, platform_message.File): + parts.append(component.url or component.name or component.id or '[File]') + elif isinstance(component, platform_message.Quote): + if component.id is not None: + parts.append(f'[Quote {component.id}]') + if component.origin: + parts.append(await SlackMessageConverter.yiri2target(component.origin)) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + parts.append(await SlackMessageConverter.yiri2target(node.message_chain)) + else: + text = str(component) + if text: + parts.append(text) + return '\n'.join(part for part in parts if part) + + @staticmethod + async def target2yiri(event: SlackEvent, bot_token: str = '') -> platform_message.MessageChain: + message_id = event.message_id or event.get('event', {}).get('event_ts') or '' + components: list[platform_message.MessageComponent] = [ + platform_message.Source( + id=message_id, + time=_event_datetime(event), + ) + ] + + if event.type == 'channel': + components.append(platform_message.At(target='SlackBot')) + + if event.pic_url: + try: + components.append(platform_message.Image(base64=await image.get_slack_image_to_base64(event.pic_url, bot_token))) + except Exception: + components.append(platform_message.Image(url=event.pic_url)) + + if event.text: + components.append(platform_message.Plain(text=event.text)) + + if len(components) == 1 or ( + len(components) == 2 and isinstance(components[1], platform_message.At) + ): + components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]')) + + return platform_message.MessageChain(components) + + +def _event_datetime(event: SlackEvent) -> datetime.datetime: + raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts') + try: + return datetime.datetime.fromtimestamp(float(raw_ts)) + except (TypeError, ValueError): + return datetime.datetime.now() diff --git a/src/langbot/pkg/platform/adapters/slack/platform_api.py b/src/langbot/pkg/platform/adapters/slack/platform_api.py new file mode 100644 index 000000000..e1a80bf04 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/platform_api.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import typing + + +async def get_mode(adapter, params: dict) -> dict: + return { + 'webhook': True, + 'bot_account_id': adapter.bot_account_id, + } + + +async def auth_test(adapter, params: dict) -> dict: + response = await adapter.bot.client.auth_test() + if hasattr(response, 'data'): + return dict(response.data) + return dict(response) + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'get_mode': get_mode, + 'auth_test': auth_test, +} diff --git a/src/langbot/pkg/platform/adapters/slack/slack.png b/src/langbot/pkg/platform/adapters/slack/slack.png new file mode 100644 index 000000000..91d92fe2e Binary files /dev/null and b/src/langbot/pkg/platform/adapters/slack/slack.png differ diff --git a/src/langbot/pkg/platform/adapters/slack/types.py b/src/langbot/pkg/platform/adapters/slack/types.py new file mode 100644 index 000000000..cb8668168 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/slack/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'slack-eba' diff --git a/src/langbot/pkg/platform/adapters/telegram/__init__.py b/src/langbot/pkg/platform/adapters/telegram/__init__.py new file mode 100644 index 000000000..f4d2d73d7 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/__init__.py @@ -0,0 +1,3 @@ +from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter + +__all__ = ["TelegramAdapter"] diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py new file mode 100644 index 000000000..fc92ab969 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -0,0 +1,435 @@ +"""Telegram adapter main class (EBA version). + +Inherits AbstractPlatformAdapter, integrating all modules. +Preserves all existing functionality (messaging, streaming output, markdown card, forum topics, etc.). +""" + +from __future__ import annotations + +import time +import typing +import traceback + +import telegram +import telegram.ext +from telegram import Update +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + ChatMemberHandler, + ContextTypes, + MessageHandler, + MessageReactionHandler, + filters, +) +import telegramify_markdown +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + +from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter +from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter +from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin +from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP + + +class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + """Telegram adapter (EBA version).""" + + bot: telegram.Bot = pydantic.Field(exclude=True) + application: telegram.ext.Application = pydantic.Field(exclude=True) + + message_converter: TelegramMessageConverter = TelegramMessageConverter() + event_converter: TelegramEventConverter = TelegramEventConverter() + legacy_event_converter: LegacyEventConverter = LegacyEventConverter() + + config: dict + + msg_stream_id: dict + """Stream message ID map. Key: stream message ID, value: first message source ID.""" + + seq: int + """Sequence number for message ordering.""" + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + if ( + not update.message + and not update.edited_message + and not update.chat_member + and not update.my_chat_member + and not update.callback_query + and not update.message_reaction + ): + return + + # Skip messages from the bot itself + if update.message and update.message.from_user and update.message.from_user.is_bot: + return + + try: + # Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners) + if update.message and ( + platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners + ): + legacy_event = await self.legacy_event_converter.target2yiri(update, self.bot, self.bot_account_id) + if legacy_event and type(legacy_event) in self.listeners: + await self.listeners[type(legacy_event)](legacy_event, self) + + eba_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id) + if eba_event: + await self._dispatch_eba_event(eba_event) + + except Exception: + await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}') + + application = ApplicationBuilder().token(config['token']).build() + bot = application.bot + + # Register handler for all common update types + application.add_handler( + MessageHandler( + filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL, + telegram_callback, + ) + ) + # Register edited message handler + application.add_handler( + MessageHandler( + filters.UpdateType.EDITED_MESSAGE, + telegram_callback, + ) + ) + application.add_handler( + ChatMemberHandler( + telegram_callback, + ChatMemberHandler.CHAT_MEMBER, + ) + ) + application.add_handler( + ChatMemberHandler( + telegram_callback, + ChatMemberHandler.MY_CHAT_MEMBER, + ) + ) + application.add_handler(CallbackQueryHandler(telegram_callback)) + application.add_handler( + MessageReactionHandler( + telegram_callback, + MessageReactionHandler.MESSAGE_REACTION, + ) + ) + + super().__init__( + config=config, + logger=logger, + msg_stream_id={}, + seq=1, + bot=bot, + application=application, + bot_account_id='', + listeners={}, + ) + + # ---- Capability Declaration ---- + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'message.edited', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'group.member_banned', + 'bot.invited_to_group', + 'bot.removed_from_group', + 'bot.muted', + 'bot.unmuted', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'edit_message', + 'delete_message', + 'forward_message', + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'get_user_info', + 'get_file_url', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api', + ] + + # ---- Message Send / Reply (preserving original logic) ---- + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + components = await TelegramMessageConverter.yiri2target(message, self.bot) + + chat_id_str, _, thread_id_str = str(target_id).partition('#') + chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str + message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None + + for component in components: + component_type = component.get('type') + args = {'chat_id': chat_id} + if message_thread_id is not None: + args['message_thread_id'] = message_thread_id + + if component_type == 'text': + text = component.get('text', '') + if self.config['markdown_card'] is True: + text = telegramify_markdown.markdownify(content=text) + args['parse_mode'] = 'MarkdownV2' + args['text'] = text + await self.bot.send_message(**args) + elif component_type == 'photo': + photo = component.get('photo') + if photo is None: + continue + args['photo'] = telegram.InputFile(photo) + await self.bot.send_photo(**args) + elif component_type == 'document': + doc = component.get('document') + if doc is None: + continue + filename = component.get('filename', 'file') + args['document'] = telegram.InputFile(doc, filename=filename) + await self.bot.send_document(**args) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + assert isinstance(message_source.source_platform_object, Update) + components = await TelegramMessageConverter.yiri2target(message, self.bot) + + for component in components: + component_type = component.get('type') + args = { + 'chat_id': message_source.source_platform_object.effective_chat.id, + } + + if message_source.source_platform_object.message.message_thread_id: + args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id + + if quote_origin: + args['reply_to_message_id'] = message_source.source_platform_object.message.id + + if component_type == 'text': + if self.config['markdown_card'] is True: + content = telegramify_markdown.markdownify( + content=component['text'], + ) + else: + content = component['text'] + if self.config['markdown_card'] is True: + args['parse_mode'] = 'MarkdownV2' + args['text'] = content + await self.bot.send_message(**args) + elif component_type == 'photo': + photo = component.get('photo') + if photo is None: + continue + args['photo'] = telegram.InputFile(photo) + await self.bot.send_photo(**args) + elif component_type == 'document': + doc = component.get('document') + if doc is None: + continue + filename = component.get('filename', 'file') + args['document'] = telegram.InputFile(doc, filename=filename) + await self.bot.send_document(**args) + + # ---- Streaming Output (preserving original logic) ---- + + def _process_markdown(self, text: str) -> str: + if self.config.get('markdown_card', False): + return telegramify_markdown.markdownify(content=text) + return text + + def _build_message_args(self, chat_id: int, text: str, message_thread_id: int = None, **extra_args) -> dict: + args = {'chat_id': chat_id, 'text': self._process_markdown(text), **extra_args} + if message_thread_id: + args['message_thread_id'] = message_thread_id + if self.config.get('markdown_card', False): + args['parse_mode'] = 'MarkdownV2' + return args + + async def create_message_card(self, message_id, event): + assert isinstance(event.source_platform_object, Update) + update = event.source_platform_object + chat_id = update.effective_chat.id + chat_type = update.effective_chat.type + message_thread_id = update.message.message_thread_id + + if chat_type == 'private': + draft_id = int(time.time() * 1000) + self.msg_stream_id[message_id] = ('private', draft_id) + + args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id) + await self.bot.send_message_draft(**args) + else: + args = self._build_message_args(chat_id, 'Thinking...', message_thread_id) + send_msg = await self.bot.send_message(**args) + self.msg_stream_id[message_id] = ('group', send_msg.message_id) + + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + message_id = bot_message.resp_message_id + msg_seq = bot_message.msg_sequence + assert isinstance(message_source.source_platform_object, Update) + update = message_source.source_platform_object + chat_id = update.effective_chat.id + message_thread_id = update.message.message_thread_id + + if message_id not in self.msg_stream_id: + return + + chat_mode, draft_id = self.msg_stream_id[message_id] + components = await TelegramMessageConverter.yiri2target(message, self.bot) + + if not components or components[0]['type'] != 'text': + if is_final and bot_message.tool_calls is None: + self.msg_stream_id.pop(message_id) + return + + content = components[0]['text'] + + if chat_mode == 'private': + args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id) + await self.bot.send_message_draft(**args) + if is_final and bot_message.tool_calls is None: + del args['draft_id'] + await self.bot.send_message(**args) + self.msg_stream_id.pop(message_id) + else: + stream_id = draft_id + if (msg_seq - 1) % 8 == 0 or is_final: + args = { + 'message_id': stream_id, + 'chat_id': chat_id, + 'text': self._process_markdown(content), + } + if self.config.get('markdown_card', False): + args['parse_mode'] = 'MarkdownV2' + await self.bot.edit_message_text(**args) + + if is_final and bot_message.tool_calls is None: + self.msg_stream_id.pop(message_id) + + # ---- Forum Topic / Custom launcher_id (preserving original logic) ---- + + def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None: + if not isinstance(event.source_platform_object, Update): + return None + + message = event.source_platform_object.message + if not message: + return None + + if message.message_thread_id: + if isinstance(event, platform_events.GroupMessage): + return f'{event.group.id}#{message.message_thread_id}' + elif isinstance(event, platform_events.FriendMessage): + return f'{event.sender.id}#{message.message_thread_id}' + + return None + + # ---- Stream Output Support Check ---- + + async def is_stream_output_supported(self) -> bool: + is_stream = False + if self.config.get('enable-stream-reply', None): + is_stream = True + return is_stream + + async def is_muted(self, group_id: int) -> bool: + return False + + # ---- Event Listeners ---- + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + """Dispatch once, preferring the most specific registered listener.""" + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type, None) + + # ---- Pass-through API ---- + + async def call_platform_api( + self, + action: str, + params: dict = {}, + ) -> dict: + """Call a Telegram-specific platform API.""" + handler = PLATFORM_API_MAP.get(action) + if handler is None: + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + # ---- Lifecycle ---- + + async def run_async(self): + await self.application.initialize() + self.bot_account_id = (await self.bot.get_me()).username + await self.application.updater.start_polling(allowed_updates=Update.ALL_TYPES) + await self.application.start() + await self.logger.info('Telegram adapter running') + + async def kill(self) -> bool: + if self.application.running: + await self.application.stop() + if self.application.updater: + await self.application.updater.stop() + await self.logger.info('Telegram adapter stopped') + return True diff --git a/src/langbot/pkg/platform/adapters/telegram/api_impl.py b/src/langbot/pkg/platform/adapters/telegram/api_impl.py new file mode 100644 index 000000000..f058d54e6 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/api_impl.py @@ -0,0 +1,252 @@ +"""Telegram universal API implementation (EBA version). + +Implements optional API methods defined in AbstractPlatformAdapter. +""" + +from __future__ import annotations + +import typing + +import telegram + +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events + +from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter + + +class TelegramAPIMixin: + """Telegram universal API implementation mixin. + + Used via multiple inheritance in TelegramAdapter. + Requires self.bot: telegram.Bot and self.config: dict attributes. + """ + + bot: telegram.Bot + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + """Edit a previously sent message.""" + components = await TelegramMessageConverter.yiri2target(new_content, self.bot) + + for component in components: + if component['type'] == 'text': + text = component['text'] + if self.config.get('markdown_card', False): + import telegramify_markdown + + text = telegramify_markdown.markdownify(content=text) + args = { + 'chat_id': chat_id, + 'message_id': message_id, + 'text': text, + } + if self.config.get('markdown_card', False): + args['parse_mode'] = 'MarkdownV2' + await self.bot.edit_message_text(**args) + return + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + """Delete / recall a message.""" + await self.bot.delete_message(chat_id=chat_id, message_id=message_id) + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + """Forward a message to another chat.""" + result = await self.bot.forward_message( + chat_id=to_chat_id, + from_chat_id=from_chat_id, + message_id=message_id, + ) + return platform_events.MessageResult( + message_id=result.message_id, + raw={'message_id': result.message_id}, + ) + + async def get_group_info( + self, + group_id: typing.Union[int, str], + ) -> platform_entities.UserGroup: + """Get group information.""" + chat = await self.bot.get_chat(chat_id=group_id) + return platform_entities.UserGroup( + id=chat.id, + name=chat.title or '', + description=chat.description or None, + member_count=await self._get_member_count(group_id), + ) + + async def _get_member_count(self, group_id: typing.Union[int, str]) -> typing.Optional[int]: + """Get group member count.""" + try: + return await self.bot.get_chat_member_count(chat_id=group_id) + except Exception: + return None + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + """Get group member list. + + Note: Telegram Bot API only supports fetching the admin list + (get_chat_administrators), not the full member list. + This method returns the admin list. + """ + admins = await self.bot.get_chat_administrators(chat_id=group_id) + members = [] + for admin in admins: + role = platform_entities.MemberRole.MEMBER + if admin.status == 'creator': + role = platform_entities.MemberRole.OWNER + elif admin.status == 'administrator': + role = platform_entities.MemberRole.ADMIN + + members.append( + platform_entities.UserGroupMember( + user=platform_entities.User( + id=admin.user.id, + nickname=admin.user.first_name or '', + username=admin.user.username, + is_bot=admin.user.is_bot, + ), + group_id=group_id, + role=role, + display_name=admin.custom_title if hasattr(admin, 'custom_title') else None, + ) + ) + return members + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + """Get information about a specific group member.""" + member = await self.bot.get_chat_member(chat_id=group_id, user_id=user_id) + + role = platform_entities.MemberRole.MEMBER + if member.status == 'creator': + role = platform_entities.MemberRole.OWNER + elif member.status == 'administrator': + role = platform_entities.MemberRole.ADMIN + + return platform_entities.UserGroupMember( + user=platform_entities.User( + id=member.user.id, + nickname=member.user.first_name or '', + username=member.user.username, + is_bot=member.user.is_bot, + ), + group_id=group_id, + role=role, + display_name=member.custom_title if hasattr(member, 'custom_title') else None, + ) + + async def get_user_info( + self, + user_id: typing.Union[int, str], + ) -> platform_entities.User: + """Get user information.""" + chat = await self.bot.get_chat(chat_id=user_id) + return platform_entities.User( + id=chat.id, + nickname=chat.first_name or '', + username=chat.username, + ) + + async def upload_file( + self, + file_data: bytes, + filename: str, + ) -> str: + """Upload a file. + + Telegram does not support standalone file uploads; files are sent as + part of messages. This method raises NotSupportedError. + """ + from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + raise NotSupportedError('upload_file') + + async def get_file_url( + self, + file_id: str, + ) -> str: + """Get file download URL.""" + file = await self.bot.get_file(file_id) + return file.file_path + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + """Mute a group member.""" + import datetime + + permissions = telegram.ChatPermissions(can_send_messages=False) + kwargs = { + 'chat_id': group_id, + 'user_id': user_id, + 'permissions': permissions, + } + if duration > 0: + kwargs['until_date'] = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=duration) + await self.bot.restrict_chat_member(**kwargs) + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """Unmute a group member.""" + permissions = telegram.ChatPermissions( + can_send_messages=True, + can_send_other_messages=True, + can_add_web_page_previews=True, + can_send_audios=True, + can_send_documents=True, + can_send_photos=True, + can_send_videos=True, + can_send_video_notes=True, + can_send_voice_notes=True, + ) + await self.bot.restrict_chat_member( + chat_id=group_id, + user_id=user_id, + permissions=permissions, + ) + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """Kick a member from the group.""" + await self.bot.ban_chat_member(chat_id=group_id, user_id=user_id) + + async def leave_group( + self, + group_id: typing.Union[int, str], + ) -> None: + """Make the bot leave a group.""" + await self.bot.leave_chat(chat_id=group_id) diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py new file mode 100644 index 000000000..10dabe39c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -0,0 +1,424 @@ +"""Telegram event converter (EBA version). + +Converts all Telegram Update types to unified EBA events, not just messages. +""" + +from __future__ import annotations + +import typing + +import telegram +from telegram import Update + +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter + +from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter + + +def _make_user(tg_user: telegram.User) -> platform_entities.User: + """Convert a Telegram User to a unified User entity.""" + return platform_entities.User( + id=tg_user.id, + nickname=tg_user.first_name or '', + username=tg_user.username, + is_bot=tg_user.is_bot, + ) + + +def _make_user_group(tg_chat: telegram.Chat) -> platform_entities.UserGroup: + """Convert a Telegram Chat to a unified UserGroup entity.""" + return platform_entities.UserGroup( + id=tg_chat.id, + name=tg_chat.title or tg_chat.first_name or '', + description=tg_chat.description if hasattr(tg_chat, 'description') else None, + ) + + +def _chat_type(tg_chat: telegram.Chat) -> platform_entities.ChatType: + """Map Telegram Chat type to unified ChatType.""" + if tg_chat.type == 'private': + return platform_entities.ChatType.PRIVATE + return platform_entities.ChatType.GROUP + + +class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Telegram event converter (EBA version).""" + + @staticmethod + async def yiri2target(event: platform_events.Event, bot: telegram.Bot): + """Convert a unified event to a raw Telegram event (generally not needed).""" + if hasattr(event, 'source_platform_object'): + return event.source_platform_object + return None + + @staticmethod + async def target2yiri( + update: Update, + bot: telegram.Bot, + bot_account_id: str, + ) -> typing.Optional[platform_events.EBAEvent]: + """Convert a Telegram Update to a unified EBA event. + + Supports: message, edited_message, chat_member, my_chat_member, + callback_query, message_reaction, etc. + Unmappable events are wrapped as PlatformSpecificEvent. + """ + import time + + # ---- Message event ---- + if ( + update.message + and update.message.text is not None + or (update.message and (update.message.photo or update.message.voice or update.message.document)) + ): + return await TelegramEventConverter._convert_message(update, bot, bot_account_id) + + # ---- Edited message event ---- + if update.edited_message: + return await TelegramEventConverter._convert_edited_message(update, bot, bot_account_id) + + # ---- Member change event (chat_member) ---- + if update.chat_member: + return TelegramEventConverter._convert_chat_member(update) + + # ---- Bot's own member status change (my_chat_member) ---- + if update.my_chat_member: + return TelegramEventConverter._convert_my_chat_member(update) + + # ---- Callback query (button clicks, etc.) ---- + if update.callback_query: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + timestamp=time.time(), + adapter_name='telegram', + action='callback_query', + data={ + 'callback_query_id': update.callback_query.id, + 'data': update.callback_query.data, + 'from_user_id': update.callback_query.from_user.id if update.callback_query.from_user else None, + 'message_id': update.callback_query.message.message_id if update.callback_query.message else None, + }, + source_platform_object=update, + ) + + # ---- Message reaction ---- + if update.message_reaction: + return TelegramEventConverter._convert_reaction(update) + + # ---- Fallback: wrap as PlatformSpecificEvent ---- + return platform_events.PlatformSpecificEvent( + type='platform.specific', + timestamp=time.time(), + adapter_name='telegram', + action='unknown_update', + data={'update_id': update.update_id}, + source_platform_object=update, + ) + + @staticmethod + async def _convert_message( + update: Update, + bot: telegram.Bot, + bot_account_id: str, + ) -> platform_events.MessageReceivedEvent: + """Convert a Telegram message to MessageReceivedEvent.""" + message = update.message + lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id) + + sender = _make_user(message.from_user) if message.from_user else platform_entities.User(id='') + chat = message.chat + ct = _chat_type(chat) + + group = None + if ct == platform_entities.ChatType.GROUP: + group = _make_user_group(chat) + + return platform_events.MessageReceivedEvent( + type='message.received', + timestamp=message.date.timestamp() if message.date else 0.0, + adapter_name='telegram', + message_id=message.message_id, + message_chain=lb_message, + sender=sender, + chat_type=ct, + chat_id=chat.id, + group=group, + source_platform_object=update, + ) + + @staticmethod + async def _convert_edited_message( + update: Update, + bot: telegram.Bot, + bot_account_id: str, + ) -> platform_events.MessageEditedEvent: + """Convert a Telegram edited message to MessageEditedEvent.""" + message = update.edited_message + lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id) + + editor = _make_user(message.from_user) if message.from_user else platform_entities.User(id='') + chat = message.chat + ct = _chat_type(chat) + + group = None + if ct == platform_entities.ChatType.GROUP: + group = _make_user_group(chat) + + return platform_events.MessageEditedEvent( + type='message.edited', + timestamp=message.edit_date.timestamp() if message.edit_date else 0.0, + adapter_name='telegram', + message_id=message.message_id, + new_content=lb_message, + editor=editor, + chat_type=ct, + chat_id=chat.id, + group=group, + source_platform_object=update, + ) + + @staticmethod + def _convert_chat_member(update: Update) -> typing.Optional[platform_events.EBAEvent]: + """Convert a chat_member update to MemberJoinedEvent / MemberLeftEvent / etc.""" + import time + + cm = update.chat_member + chat = cm.chat + group = _make_user_group(chat) + member = _make_user(cm.new_chat_member.user) if cm.new_chat_member else platform_entities.User(id='') + inviter = _make_user(cm.from_user) if cm.from_user else None + + old_status = cm.old_chat_member.status if cm.old_chat_member else None + new_status = cm.new_chat_member.status if cm.new_chat_member else None + + # Member joined + if old_status in (None, 'left', 'kicked') and new_status in ( + 'member', + 'administrator', + 'creator', + 'restricted', + ): + return platform_events.MemberJoinedEvent( + type='group.member_joined', + timestamp=cm.date.timestamp() if cm.date else time.time(), + adapter_name='telegram', + group=group, + member=member, + inviter=inviter, + join_type='invite' if inviter and inviter.id != member.id else 'direct', + source_platform_object=update, + ) + + # Member left / kicked + if old_status in ('member', 'administrator', 'creator', 'restricted') and new_status in ('left', 'kicked'): + is_kicked = new_status == 'kicked' + return platform_events.MemberLeftEvent( + type='group.member_left', + timestamp=cm.date.timestamp() if cm.date else time.time(), + adapter_name='telegram', + group=group, + member=member, + is_kicked=is_kicked, + operator=inviter if is_kicked else None, + source_platform_object=update, + ) + + # Member muted (restricted with can_send_messages == False) + if new_status == 'restricted' and cm.new_chat_member: + restricted = cm.new_chat_member + if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages: + duration = None + if hasattr(restricted, 'until_date') and restricted.until_date: + duration = int(restricted.until_date.timestamp() - time.time()) + return platform_events.MemberBannedEvent( + type='group.member_banned', + timestamp=cm.date.timestamp() if cm.date else time.time(), + adapter_name='telegram', + group=group, + member=member, + operator=inviter, + duration=duration, + source_platform_object=update, + ) + + # Other chat_member changes -> PlatformSpecificEvent + return platform_events.PlatformSpecificEvent( + type='platform.specific', + timestamp=cm.date.timestamp() if cm.date else time.time(), + adapter_name='telegram', + action='chat_member_updated', + data={ + 'old_status': old_status, + 'new_status': new_status, + 'chat_id': chat.id, + 'user_id': member.id, + }, + source_platform_object=update, + ) + + @staticmethod + def _convert_my_chat_member(update: Update) -> typing.Optional[platform_events.EBAEvent]: + """Convert a my_chat_member update to bot status events.""" + import time + + mcm = update.my_chat_member + chat = mcm.chat + group = _make_user_group(chat) + inviter = _make_user(mcm.from_user) if mcm.from_user else None + + old_status = mcm.old_chat_member.status if mcm.old_chat_member else None + new_status = mcm.new_chat_member.status if mcm.new_chat_member else None + + # Bot invited to group + if old_status in (None, 'left', 'kicked') and new_status in ('member', 'administrator'): + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + group=group, + inviter=inviter, + source_platform_object=update, + ) + + # Bot removed from group + if old_status in ('member', 'administrator', 'creator') and new_status in ('left', 'kicked'): + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + group=group, + operator=inviter, + source_platform_object=update, + ) + + # Bot muted + if new_status == 'restricted' and mcm.new_chat_member: + restricted = mcm.new_chat_member + if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages: + duration = None + if hasattr(restricted, 'until_date') and restricted.until_date: + duration = int(restricted.until_date.timestamp() - time.time()) + return platform_events.BotMutedEvent( + type='bot.muted', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + group=group, + operator=inviter, + duration=duration, + source_platform_object=update, + ) + + if old_status == 'restricted' and new_status in ('member', 'administrator') and mcm.new_chat_member: + return platform_events.BotUnmutedEvent( + type='bot.unmuted', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + group=group, + operator=inviter, + source_platform_object=update, + ) + + return platform_events.PlatformSpecificEvent( + type='platform.specific', + timestamp=mcm.date.timestamp() if mcm.date else time.time(), + adapter_name='telegram', + action='my_chat_member_updated', + data={ + 'old_status': old_status, + 'new_status': new_status, + 'chat_id': chat.id, + }, + source_platform_object=update, + ) + + @staticmethod + def _convert_reaction(update: Update) -> platform_events.MessageReactionEvent: + """Convert a Telegram message_reaction to MessageReactionEvent.""" + import time + + reaction = update.message_reaction + chat = reaction.chat + + # Extract newly added emojis + new_emojis = [] + if reaction.new_reaction: + for r in reaction.new_reaction: + if hasattr(r, 'emoji'): + new_emojis.append(r.emoji) + elif hasattr(r, 'custom_emoji_id'): + new_emojis.append(str(r.custom_emoji_id)) + + user = platform_entities.User(id='') + if reaction.user: + user = _make_user(reaction.user) + + ct = _chat_type(chat) + group = _make_user_group(chat) if ct == platform_entities.ChatType.GROUP else None + + return platform_events.MessageReactionEvent( + type='message.reaction', + timestamp=reaction.date.timestamp() if reaction.date else time.time(), + adapter_name='telegram', + message_id=reaction.message_id, + user=user, + reaction=new_emojis[0] if new_emojis else '', + is_add=len(new_emojis) > 0, + chat_type=ct, + chat_id=chat.id, + group=group, + source_platform_object=update, + ) + + +class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Legacy event converter (compatibility layer). + + Converts Telegram Updates to the old FriendMessage / GroupMessage format. + Used during the transition period to maintain backward compatibility. + """ + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent, bot: telegram.Bot): + return event.source_platform_object + + @staticmethod + async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str): + """Convert to legacy format (FriendMessage / GroupMessage).""" + import langbot_plugin.api.entities.builtin.platform.events as legacy_events + import langbot_plugin.api.entities.builtin.platform.entities as legacy_entities + + if not event.message: + return None + + lb_message = await TelegramMessageConverter.target2yiri(event.message, bot, bot_account_id) + + if event.effective_chat.type == 'private': + return legacy_events.FriendMessage( + sender=legacy_entities.Friend( + id=event.effective_chat.id, + nickname=event.effective_chat.first_name, + remark=str(event.effective_chat.id), + ), + message_chain=lb_message, + time=event.message.date.timestamp(), + source_platform_object=event, + ) + else: + return legacy_events.GroupMessage( + sender=legacy_entities.GroupMember( + id=event.effective_chat.id, + member_name=event.effective_chat.title, + permission=legacy_entities.Permission.Member, + group=legacy_entities.Group( + id=event.effective_chat.id, + name=event.effective_chat.title, + permission=legacy_entities.Permission.Member, + ), + special_title='', + ), + message_chain=lb_message, + time=event.message.date.timestamp(), + source_platform_object=event, + ) diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml new file mode 100644 index 000000000..7e6daa14f --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -0,0 +1,101 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: telegram-eba + label: + en_US: Telegram + zh_Hans: 电报 + description: + en_US: Telegram Bot adapter with event-driven orchestration support + zh_Hans: 电报 Bot 适配器,支持事件驱动编排 + icon: telegram.svg + +spec: + categories: + - popular + - global + config: + - name: token + label: + en_US: Token + zh_Hans: 令牌 + type: string + required: true + default: "" + - name: markdown_card + label: + en_US: Markdown Card + zh_Hans: 是否使用 Markdown 卡片 + type: boolean + required: false + default: true + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用电报流式回复模式 + description: + en_US: If enabled, the bot will use the stream of telegram reply mode + zh_Hans: 如果启用,将使用电报流式方式来回复内容 + type: boolean + required: true + default: false + + supported_events: + - message.received + - message.edited + - message.reaction + - group.member_joined + - group.member_left + - group.member_banned + - bot.invited_to_group + - bot.removed_from_group + - bot.muted + - bot.unmuted + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - edit_message + - delete_message + - forward_message + - get_group_info + - get_group_member_list + - get_group_member_info + - get_user_info + - get_file_url + - mute_member + - unmute_member + - kick_member + - leave_group + - call_platform_api + + platform_specific_apis: + - action: pin_message + description: { en_US: "Pin a message", zh_Hans: "置顶消息" } + - action: unpin_message + description: { en_US: "Unpin a message", zh_Hans: "取消置顶" } + - action: unpin_all_messages + description: { en_US: "Unpin all messages", zh_Hans: "取消所有置顶" } + - action: get_chat_administrators + description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" } + - action: set_chat_title + description: { en_US: "Set chat title", zh_Hans: "修改群名称" } + - action: set_chat_description + description: { en_US: "Set chat description", zh_Hans: "修改群描述" } + - action: get_chat_member_count + description: { en_US: "Get member count", zh_Hans: "获取群成员数量" } + - action: send_chat_action + description: { en_US: "Send chat action (typing, etc.)", zh_Hans: "发送聊天动作" } + - action: create_chat_invite_link + description: { en_US: "Create invite link", zh_Hans: "创建邀请链接" } + - action: answer_callback_query + description: { en_US: "Answer callback query", zh_Hans: "应答回调查询" } + +execution: + python: + path: ./adapter.py + attr: TelegramAdapter diff --git a/src/langbot/pkg/platform/adapters/telegram/message_converter.py b/src/langbot/pkg/platform/adapters/telegram/message_converter.py new file mode 100644 index 000000000..cea28a5b0 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/message_converter.py @@ -0,0 +1,145 @@ +"""Telegram message chain converter. + +Migrated from the original sources/telegram.py TelegramMessageConverter. Logic unchanged. +""" + +from __future__ import annotations + +import base64 + +import telegram + +from langbot.pkg.utils import httpclient +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]: + """Convert a LangBot MessageChain to a list of Telegram-sendable components.""" + components = [] + + for component in message_chain: + if isinstance(component, platform_message.Plain): + components.append({'type': 'text', 'text': component.text}) + elif isinstance(component, platform_message.Image): + photo_bytes = None + + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + photo_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + photo_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + photo_bytes = f.read() + + components.append({'type': 'photo', 'photo': photo_bytes}) + elif isinstance(component, platform_message.File): + file_bytes = None + + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + file_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + file_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + file_bytes = f.read() + + file_name = getattr(component, 'name', None) or 'file' + components.append({'type': 'document', 'document': file_bytes, 'filename': file_name}) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot)) + + return components + + @staticmethod + async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str): + """Convert a Telegram Message to a LangBot MessageChain.""" + message_components = [] + + def parse_message_text(text: str) -> list[platform_message.MessageComponent]: + msg_components = [] + + if f'@{bot_account_id}' in text: + msg_components.append(platform_message.At(target=bot_account_id)) + text = text.replace(f'@{bot_account_id}', '') + msg_components.append(platform_message.Plain(text=text)) + + return msg_components + + if message.text: + message_text = message.text + message_components.extend(parse_message_text(message_text)) + + if message.photo: + if message.caption: + message_components.extend(parse_message_text(message.caption)) + + file = await message.photo[-1].get_file() + + file_bytes = None + file_format = '' + + async with httpclient.get_session(trust_env=True).get(file.file_path) as response: + file_bytes = await response.read() + file_format = 'image/jpeg' + + message_components.append( + platform_message.Image( + base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}' + ) + ) + + if message.voice: + if message.caption: + message_components.extend(parse_message_text(message.caption)) + + file = await message.voice.get_file() + + file_bytes = None + file_format = message.voice.mime_type or 'audio/ogg' + + async with httpclient.get_session(trust_env=True).get(file.file_path) as response: + file_bytes = await response.read() + + message_components.append( + platform_message.Voice( + base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}', + length=message.voice.duration, + ) + ) + + if message.document: + if message.caption: + message_components.extend(parse_message_text(message.caption)) + + file = await message.document.get_file() + file_name = message.document.file_name or 'document' + file_size = message.document.file_size or 0 + file_format = message.document.mime_type or 'application/octet-stream' + + file_bytes = None + async with httpclient.get_session(trust_env=True).get(file.file_path) as response: + file_bytes = await response.read() + + message_components.append( + platform_message.File( + name=file_name, + size=file_size, + base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}', + ) + ) + + return platform_message.MessageChain(message_components) diff --git a/src/langbot/pkg/platform/adapters/telegram/platform_api.py b/src/langbot/pkg/platform/adapters/telegram/platform_api.py new file mode 100644 index 000000000..ccd81950d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/platform_api.py @@ -0,0 +1,125 @@ +"""Telegram platform-specific API dispatch table for call_platform_api.""" + +from __future__ import annotations + +import typing + +import telegram + + +async def pin_message(bot: telegram.Bot, params: dict) -> dict: + """Pin a message in a chat.""" + await bot.pin_chat_message( + chat_id=params['chat_id'], + message_id=params['message_id'], + disable_notification=params.get('disable_notification', False), + ) + return {"ok": True} + + +async def unpin_message(bot: telegram.Bot, params: dict) -> dict: + """Unpin a message in a chat.""" + await bot.unpin_chat_message( + chat_id=params['chat_id'], + message_id=params.get('message_id'), + ) + return {"ok": True} + + +async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict: + """Unpin all messages in a chat.""" + await bot.unpin_all_chat_messages(chat_id=params['chat_id']) + return {"ok": True} + + +async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict: + """Get chat administrator list.""" + admins = await bot.get_chat_administrators(chat_id=params['chat_id']) + return { + "administrators": [ + { + "user_id": a.user.id, + "username": a.user.username, + "first_name": a.user.first_name, + "status": a.status, + "custom_title": getattr(a, 'custom_title', None), + } + for a in admins + ] + } + + +async def set_chat_title(bot: telegram.Bot, params: dict) -> dict: + """Set chat title.""" + await bot.set_chat_title( + chat_id=params['chat_id'], + title=params['title'], + ) + return {"ok": True} + + +async def set_chat_description(bot: telegram.Bot, params: dict) -> dict: + """Set chat description.""" + await bot.set_chat_description( + chat_id=params['chat_id'], + description=params.get('description', ''), + ) + return {"ok": True} + + +async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict: + """Get chat member count.""" + count = await bot.get_chat_member_count(chat_id=params['chat_id']) + return {"count": count} + + +async def send_chat_action(bot: telegram.Bot, params: dict) -> dict: + """Send a chat action (e.g. typing).""" + await bot.send_chat_action( + chat_id=params['chat_id'], + action=params.get('action', 'typing'), + ) + return {"ok": True} + + +async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict: + """Create a chat invite link.""" + link = await bot.create_chat_invite_link( + chat_id=params['chat_id'], + name=params.get('name'), + expire_date=params.get('expire_date'), + member_limit=params.get('member_limit'), + ) + return { + "invite_link": link.invite_link, + "name": link.name, + "is_primary": link.is_primary, + "is_revoked": link.is_revoked, + } + + +async def answer_callback_query(bot: telegram.Bot, params: dict) -> dict: + """Answer a callback query.""" + await bot.answer_callback_query( + callback_query_id=params['callback_query_id'], + text=params.get('text'), + show_alert=params.get('show_alert', False), + url=params.get('url'), + ) + return {"ok": True} + + +# ---- Action dispatch table ---- + +PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = { + "pin_message": pin_message, + "unpin_message": unpin_message, + "unpin_all_messages": unpin_all_messages, + "get_chat_administrators": get_chat_administrators, + "set_chat_title": set_chat_title, + "set_chat_description": set_chat_description, + "get_chat_member_count": get_chat_member_count, + "send_chat_action": send_chat_action, + "create_chat_invite_link": create_chat_invite_link, + "answer_callback_query": answer_callback_query, +} diff --git a/src/langbot/pkg/platform/adapters/telegram/telegram.svg b/src/langbot/pkg/platform/adapters/telegram/telegram.svg new file mode 100644 index 000000000..acd7fce64 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/telegram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/langbot/pkg/platform/adapters/telegram/types.py b/src/langbot/pkg/platform/adapters/telegram/types.py new file mode 100644 index 000000000..d36239ff6 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/types.py @@ -0,0 +1,13 @@ +"""Telegram platform-specific type definitions.""" + +from __future__ import annotations + +from enum import Enum + + +class TelegramChatType(str, Enum): + """Telegram chat type.""" + PRIVATE = "private" + GROUP = "group" + SUPERGROUP = "supergroup" + CHANNEL = "channel" diff --git a/src/langbot/pkg/platform/adapters/wecom/__init__.py b/src/langbot/pkg/platform/adapters/wecom/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/__init__.py @@ -0,0 +1 @@ + diff --git a/src/langbot/pkg/platform/adapters/wecom/adapter.py b/src/langbot/pkg/platform/adapters/wecom/adapter.py new file mode 100644 index 000000000..50fd5ff3c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/adapter.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import asyncio +import traceback +import typing + +import pydantic + +from langbot.libs.wecom_api.api import WecomClient +from langbot.libs.wecom_api.wecomevent import WecomEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.wecom.api_impl import WecomAPIMixin +from langbot.pkg.platform.adapters.wecom.event_converter import WecomEventConverter +from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter +from langbot.pkg.platform.adapters.wecom.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: WecomClient = pydantic.Field(exclude=True) + + message_converter: WecomMessageConverter = WecomMessageConverter() + event_converter: WecomEventConverter = WecomEventConverter() + + config: dict + bot_uuid: str | None = None + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, typing.Any] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = [ + 'corpid', + 'secret', + 'token', + 'EncodingAESKey', + ] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise Exception(f'WeCom missing required config fields: {missing_keys}') + + bot = WecomClient( + corpid=config['corpid'], + secret=config['secret'], + token=config['token'], + EncodingAESKey=config['EncodingAESKey'], + contacts_secret=config.get('contacts_secret', ''), + logger=logger, + unified_mode=True, + api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'), + ) + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id='', + bot_uuid=None, + listeners={}, + _message_cache={}, + _user_cache={}, + ) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + if target_type not in ('person', 'private'): + raise NotSupportedError(f'send_message:{target_type}') + + user_id, agent_id = self._parse_target_id(target_id) + content_list = await WecomMessageConverter.yiri2target(message, self.bot) + raw_results = [] + for content in content_list: + raw_results.append(await self._send_content(user_id, agent_id, content)) + return platform_events.MessageResult(raw={'results': raw_results}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + wecom_event = await WecomEventConverter.yiri2target(message_source) + if not isinstance(wecom_event, WecomEvent): + raise ValueError('WeCom reply_message requires a WecomEvent source object') + content_list = await WecomMessageConverter.yiri2target(message, self.bot) + raw_results = [] + for content in content_list: + raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content)) + return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results}) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await self.logger.info('WeCom EBA adapter running in unified webhook mode') + await keep_alive() + + async def kill(self) -> bool: + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + def _register_native_handlers(self): + async def on_message(event: WecomEvent): + await self._handle_native_event(event) + + self.bot.on_message('text')(on_message) + self.bot.on_message('image')(on_message) + + async def _handle_native_event(self, event: WecomEvent): + self.bot_account_id = event.receiver_id or self.bot_account_id + try: + if platform_events.FriendMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event, self.bot) + if legacy_event: + callback = self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event, self.bot) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + + async def _send_content(self, user_id: str, agent_id: int, content: dict): + content_type = content.get('type') + if content_type == 'text': + return await self.bot.send_private_msg(user_id, agent_id, content.get('content', '')) + if content_type == 'image': + return await self.bot.send_image(user_id, agent_id, content['media_id']) + if content_type == 'voice': + return await self.bot.send_voice(user_id, agent_id, content['media_id']) + if content_type == 'file': + return await self.bot.send_file(user_id, agent_id, content['media_id']) + raise NotSupportedError(f'send_content:{content_type}') + + @staticmethod + def _parse_target_id(target_id: str) -> tuple[str, int]: + user_id, sep, agent_id = str(target_id).partition('|') + if not user_id or not sep or not agent_id: + raise ValueError('WeCom target_id must be formatted as "user_id|agent_id"') + return user_id, int(agent_id) diff --git a/src/langbot/pkg/platform/adapters/wecom/api_impl.py b/src/langbot/pkg/platform/adapters/wecom/api_impl.py new file mode 100644 index 000000000..07ec2784d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/api_impl.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_api.api import WecomClient +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomAPIMixin: + bot: WecomClient + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + cached = self._user_cache.get(str(user_id)) + if cached is not None: + return cached + info = await self.bot.get_user_info(str(user_id)) + return platform_entities.User( + id=info.get('userid') or user_id, + nickname=info.get('name') or str(user_id), + username=info.get('alias') or info.get('userid') or None, + ) + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + raise NotSupportedError('get_group_info') + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raise NotSupportedError('get_group_member_list') + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + raise NotSupportedError('get_group_member_info') + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') diff --git a/src/langbot/pkg/platform/adapters/wecom/event_converter.py b/src/langbot/pkg/platform/adapters/wecom/event_converter.py new file mode 100644 index 000000000..94c43fd78 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/event_converter.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_api.api import WecomClient +from langbot.libs.wecom_api.wecomevent import WecomEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter +from langbot.pkg.platform.adapters.wecom.types import ADAPTER_NAME, make_private_chat_id +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> WecomEvent | None: + return getattr(event, 'source_platform_object', None) + + @staticmethod + async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None: + eba_event = await WecomEventConverter.target2yiri(event, bot) + if hasattr(eba_event, 'to_legacy_event'): + return eba_event.to_legacy_event() + if event.type in {'text', 'image'} and eba_event is not None: + friend = platform_entities.Friend( + id=f'u{event.user_id}', + nickname=getattr(getattr(eba_event, 'sender', None), 'nickname', str(event.user_id or '')), + remark='', + ) + return platform_events.FriendMessage( + sender=friend, + message_chain=eba_event.message_chain, + time=getattr(eba_event, 'timestamp', None), + source_platform_object=event, + ) + return None + + @staticmethod + async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None: + if event.type in {'text', 'image'}: + return await WecomEventConverter.message_to_eba(event, bot) + return WecomEventConverter.platform_specific(event, f'message.{event.detail_type or event.type or "unknown"}') + + @staticmethod + async def message_to_eba(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.MessageReceivedEvent: + if event.type == 'image': + message_chain = await WecomMessageConverter.target2yiri_image(event.picurl, event.message_id) + else: + message_chain = await WecomMessageConverter.target2yiri_text(event.message, event.message_id) + + sender = await WecomEventConverter.user_from_event(event, bot) + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.message_id or '', + message_chain=message_chain, + sender=sender, + chat_type=platform_entities.ChatType.PRIVATE, + chat_id=make_private_chat_id(event.user_id, event.agent_id), + group=None, + timestamp=float(event.timestamp or 0), + source_platform_object=event, + ) + + @staticmethod + async def user_from_event(event: WecomEvent, bot: WecomClient | None = None) -> platform_entities.User: + nickname = str(event.user_id or '') + raw: dict[str, typing.Any] = {} + if bot and event.user_id: + try: + raw = await bot.get_user_info(event.user_id) + nickname = raw.get('name') or nickname + except Exception: + raw = {} + + return platform_entities.User( + id=event.user_id or '', + nickname=nickname, + username=raw.get('alias') or raw.get('userid') or None, + ) + + @staticmethod + def platform_specific(event: WecomEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=float(event.timestamp or 0), + source_platform_object=event, + ) diff --git a/src/langbot/pkg/platform/adapters/wecom/manifest.yaml b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml new file mode 100644 index 000000000..650e0ef45 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml @@ -0,0 +1,117 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: wecom-eba + label: + en_US: WeCom + zh_Hans: 企业微信 + zh_Hant: 企業微信 + description: + en_US: WeCom application message adapter with event-driven orchestration support + zh_Hans: 企业微信内部应用消息适配器,支持事件驱动编排 + zh_Hant: 企業微信內部應用訊息適配器,支援事件驅動編排 + icon: wecom.png + +spec: + categories: + - popular + - china + help_links: + zh: https://link.langbot.app/zh/platforms/wecom + en: https://link.langbot.app/en/platforms/wecom + ja: https://link.langbot.app/ja/platforms/wecom + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your WeCom app's webhook configuration + zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中 + zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中 + type: webhook-url + required: false + default: "" + - name: corpid + label: + en_US: Corpid + zh_Hans: 企业ID + zh_Hant: 企業ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 密钥 (Secret) + zh_Hant: 密鑰 (Secret) + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 (Token) + zh_Hant: 令牌 (Token) + type: string + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 消息加解密密钥 (EncodingAESKey) + zh_Hant: 訊息加解密密鑰 (EncodingAESKey) + type: string + required: true + default: "" + - name: contacts_secret + label: + en_US: Contacts Secret + zh_Hans: 通讯录密钥 + zh_Hant: 通訊錄密鑰 + type: string + required: false + default: "" + - name: api_base_url + label: + en_US: API Base URL + zh_Hans: API 基础 URL + zh_Hant: API 基礎 URL + description: + en_US: Optional WeCom API base URL for private network or reverse proxy deployments. + zh_Hans: 可选,若部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项 + zh_Hant: 可選,若部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項 + type: string + required: false + default: "https://qyapi.weixin.qq.com/cgi-bin" + + supported_events: + - message.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - call_platform_api + + platform_specific_apis: + - action: check_access_token + description: { en_US: "Check whether the current WeCom access token is usable", zh_Hans: "检查当前企业微信 access token 是否可用" } + - action: refresh_access_token + description: { en_US: "Refresh the WeCom access token", zh_Hans: "刷新企业微信 access token" } + - action: get_user_info + description: { en_US: "Get WeCom user information by user ID", zh_Hans: "按用户 ID 获取企业微信用户信息" } + - action: send_to_all + description: { en_US: "Send an application text message to all contacts available to the configured contacts secret", zh_Hans: "使用配置的通讯录密钥向可见成员群发应用文本消息" } + +execution: + python: + path: ./adapter.py + attr: WecomAdapter diff --git a/src/langbot/pkg/platform/adapters/wecom/message_converter.py b/src/langbot/pkg/platform/adapters/wecom/message_converter.py new file mode 100644 index 000000000..e742778d1 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/message_converter.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import datetime + +from langbot.libs.wecom_api.api import WecomClient +from langbot.pkg.utils import image +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +def split_string_by_bytes(text: str, limit: int = 2048, encoding: str = 'utf-8') -> list[str]: + """Split text without cutting a multi-byte character in half.""" + bytes_data = text.encode(encoding) + total_len = len(bytes_data) + parts: list[str] = [] + start = 0 + + while start < total_len: + end = min(start + limit, total_len) + chunk = bytes_data[start:end] + part = chunk.decode(encoding, errors='ignore') + part_len = len(part.encode(encoding)) + if part_len == 0 and end < total_len: + start += 1 + continue + parts.append(part) + start += part_len + + return parts + + +class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient) -> list[dict]: + content_list: list[dict] = [] + + for msg in message_chain: + if isinstance(msg, platform_message.Source): + continue + if isinstance(msg, platform_message.Plain): + content_list.extend({'type': 'text', 'content': chunk} for chunk in split_string_by_bytes(msg.text)) + elif isinstance(msg, platform_message.Image): + content_list.append({'type': 'image', 'media_id': await bot.get_media_id(msg)}) + elif isinstance(msg, platform_message.Voice): + content_list.append({'type': 'voice', 'media_id': await bot.get_media_id(msg)}) + elif isinstance(msg, platform_message.File): + content_list.append({'type': 'file', 'media_id': await bot.get_media_id(msg)}) + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + content_list.extend(await WecomMessageConverter.yiri2target(node.message_chain, bot)) + elif isinstance(msg, platform_message.Quote): + if msg.id is not None: + content_list.append({'type': 'text', 'content': f'[Quote {msg.id}] '}) + if msg.origin: + content_list.extend(await WecomMessageConverter.yiri2target(msg.origin, bot)) + elif isinstance(msg, platform_message.At): + content_list.append({'type': 'text', 'content': f'@{msg.display or msg.target}'}) + elif isinstance(msg, platform_message.AtAll): + content_list.append({'type': 'text', 'content': '@all'}) + else: + content_list.append({'type': 'text', 'content': str(msg)}) + + return content_list + + @staticmethod + async def target2yiri_text(message: str | None, message_id: int | str | None = -1) -> platform_message.MessageChain: + return platform_message.MessageChain( + [ + platform_message.Source(id=message_id, time=datetime.datetime.now()), + platform_message.Plain(text=message or ''), + ] + ) + + @staticmethod + async def target2yiri_image(picurl: str, message_id: int | str | None = -1) -> platform_message.MessageChain: + image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl) + return platform_message.MessageChain( + [ + platform_message.Source(id=message_id, time=datetime.datetime.now()), + platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'), + ] + ) diff --git a/src/langbot/pkg/platform/adapters/wecom/platform_api.py b/src/langbot/pkg/platform/adapters/wecom/platform_api.py new file mode 100644 index 000000000..15c937c46 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/platform_api.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_api.api import WecomClient + + +async def check_access_token(bot: WecomClient, params: dict) -> dict: + return {'valid': await bot.check_access_token()} + + +async def refresh_access_token(bot: WecomClient, params: dict) -> dict: + bot.access_token = await bot.get_access_token(bot.secret) + return {'ok': bool(bot.access_token)} + + +async def get_user_info(bot: WecomClient, params: dict) -> dict: + user_id = params.get('user_id') or params.get('userid') + if not user_id: + raise ValueError('user_id is required') + return await bot.get_user_info(str(user_id)) + + +async def send_to_all(bot: WecomClient, params: dict) -> dict: + content = params.get('content') + agent_id = params.get('agent_id') or params.get('agentid') + if not content: + raise ValueError('content is required') + if agent_id is None: + raise ValueError('agent_id is required') + await bot.send_to_all(str(content), int(agent_id)) + return {'ok': True} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[WecomClient, dict], typing.Awaitable[dict]]] = { + 'check_access_token': check_access_token, + 'refresh_access_token': refresh_access_token, + 'get_user_info': get_user_info, + 'send_to_all': send_to_all, +} diff --git a/src/langbot/pkg/platform/adapters/wecom/types.py b/src/langbot/pkg/platform/adapters/wecom/types.py new file mode 100644 index 000000000..596459e0a --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecom/types.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +ADAPTER_NAME = 'wecom-eba' + + +def make_private_chat_id(user_id: str | int | None, agent_id: str | int | None) -> str: + """Build the routable private chat id used by the WeCom EBA adapter.""" + user = str(user_id or '') + agent = str(agent_id or '') + if not user or not agent: + return user + return f'{user}|{agent}' diff --git a/src/langbot/pkg/platform/adapters/wecom/wecom.png b/src/langbot/pkg/platform/adapters/wecom/wecom.png new file mode 100644 index 000000000..8588c20d5 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/wecom/wecom.png differ diff --git a/src/langbot/pkg/platform/adapters/wecombot/__init__.py b/src/langbot/pkg/platform/adapters/wecombot/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py new file mode 100644 index 000000000..0ba7864ca --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import asyncio +import time +import traceback +import typing + +import pydantic + +from langbot.libs.wecom_ai_bot_api.api import WecomBotClient +from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent +from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient +from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin +from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter +from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter +from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: typing.Any = pydantic.Field(exclude=True) + + message_converter: WecomBotMessageConverter = WecomBotMessageConverter() + event_converter: WecomBotEventConverter + + config: dict + bot_uuid: str | None = None + bot_name: str = '' + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, platform_entities.User] = {} + _group_cache: dict[str, platform_entities.UserGroup] = {} + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} + _stream_to_monitoring_msg: dict[str, tuple[str, float]] = {} + _STREAM_MAPPING_TTL: int = 600 + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + enable_webhook = config.get('enable-webhook', False) + bot_name = config.get('robot_name', '') + if not enable_webhook: + required_keys = ['BotId', 'Secret'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise Exception(f'WeComBot WebSocket mode missing config: {missing_keys}') + bot = WecomBotWsClient( + bot_id=config['BotId'], + secret=config['Secret'], + logger=logger, + encoding_aes_key=config.get('EncodingAESKey', ''), + ) + else: + required_keys = ['Token', 'EncodingAESKey', 'Corpid'] + missing_keys = [key for key in required_keys if not config.get(key)] + if missing_keys: + raise Exception(f'WeComBot webhook mode missing config: {missing_keys}') + bot = WecomBotClient( + Token=config['Token'], + EnCodingAESKey=config['EncodingAESKey'], + Corpid=config['Corpid'], + logger=logger, + unified_mode=True, + ) + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config.get('BotId', ''), + bot_uuid=None, + bot_name=bot_name, + event_converter=WecomBotEventConverter(bot_name=bot_name), + listeners={}, + _message_cache={}, + _user_cache={}, + _group_cache={}, + _member_cache={}, + _stream_to_monitoring_msg={}, + ) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'feedback.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'get_group_info', + 'get_group_member_info', + 'get_group_member_list', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + if self.config.get('enable-webhook', False): + raise NotSupportedError('send_message:webhook_mode') + if target_type not in ('person', 'private', 'group'): + raise NotSupportedError(f'send_message:{target_type}') + content = await WecomBotMessageConverter.yiri2target(message) + raw = await self.bot.send_message(str(target_id), content) + return platform_events.MessageResult(raw={'result': raw}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + event = await WecomBotEventConverter.yiri2target(message_source) + if not isinstance(event, WecomBotEvent): + raise ValueError('WeComBot reply_message requires a WecomBotEvent source object') + content = await WecomBotMessageConverter.yiri2target(message) + if not self.config.get('enable-webhook', False) and event.get('req_id'): + raw = await self.bot.reply_text(event.get('req_id'), content) + else: + raw = await self.bot.set_message(event.message_id, content) + return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw}) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict: + event = await WecomBotEventConverter.yiri2target(message_source) + if not isinstance(event, WecomBotEvent): + raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object') + content = await WecomBotMessageConverter.yiri2target(message) + success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final) + if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'): + await self.bot.reply_text(event.get('req_id'), content) + return {'stream': success} + + async def is_stream_output_supported(self) -> bool: + return self.config.get('enable-stream-reply', True) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + if not self.config.get('enable-webhook', False): + return None + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + if not self.config.get('enable-webhook', False): + await self.bot.connect() + return + + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await self.logger.info('WeComBot EBA adapter running in unified webhook mode') + await keep_alive() + + async def kill(self) -> bool: + if not self.config.get('enable-webhook', False): + await self.bot.disconnect() + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + async def on_monitoring_message_created(self, query, monitoring_message_id: str): + try: + stream_id = query.message_event.source_platform_object.stream_id + if stream_id: + self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time()) + self._cleanup_stream_mapping() + except Exception as e: + await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}') + + def _register_native_handlers(self): + self.bot.on_message('single')(self._handle_native_event) + self.bot.on_message('group')(self._handle_native_event) + if hasattr(self.bot, 'on_feedback'): + self.bot.on_feedback()(self._handle_feedback) + if hasattr(self.bot, 'on_message'): + self.bot.on_message('event')(self._handle_native_event) + + async def _handle_native_event(self, event: WecomBotEvent): + try: + if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event) + if legacy_event and type(legacy_event) in self.listeners: + await self.listeners[type(legacy_event)](legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in wecombot native event: {traceback.format_exc()}') + + async def _handle_feedback(self, **kwargs): + try: + event = WecomBotEventConverter.feedback_to_eba(**kwargs) + if event.stream_id and event.stream_id in self._stream_to_monitoring_msg: + monitoring_msg_id, _ = self._stream_to_monitoring_msg[event.stream_id] + event.stream_id = monitoring_msg_id + await self._dispatch_eba_event(event) + except Exception: + await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + if event.group: + self._group_cache[str(event.group.id)] = event.group + self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember( + user=event.sender, + group_id=event.group.id, + role=platform_entities.MemberRole.MEMBER, + display_name=event.sender.nickname, + ) + + def _cleanup_stream_mapping(self): + now = time.time() + expired = [key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL] + for key in expired: + del self._stream_to_monitoring_msg[key] diff --git a/src/langbot/pkg/platform/adapters/wecombot/api_impl.py b/src/langbot/pkg/platform/adapters/wecombot/api_impl.py new file mode 100644 index 000000000..d2255cf85 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/api_impl.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import typing + +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomBotAPIMixin: + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + _group_cache: dict[str, platform_entities.UserGroup] + _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + cached = self._user_cache.get(str(user_id)) + if cached is None: + raise NotSupportedError('get_user_info:not_cached') + return cached + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + cached = self._group_cache.get(str(group_id)) + if cached is None: + raise NotSupportedError('get_group_info:not_cached') + return cached + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + cached = self._member_cache.get((str(group_id), str(user_id))) + if cached is None: + raise NotSupportedError('get_group_member_info:not_cached') + return cached + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + raise NotSupportedError('forward_message') + + async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0): + raise NotSupportedError('mute_member') + + async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]): + raise NotSupportedError('unmute_member') + + async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]): + raise NotSupportedError('kick_member') + + async def leave_group(self, group_id: typing.Union[int, str]): + raise NotSupportedError('leave_group') diff --git a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py new file mode 100644 index 000000000..5b94ce15e --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import time +import typing + +from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent +from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter +from langbot.pkg.platform.adapters.wecombot.types import ADAPTER_NAME +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self, bot_name: str = ''): + self.bot_name = bot_name + + @staticmethod + async def yiri2target(event: platform_events.Event) -> typing.Any: + return getattr(event, 'source_platform_object', None) + + async def target2legacy(self, event: WecomBotEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + eba_event = await self.target2yiri(event) + if not isinstance(eba_event, platform_events.MessageReceivedEvent): + return None + if eba_event.chat_type == platform_entities.ChatType.PRIVATE: + return platform_events.FriendMessage( + sender=platform_entities.Friend(id=eba_event.sender.id, nickname=eba_event.sender.nickname, remark=''), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=eba_event.sender.id, + permission='MEMBER', + member_name=eba_event.sender.nickname, + group=platform_entities.Group( + id=eba_event.group.id if eba_event.group else eba_event.chat_id, + name=eba_event.group.name if eba_event.group else '', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=eba_event.message_chain, + time=eba_event.timestamp, + source_platform_object=event, + ) + + async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event: + if event.type in {'single', 'group'} and event.msgtype != 'event': + return await self.message_to_eba(event) + return self.platform_specific(event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}') + + async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent: + sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid) + group = None + chat_type = platform_entities.ChatType.PRIVATE + chat_id = event.userid + if event.type == 'group': + chat_type = platform_entities.ChatType.GROUP + chat_id = str(event.chatid) + group = platform_entities.UserGroup(id=str(event.chatid), name=event.chatname or str(event.chatid)) + + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.message_id or '', + message_chain=await WecomBotMessageConverter.target2yiri(event, self.bot_name), + sender=sender, + chat_type=chat_type, + chat_id=chat_id or '', + group=group, + timestamp=time.time(), + source_platform_object=event, + ) + + @staticmethod + def feedback_to_eba( + *, + feedback_id: str, + feedback_type: int, + feedback_content: str | None = None, + inaccurate_reasons: list | None = None, + session=None, + ) -> platform_events.FeedbackReceivedEvent: + session_id = None + user_id = None + message_id = None + stream_id = None + if session: + if getattr(session, 'chat_id', None): + session_id = f'group_{session.chat_id}' + elif getattr(session, 'user_id', None): + session_id = f'person_{session.user_id}' + user_id = getattr(session, 'user_id', None) + message_id = getattr(session, 'msg_id', None) + stream_id = getattr(session, 'stream_id', None) + + return platform_events.FeedbackReceivedEvent( + type='feedback.received', + adapter_name=ADAPTER_NAME, + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=[str(reason) for reason in (inaccurate_reasons or [])] or None, + user_id=user_id, + session_id=session_id, + message_id=message_id, + stream_id=stream_id, + timestamp=time.time(), + source_platform_object=session, + ) + + @staticmethod + def platform_specific(event: WecomBotEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=time.time(), + source_platform_object=event, + ) diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml new file mode 100644 index 000000000..e403a1ed9 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -0,0 +1,158 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: wecombot-eba + label: + en_US: WeComBot + zh_Hans: 企业微信智能机器人 + zh_Hant: 企業微信智慧機器人 + description: + en_US: WeCom AI Bot adapter with event-driven orchestration support + zh_Hans: 企业微信智能机器人适配器,支持事件驱动编排、长连接和 Webhook 两种接入方式 + zh_Hant: 企業微信智慧機器人適配器,支援事件驅動編排、長連線和 Webhook 兩種接入方式 + icon: wecombot.png + +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/wecombot + en: https://link.langbot.app/en/platforms/wecombot + ja: https://link.langbot.app/ja/platforms/wecombot + config: + - name: BotId + label: + en_US: BotId + zh_Hans: 机器人ID (BotId) + zh_Hant: 機器人ID (BotId) + type: string + required: true + default: "" + - name: robot_name + label: + en_US: Robot Name + zh_Hans: 机器人名称 + zh_Hant: 機器人名稱 + type: string + required: true + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用 Webhook 模式 + zh_Hant: 啟用 Webhook 模式 + description: + en_US: If enabled, the bot will use webhook mode. Otherwise it uses WebSocket long connection mode and does not need a webhook URL. + zh_Hans: 如果启用,机器人将使用 Webhook 模式;否则使用 WebSocket 长连接模式,不需要配置 webhook URL。 + zh_Hant: 如果啟用,機器人將使用 Webhook 模式;否則使用 WebSocket 長連線模式,不需要設定 webhook URL。 + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL into WeComBot callback settings only when webhook mode is enabled. + zh_Hans: 仅在启用 Webhook 模式时复制此地址到企业微信智能机器人回调配置中。 + zh_Hant: 僅在啟用 Webhook 模式時複製此地址到企業微信智慧機器人回調設定中。 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + - name: Secret + label: + en_US: Secret + zh_Hans: 机器人密钥 (Secret) + zh_Hant: 機器人密鑰 (Secret) + description: + en_US: Required for WebSocket long connection mode. + zh_Hans: 使用 WebSocket 长连接模式时必填。 + zh_Hant: 使用 WebSocket 長連線模式時必填。 + type: string + required: false + default: "" + - name: Corpid + label: + en_US: Corpid + zh_Hans: 企业ID + zh_Hant: 企業ID + description: + en_US: Required for webhook mode. + zh_Hans: 使用 Webhook 模式时必填。 + zh_Hant: 使用 Webhook 模式時必填。 + type: string + required: false + default: "" + - name: Token + label: + en_US: Token + zh_Hans: 令牌 (Token) + zh_Hant: 令牌 (Token) + description: + en_US: Required for webhook mode. + zh_Hans: 使用 Webhook 模式时必填。 + zh_Hant: 使用 Webhook 模式時必填。 + type: string + required: false + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 消息加解密密钥 (EncodingAESKey) + zh_Hant: 訊息加解密密鑰 (EncodingAESKey) + description: + en_US: Required for webhook mode. Optional for WebSocket mode when encrypted files need to be decrypted. + zh_Hans: Webhook 模式必填。WebSocket 模式下如需解密文件则填写。 + zh_Hant: Webhook 模式必填。WebSocket 模式下如需解密檔案則填寫。 + type: string + required: false + default: "" + - name: enable-stream-reply + label: + en_US: Enable Stream Reply + zh_Hans: 启用流式回复 + zh_Hant: 啟用串流回覆 + description: + en_US: If enabled, the bot will use WeComBot streaming replies. + zh_Hans: 如果启用,机器人将使用企业微信智能机器人流式回复。 + zh_Hant: 如果啟用,機器人將使用企業微信智慧機器人串流回覆。 + type: boolean + required: false + default: true + + supported_events: + - message.received + - feedback.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - get_group_info + - get_group_member_info + - get_group_member_list + - call_platform_api + + platform_specific_apis: + - action: is_websocket_mode + description: { en_US: "Return whether the adapter is using WebSocket long connection mode", zh_Hans: "返回当前适配器是否使用 WebSocket 长连接模式" } + - action: get_stream_session_status + description: { en_US: "Inspect stream session state for a received message ID", zh_Hans: "按接收消息 ID 查看流式会话状态" } + - action: send_markdown + description: { en_US: "Send markdown text proactively in WebSocket mode", zh_Hans: "在 WebSocket 模式下主动发送 Markdown 文本" } + +execution: + python: + path: ./adapter.py + attr: WecomBotAdapter diff --git a/src/langbot/pkg/platform/adapters/wecombot/message_converter.py b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py new file mode 100644 index 000000000..dae78b0a4 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import datetime + +from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> str: + content_parts: list[str] = [] + for msg in message_chain: + if isinstance(msg, platform_message.Source): + continue + if isinstance(msg, platform_message.Plain): + content_parts.append(msg.text) + elif isinstance(msg, platform_message.At): + content_parts.append(f'@{msg.display or msg.target}') + elif isinstance(msg, platform_message.AtAll): + content_parts.append('@all') + elif isinstance(msg, platform_message.Image): + content_parts.append('[Image]') + elif isinstance(msg, platform_message.Voice): + content_parts.append('[Voice]') + elif isinstance(msg, platform_message.File): + content_parts.append(f'[File: {msg.name or msg.file_id or msg.url or "file"}]') + elif isinstance(msg, platform_message.Quote): + if msg.id is not None: + content_parts.append(f'[Quote {msg.id}]') + if msg.origin: + content_parts.append(await WecomBotMessageConverter.yiri2target(msg.origin)) + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + if node.message_chain: + content_parts.append(await WecomBotMessageConverter.yiri2target(node.message_chain)) + else: + content_parts.append(str(msg)) + return '\n'.join(part for part in content_parts if part) + + @staticmethod + async def target2yiri(event: WecomBotEvent, bot_name: str = '') -> platform_message.MessageChain: + components: list[platform_message.MessageComponent] = [ + platform_message.Source(id=event.message_id, time=datetime.datetime.now()), + ] + if event.type == 'group' and event.ai_bot_id: + components.append(platform_message.At(target=event.ai_bot_id)) + + if event.content: + content = event.content + if bot_name: + content = content.replace(f'@{bot_name}', '').strip() + if content: + components.append(platform_message.Plain(text=content)) + + WecomBotMessageConverter._append_images(components, event.images or ([event.picurl] if event.picurl else [])) + WecomBotMessageConverter._append_file(components, event.file) + WecomBotMessageConverter._append_voice(components, event.voice) + WecomBotMessageConverter._append_video(components, event.video) + WecomBotMessageConverter._append_link(components, event.link) + WecomBotMessageConverter._append_quote(components, event.quote) + + if not any(not isinstance(component, (platform_message.Source, platform_message.At)) for component in components): + components.append(platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]')) + + return platform_message.MessageChain(components) + + @staticmethod + def _append_images(components: list[platform_message.MessageComponent], images: list[str]): + for image_data in images: + if image_data: + components.append(platform_message.Image(base64=image_data)) + + @staticmethod + def _append_file(components: list[platform_message.MessageComponent], file_info: dict | None): + if not file_info: + return + file_url = file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path') + file_base64 = file_info.get('base64') + file_name = file_info.get('filename') or file_info.get('name') + file_size = file_info.get('filesize') or file_info.get('size') + try: + kwargs = {} + if file_url: + kwargs['url'] = file_url + if file_base64: + kwargs['base64'] = file_base64 + if file_name: + kwargs['name'] = file_name + if file_size is not None: + kwargs['size'] = file_size + if kwargs: + components.append(platform_message.File(**kwargs)) + except Exception: + components.append(platform_message.Unknown(text='[file message unsupported]')) + + @staticmethod + def _append_voice(components: list[platform_message.MessageComponent], voice_info: dict | None): + if not voice_info: + return + voice_payload = voice_info.get('base64') or voice_info.get('url') + if not voice_payload: + return + if voice_info.get('base64') and not voice_payload.startswith('data:'): + voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}' + try: + if voice_payload.startswith('data:'): + components.append(platform_message.Voice(base64=voice_payload)) + else: + components.append(platform_message.Voice(url=voice_payload)) + except Exception: + components.append(platform_message.Unknown(text='[voice message unsupported]')) + + @staticmethod + def _append_video(components: list[platform_message.MessageComponent], video_info: dict | None): + if not video_info: + return + video_payload = ( + video_info.get('base64') + or video_info.get('url') + or video_info.get('download_url') + or video_info.get('fileurl') + ) + if not video_payload: + return + try: + components.append( + platform_message.File( + url=video_payload, + name=video_info.get('filename') or video_info.get('name') or 'video', + size=video_info.get('filesize') or video_info.get('size'), + ) + ) + except Exception: + components.append(platform_message.Unknown(text='[video message unsupported]')) + + @staticmethod + def _append_link(components: list[platform_message.MessageComponent], link: dict | None, prefix: str = ''): + if not link: + return + summary = '\n'.join( + filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')]) + ) + if summary: + components.append(platform_message.Plain(text=f'{prefix}{summary}')) + + @staticmethod + def _append_quote(components: list[platform_message.MessageComponent], quote_info: dict | None): + if not quote_info: + return + origin: list[platform_message.MessageComponent] = [] + if quote_info.get('content'): + origin.append(platform_message.Plain(text=quote_info.get('content'))) + WecomBotMessageConverter._append_images(origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else [])) + WecomBotMessageConverter._append_file(origin, quote_info.get('file')) + WecomBotMessageConverter._append_voice(origin, quote_info.get('voice')) + WecomBotMessageConverter._append_video(origin, quote_info.get('video')) + WecomBotMessageConverter._append_link(origin, quote_info.get('link')) + if origin: + components.append(platform_message.Quote(origin=platform_message.MessageChain(origin))) diff --git a/src/langbot/pkg/platform/adapters/wecombot/platform_api.py b/src/langbot/pkg/platform/adapters/wecombot/platform_api.py new file mode 100644 index 000000000..73f03cc15 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/platform_api.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import typing + + +async def is_websocket_mode(bot, params: dict) -> dict: + return {'websocket': hasattr(bot, 'bot_id') and not hasattr(bot, 'Token')} + + +async def get_stream_session_status(bot, params: dict) -> dict: + msg_id = str(params.get('message_id') or params.get('msg_id') or '') + if not msg_id: + raise ValueError('message_id is required') + if hasattr(bot, 'stream_sessions'): + stream_id = bot.stream_sessions.get_stream_id_by_msg(msg_id) + session = bot.stream_sessions.get_session(stream_id) if stream_id else None + return {'stream_id': stream_id, 'active': session is not None} + stream_key = getattr(bot, '_stream_ids', {}).get(msg_id) + if not stream_key: + return {'stream_id': None, 'active': False} + _req_id, _sep, stream_id = stream_key.partition('|') + return {'stream_id': stream_id, 'active': True} + + +async def send_markdown(bot, params: dict) -> dict: + chat_id = params.get('chat_id') or params.get('chatid') or params.get('target_id') + content = params.get('content') + if not chat_id: + raise ValueError('chat_id is required') + if not content: + raise ValueError('content is required') + if not hasattr(bot, 'send_message'): + raise ValueError('send_markdown is only available in WebSocket mode') + result = await bot.send_message(str(chat_id), str(content), msgtype='markdown') + return {'ok': True, 'raw': result} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = { + 'is_websocket_mode': is_websocket_mode, + 'get_stream_session_status': get_stream_session_status, + 'send_markdown': send_markdown, +} diff --git a/src/langbot/pkg/platform/adapters/wecombot/types.py b/src/langbot/pkg/platform/adapters/wecombot/types.py new file mode 100644 index 000000000..8e0d98dfa --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/types.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +ADAPTER_NAME = 'wecombot-eba' diff --git a/src/langbot/pkg/platform/adapters/wecombot/wecombot.png b/src/langbot/pkg/platform/adapters/wecombot/wecombot.png new file mode 100644 index 000000000..0734efaf0 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/wecombot/wecombot.png differ diff --git a/src/langbot/pkg/platform/adapters/wecomcs/__init__.py b/src/langbot/pkg/platform/adapters/wecomcs/__init__.py new file mode 100644 index 000000000..e1483424c --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/__init__.py @@ -0,0 +1,5 @@ +"""WeCom Customer Service EBA platform adapter.""" + +from langbot.pkg.platform.adapters.wecomcs.adapter import WecomCSAdapter + +__all__ = ['WecomCSAdapter'] diff --git a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py new file mode 100644 index 000000000..017482b5d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +import time +import traceback +import typing +import uuid + +import pydantic + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient +from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot.pkg.platform.adapters.wecomcs.api_impl import WecomCSAPIMixin +from langbot.pkg.platform.adapters.wecomcs.event_converter import WecomCSEventConverter +from langbot.pkg.platform.adapters.wecomcs.message_converter import WecomCSMessageConverter +from langbot.pkg.platform.adapters.wecomcs.platform_api import PLATFORM_API_MAP +from langbot.pkg.platform.adapters.wecomcs.types import parse_private_chat_id +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): + bot: WecomCSClient = pydantic.Field(exclude=True) + + message_converter: WecomCSMessageConverter = WecomCSMessageConverter() + event_converter: WecomCSEventConverter = WecomCSEventConverter() + + config: dict + bot_uuid: str | None = None + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _user_cache: dict[str, typing.Any] = {} + + class Config: + arbitrary_types_allowed = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + required_keys = [ + 'corpid', + 'secret', + 'token', + 'EncodingAESKey', + ] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise Exception(f'WeComCS missing required config fields: {missing_keys}') + + bot = WecomCSClient( + corpid=config['corpid'], + secret=config['secret'], + token=config['token'], + EncodingAESKey=config['EncodingAESKey'], + logger=logger, + unified_mode=True, + api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'), + ) + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id='', + bot_uuid=None, + listeners={}, + _message_cache={}, + _user_cache={}, + ) + self._register_native_handlers() + + def set_bot_uuid(self, bot_uuid: str): + self.bot_uuid = bot_uuid + + def get_supported_events(self) -> list[str]: + return [ + 'message.received', + 'platform.specific', + ] + + def get_supported_apis(self) -> list[str]: + return [ + 'send_message', + 'reply_message', + 'get_message', + 'get_user_info', + 'get_friend_list', + 'call_platform_api', + ] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> platform_events.MessageResult: + if target_type not in ('person', 'private'): + raise NotSupportedError(f'send_message:{target_type}') + + external_userid, open_kfid = parse_private_chat_id(target_id) + content_list = await WecomCSMessageConverter.yiri2target(message, self.bot) + raw_results = [] + for content in content_list: + raw_results.append(await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)) + return platform_events.MessageResult(raw={'results': raw_results}) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> platform_events.MessageResult: + wecom_event = await WecomCSEventConverter.yiri2target(message_source) + if not isinstance(wecom_event, WecomCSEvent): + raise ValueError('WeComCS reply_message requires a WecomCSEvent source object') + content_list = await WecomCSMessageConverter.yiri2target(message, self.bot) + raw_results = [] + for content in content_list: + raw_results.append( + await self._send_content( + wecom_event.receiver_id, + wecom_event.user_id, + self._make_outbound_msgid(), + content, + ) + ) + return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results}) + + async def call_platform_api(self, action: str, params: dict = {}) -> dict: + handler = PLATFORM_API_MAP.get(action) + if handler is None: + raise NotSupportedError(f'call_platform_api:{action}') + return await handler(self.bot, params) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + registered = self.listeners.get(event_type) + if registered is callback: + self.listeners.pop(event_type, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await self.logger.info('WeComCS EBA adapter running in unified webhook mode') + await keep_alive() + + async def kill(self) -> bool: + return True + + async def is_muted(self, group_id: int | None = None) -> bool: + return False + + def _register_native_handlers(self): + async def on_message(event: WecomCSEvent): + await self._handle_native_event(event) + + for msg_type in ('text', 'image', 'file', 'voice'): + self.bot.on_message(msg_type)(on_message) + + async def _handle_native_event(self, event: WecomCSEvent): + self.bot_account_id = event.receiver_id or self.bot_account_id + try: + if event.message_id and str(event.message_id) in self._message_cache: + await self.logger.debug(f'Skip duplicated WeComCS message: {event.message_id}') + return + + if platform_events.FriendMessage in self.listeners: + legacy_event = await self.event_converter.target2legacy(event, self.bot) + if legacy_event: + callback = self.listeners.get(type(legacy_event)) + if callback: + await callback(legacy_event, self) + + eba_event = await self.event_converter.target2yiri(event, self.bot) + if eba_event: + self._cache_event(eba_event) + await self._dispatch_eba_event(eba_event) + except Exception: + await self.logger.error(f'Error in wecomcs native event: {traceback.format_exc()}') + + async def _dispatch_eba_event(self, event: platform_events.EBAEvent): + for event_type in (type(event), platform_events.EBAEvent, platform_events.Event): + callback = self.listeners.get(event_type) + if callback: + await callback(event, self) + return + + def _cache_event(self, event: platform_events.Event): + if not isinstance(event, platform_events.MessageReceivedEvent): + return + self._message_cache[str(event.message_id)] = event + self._user_cache[str(event.sender.id)] = event.sender + + async def _send_content(self, open_kfid: str, external_userid: str, msgid: str, content: dict): + content_type = content.get('type') + if content_type == 'text': + return await self.bot.send_text_msg(open_kfid, external_userid, msgid, content.get('content', '')) + if content_type == 'image': + return await self.bot.send_image_msg(open_kfid, external_userid, msgid, content['media_id']) + raise NotSupportedError(f'send_content:{content_type}') + + @staticmethod + def _make_outbound_msgid() -> str: + return f'lb-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}' diff --git a/src/langbot/pkg/platform/adapters/wecomcs/api_impl.py b/src/langbot/pkg/platform/adapters/wecomcs/api_impl.py new file mode 100644 index 000000000..3fca63275 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/api_impl.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class WecomCSAPIMixin: + bot: WecomCSClient + _message_cache: dict[str, platform_events.MessageReceivedEvent] + _user_cache: dict[str, platform_entities.User] + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + event = self._message_cache.get(str(message_id)) + if event is None: + raise NotSupportedError('get_message:message_not_cached') + return event + + async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User: + cached = self._user_cache.get(str(user_id)) + if cached is not None: + return cached + info = await self.bot.get_customer_info(str(user_id)) + if not info: + raise NotSupportedError('get_user_info:not_found') + return platform_entities.User( + id=info.get('external_userid') or user_id, + nickname=info.get('nickname') or str(user_id), + avatar_url=info.get('avatar'), + username=info.get('external_userid') or None, + ) + + async def get_friend_list(self) -> list[platform_entities.User]: + return list(self._user_cache.values()) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + raise NotSupportedError('upload_file') + + async def get_file_url(self, file_id: str) -> str: + raise NotSupportedError('get_file_url') + + async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup: + raise NotSupportedError('get_group_info') + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + raise NotSupportedError('get_group_member_list') + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + raise NotSupportedError('get_group_member_info') + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + raise NotSupportedError('edit_message') + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + raise NotSupportedError('delete_message') diff --git a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py new file mode 100644 index 000000000..7c0743657 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient +from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.pkg.platform.adapters.wecomcs.message_converter import WecomCSMessageConverter +from langbot.pkg.platform.adapters.wecomcs.types import ADAPTER_NAME, make_private_chat_id +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events + + +class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> WecomCSEvent | None: + return getattr(event, 'source_platform_object', None) + + @staticmethod + async def target2legacy(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.FriendMessage | None: + eba_event = await WecomCSEventConverter.target2yiri(event, bot) + if hasattr(eba_event, 'to_legacy_event'): + return eba_event.to_legacy_event() + return None + + @staticmethod + async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None: + if event.type in {'text', 'image', 'file', 'voice'}: + return await WecomCSEventConverter.message_to_eba(event, bot) + return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}') + + @staticmethod + async def message_to_eba(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.MessageReceivedEvent: + message_chain = await WecomCSMessageConverter.target2yiri(event) + sender = await WecomCSEventConverter.user_from_event(event, bot) + return platform_events.MessageReceivedEvent( + type='message.received', + adapter_name=ADAPTER_NAME, + message_id=event.message_id or '', + message_chain=message_chain, + sender=sender, + chat_type=platform_entities.ChatType.PRIVATE, + chat_id=make_private_chat_id(event.user_id, event.receiver_id), + group=None, + timestamp=float(event.timestamp or 0), + source_platform_object=event, + ) + + @staticmethod + async def user_from_event(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_entities.User: + nickname = str(event.user_id or '') + avatar_url = None + raw: dict[str, typing.Any] = {} + if bot and event.user_id: + try: + raw = await bot.get_customer_info(event.user_id) or {} + nickname = raw.get('nickname') or nickname + avatar_url = raw.get('avatar') + except Exception: + raw = {} + + return platform_entities.User( + id=event.user_id or '', + nickname=nickname, + avatar_url=avatar_url, + username=raw.get('external_userid') or None, + ) + + @staticmethod + def platform_specific(event: WecomCSEvent, action: str) -> platform_events.PlatformSpecificEvent: + return platform_events.PlatformSpecificEvent( + type='platform.specific', + adapter_name=ADAPTER_NAME, + action=action, + data=dict(event), + timestamp=float(event.timestamp or 0), + source_platform_object=event, + ) diff --git a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml new file mode 100644 index 000000000..2e70e52f5 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml @@ -0,0 +1,106 @@ +apiVersion: v1 +kind: MessagePlatformAdapter + +metadata: + name: wecomcs-eba + label: + en_US: WeCom Customer Service + zh_Hans: 企业微信客服 + zh_Hant: 企業微信客服 + description: + en_US: WeCom Customer Service adapter with event-driven orchestration support + zh_Hans: 企业微信客服适配器,支持事件驱动编排,通过统一 Webhook 接收客服会话消息 + zh_Hant: 企業微信客服適配器,支援事件驅動編排,透過統一 Webhook 接收客服會話訊息 + icon: wecom.png + +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/wecomcs + en: https://link.langbot.app/en/platforms/wecomcs + ja: https://link.langbot.app/ja/platforms/wecomcs + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your WeCom Customer Service webhook configuration. + zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中。 + zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中。 + type: webhook-url + required: false + default: "" + - name: corpid + label: + en_US: Corpid + zh_Hans: 企业ID + zh_Hant: 企業ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 密钥 (Secret) + zh_Hant: 密鑰 (Secret) + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 (Token) + zh_Hant: 令牌 (Token) + type: string + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 消息加解密密钥 (EncodingAESKey) + zh_Hant: 訊息加解密密鑰 (EncodingAESKey) + type: string + required: true + default: "" + - name: api_base_url + label: + en_US: API Base URL + zh_Hans: API 基础 URL + zh_Hant: API 基礎 URL + description: + en_US: Optional WeCom API base URL for private network or reverse proxy deployments. + zh_Hans: 可选,若部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项 + zh_Hant: 可選,若部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項 + type: string + required: false + default: "https://qyapi.weixin.qq.com/cgi-bin" + + supported_events: + - message.received + - platform.specific + + supported_apis: + required: + - send_message + - reply_message + optional: + - get_message + - get_user_info + - get_friend_list + - call_platform_api + + platform_specific_apis: + - action: check_access_token + description: { en_US: "Check whether the current WeCom Customer Service access token is usable", zh_Hans: "检查当前企业微信客服 access token 是否可用" } + - action: refresh_access_token + description: { en_US: "Refresh the WeCom Customer Service access token", zh_Hans: "刷新企业微信客服 access token" } + - action: get_customer_info + description: { en_US: "Get WeCom Customer Service customer information by external user ID", zh_Hans: "按 external_userid 获取企业微信客服客户信息" } + +execution: + python: + path: ./adapter.py + attr: WecomCSAdapter diff --git a/src/langbot/pkg/platform/adapters/wecomcs/message_converter.py b/src/langbot/pkg/platform/adapters/wecomcs/message_converter.py new file mode 100644 index 000000000..4d4ac7492 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/message_converter.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import datetime + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +def split_string_by_bytes(text: str, limit: int = 2048, encoding: str = 'utf-8') -> list[str]: + """Split text without cutting a multi-byte character in half.""" + bytes_data = text.encode(encoding) + total_len = len(bytes_data) + parts: list[str] = [] + start = 0 + + while start < total_len: + end = min(start + limit, total_len) + chunk = bytes_data[start:end] + part = chunk.decode(encoding, errors='ignore') + part_len = len(part.encode(encoding)) + if part_len == 0 and end < total_len: + start += 1 + continue + parts.append(part) + start += part_len + + return parts + + +class WecomCSMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomCSClient) -> list[dict]: + content_list: list[dict] = [] + + for msg in message_chain: + if isinstance(msg, platform_message.Source): + continue + if isinstance(msg, platform_message.Plain): + content_list.extend({'type': 'text', 'content': chunk} for chunk in split_string_by_bytes(msg.text)) + elif isinstance(msg, platform_message.Image): + content_list.append({'type': 'image', 'media_id': await bot.get_media_id(msg)}) + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + content_list.extend(await WecomCSMessageConverter.yiri2target(node.message_chain, bot)) + elif isinstance(msg, platform_message.Quote): + if msg.id is not None: + content_list.append({'type': 'text', 'content': f'[Quote {msg.id}] '}) + if msg.origin: + content_list.extend(await WecomCSMessageConverter.yiri2target(msg.origin, bot)) + elif isinstance(msg, platform_message.At): + content_list.append({'type': 'text', 'content': f'@{msg.display or msg.target}'}) + elif isinstance(msg, platform_message.AtAll): + content_list.append({'type': 'text', 'content': '@all'}) + elif isinstance(msg, (platform_message.Voice, platform_message.File, platform_message.Face)): + raise NotSupportedError(f'wecomcs_send_component:{msg.type}') + else: + content_list.append({'type': 'text', 'content': str(msg)}) + + return content_list + + @staticmethod + async def target2yiri(event: dict) -> platform_message.MessageChain: + message_id = event.get('msgid') or '' + timestamp = event.get('send_time') or event.get('sendtime') or datetime.datetime.now().timestamp() + components: list[platform_message.MessageComponent] = [ + platform_message.Source(id=message_id, time=datetime.datetime.fromtimestamp(float(timestamp))), + ] + + msgtype = event.get('msgtype') + if msgtype == 'text': + components.append(platform_message.Plain(text=(event.get('text') or {}).get('content', ''))) + elif msgtype == 'image': + components.append(platform_message.Image(base64=event.get('picurl') or '')) + elif msgtype == 'file': + file_data = event.get('file') or {} + components.append( + platform_message.File( + id=file_data.get('media_id'), + name=file_data.get('filename') or file_data.get('file_name') or '', + size=file_data.get('file_size') or 0, + ) + ) + elif msgtype == 'voice': + voice_data = event.get('voice') or {} + components.append(platform_message.Voice(voice_id=voice_data.get('media_id') or '')) + else: + components.append(platform_message.Unknown(text=f'[unsupported wecomcs msgtype: {msgtype or "unknown"}]')) + + return platform_message.MessageChain(components) diff --git a/src/langbot/pkg/platform/adapters/wecomcs/platform_api.py b/src/langbot/pkg/platform/adapters/wecomcs/platform_api.py new file mode 100644 index 000000000..3f6bd2baf --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/platform_api.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import typing + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient + + +async def check_access_token(bot: WecomCSClient, params: dict) -> dict: + return {'valid': await bot.check_access_token()} + + +async def refresh_access_token(bot: WecomCSClient, params: dict) -> dict: + bot.access_token = await bot.get_access_token(bot.secret) + return {'ok': bool(bot.access_token)} + + +async def get_customer_info(bot: WecomCSClient, params: dict) -> dict: + user_id = params.get('external_userid') or params.get('user_id') or params.get('userid') + if not user_id: + raise ValueError('external_userid is required') + info = await bot.get_customer_info(str(user_id)) + return info or {} + + +PLATFORM_API_MAP: dict[str, typing.Callable[[WecomCSClient, dict], typing.Awaitable[dict]]] = { + 'check_access_token': check_access_token, + 'refresh_access_token': refresh_access_token, + 'get_customer_info': get_customer_info, +} diff --git a/src/langbot/pkg/platform/adapters/wecomcs/types.py b/src/langbot/pkg/platform/adapters/wecomcs/types.py new file mode 100644 index 000000000..ce8c7e457 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecomcs/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +ADAPTER_NAME = 'wecomcs-eba' + + +def make_private_chat_id(user_id: str | int | None, open_kfid: str | int | None) -> str: + """Build the routable private chat id used by the WeCom CS EBA adapter.""" + user = str(user_id or '') + kfid = str(open_kfid or '') + if not user or not kfid: + return user + return f'{user}|{kfid}' + + +def parse_private_chat_id(chat_id: str | int) -> tuple[str, str]: + user_id, sep, open_kfid = str(chat_id).partition('|') + if not user_id or not sep or not open_kfid: + raise ValueError('WeComCS target_id must be formatted as "external_userid|open_kfid"') + return user_id, open_kfid diff --git a/src/langbot/pkg/platform/adapters/wecomcs/wecom.png b/src/langbot/pkg/platform/adapters/wecomcs/wecom.png new file mode 100644 index 000000000..8588c20d5 Binary files /dev/null and b/src/langbot/pkg/platform/adapters/wecomcs/wecom.png differ diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 8e99618c3..8f8d155f9 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -3,7 +3,10 @@ import asyncio import json import re +import time import traceback +import typing +import uuid import sqlalchemy from ..core import app, entities as core_entities, taskmgr @@ -11,16 +14,253 @@ from ..discover import engine from ..entity.persistence import bot as persistence_bot -from ..entity.persistence import pipeline as persistence_pipeline - from ..entity.errors import platform as platform_errors +from ..agent.runner.config_resolver import RunnerConfigResolver +from ..agent.runner.host_models import ( + AgentBinding, + AgentEventEnvelope, + BindingScope, + DeliveryPolicy, + StatePolicy, +) +from ..agent.runner.resource_policy import ResourcePolicyProjector from .logger import EventLogger import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.events as plugin_events import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, + SubjectContext, + RawEventRef, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +class SyntheticRouteTestAdapter: + """Adapter wrapper that suppresses outbound platform delivery for test events.""" + + SIDE_EFFECT_API_NAMES = { + 'send_message', + 'reply_message', + 'reply_message_chunk', + 'create_message_card', + 'edit_message', + 'delete_message', + 'add_reaction', + 'remove_reaction', + 'forward_message', + 'set_group_name', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'approve_friend_request', + 'approve_group_invite', + 'upload_file', + 'call_platform_api', + } + + def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter): + self.source = source + self.bot_account_id = getattr(source, 'bot_account_id', '') + self.config = getattr(source, 'config', {}) + self.logger = getattr(source, 'logger', None) + self.suppressed_outputs: list[dict[str, typing.Any]] = [] + + @staticmethod + def _message_to_payload(message: platform_message.MessageChain) -> typing.Any: + return message.model_dump() if hasattr(message, 'model_dump') else str(message) + + def _suppress(self, method: str, **payload: typing.Any) -> None: + self.suppressed_outputs.append({'method': method, **payload}) + + def __getattr__(self, name: str) -> typing.Any: + return getattr(self.source, name) + + def get_supported_apis(self) -> list[str]: + get_supported_apis = getattr(self.source, 'get_supported_apis', None) + if not callable(get_supported_apis): + return [] + return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES] + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> dict[str, typing.Any]: + self._suppress( + 'send_message', + target_type=target_type, + target_id=target_id, + message=self._message_to_payload(message), + ) + return {'suppressed': True} + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> dict[str, typing.Any]: + self._suppress( + 'reply_message', + message=self._message_to_payload(message), + quote_origin=quote_origin, + ) + return {'suppressed': True} + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: dict, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict[str, typing.Any]: + self._suppress( + 'reply_message_chunk', + message=self._message_to_payload(message), + quote_origin=quote_origin, + is_final=is_final, + ) + return {'suppressed': True} + + async def create_message_card( + self, + message_id: str | int, + event: platform_events.MessageEvent, + ) -> bool: + self._suppress('create_message_card', message_id=str(message_id)) + return False + + async def is_stream_output_supported(self) -> bool: + return False + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + self._suppress( + 'edit_message', + chat_type=str(chat_type), + chat_id=str(chat_id), + message_id=str(message_id), + new_content=self._message_to_payload(new_content), + ) + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + self._suppress( + 'delete_message', + chat_type=str(chat_type), + chat_id=str(chat_id), + message_id=str(message_id), + ) + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + self._suppress( + 'forward_message', + from_chat_type=str(from_chat_type), + from_chat_id=str(from_chat_id), + message_id=str(message_id), + to_chat_type=str(to_chat_type), + to_chat_id=str(to_chat_id), + ) + return platform_events.MessageResult(raw={'suppressed': True}) + + async def set_group_name( + self, + group_id: typing.Union[int, str], + name: str, + ) -> None: + self._suppress('set_group_name', group_id=str(group_id), name=name) + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + self._suppress( + 'mute_member', + group_id=str(group_id), + user_id=str(user_id), + duration=duration, + ) + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id)) + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id)) + + async def leave_group( + self, + group_id: typing.Union[int, str], + ) -> None: + self._suppress('leave_group', group_id=str(group_id)) + + async def approve_friend_request( + self, + request_id: typing.Union[int, str], + approve: bool = True, + remark: str | None = None, + ) -> None: + self._suppress( + 'approve_friend_request', + request_id=str(request_id), + approve=approve, + remark=remark, + ) + + async def approve_group_invite( + self, + request_id: typing.Union[int, str], + approve: bool = True, + ) -> None: + self._suppress( + 'approve_group_invite', + request_id=str(request_id), + approve=approve, + ) + + async def upload_file(self, file_data: bytes, filename: str) -> str: + self._suppress('upload_file', filename=filename, size=len(file_data)) + return f'suppressed:{filename}' + + async def call_platform_api(self, action: str, params: dict | None = None) -> dict: + self._suppress('call_platform_api', action=action, params=params or {}) + return {'suppressed': True} class RuntimeBot: @@ -34,7 +274,7 @@ class RuntimeBot: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter - task_wrapper: taskmgr.TaskWrapper + task_wrapper: taskmgr.TaskWrapper | None task_context: taskmgr.TaskContext @@ -51,89 +291,1055 @@ def __init__( self.bot_entity = bot_entity self.enable = bot_entity.enable self.adapter = adapter + self.task_wrapper = None self.task_context = taskmgr.TaskContext() self.logger = logger + PIPELINE_DISCARD = '__discard__' + PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded' + EVENT_DATA_MAX_STRING_BYTES = 512 + + @staticmethod + def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None: + """Map a platform EBA event to a plugin EventListener event model.""" + event_mapping: list[tuple[type[platform_events.EBAEvent], type[plugin_events.BaseEventModel]]] = [ + (platform_events.MessageReceivedEvent, plugin_events.MessageReceived), + (platform_events.MessageEditedEvent, plugin_events.MessageEdited), + (platform_events.MessageDeletedEvent, plugin_events.MessageDeleted), + (platform_events.MessageReactionEvent, plugin_events.MessageReactionReceived), + (platform_events.FeedbackReceivedEvent, plugin_events.FeedbackReceived), + (platform_events.MemberJoinedEvent, plugin_events.GroupMemberJoined), + (platform_events.MemberLeftEvent, plugin_events.GroupMemberLeft), + (platform_events.MemberBannedEvent, plugin_events.GroupMemberBanned), + (platform_events.BotInvitedToGroupEvent, plugin_events.BotInvitedToGroup), + (platform_events.BotRemovedFromGroupEvent, plugin_events.BotRemovedFromGroup), + (platform_events.BotMutedEvent, plugin_events.BotMuted), + (platform_events.BotUnmutedEvent, plugin_events.BotUnmuted), + (platform_events.PlatformSpecificEvent, plugin_events.PlatformSpecificEventReceived), + ] + + for platform_event_type, plugin_event_type in event_mapping: + if isinstance(event, platform_event_type): + return plugin_event_type.from_platform_event(event) + + return None + @staticmethod - def _match_operator(actual: str, operator: str, expected: str) -> bool: - """Evaluate a single operator condition.""" + def _match_event_pattern(event_type: str, pattern: str) -> bool: + if not event_type or not pattern: + return False + if pattern == '*': + return True + if pattern.endswith('.*'): + return event_type.startswith(f'{pattern[:-2]}.') + return event_type == pattern + + @classmethod + def _is_message_event_type(cls, event_type: str) -> bool: + return cls._match_event_pattern(event_type, 'message.*') + + @classmethod + def _agent_supports_event_type( + cls, + supported_patterns: list[str] | None, + event_type: str, + ) -> bool: + return any(cls._match_event_pattern(event_type, pattern) for pattern in (supported_patterns or ['*'])) + + @staticmethod + def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any: + current: typing.Any = data + for key in path.split('.'): + if isinstance(current, dict): + current = current.get(key) + else: + current = getattr(current, key, None) + if current is None: + return None + return current + + @classmethod + def _match_event_filter( + cls, + event_data: dict[str, typing.Any], + event_filter: dict[str, typing.Any], + ) -> bool: + field = str(event_filter.get('field') or event_filter.get('path') or '').strip() + if not field: + return True + + operator = str(event_filter.get('operator') or 'eq') + expected = event_filter.get('value') + actual = cls._get_nested_value(event_data, field) + if operator == 'eq': return actual == expected - elif operator == 'neq': + if operator == 'neq': return actual != expected - elif operator == 'contains': - return expected in actual - elif operator == 'not_contains': - return expected not in actual - elif operator == 'starts_with': - return actual.startswith(expected) - elif operator == 'regex': + if operator == 'contains': + if isinstance(actual, (list, tuple, set)): + return expected in actual + return str(expected) in str(actual or '') + if operator == 'not_contains': + if isinstance(actual, (list, tuple, set)): + return expected not in actual + return str(expected) not in str(actual or '') + if operator == 'starts_with': + return str(actual or '').startswith(str(expected)) + if operator == 'regex': try: - return bool(re.search(expected, actual)) + return bool(re.search(str(expected), str(actual or ''))) except re.error: return False return False - PIPELINE_DISCARD = '__discard__' - PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded' + @classmethod + def _augment_event_data( + cls, + event_data: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Inject virtual computed fields to simplify common filter patterns.""" + message_chain = event_data.get('message_chain') + if isinstance(message_chain, list): + text_parts = [ + comp.get('text', '') for comp in message_chain if isinstance(comp, dict) and comp.get('type') == 'Plain' + ] + event_data['message_text'] = ''.join(text_parts) + event_data['message_element_types'] = [ + comp.get('type', '') for comp in message_chain if isinstance(comp, dict) + ] + if 'group' in event_data: + event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person' + return event_data + + @classmethod + def _match_event_filters( + cls, + event: platform_events.EBAEvent, + filters: typing.Any, + ) -> bool: + if not filters: + return True + if not isinstance(filters, list): + return False + + event_data = cls._augment_event_data(cls._safe_model_dump(event)) + return all( + cls._match_event_filter(event_data, event_filter) + for event_filter in filters + if isinstance(event_filter, dict) + ) + + @staticmethod + def _get_event_bindings_from_value(raw_bindings: typing.Any) -> list[dict[str, typing.Any]]: + raw_bindings = raw_bindings or [] + if isinstance(raw_bindings, str): + try: + raw_bindings = json.loads(raw_bindings) + except json.JSONDecodeError: + raw_bindings = [] + if not isinstance(raw_bindings, list): + return [] + return [binding for binding in raw_bindings if isinstance(binding, dict)] + + def _get_event_bindings(self) -> list[dict[str, typing.Any]]: + return self._get_event_bindings_from_value(self.bot_entity.event_bindings) + + @classmethod + def _evaluate_eba_event_bindings( + cls, + bindings: list[dict[str, typing.Any]], + event: platform_events.EBAEvent, + event_type: str, + ) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]: + """Evaluate Bot event bindings with the same precedence used at runtime.""" + matched: list[tuple[int, int, dict[str, typing.Any], dict[str, typing.Any]]] = [] + diagnostic_steps: list[dict[str, typing.Any]] = [] + + for index, binding in enumerate(bindings): + event_pattern = str(binding.get('event_pattern') or '') + priority = int(binding.get('priority') or 0) + order = int(binding.get('order', index)) + step = { + 'step': 'evaluate_binding', + 'binding_id': binding.get('id'), + 'event_pattern': event_pattern, + 'target_type': binding.get('target_type'), + 'target_uuid': binding.get('target_uuid') or '', + 'enabled': binding.get('enabled', True), + 'priority': priority, + 'order': order, + 'matched': False, + 'failure_code': None, + 'reason': '', + } + + if not binding.get('enabled', True): + step['failure_code'] = 'binding_disabled' + step['reason'] = 'Binding is disabled' + diagnostic_steps.append(step) + continue + + if not cls._match_event_pattern(event_type, event_pattern): + step['failure_code'] = 'event_pattern_mismatch' + step['reason'] = 'Event type does not match binding event_pattern' + diagnostic_steps.append(step) + continue + if not cls._match_event_filters(event, binding.get('filters')): + step['failure_code'] = 'filters_mismatch' + step['reason'] = 'Event data does not satisfy binding filters' + diagnostic_steps.append(step) + continue + + step['matched'] = True + step['reason'] = 'Binding matched event pattern and filters' + diagnostic_steps.append(step) + matched.append((priority, -order, binding, step)) + + if not matched: + return None, diagnostic_steps + + matched.sort(key=lambda item: (item[0], item[1]), reverse=True) + selected_binding = matched[0][2] + selected_step = matched[0][3] + selected_step['selected'] = True + selected_step['reason'] = 'Selected by priority and order' + for _, _, _, step in matched[1:]: + step['selected'] = False + step['failure_code'] = 'lower_priority' + step['reason'] = 'Another matching binding has higher priority or earlier order' + return selected_binding, diagnostic_steps + + def _resolve_eba_event_binding( + self, + event: platform_events.EBAEvent, + event_type: str, + ) -> dict[str, typing.Any] | None: + """Resolve the highest priority Bot event binding for a platform event.""" + selected, _ = self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) + return selected + + def diagnose_eba_event_binding( + self, + event: platform_events.EBAEvent, + event_type: str, + ) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]: + """Return the selected event binding plus per-binding diagnostic steps.""" + return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) + + @staticmethod + def _build_test_platform_event( + event_type: str, + payload: dict[str, typing.Any] | None = None, + ) -> platform_events.EBAEvent: + """Build a synthetic platform event for route validation.""" + payload = payload or {} + now = time.time() + common = { + 'type': event_type, + 'timestamp': payload.get('timestamp') or now, + 'adapter_name': payload.get('adapter_name') or 'test-event', + 'source_platform_object': {'synthetic': True, 'payload': payload}, + } + + user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user') + user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User') + group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group') + group_name = str(payload.get('group_name') or 'Test Group') + + if event_type == 'message.received': + chat_type_value = str(payload.get('chat_type') or 'private') + chat_type = ( + platform_entities.ChatType.GROUP + if chat_type_value == platform_entities.ChatType.GROUP.value + else platform_entities.ChatType.PRIVATE + ) + chat_id = str( + payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id) + ) + message_text = str(payload.get('message_text') or payload.get('text') or '') + message_chain_data = payload.get('message_chain') + if message_chain_data is None: + message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)]) + else: + message_chain = platform_message.MessageChain.model_validate(message_chain_data) + group = ( + platform_entities.UserGroup(id=chat_id, name=group_name) + if chat_type == platform_entities.ChatType.GROUP + else None + ) + return platform_events.MessageReceivedEvent( + **common, + message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'), + message_chain=message_chain, + sender=platform_entities.User(id=user_id, nickname=user_name), + chat_type=chat_type, + chat_id=chat_id, + group=group, + ) + + if event_type == 'group.member_joined': + return platform_events.MemberJoinedEvent( + **common, + group=platform_entities.UserGroup(id=group_id, name=group_name), + member=platform_entities.User(id=user_id, nickname=user_name), + inviter=platform_entities.User( + id=str(payload.get('inviter_id')), + nickname=str(payload.get('inviter_name') or ''), + ) + if payload.get('inviter_id') + else None, + join_type=payload.get('join_type'), + ) + + if event_type == 'group.member_left': + return platform_events.MemberLeftEvent( + **common, + group=platform_entities.UserGroup(id=group_id, name=group_name), + member=platform_entities.User(id=user_id, nickname=user_name), + is_kicked=bool(payload.get('is_kicked', False)), + operator=platform_entities.User( + id=str(payload.get('operator_id')), + nickname=str(payload.get('operator_name') or ''), + ) + if payload.get('operator_id') + else None, + ) + + if event_type == 'platform.specific': + return platform_events.PlatformSpecificEvent( + **common, + action=str(payload.get('action') or 'test'), + data=payload.get('data') if isinstance(payload.get('data'), dict) else payload, + ) - def resolve_pipeline_uuid( + return platform_events.EBAEvent(**common) + + async def dispatch_test_event( self, - launcher_type: str, - launcher_id: str, - message_text: str, - message_element_types: list[str] | None = None, - ) -> tuple[str | None, bool]: - """Resolve pipeline UUID based on routing rules. - - Rules are evaluated in order; first match wins. - Falls back to use_pipeline_uuid if no rule matches. - - Rule types: - - launcher_type: session type ("person" / "group") - - launcher_id: session / group id - - message_content: message text content - - message_has_element: message contains element of given type - (Image, Voice, File, Forward, Face, At, AtAll, Quote) - Operators: eq (has), neq (doesn't have) - - Operators: eq, neq, contains, not_contains, starts_with, regex - - When pipeline_uuid is ``__discard__``, the message should be - silently dropped by the caller. - - Returns: - tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True - when a routing rule matched, False when falling back to default. - """ - rules = self.bot_entity.pipeline_routing_rules or [] - element_type_set = set(message_element_types or []) - - for rule in rules: - rule_type = rule.get('type') - operator = rule.get('operator', 'eq') - rule_value = rule.get('value', '') - target_uuid = rule.get('pipeline_uuid') - if not rule_type or not target_uuid: + event_type: str, + payload: dict[str, typing.Any] | None = None, + ) -> dict[str, typing.Any]: + """Dispatch a synthetic event through the real runtime route path.""" + event_type = str(event_type or '').strip() + if not event_type: + raise ValueError('event_type is required') + + event = self._build_test_platform_event(event_type, payload) + await self._record_event_route_trace( + event_type=event_type, + status='test_started', + reason='Synthetic test event dispatched from control plane', + text=f'Test event {event_type} dispatched from control plane', + ) + test_adapter = SyntheticRouteTestAdapter(self.adapter) + outcome = await self._dispatch_eba_event_to_processor( + event, + typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter), + ) + return { + 'event_type': event_type, + 'dispatched': outcome['status'] in {'delivered', 'discarded'}, + 'status': outcome['status'], + 'binding_id': outcome.get('binding_id'), + 'failure_code': outcome.get('failure_code'), + 'reason': outcome.get('reason'), + 'suppressed_outputs': test_adapter.suppressed_outputs, + } + + async def _record_event_route_trace( + self, + *, + event_type: str, + status: str, + text: str, + level: str = 'info', + binding: dict[str, typing.Any] | None = None, + target_type: str | None = None, + target_uuid: str | None = None, + failure_code: str | None = None, + reason: str | None = None, + run_id: str | None = None, + ) -> dict[str, typing.Any]: + """Record structured event routing state while preserving the human log.""" + binding = binding or {} + metadata = { + 'kind': 'event_route_trace', + 'event_type': event_type, + 'status': status, + 'binding_id': binding.get('id'), + 'event_pattern': binding.get('event_pattern'), + 'target_type': target_type or binding.get('target_type'), + 'target_uuid': target_uuid or binding.get('target_uuid') or '', + 'failure_code': failure_code, + 'reason': reason or text, + 'run_id': run_id, + } + log_method = getattr(self.logger, level, self.logger.info) + await log_method(text, metadata=metadata) + return metadata + + def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None: + """Return the first Pipeline target configured for an event type.""" + matched: list[tuple[int, int, str]] = [] + for index, binding in enumerate(self._get_event_bindings()): + if not binding.get('enabled', True): + continue + if binding.get('target_type') != 'pipeline': continue + target_uuid = str(binding.get('target_uuid') or '') + if not target_uuid: + continue + if not self._match_event_pattern(event_type, str(binding.get('event_pattern') or '')): + continue + priority = int(binding.get('priority') or 0) + order = int(binding.get('order', index)) + matched.append((priority, -order, target_uuid)) + + if not matched: + return None + + matched.sort(key=lambda item: (item[0], item[1]), reverse=True) + return matched[0][2] - if rule_type == 'launcher_type': - if self._match_operator(launcher_type, operator, rule_value): - return target_uuid, True - elif rule_type == 'launcher_id': - if self._match_operator(str(launcher_id), operator, str(rule_value)): - return target_uuid, True - elif rule_type == 'message_content': - if self._match_operator(message_text, operator, rule_value): - return target_uuid, True - elif rule_type == 'message_has_element': - has_element = rule_value in element_type_set - if operator == 'eq' and has_element: - return target_uuid, True - elif operator == 'neq' and not has_element: - return target_uuid, True - - return self.bot_entity.use_pipeline_uuid, False + @staticmethod + def _safe_model_dump(model: typing.Any) -> dict[str, typing.Any]: + if model is None: + return {} + if hasattr(model, 'model_dump'): + try: + return model.model_dump(mode='json') + except TypeError: + try: + return model.model_dump() + except Exception: + return {} + except Exception: + return {} + if isinstance(model, dict): + return model + return {} + + @classmethod + def _compact_event_data(cls, event: platform_events.EBAEvent) -> dict[str, typing.Any]: + raw_event_data = cls._safe_model_dump(event) + compact: dict[str, typing.Any] = {} + for key, value in raw_event_data.items(): + if key == 'source_platform_object' or key.startswith('_'): + continue + if value is None or isinstance(value, (bool, int, float)): + compact[key] = value + continue + if isinstance(value, str): + if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + continue + if isinstance(value, (list, dict)): + try: + encoded = json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + continue + if len(encoded.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES: + compact[key] = value + return compact + + @staticmethod + def _get_entity_id(entity: typing.Any) -> str | None: + entity_id = getattr(entity, 'id', None) + if entity_id is None and isinstance(entity, dict): + entity_id = entity.get('id') + if entity_id is None or entity_id == '': + return None + return str(entity_id) + + @staticmethod + def _get_entity_name(entity: typing.Any) -> str | None: + if entity is None: + return None + if hasattr(entity, 'get_name'): + try: + name = entity.get_name() + if name: + return str(name) + except Exception: + pass + for attr in ('nickname', 'member_name', 'name', 'display_name'): + value = getattr(entity, attr, None) + if value: + return str(value) + if isinstance(entity, dict): + for attr in ('nickname', 'member_name', 'name', 'display_name'): + value = entity.get(attr) + if value: + return str(value) + return None + + @classmethod + def _infer_actor_context(cls, event: platform_events.EBAEvent) -> ActorContext | None: + actor = getattr(event, 'sender', None) or getattr(event, 'member', None) or getattr(event, 'user', None) + actor_id = cls._get_entity_id(actor) + actor_name = cls._get_entity_name(actor) + + if actor_id is None: + user_id = getattr(event, 'user_id', None) + if user_id: + actor_id = str(user_id) + + if actor_id is None: + return None + + return ActorContext( + actor_type='user', + actor_id=actor_id, + actor_name=actor_name, + metadata={}, + ) + + @classmethod + def _infer_subject_context(cls, event: platform_events.EBAEvent) -> SubjectContext: + group = getattr(event, 'group', None) + if group is not None: + group_id = cls._get_entity_id(group) + return SubjectContext( + subject_type='group', + subject_id=group_id, + data={'group_name': cls._get_entity_name(group)}, + ) + + message_id = getattr(event, 'message_id', None) + if message_id: + return SubjectContext( + subject_type='message', + subject_id=str(message_id), + data={}, + ) + + feedback_id = getattr(event, 'feedback_id', None) + if feedback_id: + return SubjectContext( + subject_type='feedback', + subject_id=str(feedback_id), + data={'message_id': getattr(event, 'message_id', None)}, + ) + + action = getattr(event, 'action', None) + if action: + return SubjectContext( + subject_type='platform_action', + subject_id=str(action), + data={}, + ) + + return SubjectContext( + subject_type='event', + subject_id=getattr(event, 'type', None), + data={}, + ) + + @staticmethod + def _session_to_reply_target(session_id: str | None) -> tuple[str | None, str | None]: + if not session_id or '_' not in session_id: + return None, None + target_type, target_id = session_id.split('_', 1) + if target_type == 'person': + target_type = 'person' + elif target_type == 'group': + target_type = 'group' + else: + return None, None + return target_type, target_id or None + + @classmethod + def _infer_reply_target( + cls, + event: platform_events.EBAEvent, + ) -> tuple[str | None, str | None, dict[str, typing.Any]]: + metadata: dict[str, typing.Any] = {} + group = getattr(event, 'group', None) + group_id = cls._get_entity_id(group) + if group_id: + metadata['group_id'] = group_id + return 'group', group_id, metadata + + chat_id = getattr(event, 'chat_id', None) + chat_type = getattr(event, 'chat_type', None) + chat_type_value = getattr(chat_type, 'value', chat_type) + if chat_id: + metadata['chat_id'] = str(chat_id) + if chat_type_value == 'group': + return 'group', str(chat_id), metadata + return 'person', str(chat_id), metadata + + session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None)) + if session_target_type and session_target_id: + return session_target_type, session_target_id, metadata + + raw_data = getattr(event, 'data', None) + if isinstance(raw_data, dict): + target_type = raw_data.get('target_type') or raw_data.get('chat_type') + target_id = ( + raw_data.get('target_id') + or raw_data.get('chat_id') + or raw_data.get('group_id') + or raw_data.get('user_id') + ) + if target_type and target_id: + return str(target_type), str(target_id), metadata + + return None, None, metadata + + @staticmethod + def _extract_message_id(message_chain: platform_message.MessageChain) -> str: + for component in message_chain: + if isinstance(component, platform_message.Source): + value = getattr(component, 'id', '') + return str(value) if value is not None else '' + return '' + + def _legacy_message_to_eba_event( + self, + event: platform_events.FriendMessage | platform_events.GroupMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> platform_events.MessageReceivedEvent: + if isinstance(event, platform_events.GroupMessage): + group = platform_entities.UserGroup( + id=event.group.id, + name=event.group.name, + ) + return platform_events.MessageReceivedEvent( + message_id=self._extract_message_id(event.message_chain), + message_chain=event.message_chain, + sender=platform_entities.User( + id=event.sender.id, + nickname=event.sender.member_name, + ), + chat_type=platform_entities.ChatType.GROUP, + chat_id=event.group.id, + group=group, + timestamp=event.time or time.time(), + bot_uuid=self.bot_entity.uuid, + adapter_name=adapter.__class__.__name__, + source_platform_object=event.source_platform_object, + ) + + return platform_events.MessageReceivedEvent( + message_id=self._extract_message_id(event.message_chain), + message_chain=event.message_chain, + sender=platform_entities.User( + id=event.sender.id, + nickname=event.sender.nickname, + remark=event.sender.remark, + ), + chat_type=platform_entities.ChatType.PRIVATE, + chat_id=event.sender.id, + timestamp=event.time or time.time(), + bot_uuid=self.bot_entity.uuid, + adapter_name=adapter.__class__.__name__, + source_platform_object=event.source_platform_object, + ) + + @classmethod + def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput: + text = None + contents: list[dict[str, typing.Any]] = [] + + message_chain = getattr(event, 'message_chain', None) + if message_chain: + text_parts: list[str] = [] + try: + for component in message_chain: + if isinstance(component, platform_message.Plain): + text_parts.append(component.text) + elif isinstance(component, platform_message.Image): + if component.url: + contents.append({'type': 'image_url', 'image_url': {'url': component.url}}) + elif component.base64: + contents.append({'type': 'image_base64', 'image_base64': component.base64}) + except TypeError: + text_parts.append(str(message_chain)) + text = ''.join(text_parts) or str(message_chain) + + if text is None: + feedback_content = getattr(event, 'feedback_content', None) + if feedback_content: + text = str(feedback_content) + elif getattr(event, 'action', None): + text = str(getattr(event, 'action')) + else: + text = str(getattr(event, 'type', 'event')) + + if text: + contents.insert(0, {'type': 'text', 'text': text}) + + return AgentInput( + text=text, + contents=contents, + attachments=[], + ) + + def _eba_event_to_agent_envelope( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> AgentEventEnvelope: + event_type = getattr(event, 'type', None) or event.__class__.__name__ + event_time = getattr(event, 'timestamp', None) or time.time() + event_id = ( + getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}' + ) + target_type, target_id, target_metadata = self._infer_reply_target(event) + supported_apis = self._get_adapter_supported_apis(adapter) + + conversation_id = None + if target_type and target_id: + conversation_id = f'{target_type}_{target_id}' + elif getattr(event, 'session_id', None): + conversation_id = str(getattr(event, 'session_id')) + + return AgentEventEnvelope( + event_id=f'platform:{self.bot_entity.uuid}:{event_id}', + event_type=event_type, + event_time=int(event_time) if isinstance(event_time, (int, float)) else None, + source='platform', + source_event_type=event_type, + bot_id=self.bot_entity.uuid, + workspace_id=None, + conversation_id=conversation_id, + thread_id=None, + actor=self._infer_actor_context(event), + subject=self._infer_subject_context(event), + input=self._build_agent_input(event), + delivery=DeliveryContext( + surface='platform', + reply_target={ + 'target_type': target_type, + 'target_id': target_id, + 'message_id': getattr(event, 'message_id', None), + **target_metadata, + }, + supports_streaming=False, + supports_edit='edit_message' in supported_apis, + supports_reaction=bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), + platform_capabilities={ + 'adapter': adapter.__class__.__name__, + 'event_type': event_type, + 'supported_apis': supported_apis, + }, + ), + raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None), + data=self._compact_event_data(event), + ) + + @staticmethod + def _get_adapter_supported_apis( + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> list[str]: + get_supported_apis = getattr(adapter, 'get_supported_apis', None) + if not callable(get_supported_apis): + return [] + try: + declared_apis = get_supported_apis() + except Exception: + return [] + if not isinstance(declared_apis, (list, tuple, set)): + return [] + return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name)) + + @staticmethod + def _agent_product_to_binding( + agent: dict[str, typing.Any], + event_binding: dict[str, typing.Any], + event_type: str, + bot_uuid: str, + ) -> AgentBinding | None: + config = agent.get('config') if isinstance(agent, dict) else None + if config is None: + return None + + _, runner_id, runner_config = RunnerConfigResolver.resolve_agent_runner_config(config) + if not runner_id: + return None + + return AgentBinding( + binding_id=f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}', + scope=BindingScope(scope_type='bot', scope_id=bot_uuid), + event_types=[event_type], + runner_id=runner_id, + runner_config=runner_config, + resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), + state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), + delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), + enabled=True, + agent_id=agent.get('uuid'), + ) + + @staticmethod + def _provider_content_to_text(content: typing.Any) -> str: + if content is None: + return '' + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item + if isinstance(item_data, dict): + if item_data.get('type') == 'text' and item_data.get('text') is not None: + parts.append(str(item_data.get('text'))) + elif item_data.get('text') is not None: + parts.append(str(item_data.get('text'))) + elif item_data is not None: + parts.append(str(item_data)) + return ''.join(parts) + return str(content) + + @classmethod + def _provider_output_to_text(cls, result: provider_message.Message | provider_message.MessageChunk) -> str: + if getattr(result, 'all_content', None): + return str(getattr(result, 'all_content')) + return cls._provider_content_to_text(getattr(result, 'content', None)) + + async def _deliver_agent_outputs( + self, + envelope: AgentEventEnvelope, + outputs: list[provider_message.Message | provider_message.MessageChunk], + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None, + ) -> None: + if not outputs or not envelope.delivery.reply_target: + return + + reply_target = envelope.delivery.reply_target + target_type = reply_target.get('target_type') + target_id = reply_target.get('target_id') + if not target_type or not target_id: + return + + final_text = '' + for output in outputs: + output_text = self._provider_output_to_text(output) + if isinstance(output, provider_message.Message): + final_text = output_text or final_text + elif output_text: + final_text = output_text + + if not final_text: + return + + delivery_adapter = adapter or self.adapter + await delivery_adapter.send_message( + str(target_type), + str(target_id), + platform_message.MessageChain([platform_message.Plain(text=final_text)]), + ) + + async def _handle_platform_event( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> None: + event.bot_uuid = self.bot_entity.uuid + plugin_event = self._eba_event_to_plugin_event(event) + + if plugin_event is not None: + try: + await self.ap.plugin_connector.emit_event(plugin_event) + except Exception: + await self.logger.error(f'Failed to dispatch platform event to plugins: {traceback.format_exc()}') + + await self._dispatch_eba_event_to_processor(event, adapter) + + async def _dispatch_eba_event_to_processor( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> dict[str, typing.Any]: + event_type = getattr(event, 'type', None) or event.__class__.__name__ + + event_binding = self._resolve_eba_event_binding(event, event_type) + if event_binding is None: + return await self._record_event_route_trace( + event_type=event_type, + status='not_matched', + failure_code='route_not_found', + reason='No event route matched', + text=f'Platform event {event_type} ignored: no event route matched', + ) + + target_type = event_binding.get('target_type') + await self._record_event_route_trace( + event_type=event_type, + status='matched', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + text=f'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(), + ) + if target_type == 'discard': + if isinstance(event, platform_events.MessageReceivedEvent): + await self._dispatch_eba_message_to_pipeline( + event, + adapter, + pipeline_uuid=self.PIPELINE_DISCARD, + routed_by_event_binding=True, + ) + return await self._record_event_route_trace( + event_type=event_type, + status='discarded', + binding=event_binding, + target_type=target_type, + text=f'EBA event {event_type} discarded by event binding', + ) + return await self._record_event_route_trace( + event_type=event_type, + status='discarded', + binding=event_binding, + target_type=target_type, + text=f'EBA event {event_type} discarded by event binding', + ) + if target_type == 'pipeline': + if not self._is_message_event_type(event_type): + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + failure_code='processor_incompatible', + reason='Pipeline targets only support message events', + text=f'EBA event {event_type} ignored Pipeline target for non-message event', + ) + await self._dispatch_eba_message_to_pipeline( + event, + adapter, + pipeline_uuid=event_binding.get('target_uuid'), + routed_by_event_binding=True, + ) + return await self._record_event_route_trace( + event_type=event_type, + status='delivered', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), + ) + if target_type != 'agent': + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + failure_code='processor_incompatible', + reason=f'Unsupported event binding target type: {target_type}', + text=f'EBA event {event_type} ignored unsupported target type {target_type}', + ) + + target_uuid = event_binding.get('target_uuid') + agent = await self.ap.agent_service.get_agent(target_uuid) + if not agent or agent.get('kind') != 'agent': + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_not_found', + reason='Agent target not found', + text=f'EBA event {event_type} target agent not found: {target_uuid}', + ) + if not agent.get('enabled', True): + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_disabled', + reason='Agent target is disabled', + text=f'EBA event {event_type} target agent disabled: {target_uuid}', + ) + if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type): + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_incompatible', + reason='Agent target does not support this event type', + text=f'EBA event {event_type} target agent does not support this event: {target_uuid}', + ) + + try: + binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid) + except Exception: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='runner_failed', + reason='Agent configuration is invalid', + text=f'Failed to build Agent binding for EBA event {event_type}: {traceback.format_exc()}', + ) + if binding is None: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_not_found', + reason='Agent target has no runner', + text=f'EBA event {event_type} target agent has no runner: {target_uuid}', + ) + + envelope = self._eba_event_to_agent_envelope(event, adapter) + outputs: list[provider_message.Message | provider_message.MessageChunk] = [] + try: + async for output in self.ap.agent_run_orchestrator.run(envelope, binding): + outputs.append(output) + except Exception: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='runner_failed', + reason='Agent runner failed', + text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}', + ) + + try: + await self._deliver_agent_outputs(envelope, outputs, adapter=adapter) + except Exception: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='delivery_failed', + reason='Agent output delivery failed', + text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}', + ) + return await self._record_event_route_trace( + event_type=event_type, + status='delivered', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + text=f'EBA event {event_type} delivered to Agent {target_uuid}', + ) async def _record_discarded_message( self, @@ -196,128 +1402,126 @@ async def _record_discarded_message( except Exception as e: await self.logger.error(f'Failed to record discarded message: {e}') - async def initialize(self): - async def on_friend_message( - event: platform_events.FriendMessage, - adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, - ): - image_components = [ - component for component in event.message_chain if isinstance(component, platform_message.Image) - ] + async def _handle_legacy_message_event( + self, + event: platform_events.FriendMessage | platform_events.GroupMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid_override: str | None = None, + routed_by_event_binding: bool = False, + ) -> None: + is_group_message = isinstance(event, platform_events.GroupMessage) + launcher_kind = 'group' if is_group_message else 'person' + launcher_type = ( + provider_session.LauncherTypes.GROUP if is_group_message else provider_session.LauncherTypes.PERSON + ) + launcher_id = event.group.id if is_group_message else event.sender.id + sender_id = event.sender.id - await self.logger.info( - f'{event.message_chain}', - images=image_components, - message_session_id=f'person_{event.sender.id}', - ) + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'{launcher_kind}_{launcher_id}', + ) - # Push to webhooks and check if pipeline should be skipped - skip_pipeline = False - if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + skip_pipeline = False + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + if is_group_message: + skip_pipeline = await self.ap.webhook_pusher.push_group_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + else: skip_pipeline = await self.ap.webhook_pusher.push_person_message( event, self.bot_entity.uuid, adapter.__class__.__name__ ) - # Only add to query pool if no webhook requested to skip pipeline - if not skip_pipeline: - launcher_id = event.sender.id - - if hasattr(adapter, 'get_launcher_id'): - custom_launcher_id = adapter.get_launcher_id(event) - if custom_launcher_id: - launcher_id = custom_launcher_id + if skip_pipeline: + await self.logger.info(f'Pipeline skipped for {launcher_kind} message due to webhook response') + return + + if hasattr(adapter, 'get_launcher_id'): + custom_launcher_id = adapter.get_launcher_id(event) + if custom_launcher_id: + launcher_id = custom_launcher_id + + if pipeline_uuid_override is None: + pipeline_uuid = None + routed_by_rule = False + else: + pipeline_uuid = pipeline_uuid_override + routed_by_rule = routed_by_event_binding + + if pipeline_uuid == self.PIPELINE_DISCARD: + await self.logger.info(f'{launcher_kind.title()} message discarded by routing rule') + await self._record_discarded_message( + launcher_type, + launcher_id, + sender_id, + event, + event.message_chain, + ) + return + + await self.ap.msg_aggregator.add_message( + bot_uuid=self.bot_entity.uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=event, + message_chain=event.message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) - message_text = str(event.message_chain) - element_types = [comp.type for comp in event.message_chain] - pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( - 'person', launcher_id, message_text, element_types - ) + async def _dispatch_eba_message_to_pipeline( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid: str | None = None, + routed_by_event_binding: bool = False, + ) -> None: + if not isinstance(event, platform_events.MessageReceivedEvent): + event_type = getattr(event, 'type', None) or event.__class__.__name__ + await self.logger.warning(f'EBA event {event_type} cannot be dispatched to legacy Pipeline') + return + + await self._handle_legacy_message_event( + event.to_legacy_event(), + adapter, + pipeline_uuid_override=pipeline_uuid, + routed_by_event_binding=routed_by_event_binding, + ) - if pipeline_uuid == self.PIPELINE_DISCARD: - await self.logger.info('Person message discarded by routing rule') - await self._record_discarded_message( - provider_session.LauncherTypes.PERSON, - launcher_id, - event.sender.id, - event, - event.message_chain, - ) - return + async def initialize(self): + def websocket_pipeline_uuid(event, adapter): + if adapter.__class__.__name__ != 'WebSocketAdapter': + return None + value = getattr(event, '_langbot_pipeline_uuid', None) + return value if isinstance(value, str) and value else None - await self.ap.msg_aggregator.add_message( - bot_uuid=self.bot_entity.uuid, - launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id=launcher_id, - sender_id=event.sender.id, - message_event=event, - message_chain=event.message_chain, - adapter=adapter, - pipeline_uuid=pipeline_uuid, - routed_by_rule=routed_by_rule, - ) - else: - await self.logger.info('Pipeline skipped for person message due to webhook response') + async def on_friend_message( + event: platform_events.FriendMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + pipeline_uuid = websocket_pipeline_uuid(event, adapter) + if pipeline_uuid: + await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid) + return + await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter) async def on_group_message( event: platform_events.GroupMessage, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): - image_components = [ - component for component in event.message_chain if isinstance(component, platform_message.Image) - ] - - await self.logger.info( - f'{event.message_chain}', - images=image_components, - message_session_id=f'group_{event.group.id}', - ) - - # Push to webhooks and check if pipeline should be skipped - skip_pipeline = False - if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: - skip_pipeline = await self.ap.webhook_pusher.push_group_message( - event, self.bot_entity.uuid, adapter.__class__.__name__ - ) - - # Only add to query pool if no webhook requested to skip pipeline - if not skip_pipeline: - launcher_id = event.group.id - - if hasattr(adapter, 'get_launcher_id'): - custom_launcher_id = adapter.get_launcher_id(event) - if custom_launcher_id: - launcher_id = custom_launcher_id - - message_text = str(event.message_chain) - element_types = [comp.type for comp in event.message_chain] - pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( - 'group', launcher_id, message_text, element_types - ) - - if pipeline_uuid == self.PIPELINE_DISCARD: - await self.logger.info('Group message discarded by routing rule') - await self._record_discarded_message( - provider_session.LauncherTypes.GROUP, - launcher_id, - event.sender.id, - event, - event.message_chain, - ) - return - - await self.ap.msg_aggregator.add_message( - bot_uuid=self.bot_entity.uuid, - launcher_type=provider_session.LauncherTypes.GROUP, - launcher_id=launcher_id, - sender_id=event.sender.id, - message_event=event, - message_chain=event.message_chain, - adapter=adapter, - pipeline_uuid=pipeline_uuid, - routed_by_rule=routed_by_rule, - ) - else: - await self.logger.info('Pipeline skipped for group message due to webhook response') + pipeline_uuid = websocket_pipeline_uuid(event, adapter) + if pipeline_uuid: + await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid) + return + await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter) self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message) @@ -328,20 +1532,11 @@ async def on_feedback( adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ): try: - # Resolve pipeline name + pipeline_id = self.get_pipeline_target_for_event_type('message.received') or '' pipeline_name = '' - if self.bot_entity.use_pipeline_uuid: - try: - pipeline_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where( - persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid - ) - ) - pipeline_row = pipeline_result.first() - if pipeline_row: - pipeline_name = pipeline_row[0] - except Exception: - pass + if pipeline_id: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_id) + pipeline_name = pipeline.get('name', '') if pipeline else '' await self.ap.monitoring_service.record_feedback( feedback_id=event.feedback_id, @@ -350,7 +1545,7 @@ async def on_feedback( inaccurate_reasons=event.inaccurate_reasons, bot_id=self.bot_entity.uuid, bot_name=self.bot_entity.name, - pipeline_id=self.bot_entity.use_pipeline_uuid or '', + pipeline_id=pipeline_id, pipeline_name=pipeline_name, session_id=event.session_id, message_id=event.message_id, @@ -366,6 +1561,14 @@ async def on_feedback( self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback) + async def on_eba_event( + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + await self._handle_platform_event(event, adapter) + + self.adapter.register_listener(platform_events.EBAEvent, on_eba_event) + async def run(self): async def exception_wrapper(): try: @@ -395,7 +1598,9 @@ async def exception_wrapper(): async def shutdown(self): await self.adapter.kill() - self.ap.task_mgr.cancel_task(self.task_wrapper.id) + if self.task_wrapper is not None: + self.ap.task_mgr.cancel_task(self.task_wrapper.id) + self.task_wrapper = None # 控制QQ消息输入输出的类 diff --git a/src/langbot/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py index 681648656..5c86d8cc9 100644 --- a/src/langbot/pkg/platform/logger.py +++ b/src/langbot/pkg/platform/logger.py @@ -41,6 +41,9 @@ class EventLog(pydantic.BaseModel): message_session_id: typing.Optional[str] = None """消息会话ID,仅收发消息事件有值""" + metadata: typing.Optional[dict[str, typing.Any]] = None + """Structured machine-readable metadata for product surfaces.""" + def to_json(self) -> dict: return { 'seq_id': self.seq_id, @@ -49,6 +52,7 @@ def to_json(self) -> dict: 'text': self.text, 'images': self.images, 'message_session_id': self.message_session_id, + 'metadata': self.metadata, } @@ -131,6 +135,7 @@ async def _add_log( images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): try: image_keys = [] @@ -161,6 +166,7 @@ async def _add_log( text=text, images=image_keys, message_session_id=message_session_id, + metadata=metadata, ) ) self.seq_id_inc += 1 @@ -179,6 +185,7 @@ async def info( images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.INFO, @@ -186,6 +193,7 @@ async def info( images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def debug( @@ -194,6 +202,7 @@ async def debug( images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.DEBUG, @@ -201,6 +210,7 @@ async def debug( images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def warning( @@ -209,6 +219,7 @@ async def warning( images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.WARNING, @@ -216,6 +227,7 @@ async def warning( images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def error( @@ -224,6 +236,7 @@ async def error( images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.ERROR, @@ -231,4 +244,5 @@ async def error( images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml index b93a25243..18b233750 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.yaml +++ b/src/langbot/pkg/platform/sources/aiocqhttp.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式 icon: onebot.png spec: + legacy: true categories: - protocol help_links: diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index c7c25e673..32412ef2d 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: 釘釘適配器,請查看文件了解使用方式 icon: dingtalk.svg spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/platform/sources/discord.yaml b/src/langbot/pkg/platform/sources/discord.yaml index 28149cbda..c781110bd 100644 --- a/src/langbot/pkg/platform/sources/discord.yaml +++ b/src/langbot/pkg/platform/sources/discord.yaml @@ -20,6 +20,7 @@ metadata: es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord icon: discord.svg spec: + legacy: true categories: - popular - global diff --git a/src/langbot/pkg/platform/sources/kook.yaml b/src/langbot/pkg/platform/sources/kook.yaml index c63d35eed..e485190de 100644 --- a/src/langbot/pkg/platform/sources/kook.yaml +++ b/src/langbot/pkg/platform/sources/kook.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息 icon: kook.png spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index 94509c470..acde1da56 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -14,6 +14,7 @@ metadata: ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。 icon: lark.svg spec: + legacy: true categories: - popular - china diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index d09538028..19b7c8621 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式 icon: officialaccount.png spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index d66a770bf..1ded7e218 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式 icon: qqofficial.svg spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index f1a2e740a..2827276a2 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -20,6 +20,7 @@ metadata: es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso icon: slack.png spec: + legacy: true categories: - popular - global diff --git a/src/langbot/pkg/platform/sources/telegram.yaml b/src/langbot/pkg/platform/sources/telegram.yaml index f652ae420..c3f38d7e3 100644 --- a/src/langbot/pkg/platform/sources/telegram.yaml +++ b/src/langbot/pkg/platform/sources/telegram.yaml @@ -20,6 +20,7 @@ metadata: es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso icon: telegram.svg spec: + legacy: true categories: - popular - global diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 0574292f3..f910950f7 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -77,6 +77,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) stream_enabled: bool = pydantic.Field(default=True, exclude=True) """是否启用流式输出""" + current_pipeline_uuid: str = pydantic.Field(default='', exclude=True) + """Pipeline currently associated with the active WebSocket exchange.""" + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): super().__init__( config=config, @@ -90,6 +93,18 @@ def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventL self.bot_account_id = 'websocketbot' self.outbound_message_queue = asyncio.Queue() self.stream_enabled = True + self.current_pipeline_uuid = '' + + def _resolve_pipeline_uuid( + self, + message_source: platform_events.MessageEvent | None = None, + target_id: str = '', + ) -> str: + if message_source is not None: + event_pipeline_uuid = getattr(message_source, '_langbot_pipeline_uuid', '') + if isinstance(event_pipeline_uuid, str) and event_pipeline_uuid: + return event_pipeline_uuid + return self.current_pipeline_uuid or target_id async def send_message( self, @@ -103,8 +118,7 @@ async def send_message( target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。 我们需要尝试两种方式来确保消息能够送达。 """ - # 获取当前的 pipeline_uuid - pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid + pipeline_uuid = self._resolve_pipeline_uuid(target_id=str(target_id)) session_type = 'group' if target_type == 'group' else 'person' # 选择会话 @@ -152,8 +166,7 @@ async def reply_message( else self.websocket_person_session ) - # 从message_source获取pipeline_uuid和connection_id - pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid + pipeline_uuid = self._resolve_pipeline_uuid(message_source) session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' # 生成新的消息ID @@ -200,7 +213,7 @@ async def reply_message_chunk( else self.websocket_person_session ) - pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid + pipeline_uuid = self._resolve_pipeline_uuid(message_source) session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' message_list = session.get_message_list(pipeline_uuid) stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid) @@ -380,6 +393,7 @@ async def handle_websocket_message( When provided, its identity is used for logging and session tracking. """ pipeline_uuid = connection.pipeline_uuid + self.current_pipeline_uuid = pipeline_uuid session_type = connection.session_type # 获取stream参数,默认为True @@ -447,10 +461,7 @@ async def handle_websocket_message( sender=sender, message_chain=message_chain, time=datetime.now().timestamp() ) - # 设置流水线UUID (proxy bot always needs it for reply_message routing) - self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid - if owner_bot is not None: - owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid + object.__setattr__(event, '_langbot_pipeline_uuid', pipeline_uuid) # 异步触发事件处理 # Use owner_bot's listeners if available, otherwise fall back to proxy bot diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index 509fa4e0f..64f4c00e4 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: 企業微信內部機器人,請查看文件了解使用方式 icon: wecom.png spec: + legacy: true categories: - popular - china diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 7e95402ee..0dc1f2cd0 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式 icon: wecombot.png spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index 521e7bb0e..5f6fb26ee 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -12,6 +12,7 @@ metadata: zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式 icon: wecom.png spec: + legacy: true categories: - china help_links: diff --git a/src/langbot/pkg/plugin/agent_pull_actions.py b/src/langbot/pkg/plugin/agent_pull_actions.py new file mode 100644 index 000000000..e1e3a1714 --- /dev/null +++ b/src/langbot/pkg/plugin/agent_pull_actions.py @@ -0,0 +1,293 @@ +"""Agent-runner pull actions (history / event).""" + +from __future__ import annotations + +from typing import Any + + +from langbot_plugin.runtime.io import handler +from langbot_plugin.entities.io.actions.enums import ( + PluginToRuntimeAction, +) + + +from .agent_run_support import ( + _get_run_authorization, + _validate_agent_run_session, + _resolve_run_conversation, + _run_scope_filters, + _event_matches_run_scope, + _project_event_record_for_api, +) + + +def register(h): + @h.action(PluginToRuntimeAction.HISTORY_PAGE) + async def history_page(data: dict[str, Any]) -> handler.ActionResponse: + """Page through transcript history for a conversation. + + Requires run_id authorization. Only allows access to current run's conversation. + """ + run_id = data.get('run_id') + conversation_id = data.get('conversation_id') + before_cursor = data.get('before_cursor') + after_cursor = data.get('after_cursor') + limit = data.get('limit', 50) + direction = data.get('direction', 'backward') + include_attachments = data.get('include_attachments', False) + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'History page', + api_capability='history_page', + ) + if error: + return error + + conversation_id, scope_error = _resolve_run_conversation( + session, + conversation_id, + 'History page', + ) + if scope_error: + return scope_error + + if not conversation_id: + return handler.ActionResponse.success( + data={ + 'items': [], + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + } + ) + + # Parse cursors + before_seq = int(before_cursor) if before_cursor else None + after_seq = int(after_cursor) if after_cursor else None + + # Query transcript + from ..agent.runner.transcript_store import TranscriptStore + + store = TranscriptStore(h.ap.persistence_mgr.get_db_engine()) + + try: + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id=conversation_id, + before_seq=before_seq, + after_seq=after_seq, + limit=limit, + direction=direction, + include_attachments=include_attachments, + **_run_scope_filters(session), + ) + + return handler.ActionResponse.success( + data={ + 'items': items, + 'next_cursor': str(next_seq) if next_seq else None, + 'prev_cursor': str(prev_seq) if prev_seq else None, + 'has_more': has_more, + } + ) + except Exception as e: + h.ap.logger.error(f'HISTORY_PAGE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'History page error: {e}') + + @h.action(PluginToRuntimeAction.HISTORY_SEARCH) + async def history_search(data: dict[str, Any]) -> handler.ActionResponse: + """Search transcript history. + + Requires run_id authorization. Only searches current run's conversation. + Basic implementation using LIKE filtering. + """ + run_id = data.get('run_id') + query_text = data.get('query', '') + filters = data.get('filters') or {} + top_k = data.get('top_k', 10) + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'History search', + api_capability='history_search', + ) + if error: + return error + + requested_conversation_id = filters.get('conversation_id') + conversation_id, scope_error = _resolve_run_conversation( + session, + requested_conversation_id, + 'History search', + ) + if scope_error: + return scope_error + + if not conversation_id: + return handler.ActionResponse.success( + data={ + 'items': [], + 'total_count': 0, + 'query': query_text, + } + ) + + # Search transcript + from ..agent.runner.transcript_store import TranscriptStore + + store = TranscriptStore(h.ap.persistence_mgr.get_db_engine()) + + try: + safe_filters = {k: v for k, v in filters.items() if k != 'conversation_id'} + items = await store.search_transcript( + conversation_id=conversation_id, + query_text=query_text, + filters=safe_filters, + top_k=top_k, + **_run_scope_filters(session), + ) + + return handler.ActionResponse.success( + data={ + 'items': items, + 'total_count': len(items), + 'query': query_text, + } + ) + except Exception as e: + h.ap.logger.error(f'HISTORY_SEARCH error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'History search error: {e}') + + @h.action(PluginToRuntimeAction.EVENT_GET) + async def event_get(data: dict[str, Any]) -> handler.ActionResponse: + """Get a single event record by ID. + + Requires run_id authorization. Only allows access to events in current run's conversation. + """ + run_id = data.get('run_id') + event_id = data.get('event_id') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if not event_id: + return handler.ActionResponse.error(message='event_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Event get', + api_capability='event_get', + ) + if error: + return error + + # Get event + from ..agent.runner.event_log_store import EventLogStore + + store = EventLogStore(h.ap.persistence_mgr.get_db_engine()) + + try: + event = await store.get_event(event_id) + if not event: + return handler.ActionResponse.error(message=f'Event {event_id} not found') + + # Validate event is in the same conversation as the run, or was created by the same run. + session_conversation_id = _get_run_authorization(session).get('conversation_id') + event_run_id = event.get('run_id') + if event_run_id and event_run_id == run_id: + return handler.ActionResponse.success(data=_project_event_record_for_api(event)) + if not session_conversation_id or not _event_matches_run_scope(session, event): + return handler.ActionResponse.error(message=f'Event {event_id} is not accessible by this run') + + return handler.ActionResponse.success(data=_project_event_record_for_api(event)) + except Exception as e: + h.ap.logger.error(f'EVENT_GET error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Event get error: {e}') + + @h.action(PluginToRuntimeAction.EVENT_PAGE) + async def event_page(data: dict[str, Any]) -> handler.ActionResponse: + """Page through event records. + + Requires run_id authorization. Only allows access to current run's conversation. + """ + run_id = data.get('run_id') + conversation_id = data.get('conversation_id') + event_types = data.get('event_types') + before_cursor = data.get('before_cursor') + limit = data.get('limit', 50) + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Event page', + api_capability='event_page', + ) + if error: + return error + + conversation_id, scope_error = _resolve_run_conversation( + session, + conversation_id, + 'Event page', + ) + if scope_error: + return scope_error + + if not conversation_id: + return handler.ActionResponse.success( + data={ + 'items': [], + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + } + ) + + # Parse cursor + before_seq = int(before_cursor) if before_cursor else None + + # Query events + from ..agent.runner.event_log_store import EventLogStore + + store = EventLogStore(h.ap.persistence_mgr.get_db_engine()) + + try: + items, next_seq, has_more = await store.page_events( + conversation_id=conversation_id, + event_types=event_types, + before_seq=before_seq, + limit=limit, + **_run_scope_filters(session), + ) + + return handler.ActionResponse.success( + data={ + 'items': [_project_event_record_for_api(item) for item in items], + 'next_cursor': str(next_seq) if next_seq else None, + 'prev_cursor': None, + 'has_more': has_more, + } + ) + except Exception as e: + h.ap.logger.error(f'EVENT_PAGE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Event page error: {e}') diff --git a/src/langbot/pkg/plugin/agent_run_support.py b/src/langbot/pkg/plugin/agent_run_support.py new file mode 100644 index 000000000..d19a08b55 --- /dev/null +++ b/src/langbot/pkg/plugin/agent_run_support.py @@ -0,0 +1,488 @@ +"""Agent-runner protocol support: shared constants and authorization/scope/projection helpers extracted from handler.py.""" + +from __future__ import annotations + +from typing import Any, Union +import json +import time + +import sqlalchemy + +from langbot_plugin.runtime.io import handler +from langbot_plugin.entities.io.actions.enums import ( + PluginToRuntimeAction, +) + + +from ..core import app +from ..agent.runner.session_registry import get_session_registry +from ..agent.runner.result_normalizer import MAX_RESULT_SIZE_BYTES, STRICT_RESULT_PAYLOADS + + +class _RuntimeActionName: + def __init__(self, value: str): + self.value = value + + +AGENT_RUN_ADMIN_PERMISSION = 'agent_run:admin' +RUNTIME_ADMIN_PERMISSION = 'runtime:admin' +AGENT_RUNNER_ADMIN_PERMISSION = 'agent_runner:admin' +LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES = { + 'message.delta', + 'message.completed', + 'state.updated', + 'run.completed', + 'run.failed', +} + + +def _plugin_runtime_action(name: str, value: str) -> Any: + return getattr(PluginToRuntimeAction, name, _RuntimeActionName(value)) + + +def _normalize_permission_set(value: Any) -> set[str]: + if isinstance(value, str): + return {permission.strip() for permission in value.split(',') if permission.strip()} + if isinstance(value, list): + return {str(item).strip() for item in value if str(item).strip()} + if isinstance(value, dict): + return {str(item).strip() for item, enabled in value.items() if enabled and str(item).strip()} + return set() + + +def _iter_agent_runner_admin_plugin_configs(ap: app.Application) -> list[dict[str, Any]]: + instance_config = getattr(ap, 'instance_config', None) + config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {} + if not isinstance(config_data, dict): + return [] + agent_runner_config = config_data.get('agent_runner', {}) + if not isinstance(agent_runner_config, dict): + return [] + raw_admin_plugins = agent_runner_config.get('admin_plugins', []) + if isinstance(raw_admin_plugins, dict): + items: list[dict[str, Any]] = [] + for identity, entry in raw_admin_plugins.items(): + if isinstance(entry, dict): + merged = dict(entry) + merged.setdefault('identity', identity) + items.append(merged) + else: + items.append({'identity': identity, 'permissions': entry}) + return items + if isinstance(raw_admin_plugins, list): + return [item for item in raw_admin_plugins if isinstance(item, dict)] + return [] + + +def _agent_runner_admin_permissions(ap: app.Application, plugin_identity: str | None) -> set[str]: + if not isinstance(plugin_identity, str) or not plugin_identity.strip(): + return set() + normalized_identity = plugin_identity.strip() + permissions: set[str] = set() + for entry in _iter_agent_runner_admin_plugin_configs(ap): + if entry.get('enabled', True) is False: + continue + identity = entry.get('identity') or entry.get('plugin_identity') or entry.get('plugin') or entry.get('id') + if identity != normalized_identity: + continue + permissions.update(_normalize_permission_set(entry.get('permissions'))) + permissions.update(_normalize_permission_set(entry.get('scopes'))) + return permissions + + +def _has_agent_runner_admin_permission( + ap: app.Application, + plugin_identity: str | None, + permission: str, +) -> bool: + permissions = _agent_runner_admin_permissions(ap, plugin_identity) + if not permissions: + return False + domain = permission.split(':', 1)[0] + return bool( + permission in permissions + or f'{domain}:*' in permissions + or AGENT_RUNNER_ADMIN_PERMISSION in permissions + or '*' in permissions + ) + + +def _deadline_seconds_from_payload(data: dict[str, Any], default: int = 60) -> int: + deadline_at = data.get('heartbeat_deadline_at') + if deadline_at is not None: + try: + return max(int(float(deadline_at) - time.time()), 1) + except (TypeError, ValueError): + pass + try: + return max(int(data.get('heartbeat_ttl_seconds') or default), 1) + except (TypeError, ValueError): + return default + + +def _get_run_authorization(session: dict[str, Any]) -> dict[str, Any]: + """Return the run-scoped authorization snapshot.""" + return session['authorization'] + + +def _run_matches_run_scope(session: dict[str, Any], run: dict[str, Any]) -> bool: + authorization = _get_run_authorization(session) + session_run_id = session.get('run_id') + if run.get('run_id') == session_run_id: + return True + session_runner_id = session.get('runner_id') or authorization.get('runner_id') + if not session_runner_id or run.get('runner_id') != session_runner_id: + return False + if not authorization.get('conversation_id'): + return False + if run.get('conversation_id') != authorization.get('conversation_id'): + return False + if authorization.get('bot_id') is not None and authorization.get('bot_id') != run.get('bot_id'): + return False + if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != run.get('workspace_id'): + return False + if authorization.get('thread_id') != run.get('thread_id'): + return False + return True + + +def _authorize_target_run( + session: dict[str, Any], + run: dict[str, Any], +) -> handler.ActionResponse | None: + """Authorize non-admin target-run access against scope and runner owner.""" + if _run_matches_run_scope(session, run): + return None + return handler.ActionResponse.error(message=f'Run {run.get("run_id")} is not accessible by this run') + + +def _validate_ledger_only_result_payload( + *, + ap: app.Application, + runner_id: str | None, + event_type: str, + data: dict[str, Any], +) -> str | None: + """Validate result payloads that can be safely stored without side effects.""" + try: + result_json = json.dumps({'type': event_type, 'data': data}) + except (TypeError, ValueError) as exc: + return f'event data must be JSON serializable: {exc}' + if len(result_json) > MAX_RESULT_SIZE_BYTES: + return f'event payload exceeds {MAX_RESULT_SIZE_BYTES} bytes' + + payload_model = STRICT_RESULT_PAYLOADS.get(event_type) + if payload_model is None: + return f'unknown result type: {event_type}' + try: + payload_model.model_validate(data) + except Exception as exc: + return f'invalid {event_type} payload: {exc}' + + if event_type in LEDGER_ONLY_SIDE_EFFECTING_RESULT_TYPES: + if runner_id: + ap.logger.warning( + f'Runner {runner_id} attempted ledger-only append for side-effecting result type {event_type}' + ) + return f'{event_type} must be emitted through the canonical runner result path' + return None + + +async def _require_runtime_write_ownership( + *, + store: Any, + session: dict[str, Any], + run: dict[str, Any], + data: dict[str, Any], + api_name: str, +) -> handler.ActionResponse | None: + """Require current-run ownership or an active runtime claim for run writes.""" + if run.get('run_id') == session.get('run_id') and run.get('status') != 'claimed': + return None + + runtime_id = data.get('runtime_id') + claim_token = data.get('claim_token') + if not runtime_id or not claim_token: + return handler.ActionResponse.error( + message=f'{api_name} requires active claim ownership for target run {run.get("run_id")}' + ) + + if not await store.validate_active_claim( + run_id=str(run.get('run_id')), + runtime_id=str(runtime_id), + claim_token=str(claim_token), + ): + return handler.ActionResponse.error( + message=f'{api_name} claim ownership is not active for target run {run.get("run_id")}' + ) + + return None + + +def _resolve_state_scope( + session: dict[str, Any], + scope: str, +) -> tuple[dict[str, Any] | None, str | None, handler.ActionResponse | None]: + """Resolve state policy/context for an authorized run scope.""" + authorization = _get_run_authorization(session) + state_policy = authorization['state_policy'] + + if not state_policy.get('enable_state', True): + return None, None, handler.ActionResponse.error(message='State access is disabled by binding policy') + + state_scopes = state_policy.get('state_scopes', ['conversation', 'actor']) + if scope not in state_scopes: + return None, None, handler.ActionResponse.error(message=f'Scope "{scope}" is not enabled by binding policy') + + state_context = authorization['state_context'] + scope_key = state_context.get('scope_keys', {}).get(scope) + if not scope_key: + return None, None, handler.ActionResponse.error(message=f'Scope key not available for scope "{scope}"') + + return state_context, scope_key, None + + +async def _validate_agent_run_session( + run_id: str, + caller_plugin_identity: str | None, + ap: app.Application, + api_name: str, + api_capability: str | None = None, + allow_persistent_authorization: bool = False, + admin_permission: str | None = None, +) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]: + """Validate an AgentRunner pull API run session and run-scoped API access.""" + if ( + not run_id + and admin_permission + and _has_agent_runner_admin_permission( + ap, + caller_plugin_identity, + admin_permission, + ) + ): + return { + 'run_id': run_id, + 'runner_id': None, + 'query_id': None, + 'plugin_identity': caller_plugin_identity, + 'authorization': {}, + 'status': {}, + 'steering_queue': [], + }, None + + session_registry = get_session_registry() + session = await session_registry.get(run_id) + if not session: + if allow_persistent_authorization: + session = await _load_persistent_agent_run_session(run_id, ap, api_name) + if not session: + return None, handler.ActionResponse.error(message=f'Run session {run_id} not found or expired') + + session_plugin_identity = session.get('plugin_identity') + if not isinstance(session_plugin_identity, str) or not session_plugin_identity.strip(): + ap.logger.warning(f'{api_name}: run_id {run_id} has no plugin_identity') + return None, handler.ActionResponse.error(message=f'Run session {run_id} has no plugin_identity') + if not caller_plugin_identity: + return None, handler.ActionResponse.error(message=f'caller_plugin_identity is required for run_id {run_id}') + if caller_plugin_identity != session_plugin_identity: + ap.logger.warning( + f'{api_name}: caller_plugin_identity {caller_plugin_identity} ' + f'does not match session plugin_identity {session_plugin_identity}' + ) + return None, handler.ActionResponse.error(message=f'Plugin identity mismatch for run_id {run_id}') + + if api_capability: + available_apis = _get_run_authorization(session).get('available_apis', {}) + has_admin_permission = bool(admin_permission) and _has_agent_runner_admin_permission( + ap, + caller_plugin_identity, + admin_permission, + ) + if not available_apis.get(api_capability, False) and not has_admin_permission: + return None, handler.ActionResponse.error(message=f'{api_name} access not authorized') + + return session, None + + +async def _load_persistent_agent_run_session( + run_id: str, + ap: app.Application, + api_name: str, +) -> dict[str, Any] | None: + """Load an expired run session from the AgentRun authorization snapshot.""" + try: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.orm import sessionmaker + + from ..entity.persistence.agent_run import AgentRun + + engine = ap.persistence_mgr.get_db_engine() + session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as db_session: + result = await db_session.execute(sqlalchemy.select(AgentRun).where(AgentRun.run_id == run_id)) + run = result.scalars().first() + except Exception as e: + ap.logger.error(f'{api_name}: failed to load persistent authorization for run_id {run_id}: {e}', exc_info=True) + return None + + if run is None: + return None + + try: + authorization = json.loads(run.authorization_json) if run.authorization_json else {} + except (TypeError, ValueError) as e: + ap.logger.warning(f'{api_name}: run_id {run_id} has invalid authorization_json: {e}') + return None + + if not isinstance(authorization, dict): + ap.logger.warning(f'{api_name}: run_id {run_id} authorization_json is not an object') + return None + + return { + 'run_id': run.run_id, + 'runner_id': authorization.get('runner_id') or run.runner_id, + 'query_id': None, + 'plugin_identity': authorization.get('plugin_identity'), + 'authorization': authorization, + 'status': {}, + 'steering_queue': [], + } + + +def _resolve_run_conversation( + session: dict[str, Any], + requested_conversation_id: str | None, + api_name: str, +) -> tuple[str | None, handler.ActionResponse | None]: + """Resolve and enforce current-run conversation scope.""" + session_conversation_id = _get_run_authorization(session).get('conversation_id') + + if requested_conversation_id: + if not session_conversation_id: + return None, handler.ActionResponse.error(message=f'{api_name} is not available without a run conversation') + if requested_conversation_id != session_conversation_id: + return None, handler.ActionResponse.error( + message=f'Conversation {requested_conversation_id} is not accessible by this run' + ) + return requested_conversation_id, None + + return session_conversation_id, None + + +def _run_scope_filters(session: dict[str, Any]) -> dict[str, Any]: + authorization = _get_run_authorization(session) + return { + 'bot_id': authorization.get('bot_id'), + 'workspace_id': authorization.get('workspace_id'), + 'thread_id': authorization.get('thread_id'), + 'strict_thread': True, + } + + +def _run_ledger_scope_filters(session: dict[str, Any]) -> dict[str, Any]: + authorization = _get_run_authorization(session) + filters = _run_scope_filters(session) + filters['runner_id'] = session.get('runner_id') or authorization.get('runner_id') + return filters + + +def _event_matches_run_scope(session: dict[str, Any], event: dict[str, Any]) -> bool: + authorization = _get_run_authorization(session) + if authorization.get('conversation_id') != event.get('conversation_id'): + return False + if authorization.get('bot_id') is not None and authorization.get('bot_id') != event.get('bot_id'): + return False + if authorization.get('workspace_id') is not None and authorization.get('workspace_id') != event.get('workspace_id'): + return False + if authorization.get('thread_id') != event.get('thread_id'): + return False + return True + + +def _project_event_record_for_api(event: dict[str, Any]) -> dict[str, Any]: + """Project EventLogStore rows onto the SDK AgentEventRecord DTO.""" + seq = event.get('seq') or event.get('id') + return { + 'event_id': event.get('event_id'), + 'event_type': event.get('event_type'), + 'event_time': event.get('event_time'), + 'source': event.get('source'), + 'bot_id': event.get('bot_id'), + 'workspace_id': event.get('workspace_id'), + 'conversation_id': event.get('conversation_id'), + 'thread_id': event.get('thread_id'), + 'actor_type': event.get('actor_type'), + 'actor_id': event.get('actor_id'), + 'actor_name': event.get('actor_name'), + 'subject_type': event.get('subject_type'), + 'subject_id': event.get('subject_id'), + 'input_summary': event.get('input_summary'), + 'input_ref': event.get('input_ref'), + 'raw_ref': event.get('raw_ref'), + 'seq': seq, + 'cursor': event.get('cursor') or (str(seq) if seq is not None else None), + 'created_at': event.get('created_at'), + 'metadata': event.get('metadata') or {}, + } + + +def _project_runner_descriptor_for_api(descriptor: Any) -> dict[str, Any]: + """Project an AgentRunnerDescriptor-like object onto a JSON dict.""" + if isinstance(descriptor, dict): + return dict(descriptor) + if hasattr(descriptor, 'model_dump'): + return descriptor.model_dump(mode='json') + return { + 'id': getattr(descriptor, 'id', None), + 'source': getattr(descriptor, 'source', None), + 'label': getattr(descriptor, 'label', {}), + 'description': getattr(descriptor, 'description', None), + 'plugin_author': getattr(descriptor, 'plugin_author', None), + 'plugin_name': getattr(descriptor, 'plugin_name', None), + 'runner_name': getattr(descriptor, 'runner_name', None), + 'plugin_version': getattr(descriptor, 'plugin_version', None), + 'config_schema': getattr(descriptor, 'config_schema', []), + 'capabilities': getattr(descriptor, 'capabilities', {}), + 'permissions': getattr(descriptor, 'permissions', {}), + 'raw_manifest': getattr(descriptor, 'raw_manifest', {}), + } + + +async def _record_agent_runner_admin_action( + ap: app.Application, + store: Any, + *, + action: str, + caller_plugin_identity: str | None, + permission: str, + durable_run_id: str | None = None, + target_runtime_id: str | None = None, + detail: dict[str, Any] | None = None, +) -> None: + """Record a small audit trail for privileged AgentRunner operations.""" + audit_data: dict[str, Any] = { + 'action': action, + 'caller_plugin_identity': caller_plugin_identity, + 'permission': permission, + } + if durable_run_id: + audit_data['target_run_id'] = durable_run_id + if target_runtime_id: + audit_data['target_runtime_id'] = target_runtime_id + if detail: + audit_data['detail'] = detail + + ap.logger.info('Agent runner admin action: %s', audit_data) + if not durable_run_id or store is None or not hasattr(store, 'append_audit_event'): + return + + try: + await store.append_audit_event( + run_id=str(durable_run_id), + event_type=f'admin.{action}', + data=audit_data, + metadata={'permission': permission}, + ) + except Exception as exc: + ap.logger.warning(f'Failed to record AgentRunner admin audit event: {exc}', exc_info=True) diff --git a/src/langbot/pkg/plugin/agent_runner_actions.py b/src/langbot/pkg/plugin/agent_runner_actions.py new file mode 100644 index 000000000..40e72e194 --- /dev/null +++ b/src/langbot/pkg/plugin/agent_runner_actions.py @@ -0,0 +1,1195 @@ +"""Agent-runner run / runtime / stats / claim actions.""" + +from __future__ import annotations + +from typing import Any +import time + + +from langbot_plugin.runtime.io import handler + + +from ..agent.runner.run_ledger_store import TERMINAL_STATUSES + +from .agent_run_support import ( + AGENT_RUN_ADMIN_PERMISSION, + RUNTIME_ADMIN_PERMISSION, + _plugin_runtime_action, + _has_agent_runner_admin_permission, + _deadline_seconds_from_payload, + _get_run_authorization, + _authorize_target_run, + _validate_ledger_only_result_payload, + _require_runtime_write_ownership, + _validate_agent_run_session, + _resolve_run_conversation, + _run_scope_filters, + _run_ledger_scope_filters, + _project_runner_descriptor_for_api, + _record_agent_runner_admin_action, +) + + +def register(h): + @h.action(_plugin_runtime_action('RUN_GET', 'run_get')) + async def run_get(data: dict[str, Any]) -> handler.ActionResponse: + """Get one Host-owned run record visible to the current run.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') or run_id + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run get', + api_capability='run_get', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + run = await store.get_run(str(target_run_id)) + if not run: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, run) + if auth_error: + return auth_error + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_get', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={'target_run_id': str(target_run_id)}, + ) + return handler.ActionResponse.success(data=run) + except Exception as e: + h.ap.logger.error(f'RUN_GET error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run get error: {e}') + + @h.action(_plugin_runtime_action('RUN_LIST', 'run_list')) + async def run_list(data: dict[str, Any]) -> handler.ActionResponse: + """List Host-owned runs visible to the current run conversation.""" + run_id = data.get('run_id') + conversation_id = data.get('conversation_id') + statuses = data.get('statuses') + before_cursor = data.get('before_cursor') + limit = data.get('limit', 50) + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + + scope_filters: dict[str, Any] = {} + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run list', + api_capability='run_list', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + if not is_admin: + conversation_id, scope_error = _resolve_run_conversation( + session, + conversation_id, + 'Run list', + ) + if scope_error: + return scope_error + scope_filters = _run_ledger_scope_filters(session) + + if not is_admin and not conversation_id: + return handler.ActionResponse.success( + data={ + 'items': [], + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + 'total_count': 0, + } + ) + + if statuses is not None and not isinstance(statuses, list): + return handler.ActionResponse.error(message='statuses must be a list') + try: + before_id = int(before_cursor) if before_cursor else None + except (TypeError, ValueError): + return handler.ActionResponse.error(message='before_cursor must be an integer cursor') + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + items, next_cursor, has_more, total_count = await store.list_runs( + conversation_id=conversation_id, + statuses=[str(status) for status in statuses] if statuses else None, + before_id=before_id, + limit=limit, + **scope_filters, + ) + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_list', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={ + 'statuses': [str(status) for status in statuses] if statuses else None, + 'limit': limit, + }, + ) + return handler.ActionResponse.success( + data={ + 'items': items, + 'next_cursor': str(next_cursor) if next_cursor else None, + 'prev_cursor': None, + 'has_more': has_more, + 'total_count': total_count, + } + ) + except Exception as e: + h.ap.logger.error(f'RUN_LIST error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run list error: {e}') + + @h.action(_plugin_runtime_action('RUNNER_LIST', 'runner_list')) + async def runner_list(data: dict[str, Any]) -> handler.ActionResponse: + """List Host-discovered AgentRunner descriptors.""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin: + return handler.ActionResponse.error(message='Runner list access not authorized') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runner list', + api_capability='runner_list', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + include_plugins = data.get('include_plugins') + if include_plugins is not None and not isinstance(include_plugins, list): + return handler.ActionResponse.error(message='include_plugins must be a list') + + registry = getattr(h.ap, 'agent_runner_registry', None) + if registry is None: + return handler.ActionResponse.success(data={'items': []}) + + try: + runners = await registry.list_runners( + bound_plugins=[str(item) for item in include_plugins] if include_plugins else None, + use_cache=bool(data.get('use_cache', True)), + ) + items = [_project_runner_descriptor_for_api(item) for item in runners] + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + None, + action='runner_list', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={ + 'include_plugins': [str(item) for item in include_plugins] if include_plugins else None, + 'count': len(items), + }, + ) + return handler.ActionResponse.success(data={'items': items}) + except Exception as e: + h.ap.logger.error(f'RUNNER_LIST error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runner list error: {e}') + + @h.action(_plugin_runtime_action('RUN_EVENTS_PAGE', 'run_events_page')) + async def run_events_page(data: dict[str, Any]) -> handler.ActionResponse: + """Page result events for one Host-owned run visible to current run.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') or run_id + before_cursor = data.get('before_cursor') + after_cursor = data.get('after_cursor') + limit = data.get('limit', 50) + direction = data.get('direction', 'forward') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run events page', + api_capability='run_events_page', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + try: + before_sequence = int(before_cursor) if before_cursor else None + after_sequence = int(after_cursor) if after_cursor else None + except (TypeError, ValueError): + return handler.ActionResponse.error(message='run event cursors must be integer sequences') + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + run = await store.get_run(str(target_run_id)) + if not run: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, run) + if auth_error: + return auth_error + + items, next_cursor, prev_cursor, has_more = await store.page_run_events( + run_id=str(target_run_id), + before_sequence=before_sequence, + after_sequence=after_sequence, + limit=limit, + direction=str(direction or 'forward'), + ) + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_events_page', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={'target_run_id': str(target_run_id), 'limit': limit}, + ) + return handler.ActionResponse.success( + data={ + 'items': items, + 'next_cursor': str(next_cursor) if next_cursor else None, + 'prev_cursor': str(prev_cursor) if prev_cursor else None, + 'has_more': has_more, + } + ) + except Exception as e: + h.ap.logger.error(f'RUN_EVENTS_PAGE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run events page error: {e}') + + @h.action(_plugin_runtime_action('RUN_CANCEL', 'run_cancel')) + async def run_cancel(data: dict[str, Any]) -> handler.ActionResponse: + """Request cancellation for one Host-owned run visible to the current run.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') or run_id + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run cancel', + api_capability='run_cancel', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + run = await store.get_run(str(target_run_id)) + if not run: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, run) + if auth_error: + return auth_error + + updated = await store.request_cancel( + run_id=str(target_run_id), + status_reason=data.get('status_reason') or data.get('reason'), + ) + if not updated: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_cancel', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + durable_run_id=str(target_run_id), + detail={'status_reason': data.get('status_reason') or data.get('reason')}, + ) + return handler.ActionResponse.success(data=updated) + except Exception as e: + h.ap.logger.error(f'RUN_CANCEL error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run cancel error: {e}') + + @h.action(_plugin_runtime_action('RUN_APPEND_RESULT', 'run_append_result')) + async def run_append_result(data: dict[str, Any]) -> handler.ActionResponse: + """Append one result event for a Host-owned run visible to the current run.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') or run_id + caller_plugin_identity = data.get('caller_plugin_identity') + result = data.get('result') if isinstance(data.get('result'), dict) else {} + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + + try: + sequence = int(data.get('sequence') or result.get('sequence')) + except (TypeError, ValueError): + return handler.ActionResponse.error(message='sequence is required and must be an integer') + + event_type = data.get('event_type') or data.get('type') or result.get('type') + if not event_type: + return handler.ActionResponse.error(message='event_type is required') + + event_data = data.get('data') if isinstance(data.get('data'), dict) else result.get('data') + usage = data.get('usage') if isinstance(data.get('usage'), dict) else result.get('usage') + metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else None + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run append result', + api_capability='run_append_result', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + run = await store.get_run(str(target_run_id)) + if not run: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, run) + if auth_error: + return auth_error + if run.get('status') in TERMINAL_STATUSES: + return handler.ActionResponse.error( + message=f'Run append result is not allowed for terminal run {target_run_id}' + ) + claim_error = await _require_runtime_write_ownership( + store=store, + session=session, + run=run, + data=data, + api_name='Run append result', + ) + if claim_error: + return claim_error + + event_payload = event_data if isinstance(event_data, dict) else {} + payload_error = _validate_ledger_only_result_payload( + ap=h.ap, + runner_id=run.get('runner_id'), + event_type=str(event_type), + data=event_payload, + ) + if payload_error: + return handler.ActionResponse.error(message=payload_error) + + event = await store.append_event( + run_id=str(target_run_id), + sequence=sequence, + event_type=str(event_type), + data=event_payload, + usage=usage if isinstance(usage, dict) else None, + source=str(data.get('source') or result.get('source') or 'runner'), + metadata=metadata, + ) + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_append_result', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + durable_run_id=str(target_run_id), + detail={'event_type': str(event_type), 'sequence': sequence}, + ) + return handler.ActionResponse.success(data=event) + except Exception as e: + h.ap.logger.error(f'RUN_APPEND_RESULT error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run append result error: {e}') + + @h.action(_plugin_runtime_action('RUN_FINALIZE', 'run_finalize')) + async def run_finalize(data: dict[str, Any]) -> handler.ActionResponse: + """Finalize one Host-owned run visible to the current run.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') or run_id + caller_plugin_identity = data.get('caller_plugin_identity') + status = data.get('status') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + if not status: + return handler.ActionResponse.error(message='status is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run finalize', + api_capability='run_finalize', + allow_persistent_authorization=True, + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + run = await store.get_run(str(target_run_id)) + if not run: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, run) + if auth_error: + return auth_error + claim_error = await _require_runtime_write_ownership( + store=store, + session=session, + run=run, + data=data, + api_name='Run finalize', + ) + if claim_error: + return claim_error + + updated = await store.finalize_run( + run_id=str(target_run_id), + status=str(status), + status_reason=data.get('status_reason') or data.get('reason'), + usage=data.get('usage') if isinstance(data.get('usage'), dict) else None, + cost=data.get('cost') if isinstance(data.get('cost'), dict) else None, + metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, + ) + if not updated: + return handler.ActionResponse.error(message=f'Run {target_run_id} not found') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_finalize', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + durable_run_id=str(target_run_id), + detail={'status': str(status)}, + ) + return handler.ActionResponse.success(data=updated) + except Exception as e: + h.ap.logger.error(f'RUN_FINALIZE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run finalize error: {e}') + + @h.action(_plugin_runtime_action('RUNTIME_REGISTER', 'runtime_register')) + async def runtime_register(data: dict[str, Any]) -> handler.ActionResponse: + """Register or update one Host-owned runtime registry record.""" + run_id = data.get('run_id') + runtime_id = data.get('runtime_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not runtime_id: + return handler.ActionResponse.error(message='runtime_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runtime register', + api_capability='runtime_register', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + runtime = await store.register_runtime( + runtime_id=str(runtime_id), + status=str(data.get('status') or 'online'), + display_name=data.get('display_name'), + endpoint=data.get('endpoint'), + version=data.get('version'), + capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else {}, + labels=data.get('labels') if isinstance(data.get('labels'), dict) else {}, + metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else {}, + heartbeat_deadline_seconds=_deadline_seconds_from_payload(data), + ) + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='runtime_register', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + target_runtime_id=str(runtime_id), + detail={'status': runtime.get('status')}, + ) + return handler.ActionResponse.success(data=runtime) + except Exception as e: + h.ap.logger.error(f'RUNTIME_REGISTER error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runtime register error: {e}') + + @h.action(_plugin_runtime_action('RUNTIME_HEARTBEAT', 'runtime_heartbeat')) + async def runtime_heartbeat(data: dict[str, Any]) -> handler.ActionResponse: + """Refresh one Host-owned runtime heartbeat.""" + run_id = data.get('run_id') + runtime_id = data.get('runtime_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not runtime_id: + return handler.ActionResponse.error(message='runtime_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runtime heartbeat', + api_capability='runtime_heartbeat', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + runtime = await store.heartbeat_runtime( + runtime_id=str(runtime_id), + status=str(data.get('status') or 'online'), + capabilities=data.get('capabilities') if isinstance(data.get('capabilities'), dict) else None, + labels=data.get('labels') if isinstance(data.get('labels'), dict) else None, + metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, + heartbeat_deadline_seconds=_deadline_seconds_from_payload(data), + ) + if runtime is None: + return handler.ActionResponse.error(message=f'Runtime {runtime_id} not found') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='runtime_heartbeat', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + target_runtime_id=str(runtime_id), + detail={'status': runtime.get('status')}, + ) + return handler.ActionResponse.success(data=runtime) + except Exception as e: + h.ap.logger.error(f'RUNTIME_HEARTBEAT error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runtime heartbeat error: {e}') + + @h.action(_plugin_runtime_action('RUNTIME_LIST', 'runtime_list')) + async def runtime_list(data: dict[str, Any]) -> handler.ActionResponse: + """List Host-owned runtime registry records.""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + + _session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runtime list', + api_capability='runtime_list', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + statuses = data.get('statuses') + if statuses is not None and not isinstance(statuses, list): + return handler.ActionResponse.error(message='statuses must be a list') + labels = data.get('labels') if isinstance(data.get('labels'), dict) else {} + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + runtimes, total_count = await store.list_runtimes( + statuses=[str(status) for status in statuses] if statuses else None, + labels=labels, + limit=data.get('limit', 50), + ) + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='runtime_list', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + detail={ + 'statuses': [str(status) for status in statuses] if statuses else None, + 'limit': data.get('limit', 50), + }, + ) + return handler.ActionResponse.success( + data={ + 'items': runtimes, + 'next_cursor': None, + 'prev_cursor': None, + 'has_more': False, + 'total_count': total_count, + } + ) + except Exception as e: + h.ap.logger.error(f'RUNTIME_LIST error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runtime list error: {e}') + + @h.action(_plugin_runtime_action('RUNTIME_RECONCILE', 'runtime_reconcile')) + async def runtime_reconcile(data: dict[str, Any]) -> handler.ActionResponse: + """Reconcile stale runtime heartbeats and expired claim leases.""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin: + return handler.ActionResponse.error(message='Runtime reconcile access not authorized') + + _session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runtime reconcile', + api_capability='runtime_reconcile', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + stale_after_seconds = data.get('stale_after_seconds') + if stale_after_seconds is not None: + try: + stale_after_seconds = max(float(stale_after_seconds), 0) + except (TypeError, ValueError): + return handler.ActionResponse.error(message='stale_after_seconds must be a number') + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + stale_runtimes = await store.mark_stale_runtimes( + stale_after_seconds=stale_after_seconds, + ) + released_claims = await store.release_expired_claims() + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='runtime_reconcile', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + detail={ + 'stale_count': len(stale_runtimes), + 'released_claim_count': len(released_claims), + }, + ) + return handler.ActionResponse.success( + data={ + 'stale_runtimes': stale_runtimes, + 'released_claims': released_claims, + 'stale_count': len(stale_runtimes), + 'released_claim_count': len(released_claims), + } + ) + except Exception as e: + h.ap.logger.error(f'RUNTIME_RECONCILE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runtime reconcile error: {e}') + + @h.action(_plugin_runtime_action('RUN_STATS', 'run_stats')) + async def run_stats(data: dict[str, Any]) -> handler.ActionResponse: + """Get run statistics within a time window (admin-only).""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin: + return handler.ActionResponse.error(message='Run stats access not authorized') + + _session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run stats', + api_capability='run_stats', + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + end_time = data.get('end_time') or int(time.time()) + start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour + runner_id = data.get('runner_id') + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + stats = await store.get_run_stats( + start_time=start_time, + end_time=end_time, + runner_id=runner_id, + ) + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_stats', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={ + 'start_time': start_time, + 'end_time': end_time, + 'runner_id': runner_id, + }, + ) + return handler.ActionResponse.success(data=stats) + except Exception as e: + h.ap.logger.error(f'RUN_STATS error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run stats error: {e}') + + @h.action(_plugin_runtime_action('RUNTIME_STATS', 'runtime_stats')) + async def runtime_stats(data: dict[str, Any]) -> handler.ActionResponse: + """Get runtime registry statistics (admin-only).""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin: + return handler.ActionResponse.error(message='Runtime stats access not authorized') + + _session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runtime stats', + api_capability='runtime_stats', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + stats = await store.get_runtime_stats() + await _record_agent_runner_admin_action( + h.ap, + store, + action='runtime_stats', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + detail={}, + ) + return handler.ActionResponse.success(data=stats) + except Exception as e: + h.ap.logger.error(f'RUNTIME_STATS error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runtime stats error: {e}') + + @h.action(_plugin_runtime_action('RUNNER_STATS', 'runner_stats')) + async def runner_stats(data: dict[str, Any]) -> handler.ActionResponse: + """Get runner-aggregated statistics (admin-only).""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + AGENT_RUN_ADMIN_PERMISSION, + ) + + if not is_admin: + return handler.ActionResponse.error(message='Runner stats access not authorized') + + _session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Runner stats', + api_capability='runner_stats', + admin_permission=AGENT_RUN_ADMIN_PERMISSION, + ) + if error: + return error + + end_time = data.get('end_time') or int(time.time()) + start_time = data.get('start_time') or (end_time - 3600) # Default: 1 hour + limit = min(int(data.get('limit', 50)), 100) + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + stats = await store.get_runner_stats( + start_time=start_time, + end_time=end_time, + limit=limit, + ) + await _record_agent_runner_admin_action( + h.ap, + store, + action='runner_stats', + caller_plugin_identity=caller_plugin_identity, + permission=AGENT_RUN_ADMIN_PERMISSION, + detail={ + 'start_time': start_time, + 'end_time': end_time, + 'limit': limit, + }, + ) + return handler.ActionResponse.success(data={'items': stats, 'total_count': len(stats), 'has_more': False}) + except Exception as e: + h.ap.logger.error(f'RUNNER_STATS error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Runner stats error: {e}') + + @h.action(_plugin_runtime_action('RUN_CLAIM', 'run_claim')) + async def run_claim(data: dict[str, Any]) -> handler.ActionResponse: + """Claim one queued run for a runtime lease.""" + run_id = data.get('run_id') + runtime_id = data.get('runtime_id') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not runtime_id: + return handler.ActionResponse.error(message='runtime_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run claim', + api_capability='run_claim', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + runner_ids = data.get('runner_ids') + if runner_ids is not None and not isinstance(runner_ids, list): + return handler.ActionResponse.error(message='runner_ids must be a list') + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + scope_filters: dict[str, Any] = {} + if not is_admin: + authorization = _get_run_authorization(session) + session_runner_id = session.get('runner_id') or authorization.get('runner_id') + if not session_runner_id: + return handler.ActionResponse.error(message='Run claim is not available without a runner_id') + if runner_ids and any(str(item) != session_runner_id for item in runner_ids): + return handler.ActionResponse.error(message='Run claim runner_ids are not accessible by this run') + runner_ids = [session_runner_id] + scope_filters = { + 'conversation_id': authorization.get('conversation_id'), + **_run_scope_filters(session), + } + run = await store.claim_next_run( + runtime_id=str(runtime_id), + queue_name=data.get('queue_name'), + lease_seconds=data.get('lease_seconds', 60), + runner_ids=[str(item) for item in runner_ids] if runner_ids else None, + **scope_filters, + ) + if run is None: + return handler.ActionResponse.error(message='No queued run available') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_claim', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + durable_run_id=str(run.get('run_id')), + target_runtime_id=str(runtime_id), + detail={ + 'queue_name': data.get('queue_name'), + 'runner_ids': [str(item) for item in runner_ids] if runner_ids else None, + }, + ) + return handler.ActionResponse.success(data=run) + except Exception as e: + h.ap.logger.error(f'RUN_CLAIM error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run claim error: {e}') + + @h.action(_plugin_runtime_action('RUN_RENEW_CLAIM', 'run_renew_claim')) + async def run_renew_claim(data: dict[str, Any]) -> handler.ActionResponse: + """Renew one run claim lease.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') + runtime_id = data.get('runtime_id') + claim_token = data.get('claim_token') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + if not runtime_id: + return handler.ActionResponse.error(message='runtime_id is required') + if not claim_token: + return handler.ActionResponse.error(message='claim_token is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run renew claim', + api_capability='run_renew_claim', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + current = await store.get_run(str(target_run_id)) + if not current or current.get('claimed_by_runtime_id') != runtime_id: + return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, current) + if auth_error: + return auth_error + run = await store.renew_claim( + run_id=str(target_run_id), + claim_token=str(claim_token), + runtime_id=str(runtime_id), + lease_seconds=data.get('lease_seconds', 60), + ) + if run is None: + return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_renew_claim', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + durable_run_id=str(target_run_id), + target_runtime_id=str(runtime_id), + detail={'lease_seconds': data.get('lease_seconds', 60)}, + ) + return handler.ActionResponse.success(data=run) + except Exception as e: + h.ap.logger.error(f'RUN_RENEW_CLAIM error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run renew claim error: {e}') + + @h.action(_plugin_runtime_action('RUN_RELEASE_CLAIM', 'run_release_claim')) + async def run_release_claim(data: dict[str, Any]) -> handler.ActionResponse: + """Release one run claim lease.""" + run_id = data.get('run_id') + target_run_id = data.get('target_run_id') + runtime_id = data.get('runtime_id') + claim_token = data.get('claim_token') + caller_plugin_identity = data.get('caller_plugin_identity') + is_admin = _has_agent_runner_admin_permission( + h.ap, + caller_plugin_identity, + RUNTIME_ADMIN_PERMISSION, + ) + + if not is_admin and not run_id: + return handler.ActionResponse.error(message='run_id is required') + if not target_run_id: + return handler.ActionResponse.error(message='target_run_id is required') + if not runtime_id: + return handler.ActionResponse.error(message='runtime_id is required') + if not claim_token: + return handler.ActionResponse.error(message='claim_token is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Run release claim', + api_capability='run_release_claim', + admin_permission=RUNTIME_ADMIN_PERMISSION, + ) + if error: + return error + + from ..agent.runner.run_ledger_store import RunLedgerStore + + store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine()) + + try: + current = await store.get_run(str(target_run_id)) + if not current or current.get('claimed_by_runtime_id') != runtime_id: + return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found') + if not is_admin: + auth_error = _authorize_target_run(session, current) + if auth_error: + return auth_error + release_status = str(data.get('status') or 'queued') + if release_status in TERMINAL_STATUSES: + return handler.ActionResponse.error( + message='Run release claim cannot finalize a run; use run_finalize' + ) + run = await store.release_claim( + run_id=str(target_run_id), + claim_token=str(claim_token), + runtime_id=str(runtime_id), + status=str(data.get('status') or 'queued'), + status_reason=data.get('status_reason') or data.get('reason'), + ) + if run is None: + return handler.ActionResponse.error(message=f'Run claim {target_run_id} not found') + if is_admin: + await _record_agent_runner_admin_action( + h.ap, + store, + action='run_release_claim', + caller_plugin_identity=caller_plugin_identity, + permission=RUNTIME_ADMIN_PERMISSION, + durable_run_id=str(target_run_id), + target_runtime_id=str(runtime_id), + detail={ + 'status': str(data.get('status') or 'queued'), + 'status_reason': data.get('status_reason') or data.get('reason'), + }, + ) + return handler.ActionResponse.success(data=run) + except Exception as e: + h.ap.logger.error(f'RUN_RELEASE_CLAIM error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'Run release claim error: {e}') diff --git a/src/langbot/pkg/plugin/agent_state_actions.py b/src/langbot/pkg/plugin/agent_state_actions.py new file mode 100644 index 000000000..2479ad426 --- /dev/null +++ b/src/langbot/pkg/plugin/agent_state_actions.py @@ -0,0 +1,316 @@ +"""Agent-runner steering / state actions.""" + +from __future__ import annotations + +from typing import Any + + +from langbot_plugin.runtime.io import handler +from langbot_plugin.entities.io.actions.enums import ( + PluginToRuntimeAction, +) + + +from ..agent.runner.session_registry import get_session_registry + +from .agent_run_support import ( + _resolve_state_scope, + _validate_agent_run_session, +) + + +def register(h): + @h.action(PluginToRuntimeAction.STEERING_PULL) + async def steering_pull(data: dict[str, Any]) -> handler.ActionResponse: + """Pull pending steering/follow-up inputs for the current run.""" + run_id = data.get('run_id') + mode = data.get('mode', 'all') + limit = data.get('limit') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if limit is not None: + try: + limit = int(limit) + except (TypeError, ValueError): + return handler.ActionResponse.error(message='limit must be an integer') + if limit <= 0: + return handler.ActionResponse.error(message='limit must be > 0') + limit = min(limit, 100) + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'Steering pull', + api_capability='steering_pull', + ) + if error: + return error + + session_registry = get_session_registry() + items = await session_registry.pull_steering( + run_id, + mode=str(mode or 'all'), + limit=limit, + ) + if items: + try: + from ..agent.runner.event_log_store import EventLogStore + + store = EventLogStore(h.ap.persistence_mgr.get_db_engine()) + for item in items: + event = item.get('event') if isinstance(item, dict) else None + conversation = item.get('conversation') if isinstance(item, dict) else None + actor = item.get('actor') if isinstance(item, dict) else None + subject = item.get('subject') if isinstance(item, dict) else None + if not isinstance(event, dict): + continue + await store.append_event( + event_id=None, + event_type='steering.injected', + source='agent_runner', + bot_id=conversation.get('bot_id') if isinstance(conversation, dict) else None, + workspace_id=conversation.get('workspace_id') if isinstance(conversation, dict) else None, + conversation_id=conversation.get('conversation_id') if isinstance(conversation, dict) else None, + thread_id=conversation.get('thread_id') if isinstance(conversation, dict) else None, + actor_type=actor.get('actor_type') if isinstance(actor, dict) else None, + actor_id=actor.get('actor_id') if isinstance(actor, dict) else None, + actor_name=actor.get('actor_name') if isinstance(actor, dict) else None, + subject_type=subject.get('subject_type') if isinstance(subject, dict) else None, + subject_id=subject.get('subject_id') if isinstance(subject, dict) else None, + input_summary=f'steering injected from {event.get("event_id")}', + run_id=run_id, + runner_id=session.get('runner_id') if isinstance(session, dict) else None, + metadata={ + 'steering': { + 'status': 'injected', + 'source_event_id': event.get('event_id'), + 'claimed_by_run_id': item.get('claimed_run_id') if isinstance(item, dict) else run_id, + 'claimed_runner_id': item.get('runner_id') if isinstance(item, dict) else None, + 'claimed_at': item.get('claimed_at') if isinstance(item, dict) else None, + 'pull_mode': str(mode or 'all'), + }, + }, + ) + except Exception as exc: + h.ap.logger.warning( + f'Failed to write steering injection audit for run {run_id}: {exc}', + exc_info=True, + ) + return handler.ActionResponse.success(data={'items': items}) + + # ================= State APIs (run-scoped, policy-enforced) ================= + + @h.action(PluginToRuntimeAction.STATE_GET) + async def state_get(data: dict[str, Any]) -> handler.ActionResponse: + """Get a state value from host-owned state store. + + Requires run_id authorization and scope enabled by state_policy. + """ + run_id = data.get('run_id') + scope = data.get('scope') + key = data.get('key') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if not scope: + return handler.ActionResponse.error(message='scope is required') + + if not key: + return handler.ActionResponse.error(message='key is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'State get', + api_capability='state', + ) + if error: + return error + + _state_context, scope_key, state_error = _resolve_state_scope(session, scope) + if state_error: + return state_error + + # Get state from persistent store + from ..agent.runner.persistent_state_store import get_persistent_state_store + + store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine()) + + try: + value = await store.state_get(scope_key, key) + return handler.ActionResponse.success(data={'value': value}) + except Exception as e: + h.ap.logger.error(f'STATE_GET error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'State get error: {e}') + + @h.action(PluginToRuntimeAction.STATE_SET) + async def state_set(data: dict[str, Any]) -> handler.ActionResponse: + """Set a state value in host-owned state store. + + Requires run_id authorization and scope enabled by state_policy. + Value must be JSON-serializable and size-limited. + """ + run_id = data.get('run_id') + scope = data.get('scope') + key = data.get('key') + value = data.get('value') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if not scope: + return handler.ActionResponse.error(message='scope is required') + + if not key: + return handler.ActionResponse.error(message='key is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'State set', + api_capability='state', + ) + if error: + return error + + state_context, scope_key, state_error = _resolve_state_scope(session, scope) + if state_error: + return state_error + + # Get additional context for DB insert + runner_id = session.get('runner_id', '') + binding_identity = state_context.get('binding_identity', 'unknown') + + # Set state in persistent store + from ..agent.runner.persistent_state_store import get_persistent_state_store + + store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine()) + + try: + success, error = await store.state_set( + scope_key=scope_key, + state_key=key, + value=value, + runner_id=runner_id, + binding_identity=binding_identity, + scope=scope, + context=state_context, + logger=h.ap.logger, + ) + + if not success: + return handler.ActionResponse.error(message=error or 'Failed to set state') + + return handler.ActionResponse.success(data={'success': True}) + except Exception as e: + h.ap.logger.error(f'STATE_SET error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'State set error: {e}') + + @h.action(PluginToRuntimeAction.STATE_DELETE) + async def state_delete(data: dict[str, Any]) -> handler.ActionResponse: + """Delete a state value from host-owned state store. + + Requires run_id authorization and scope enabled by state_policy. + """ + run_id = data.get('run_id') + scope = data.get('scope') + key = data.get('key') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if not scope: + return handler.ActionResponse.error(message='scope is required') + + if not key: + return handler.ActionResponse.error(message='key is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'State delete', + api_capability='state', + ) + if error: + return error + + _state_context, scope_key, state_error = _resolve_state_scope(session, scope) + if state_error: + return state_error + + # Delete state from persistent store + from ..agent.runner.persistent_state_store import get_persistent_state_store + + store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine()) + + try: + deleted = await store.state_delete(scope_key, key) + return handler.ActionResponse.success(data={'success': deleted}) + except Exception as e: + h.ap.logger.error(f'STATE_DELETE error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'State delete error: {e}') + + @h.action(PluginToRuntimeAction.STATE_LIST) + async def state_list(data: dict[str, Any]) -> handler.ActionResponse: + """List state keys in a scope. + + Requires run_id authorization and scope enabled by state_policy. + """ + run_id = data.get('run_id') + scope = data.get('scope') + prefix = data.get('prefix') + limit = data.get('limit', 100) + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + if not scope: + return handler.ActionResponse.error(message='scope is required') + + # Validate limit + if not isinstance(limit, int) or limit <= 0: + limit = 100 + limit = min(limit, 100) # Cap at 100 + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + h.ap, + 'State list', + api_capability='state', + ) + if error: + return error + + _state_context, scope_key, state_error = _resolve_state_scope(session, scope) + if state_error: + return state_error + + # List state keys from persistent store + from ..agent.runner.persistent_state_store import get_persistent_state_store + + store = get_persistent_state_store(h.ap.persistence_mgr.get_db_engine()) + + try: + keys, has_more = await store.state_list(scope_key, prefix, limit) + return handler.ActionResponse.success( + data={ + 'keys': keys, + 'has_more': has_more, + } + ) + except Exception as e: + h.ap.logger.error(f'STATE_LIST error: {e}', exc_info=True) + return handler.ActionResponse.error(message=f'State list error: {e}') diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 6075d4b68..337d8b352 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -187,6 +187,15 @@ async def make_connection_failed_callback( async def initialize_plugins(self): pass + async def _refresh_agent_runner_registry(self) -> None: + registry = getattr(self.ap, 'agent_runner_registry', None) + if registry is None: + return + try: + await registry.refresh() + except Exception as e: + self.ap.logger.warning(f'Failed to refresh agent runner registry: {e}') + async def ping_plugin_runtime(self): if not hasattr(self, 'handler'): raise PluginRuntimeNotConnectedError('Plugin runtime is not connected') @@ -559,6 +568,7 @@ async def install_plugin( task_context.metadata.update(metadata) await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context) + await self._refresh_agent_runner_registry() async def upgrade_plugin( self, @@ -577,6 +587,8 @@ async def upgrade_plugin( if task_context is not None: task_context.trace(trace) + await self._refresh_agent_runner_registry() + async def delete_plugin( self, plugin_author: str, @@ -601,6 +613,8 @@ async def delete_plugin( task_context.trace('Cleaning up plugin configuration and storage...') await self.handler.cleanup_plugin_data(plugin_author, plugin_name) + await self._refresh_agent_runner_registry() + async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]: """List plugins, optionally filtered by component kinds. @@ -815,6 +829,53 @@ async def execute_command( yield cmd_ret + # AgentRunner methods + async def list_agent_runners(self, bound_plugins: list[str] | None = None) -> list[dict[str, Any]]: + """List all available AgentRunner components. + + Returns list of dicts with plugin_author, plugin_name, runner_name, manifest, etc. + """ + if not self.is_enable_plugin: + return [] + + runners_data = await self.handler.list_agent_runners(include_plugins=bound_plugins) + return runners_data + + async def run_agent( + self, + plugin_author: str, + plugin_name: str, + runner_name: str, + context: dict[str, Any], + ) -> typing.AsyncGenerator[dict[str, Any], None]: + """Run an AgentRunner from a plugin. + + Args: + plugin_author: Plugin author + plugin_name: Plugin name + runner_name: AgentRunner component name + context: AgentRunContext as dict + + Yields: + AgentRunResult dicts + """ + if not self.is_enable_plugin: + # Return a protocol-level failure result. + yield { + 'type': 'run.failed', + 'data': { + 'error': 'Plugin system is disabled', + 'code': 'plugin.disabled', + 'retryable': False, + }, + } + return + + gen = self.handler.run_agent(plugin_author, plugin_name, runner_name, context) + + async for ret in gen: + yield ret + async def retrieve_knowledge( self, plugin_author: str, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index dcfb006b5..fc6b980b0 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1,10 +1,11 @@ from __future__ import annotations import typing -from typing import Any +from typing import Any, Union import base64 import traceback +import pydantic import sqlalchemy from langbot_plugin.runtime.io import handler @@ -21,9 +22,42 @@ from ..entity.persistence import plugin as persistence_plugin from ..entity.persistence import bstorage as persistence_bstorage +from ..provider.modelmgr import requester as model_requester from ..core import app from ..utils import constants +from ..agent.runner.session_registry import get_session_registry +from ..agent.runner.config_resolver import RunnerConfigResolver +from ..agent.runner import config_schema + + +from . import agent_pull_actions, agent_runner_actions, agent_state_actions +from .agent_run_support import ( + _validate_agent_run_session, +) + + +_HOST_RESERVED_QUERY_VAR_PREFIXES = ( + '_host_', + '_pipeline_bound_', + '_pipeline_mcp_', + '_monitoring_', + '_sandbox_', + '_authorized', + '_permission', +) +_HOST_RESERVED_QUERY_VAR_KEYS = frozenset( + { + '_activated_skills', + '_fallback_model_uuids', + '_routed_by_rule', + } +) + + +def _is_host_reserved_query_var(key: str) -> bool: + """Return whether a Query variable controls Host authorization or runtime state.""" + return key in _HOST_RESERVED_QUERY_VAR_KEYS or key.startswith(_HOST_RESERVED_QUERY_VAR_PREFIXES) class _RawAction: @@ -35,6 +69,20 @@ def _langbot_to_runtime_action(enum_name: str, fallback_value: str) -> Any: return getattr(LangBotToRuntimeAction, enum_name, _RawAction(fallback_value)) +def _serialize_plugin_api_result(value: Any) -> Any: + if isinstance(value, pydantic.BaseModel): + return value.model_dump(mode='json', serialize_as_any=True, exclude={'source_platform_object'}) + if isinstance(value, list): + return [_serialize_plugin_api_result(item) for item in value] + if isinstance(value, tuple): + return [_serialize_plugin_api_result(item) for item in value] + if isinstance(value, dict): + return {key: _serialize_plugin_api_result(item) for key, item in value.items()} + if isinstance(value, bytes): + return base64.b64encode(value).decode('utf-8') + return value + + def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse: """Create a clean error response for RAG operations. @@ -49,6 +97,225 @@ def _make_rag_error_response(error: Exception, error_type: str, **extra_context) return handler.ActionResponse.error(message=message) +def _pop_query_llm_usage(query: Any) -> dict[str, Any] | None: + """Read provider usage stashed on a query by RuntimeProvider.""" + if query is None or not getattr(query, 'variables', None): + return None + usage = query.variables.pop(model_requester.LLM_USAGE_QUERY_VARIABLE, None) + if usage is None: + return None + if isinstance(usage, dict): + return dict(usage) + return None + + +def _normalize_uuid_list(values: Any) -> list[str]: + """Normalize a user/config supplied UUID list while preserving order.""" + if not isinstance(values, list): + return [] + return list( + dict.fromkeys(value for value in values if isinstance(value, str) and value not in config_schema.NONE_SENTINELS) + ) + + +async def _get_pipeline_knowledge_base_uuids(ap: app.Application, query: Any) -> list[str]: + """Resolve pipeline-scoped KBs from preprocessed variables or runner schema.""" + variables = getattr(query, 'variables', {}) or {} + if '_knowledge_base_uuids' in variables: + return _normalize_uuid_list(variables.get('_knowledge_base_uuids')) + + pipeline_config = getattr(query, 'pipeline_config', None) + if not pipeline_config: + return [] + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + if not runner_id: + return [] + + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + registry = getattr(ap, 'agent_runner_registry', None) + if registry is None: + return [] + + bound_plugins = variables.get('_pipeline_bound_plugins') + try: + descriptor = await registry.get(runner_id, bound_plugins) + except Exception as e: + ap.logger.warning(f'Failed to load AgentRunner descriptor for knowledge-base scope: {e}') + return [] + + return config_schema.extract_knowledge_base_uuids(descriptor, runner_config) + + +async def _validate_run_authorization( + run_id: str, + resource_type: str, + resource_id: str, + ap: app.Application, + caller_plugin_identity: str | None = None, + operation: str | None = None, +) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]: + """Validate run_id authorization for a resource access. + + Common validation logic for INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL, + RETRIEVE_KNOWLEDGE_BASE, RETRIEVE_KNOWLEDGE, and storage actions. + + Args: + run_id: The run_id to validate. + resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage'). + resource_id: Resource identifier (model_uuid, tool_name, kb_id, 'plugin'/'workspace'). + ap: Application instance for logging. + caller_plugin_identity: Plugin identity (author/name) of the caller. + Required when the run session is bound to a plugin identity. + operation: Optional resource operation required by the runtime action. + + Returns: + Tuple of (session, None) if validation passes. + Tuple of (None, error_response) if validation fails. + """ + session_registry = get_session_registry() + session = await session_registry.get(run_id) + if not session: + ap.logger.warning(f'{resource_type.upper()}: run_id {run_id} not found in session registry') + return None, handler.ActionResponse.error( + message=f'Run session {run_id} not found or expired', + ) + + session_plugin_identity = session.get('plugin_identity') + if not isinstance(session_plugin_identity, str) or not session_plugin_identity.strip(): + ap.logger.warning(f'{resource_type.upper()}: run_id {run_id} has no plugin_identity') + return None, handler.ActionResponse.error( + message=f'Run session {run_id} has no plugin_identity', + ) + if not caller_plugin_identity: + return None, handler.ActionResponse.error( + message=f'caller_plugin_identity is required for run_id {run_id}', + ) + if caller_plugin_identity != session_plugin_identity: + ap.logger.warning( + f'{resource_type.upper()}: caller_plugin_identity {caller_plugin_identity} ' + f'does not match session plugin_identity {session_plugin_identity}' + ) + return None, handler.ActionResponse.error( + message=f'Plugin identity mismatch: caller {caller_plugin_identity} is not authorized for run_id {run_id}', + ) + + if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation): + ap.logger.warning( + f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}' + ) + operation_suffix = f' for operation {operation}' if operation else '' + return None, handler.ActionResponse.error( + message=f'{resource_type} {resource_id} is not authorized{operation_suffix} for this agent run', + ) + + return session, None + + +def _validate_frozen_tool_source_identity( + session: Any, + tool_name: str, + ap: app.Application, +) -> Union[tuple[None, handler.ActionResponse], tuple[dict[str, str | None], None]]: + """Resolve the exact Host tool implementation frozen for this run.""" + authorization = session.get('authorization') + resources = authorization.get('resources') if isinstance(authorization, dict) else None + tools = resources.get('tools') if isinstance(resources, dict) else None + matching_tools = ( + [tool for tool in tools if isinstance(tool, dict) and tool.get('tool_name') == tool_name] + if isinstance(tools, list) + else [] + ) + + source_ref: dict[str, str | None] | None = None + if len(matching_tools) == 1: + tool = matching_tools[0] + source = tool.get('source') + source_id = tool.get('source_id') + has_complete_shape = ( + 'source' in tool + and 'source_id' in tool + and isinstance(source, str) + and bool(source) + and source == source.strip() + and ( + source_id is None or (isinstance(source_id, str) and bool(source_id) and source_id == source_id.strip()) + ) + ) + if has_complete_shape: + if source in {'builtin', 'native', 'skill'} and source_id is None: + source_ref = {'source': source, 'source_id': None} + elif source == 'plugin' and isinstance(source_id, str): + source_ref = {'source': source, 'source_id': source_id} + elif source == 'mcp': + from ..provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE + + if isinstance(source_id, str) or tool_name in { + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + }: + source_ref = {'source': source, 'source_id': source_id} + + if source_ref is not None: + return source_ref, None + + run_id = session.get('run_id') + ap.logger.warning(f'TOOL: {tool_name} has an invalid frozen source identity for run_id {run_id}') + return None, handler.ActionResponse.error( + message=f'Tool {tool_name} has an invalid frozen source identity for this agent run', + ) + + +def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None: + """Return a cached Query for query-based runtime actions when available.""" + if query_id is None: + return None + + try: + return ap.query_pool.cached_queries.get(query_id) + except Exception: + return None + + +def _resolve_action_query(data: dict[str, Any], session: Any | None, ap: app.Application) -> Any | None: + """Resolve the current Query from internal run state or query-based action payload.""" + query_id = None + if session: + query_id = session.get('query_id') + if query_id is None: + query_id = data.get('query_id') + query = _get_cached_query(ap, query_id) + if query is None and session is not None: + query = session.get('execution_query') + if query is not None and session is not None: + object.__setattr__(query, '_agent_run_session', session) + return query + + +def _resolve_remove_think(data: dict[str, Any], query: Any | None) -> bool: + """Resolve remove-think using explicit action override, then pipeline config.""" + if 'remove_think' in data: + return bool(data.get('remove_think')) + + if query and getattr(query, 'pipeline_config', None): + return bool(query.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)) + + return False + + +def _merge_model_extra_args(model: Any, call_extra_args: Any) -> dict[str, Any]: + """Merge persisted model extra_args with action-level overrides.""" + merged: dict[str, Any] = {} + + model_extra_args = getattr(getattr(model, 'model_entity', None), 'extra_args', None) + if isinstance(model_extra_args, dict): + merged.update(model_extra_args) + if isinstance(call_extra_args, dict): + merged.update(call_extra_args) + + return merged + + class RuntimeConnectionHandler(handler.Handler): """Runtime connection handler""" @@ -205,6 +472,16 @@ async def set_query_var(data: dict[str, Any]) -> handler.ActionResponse: query = self.ap.query_pool.cached_queries[query_id] + if not isinstance(key, str) or not key: + return handler.ActionResponse.error( + message='Query variable key must be a non-empty string', + ) + if _is_host_reserved_query_var(key): + self.ap.logger.warning(f'Plugin attempted to write Host-reserved Query variable {key!r}') + return handler.ActionResponse.error( + message=f'Query variable {key!r} is reserved for LangBot Host', + ) + query.variables[key] = value return handler.ActionResponse.success( @@ -311,14 +588,60 @@ async def send_message(data: dict[str, Any]) -> handler.ActionResponse: message=f'Bot with bot_uuid {bot_uuid} not found', ) - await bot.adapter.send_message( + result = await bot.adapter.send_message( target_type, target_id, message_chain_obj, ) return handler.ActionResponse.success( - data={}, + data={ + 'result': _serialize_plugin_api_result(result), + }, + ) + + @self.action(PluginToRuntimeAction.CALL_PLATFORM_API) + async def call_platform_api(data: dict[str, Any]) -> handler.ActionResponse: + """Call a platform adapter API""" + bot_uuid = data['bot_uuid'] + action = data['action'] + params = data.get('params') or {} + + bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if bot is None: + return handler.ActionResponse.error( + message=f'Bot with bot_uuid {bot_uuid} not found', + ) + + supported_apis = bot.adapter.get_supported_apis() + if action not in supported_apis: + return handler.ActionResponse.error( + message=f'Platform API {action} is not supported by bot {bot_uuid}', + ) + + try: + if action == 'call_platform_api': + platform_action = params['action'] + platform_params = params.get('params') or {} + result = await bot.adapter.call_platform_api(platform_action, platform_params) + else: + api_func = getattr(bot.adapter, action, None) + if api_func is None: + return handler.ActionResponse.error( + message=f'Platform API {action} is declared but not implemented by bot {bot_uuid}', + ) + result = await api_func(**params) + if isinstance(result, pydantic.BaseModel) and hasattr(result, 'bot_uuid') and not result.bot_uuid: + result.bot_uuid = bot_uuid + except Exception as e: + return handler.ActionResponse.error( + message=f'Platform API {action} failed: {type(e).__name__}: {e}', + ) + + return handler.ActionResponse.success( + data={ + 'result': _serialize_plugin_api_result(result), + }, ) @self.action(PluginToRuntimeAction.GET_LLM_MODELS) @@ -331,13 +654,77 @@ async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse: }, ) + @self.action(PluginToRuntimeAction.COUNT_TOKENS) + async def count_tokens(data: dict[str, Any]) -> handler.ActionResponse: + """Count model input tokens. + + For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ + llm_model_uuid = data['llm_model_uuid'] + messages = data['messages'] + funcs = data.get('funcs', []) + extra_args = data.get('extra_args', {}) + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + + if run_id: + _session, error = await _validate_run_authorization( + run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens' + ) + if error: + return error + + llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid) + if llm_model is None: + return handler.ActionResponse.error( + message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', + ) + + messages_obj = [provider_message.Message.model_validate(message) for message in messages] + + async def _placeholder_func(**kwargs): + pass + + funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] + count_tokens_method = getattr(llm_model.provider.requester, 'count_tokens', None) + if not callable(count_tokens_method): + return handler.ActionResponse.error(message='LLM provider does not support token counting') + + try: + tokens = await count_tokens_method( + model=llm_model, + messages=messages_obj, + funcs=funcs_obj, + extra_args=extra_args, + ) + except Exception as exc: + return handler.ActionResponse.error(message=f'Token counting failed: {exc}') + + return handler.ActionResponse.success(data={'tokens': tokens}) + @self.action(PluginToRuntimeAction.INVOKE_LLM) async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: - """Invoke llm""" + """Invoke llm + + For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ llm_model_uuid = data['llm_model_uuid'] messages = data['messages'] funcs = data.get('funcs', []) extra_args = data.get('extra_args', {}) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke' + ) + if error: + return error llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid) if llm_model is None: @@ -354,28 +741,235 @@ async def _placeholder_func(**kwargs): pass funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] + query = _resolve_action_query(data, session, self.ap) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + remove_think = _resolve_remove_think(data, query) + effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] result = await llm_model.provider.invoke_llm( - query=None, + query=query, model=llm_model, messages=messages_obj, - funcs=funcs_obj, - extra_args=extra_args, + funcs=effective_funcs, + extra_args=effective_extra_args, + remove_think=remove_think, ) + usage = None + if isinstance(result, tuple): + result, usage = result + if usage is None: + usage = _pop_query_llm_usage(query) + + response_data = { + 'message': result.model_dump(), + } + if usage is not None: + response_data['usage'] = usage + return handler.ActionResponse.success( - data={ - 'message': result.model_dump(), - }, + data=response_data, ) + @self.action(PluginToRuntimeAction.INVOKE_LLM_STREAM) + async def invoke_llm_stream(data: dict[str, Any]): + """Invoke llm with streaming response + + For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ + llm_model_uuid = data['llm_model_uuid'] + messages = data['messages'] + funcs = data.get('funcs', []) + extra_args = data.get('extra_args', {}) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream' + ) + if error: + yield error + return + + llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid) + if llm_model is None: + yield handler.ActionResponse.error( + message=f'LLM model with llm_model_uuid {llm_model_uuid} not found', + ) + return + + messages_obj = [provider_message.Message.model_validate(message) for message in messages] + + # The func field is excluded during model_dump() in plugin side + # but required by LLMTool validation on Host. + async def _placeholder_func(**kwargs): + pass + + funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] + query = _resolve_action_query(data, session, self.ap) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + remove_think = _resolve_remove_think(data, query) + effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] + + async for chunk in llm_model.provider.invoke_llm_stream( + query=query, + model=llm_model, + messages=messages_obj, + funcs=effective_funcs, + extra_args=effective_extra_args, + remove_think=remove_think, + ): + if chunk is None: + continue + yield handler.ActionResponse.success( + data={ + 'chunk': chunk.model_dump(), + }, + ) + usage = _pop_query_llm_usage(query) + if usage is not None: + yield handler.ActionResponse.success( + data={ + 'usage': usage, + }, + ) + + @self.action(PluginToRuntimeAction.CALL_TOOL) + async def call_tool(data: dict[str, Any]) -> handler.ActionResponse: + """Call a tool + + For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + """ + tool_name = data['tool_name'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + source_ref = None + is_agent_runner_call = bool(run_id) + + if is_agent_runner_call: + if 'parameters' not in data: + return handler.ActionResponse.error( + message='parameters is required for AgentRunner tool calls', + ) + parameters = data.get('parameters') or {} + else: + parameters = data.get('tool_parameters') or {} + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'tool', tool_name, self.ap, caller_plugin_identity, operation='call' + ) + if error: + return error + source_ref, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap) + if error: + return error + + # Convert session_data to Session object (simplified) + # In real implementation, you would reconstruct the full session + # For now, we'll call the tool manager's execute method + try: + query = _resolve_action_query(data, session, self.ap) + execute_kwargs: dict[str, Any] = { + 'name': tool_name, + 'parameters': parameters, + 'query': query, + } + if source_ref is not None: + execute_kwargs['source_ref'] = source_ref + result = await self.ap.tool_mgr.execute_func_call( + **execute_kwargs, + ) + if is_agent_runner_call: + return handler.ActionResponse.success(data={'result': result}) + return handler.ActionResponse.success(data={'tool_response': result}) + except Exception as e: + traceback.print_exc() + return handler.ActionResponse.error( + message=f'Failed to execute tool {tool_name}: {e}', + ) + + @self.action(PluginToRuntimeAction.GET_TOOL_DETAIL) + async def get_tool_detail(data: dict[str, Any]) -> handler.ActionResponse: + """Get tool detail for LLM function calling. + + For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + + Returns tool manifest including name, description, and parameters schema. + """ + tool_name = data['tool_name'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + source_ref = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'tool', tool_name, self.ap, caller_plugin_identity, operation='detail' + ) + if error: + return error + source_ref, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap) + if error: + return error + + try: + detail_kwargs: dict[str, Any] = {} + if source_ref is not None: + detail_kwargs['source_ref'] = source_ref + tool_detail = await self.ap.tool_mgr.get_tool_detail(tool_name, **detail_kwargs) + if tool_detail is None: + return handler.ActionResponse.error( + message=f'Tool {tool_name} not found', + ) + + return handler.ActionResponse.success(data={'tool': tool_detail}) + except Exception as e: + traceback.print_exc() + return handler.ActionResponse.error( + message=f'Failed to get tool detail for {tool_name}: {e}', + ) + + # ================= Binary Storage Handlers ================= + # Permission validation: + # - For AgentRunner calls (with run_id): validates storage permission via session_registry + # - For regular plugin calls (no run_id): unrestricted access (backward compatibility) + # - Plugin storage: inherent isolation via owner = plugin identity (set by SDK runtime) + # - Workspace storage: requires ctx.resources.storage.workspace_storage for AgentRunner + @self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE) async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Set binary storage""" + """Set binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] value = base64.b64decode(data['value_base64']) + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + # Determine storage type from owner_type + storage_type = owner_type # 'plugin' or 'workspace' + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error + max_value_bytes = ( self.ap.instance_config.data.get('plugin', {}) .get('binary_storage', {}) @@ -425,10 +1019,25 @@ async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE) async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Get binary storage""" + """Get binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bstorage.BinaryStorage) @@ -451,10 +1060,25 @@ async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: @self.action(RuntimeToLangBotAction.DELETE_BINARY_STORAGE) async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: - """Delete binary storage""" + """Delete binary storage + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ key = data['key'] owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_bstorage.BinaryStorage) @@ -469,9 +1093,24 @@ async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse: @self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS) async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionResponse: - """Get binary storage keys""" + """Get binary storage keys + + For AgentRunner calls: validates storage permission via session_registry. + For regular plugin calls: unrestricted access (backward compatibility). + """ owner_type = data['owner_type'] owner = data['owner'] + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + storage_type = owner_type + session, error = await _validate_run_authorization( + run_id, 'storage', storage_type, self.ap, caller_plugin_identity + ) + if error: + return error result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bstorage.BinaryStorage.key) @@ -487,7 +1126,11 @@ async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionRespons @self.action(PluginToRuntimeAction.GET_CONFIG_FILE) async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse: - """Get a config file by file key""" + """Get a config file by file key + + Regular plugin config files are still host storage files. AgentRunner + file access goes through sandbox tools, not this action. + """ file_key = data['file_key'] try: @@ -525,11 +1168,20 @@ async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse: @self.action(PluginToRuntimeAction.INVOKE_RERANK) async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse: + """Invoke rerank model, with run-scoped authorization for agent runner calls.""" + run_id = data.get('run_id') rerank_model_uuid = data['rerank_model_uuid'] query = data['query'] documents = data['documents'] top_k = data.get('top_k') - extra_args = data.get('extra_args', {}) + caller_plugin_identity = data.get('caller_plugin_identity') + + if run_id: + _, error = await _validate_run_authorization( + run_id, 'model', rerank_model_uuid, self.ap, caller_plugin_identity, operation='rerank' + ) + if error: + return error try: rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid) @@ -539,11 +1191,12 @@ async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse: ) try: + documents_capped = documents[:64] scores = await rerank_model.provider.invoke_rerank( model=rerank_model, query=query, - documents=documents[:64], - extra_args=extra_args, + documents=documents_capped, + extra_args=_merge_model_extra_args(rerank_model, data.get('extra_args', {})), ) scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True) if top_k is not None: @@ -685,11 +1338,27 @@ async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse: @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE) async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse: - """Retrieve documents from any knowledge base (unrestricted).""" + """Retrieve documents from any knowledge base. + + For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases. + For regular plugin calls: no run_id, unrestricted access (backward compatibility). + + Note: SDK AgentRunAPIProxy.retrieve_knowledge calls this action with run_id. + """ kb_id = data['kb_id'] query_text = data['query_text'] top_k = data.get('top_k', 5) - filters = data.get('filters', {}) + filters = data.get('filters') or {} + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'knowledge_base', kb_id, self.ap, caller_plugin_identity, operation='retrieve' + ) + if error: + return error kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id) if not kb: @@ -722,15 +1391,7 @@ async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionR query = self.ap.query_pool.cached_queries[query_id] - kb_uuids = [] - if query.pipeline_config: - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - kb_uuids = local_agent_config.get('knowledge-bases', []) - # Backward compatibility - if not kb_uuids: - old_kb_uuid = local_agent_config.get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - kb_uuids = [old_kb_uuid] + kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) knowledge_bases = [] for kb_uuid in kb_uuids: @@ -748,34 +1409,49 @@ async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionR @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse: - """Retrieve documents from a knowledge base within the pipeline's scope.""" - query_id = data['query_id'] + """Retrieve documents from a knowledge base within the current run or query scope. + + For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases. + For regular plugin calls: no run_id, validates against pipeline's configured knowledge bases. + + Note: This action has dual validation paths: + - AgentRunner: uses session_registry for permission check + - Regular plugin: uses RunnerConfigResolver.resolve_runner_config for pipeline-level check + """ kb_id = data['kb_id'] query_text = data['query_text'] top_k = data.get('top_k', 5) - filters = data.get('filters', {}) - - if query_id not in self.ap.query_pool.cached_queries: - return handler.ActionResponse.error( - message=f'Query with query_id {query_id} not found', + filters = data.get('filters') or {} + run_id = data.get('run_id') # Optional: present for AgentRunner calls + caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + query = None + + # Permission validation for AgentRunner calls + if run_id: + session, error = await _validate_run_authorization( + run_id, 'knowledge_base', kb_id, self.ap, caller_plugin_identity, operation='retrieve' ) + if error: + return error + query = _resolve_action_query(data, session, self.ap) + else: + query_id = data['query_id'] + if query_id not in self.ap.query_pool.cached_queries: + return handler.ActionResponse.error( + message=f'Query with query_id {query_id} not found', + ) - query = self.ap.query_pool.cached_queries[query_id] + query = self.ap.query_pool.cached_queries[query_id] - # Validate kb_id is in pipeline's allowed list - allowed_kb_uuids = [] - if query.pipeline_config: - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - allowed_kb_uuids = local_agent_config.get('knowledge-bases', []) - if not allowed_kb_uuids: - old_kb_uuid = local_agent_config.get('knowledge-base', '') - if old_kb_uuid and old_kb_uuid != '__none__': - allowed_kb_uuids = [old_kb_uuid] - - if kb_id not in allowed_kb_uuids: - return handler.ActionResponse.error( - message=f'Knowledge base {kb_id} is not configured for this pipeline', - ) + # Regular plugin call: validate against the runner binding's + # schema-defined KB selectors or the preprocessed query scope. + allowed_kb_uuids = await _get_pipeline_knowledge_base_uuids(self.ap, query) + + if kb_id not in allowed_kb_uuids: + return handler.ActionResponse.error( + message=f'Knowledge base {kb_id} is not configured for this pipeline', + ) kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id) if not kb: @@ -784,22 +1460,70 @@ async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionRespons ) try: - session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + settings: dict[str, Any] = { + 'top_k': top_k, + 'filters': filters, + } + if query is not None: + session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + settings.update( + { + 'session_name': session_name, + 'bot_uuid': query.bot_uuid or '', + 'sender_id': str(query.sender_id), + } + ) entries = await kb.retrieve( query_text, - settings={ - 'top_k': top_k, - 'filters': filters, - 'session_name': session_name, - 'bot_uuid': query.bot_uuid or '', - 'sender_id': str(query.sender_id), - }, + settings=settings, ) results = [entry.model_dump(mode='json') for entry in entries] return handler.ActionResponse.success(data={'results': results}) except Exception as e: return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id) + # ================= Agent History/Event APIs ================= + + @self.action(PluginToRuntimeAction.GET_PROMPT) + async def get_prompt(data: dict[str, Any]) -> handler.ActionResponse: + """Return the current run's effective prompt after PromptPreProcessing.""" + run_id = data.get('run_id') + caller_plugin_identity = data.get('caller_plugin_identity') + + if not run_id: + return handler.ActionResponse.error(message='run_id is required') + + session, error = await _validate_agent_run_session( + run_id, + caller_plugin_identity, + self.ap, + 'Get prompt', + api_capability='prompt_get', + ) + if error: + return error + + query = _resolve_action_query(data, session, self.ap) + if query is None: + return handler.ActionResponse.error( + message=f'Query for run_id {run_id} not found or expired', + ) + + prompt = getattr(query, 'prompt', None) + messages = getattr(prompt, 'messages', []) or [] + return handler.ActionResponse.success( + data={ + 'prompt': [ + message.model_dump(mode='json') if hasattr(message, 'model_dump') else message + for message in messages + ], + } + ) + + agent_pull_actions.register(self) + agent_runner_actions.register(self) + agent_state_actions.register(self) + @self.action(CommonAction.PING) async def ping(data: dict[str, Any]) -> handler.ActionResponse: """Ping""" @@ -956,6 +1680,66 @@ async def list_tools(self, include_plugins: list[str] | None = None) -> list[dic return result['tools'] + async def list_agent_runners(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: + """List agent runners from plugin runtime. + + Returns list of dicts with: + - plugin_author + - plugin_name + - runner_name + - manifest + """ + result = await self.call_action( + LangBotToRuntimeAction.LIST_AGENT_RUNNERS, + { + 'include_plugins': include_plugins, + }, + timeout=20, + ) + + return result['runners'] + + async def run_agent( + self, + plugin_author: str, + plugin_name: str, + runner_name: str, + context: dict[str, Any], + ) -> typing.AsyncGenerator[dict[str, Any], None]: + """Run an AgentRunner component. + + Yields AgentRunResult dicts. + """ + timeout = self._get_runner_action_timeout(context) + gen = self.call_action_generator( + LangBotToRuntimeAction.RUN_AGENT, + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + 'context': context, + }, + timeout=timeout, + ) + + async for ret in gen: + yield ret + + def _get_runner_action_timeout(self, context: dict[str, Any]) -> float: + """Use the run deadline as the transport idle timeout when available.""" + try: + import time + + deadline_at = (context.get('runtime') or {}).get('deadline_at') + if deadline_at is None: + return 300 + remaining = float(deadline_at) - time.time() + if remaining <= 0: + return 0.001 + return max(remaining + 1.0, 0.001) + except (TypeError, ValueError): + return 300 + async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]: """Get plugin icon""" result = await self.call_action( diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py index 377f7d4a8..80f1bc2e9 100644 --- a/src/langbot/pkg/provider/modelmgr/requester.py +++ b/src/langbot/pkg/provider/modelmgr/requester.py @@ -411,6 +411,20 @@ async def invoke_llm( """ pass + async def count_tokens( + self, + model: RuntimeLLMModel, + messages: typing.List[provider_message.Message], + funcs: typing.List[resource_tool.LLMTool] = None, + extra_args: dict[str, typing.Any] = {}, + ) -> int: + """Count model input tokens before invoking the model. + + Requesters should use the same provider/model conversion path as + ``invoke_llm`` so the preflight count matches the actual request shape. + """ + raise NotImplementedError('This requester does not support token counting') + async def invoke_llm_stream( self, query: pipeline_query.Query, diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index c1b5ae0b6..d94b5bb76 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -521,6 +521,33 @@ async def _build_completion_args( return args + async def count_tokens( + self, + model: requester.RuntimeLLMModel, + messages: typing.List[provider_message.Message], + funcs: typing.List[resource_tool.LLMTool] = None, + extra_args: dict[str, typing.Any] = {}, + ) -> int: + """Count input tokens with LiteLLM's model-aware tokenizer.""" + args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False) + count_args: dict[str, typing.Any] = { + 'model': args['model'], + 'messages': args['messages'], + } + if 'tools' in args: + count_args['tools'] = args['tools'] + if 'tool_choice' in args: + count_args['tool_choice'] = args['tool_choice'] + + try: + tokens = litellm.token_counter(**count_args) + except Exception as e: + self._handle_litellm_error(e) + + if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0: + raise errors.RequesterError(f'token counter returned invalid value: {tokens!r}') + return tokens + async def invoke_llm( self, query: pipeline_query.Query, diff --git a/src/langbot/pkg/provider/runner.py b/src/langbot/pkg/provider/runner.py deleted file mode 100644 index 987b3a0e9..000000000 --- a/src/langbot/pkg/provider/runner.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import abc -import typing -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ..core import app - import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -preregistered_runners: list[typing.Type[RequestRunner]] = [] - - -def runner_class(name: str): - """注册一个请求运行器""" - - def decorator(cls: typing.Type[RequestRunner]) -> typing.Type[RequestRunner]: - cls.name = name - preregistered_runners.append(cls) - return cls - - return decorator - - -class RequestRunner(abc.ABC): - """请求运行器""" - - name: str = None - - ap: app.Application - - pipeline_config: dict - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - @abc.abstractmethod - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """运行请求""" - pass diff --git a/src/langbot/pkg/provider/runners/__init__.py b/src/langbot/pkg/provider/runners/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/provider/runners/cozeapi.py b/src/langbot/pkg/provider/runners/cozeapi.py deleted file mode 100644 index 26980f81e..000000000 --- a/src/langbot/pkg/provider/runners/cozeapi.py +++ /dev/null @@ -1,288 +0,0 @@ -from __future__ import annotations - -import typing -import json -import base64 - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from langbot.pkg.utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.coze_server_api.client import AsyncCozeAPIClient - - -@runner.runner_class('coze-api') -class CozeAPIRunner(runner.RequestRunner): - """Coze API 对话请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.pipeline_config = pipeline_config - self.ap = ap - self.agent_token = pipeline_config['ai']['coze-api']['api-key'] - self.bot_id = pipeline_config['ai']['coze-api'].get('bot-id') - self.chat_timeout = pipeline_config['ai']['coze-api'].get('timeout') - self.auto_save_history = pipeline_config['ai']['coze-api'].get('auto_save_history') - self.api_base = pipeline_config['ai']['coze-api'].get('api-base') - - self.coze = AsyncCozeAPIClient(self.agent_token, self.api_base) - - def _process_thinking_content( - self, - content: str, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - Returns: - (处理后的内容, 提取的思维链内容) - """ - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - thinking_content = '' - # 从 content 中提取 标签内容 - if content and '' in content and '' in content: - import re - - think_pattern = r'(.*?)' - think_matches = re.findall(think_pattern, content, re.DOTALL) - if think_matches: - thinking_content = '\n'.join(think_matches) - # 移除 content 中的 标签 - content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() - - # 根据 remove_think 参数决定是否保留思维链 - if remove_think: - return content, '' - else: - # 如果有思维链内容,将其以 格式添加到 content 开头 - if thinking_content: - content = f'\n{thinking_content}\n\n{content}'.strip() - return content, thinking_content - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]: - """预处理用户消息,转换为Coze消息格式 - - Returns: - list[dict]: Coze消息列表 - """ - messages = [] - - if isinstance(query.user_message.content, list): - # 多模态消息处理 - content_parts = [] - - for ce in query.user_message.content: - if ce.type == 'text': - content_parts.append({'type': 'text', 'text': ce.text}) - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - file_bytes = base64.b64decode(image_b64) - file_id = await self._get_file_id(file_bytes) - content_parts.append({'type': 'image', 'file_id': file_id}) - elif ce.type == 'file': - # 处理文件,上传到Coze - file_id = await self._get_file_id(ce.file) - content_parts.append({'type': 'file', 'file_id': file_id}) - - # 创建多模态消息 - if content_parts: - messages.append( - { - 'role': 'user', - 'content': json.dumps(content_parts), - 'content_type': 'object_string', - 'meta_data': None, - } - ) - - elif isinstance(query.user_message.content, str): - # 纯文本消息 - messages.append( - {'role': 'user', 'content': query.user_message.content, 'content_type': 'text', 'meta_data': None} - ) - - return messages - - async def _get_file_id(self, file) -> str: - """上传文件到Coze服务 - Args: - file: 文件 - Returns: - str: 文件ID - """ - file_id = await self.coze.upload(file=file) - return file_id - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手(非流式) - - 注意:由于cozepy没有提供非流式API,这里使用流式API并在结束后一次性返回完整内容 - """ - user_id = f'{query.launcher_type.value}_{query.launcher_id}' - - # 预处理用户消息 - additional_messages = await self._preprocess_user_message(query) - - # 获取会话ID - conversation_id = None - - # 收集完整内容 - full_content = '' - full_reasoning = '' - - try: - # 调用Coze API流式接口 - async for chunk in self.coze.chat_messages( - bot_id=self.bot_id, - user_id=user_id, - additional_messages=additional_messages, - conversation_id=conversation_id, - timeout=self.chat_timeout, - auto_save_history=self.auto_save_history, - stream=True, - ): - self.ap.logger.debug(f'coze-chat-stream: {chunk}') - - event_type = chunk.get('event') - data = chunk.get('data', {}) - # Removed debug print statement to avoid cluttering logs in production - - if event_type == 'conversation.message.delta': - # 收集内容 - if 'content' in data: - full_content += data.get('content', '') - - # 收集推理内容(如果有) - if 'reasoning_content' in data: - full_reasoning += data.get('reasoning_content', '') - - elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done - # 保存会话ID - if 'conversation_id' in data: - conversation_id = data.get('conversation_id') - - elif event_type == 'error': - # 处理错误 - error_msg = f'Coze API错误: {data.get("message", "未知错误")}' - yield provider_message.Message( - role='assistant', - content=error_msg, - ) - return - - # 处理思维链内容 - content, thinking_content = self._process_thinking_content(full_content) - if full_reasoning: - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - if not remove_think: - content = f'\n{full_reasoning}\n\n{content}'.strip() - - # 一次性返回完整内容 - yield provider_message.Message( - role='assistant', - content=content, - ) - - # 保存会话ID - if conversation_id and query.session.using_conversation: - query.session.using_conversation.uuid = conversation_id - - except Exception as e: - self.ap.logger.error(f'Coze API错误: {str(e)}') - yield provider_message.Message( - role='assistant', - content=f'Coze API调用失败: {str(e)}', - ) - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手(流式)""" - user_id = f'{query.launcher_type.value}_{query.launcher_id}' - - # 预处理用户消息 - additional_messages = await self._preprocess_user_message(query) - - # 获取会话ID - conversation_id = None - - start_reasoning = False - stop_reasoning = False - message_idx = 1 - is_final = False - full_content = '' - remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False) - - try: - # 调用Coze API流式接口 - async for chunk in self.coze.chat_messages( - bot_id=self.bot_id, - user_id=user_id, - additional_messages=additional_messages, - conversation_id=conversation_id, - timeout=self.chat_timeout, - auto_save_history=self.auto_save_history, - stream=True, - ): - self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}') - - event_type = chunk.get('event') - data = chunk.get('data', {}) - content = '' - - if event_type == 'conversation.message.delta': - message_idx += 1 - # 处理内容增量 - if 'reasoning_content' in data and not remove_think: - reasoning_content = data.get('reasoning_content', '') - if reasoning_content and not start_reasoning: - content = '\n' - start_reasoning = True - content += reasoning_content - - if 'content' in data: - if data.get('content', ''): - content += data.get('content', '') - if not stop_reasoning and start_reasoning: - content = f'\n{content}' - stop_reasoning = True - - elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done - # 保存会话ID - if 'conversation_id' in data: - conversation_id = data.get('conversation_id') - if query.session.using_conversation: - query.session.using_conversation.uuid = conversation_id - is_final = True - - elif event_type == 'error': - # 处理错误 - error_msg = f'Coze API错误: {data.get("message", "未知错误")}' - yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error') - return - full_content += content - if message_idx % 8 == 0 or is_final: - if full_content: - yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final) - - except Exception as e: - self.ap.logger.error(f'Coze API流式调用错误: {str(e)}') - yield provider_message.MessageChunk( - role='assistant', content=f'Coze API流式调用失败: {str(e)}', finish_reason='error' - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - if await query.adapter.is_stream_output_supported(): - async for msg in self._chat_messages_chunk(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - else: - async for msg in self._chat_messages(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/dashscopeapi.py b/src/langbot/pkg/provider/runners/dashscopeapi.py deleted file mode 100644 index a2c593ccc..000000000 --- a/src/langbot/pkg/provider/runners/dashscopeapi.py +++ /dev/null @@ -1,355 +0,0 @@ -from __future__ import annotations - -import typing -import re - -import dashscope - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class DashscopeAPIError(Exception): - """Dashscope API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('dashscope-app-api') -class DashScopeAPIRunner(runner.RequestRunner): - "阿里云百炼DashsscopeAPI对话请求器" - - # 运行器内部使用的配置 - app_type: str # 应用类型 - app_id: str # 应用ID - api_key: str # API Key - references_quote: ( - str # 引用资料提示(当展示回答来源功能开启时,这个变量会作为引用资料名前的提示,可在provider.json中配置) - ) - - def __init__(self, ap: app.Application, pipeline_config: dict): - """初始化""" - self.ap = ap - self.pipeline_config = pipeline_config - - valid_app_types = ['agent', 'workflow'] - self.app_type = self.pipeline_config['ai']['dashscope-app-api']['app-type'] - # 检查配置文件中使用的应用类型是否支持 - if self.app_type not in valid_app_types: - raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}') - - # 初始化Dashscope 参数配置 - self.app_id = self.pipeline_config['ai']['dashscope-app-api']['app-id'] - self.api_key = self.pipeline_config['ai']['dashscope-app-api']['api-key'] - self.references_quote = self.pipeline_config['ai']['dashscope-app-api']['references_quote'] - - def _replace_references(self, text, references_dict): - """阿里云百炼平台的自定义应用支持资料引用,此函数可以将引用标签替换为参考资料""" - - # 匹配 [index_id] 形式的字符串 - pattern = re.compile(r'\[(.*?)\]') - - def replacement(match): - # 获取引用编号 - ref_key = match.group(1) - if ref_key in references_dict: - # 如果有对应的参考资料按照provider.json中的reference_quote返回提示,来自哪个参考资料文件 - return f'({self.references_quote} {references_dict[ref_key]})' - else: - # 如果没有对应的参考资料,保留原样 - return match.group(0) - - # 使用 re.sub() 进行替换 - return pattern.sub(replacement, text) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]: - """预处理用户消息,提取纯文本,阿里云提供的上传文件方法过于复杂,暂不支持上传文件(包括图片)""" - plain_text = '' - image_ids = [] - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - # 暂时不支持上传图片,保留代码以便后续扩展 - # elif ce.type == "image_base64": - # image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - # file_bytes = base64.b64decode(image_b64) - # file = ("img.png", file_bytes, f"image/{image_format}") - # file_upload_resp = await self.dify_client.upload_file( - # file, - # f"{query.session.launcher_type.value}_{query.session.launcher_id}", - # ) - # image_id = file_upload_resp["id"] - # image_ids.append(image_id) - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text, image_ids - - async def _agent_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """Dashscope 智能体对话请求""" - - # 局部变量 - chunk = None # 流式传输的块 - pending_content = '' # 待处理的Agent输出内容 - references_dict = {} # 用于存储引用编号和对应的参考资料 - plain_text = '' # 用户输入的纯文本信息 - image_ids = [] # 用户输入的图片ID列表 (暂不支持) - - think_start = False - think_end = False - - plain_text, image_ids = await self._preprocess_user_message(query) - has_thoughts = True # 获取思考过程 - remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') - if remove_think: - has_thoughts = False - # 发送对话请求 - response = dashscope.Application.call( - api_key=self.api_key, # 智能体应用的API Key - app_id=self.app_id, # 智能体应用的ID - prompt=plain_text, # 用户输入的文本信息 - stream=True, # 流式输出 - incremental_output=True, # 增量输出,使用流式输出需要开启增量输出 - session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话 - enable_thinking=has_thoughts, - has_thoughts=has_thoughts, - # rag_options={ # 主要用于文件交互,暂不支持 - # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个 - # } - ) - idx_chunk = 0 - try: - is_stream = await query.adapter.is_stream_output_supported() - - except AttributeError: - is_stream = False - if is_stream: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - stream_think = stream_output.get('thoughts') or [] - if stream_think and stream_think[0].get('thought'): - if not think_start: - think_start = True - pending_content += f'\n{stream_think[0].get("thought")}' - else: - # 继续输出 reasoning_content - pending_content += stream_think[0].get('thought') - elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end: - think_end = True - pending_content += '\n\n' - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - # 是否是流式最后一个chunk - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - if idx_chunk % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=is_final, - ) - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - else: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - stream_think = stream_output.get('thoughts') or [] - if stream_think and stream_think[0].get('thought'): - if not think_start: - think_start = True - pending_content += f'\n{stream_think[0].get("thought")}' - else: - # 继续输出 reasoning_content - pending_content += stream_think[0].get('thought') - elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end: - think_end = True - pending_content += '\n\n' - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - yield provider_message.Message( - role='assistant', - content=pending_content, - ) - - async def _workflow_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """Dashscope 工作流对话请求""" - - # 局部变量 - chunk = None # 流式传输的块 - pending_content = '' # 待处理的Agent输出内容 - references_dict = {} # 用于存储引用编号和对应的参考资料 - plain_text = '' # 用户输入的纯文本信息 - image_ids = [] # 用户输入的图片ID列表 (暂不支持) - - plain_text, image_ids = await self._preprocess_user_message(query) - - biz_params = {} - biz_params.update(query.variables) - - # 发送对话请求 - response = dashscope.Application.call( - api_key=self.api_key, # 智能体应用的API Key - app_id=self.app_id, # 智能体应用的ID - prompt=plain_text, # 用户输入的文本信息 - stream=True, # 流式输出 - incremental_output=True, # 增量输出,使用流式输出需要开启增量输出 - session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话 - biz_params=biz_params, # 工作流应用的自定义输入参数传递 - flow_stream_mode='message_format', # 消息模式,输出/结束节点的流式结果 - # rag_options={ # 主要用于文件交互,暂不支持 - # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个 - # } - ) - - # 处理API返回的流式输出 - try: - is_stream = await query.adapter.is_stream_output_supported() - - except AttributeError: - is_stream = False - idx_chunk = 0 - if is_stream: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - idx_chunk += 1 - # 获取流式传输的output - stream_output = chunk.get('output', {}) - if stream_output.get('workflow_message') is not None: - pending_content += stream_output.get('workflow_message').get('message').get('content') - # if stream_output.get('text') is not None: - # pending_content += stream_output.get('text') - - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - if idx_chunk % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=is_final, - ) - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - else: - for chunk in response: - if chunk.get('status_code') != 200: - raise DashscopeAPIError( - f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - if not chunk: - continue - - # 获取流式传输的output - stream_output = chunk.get('output', {}) - if stream_output.get('text') is not None: - pending_content += stream_output.get('text') - - is_final = False if stream_output.get('finish_reason', False) == 'null' else True - - # 保存当前会话的session_id用于下次对话的语境 - query.session.using_conversation.uuid = stream_output.get('session_id') - - # 获取模型传出的参考资料列表 - references_dict_list = stream_output.get('doc_references', []) - - # 从模型传出的参考资料信息中提取用于替换的字典 - if references_dict_list is not None: - for doc in references_dict_list: - if doc.get('index_id') is not None: - references_dict[doc.get('index_id')] = doc.get('doc_name') - - # 将参考资料替换到文本中 - pending_content = self._replace_references(pending_content, references_dict) - - yield provider_message.Message( - role='assistant', - content=pending_content, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - if self.app_type == 'agent': - async for msg in self._agent_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - elif self.app_type == 'workflow': - async for msg in self._workflow_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg - else: - raise DashscopeAPIError(f'不支持的 Dashscope 应用类型: {self.app_type}') diff --git a/src/langbot/pkg/provider/runners/deerflowapi.py b/src/langbot/pkg/provider/runners/deerflowapi.py deleted file mode 100644 index 79c77126e..000000000 --- a/src/langbot/pkg/provider/runners/deerflowapi.py +++ /dev/null @@ -1,511 +0,0 @@ -"""DeerFlow LangGraph API Runner - -参考 astrbot 的 deerflow_agent_runner 实现,适配 LangBot 的 Runner 接口。 - -特点: -- 使用 LangGraph HTTP API 接入 deer-flow 后端 -- 自动管理 thread_id(按 session 隔离) -- 支持 SSE 流式响应解析 -- 支持 streaming/非流式两种输出 -- 处理 values / messages-tuple / custom 三种事件 -""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import typing -from collections import deque -from dataclasses import dataclass, field - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.deerflow_api import client, errors, stream_utils - - -_MAX_VALUES_HISTORY = 200 - - -@dataclass -class _StreamState: - """流式状态跟踪""" - - latest_text: str = '' - prev_text_for_streaming: str = '' - clarification_text: str = '' - task_failures: list[str] = field(default_factory=list) - seen_message_ids: set[str] = field(default_factory=set) - seen_message_order: deque[str] = field(default_factory=deque) - no_id_message_fingerprints: dict[int, str] = field(default_factory=dict) - baseline_initialized: bool = False - has_values_text: bool = False - run_values_messages: list[dict[str, typing.Any]] = field(default_factory=list) - timed_out: bool = False - - -@runner.runner_class('deerflow-api') -class DeerFlowAPIRunner(runner.RequestRunner): - """DeerFlow LangGraph API 对话请求器""" - - deerflow_client: client.AsyncDeerFlowClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - super().__init__(ap, pipeline_config) - - cfg = self.pipeline_config['ai']['deerflow-api'] - - api_base = cfg.get('api-base', '').strip() - if not api_base or not api_base.startswith(('http://', 'https://')): - raise errors.DeerFlowAPIError( - message='DeerFlow API Base URL 格式错误,必须以 http:// 或 https:// 开头', - ) - - self.api_base = api_base - self.api_key = cfg.get('api-key', '') - self.auth_header = cfg.get('auth-header', '') - self.assistant_id = cfg.get('assistant-id', 'lead_agent') - self.model_name = cfg.get('model-name', '') - self.thinking_enabled = bool(cfg.get('thinking-enabled', False)) - self.plan_mode = bool(cfg.get('plan-mode', False)) - self.subagent_enabled = bool(cfg.get('subagent-enabled', False)) - self.max_concurrent_subagents = int(cfg.get('max-concurrent-subagents', 3)) - self.timeout = int(cfg.get('timeout', 300)) - self.recursion_limit = int(cfg.get('recursion-limit', 1000)) - - self.deerflow_client = client.AsyncDeerFlowClient( - api_base=self.api_base, - api_key=self.api_key, - auth_header=self.auth_header, - ) - - # ------------------------------------------------------------------ - # 辅助方法 - # ------------------------------------------------------------------ - - def _fingerprint_message(self, message: dict[str, typing.Any]) -> str: - try: - raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str) - except (TypeError, ValueError): - raw = repr(message) - return hashlib.sha1(raw.encode('utf-8', errors='ignore')).hexdigest() - - def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None: - if not msg_id or msg_id in state.seen_message_ids: - return - state.seen_message_ids.add(msg_id) - state.seen_message_order.append(msg_id) - while len(state.seen_message_order) > _MAX_VALUES_HISTORY: - dropped = state.seen_message_order.popleft() - state.seen_message_ids.discard(dropped) - - def _extract_new_messages_from_values( - self, - values_messages: list[typing.Any], - state: _StreamState, - ) -> list[dict[str, typing.Any]]: - new_messages: list[dict[str, typing.Any]] = [] - no_id_indexes_seen: set[int] = set() - for idx, msg in enumerate(values_messages): - if not isinstance(msg, dict): - continue - msg_id = stream_utils.get_message_id(msg) - if msg_id: - if msg_id in state.seen_message_ids: - continue - self._remember_seen_message_id(state, msg_id) - new_messages.append(msg) - continue - - no_id_indexes_seen.add(idx) - fp = self._fingerprint_message(msg) - if state.no_id_message_fingerprints.get(idx) == fp: - continue - state.no_id_message_fingerprints[idx] = fp - new_messages.append(msg) - - for idx in list(state.no_id_message_fingerprints.keys()): - if idx not in no_id_indexes_seen: - state.no_id_message_fingerprints.pop(idx, None) - return new_messages - - # ------------------------------------------------------------------ - # 用户输入处理 - # ------------------------------------------------------------------ - - def _build_user_content( - self, - prompt: str, - image_urls: list[str], - ) -> typing.Any: - """构建 LangGraph 兼容的 user content(支持多模态)""" - if not image_urls: - return prompt - - content: list[dict[str, typing.Any]] = [] - if prompt: - content.append({'type': 'text', 'text': prompt}) - for url in image_urls: - if not isinstance(url, str): - continue - url = url.strip() - if not url: - continue - if url.startswith(('http://', 'https://', 'data:')): - content.append({'type': 'image_url', 'image_url': {'url': url}}) - return content if content else prompt - - def _preprocess_user_message( - self, - query: pipeline_query.Query, - ) -> tuple[str, list[str]]: - """提取用户消息的纯文本与图片 URL 列表""" - plain_text = '' - image_urls: list[str] = [] - - if isinstance(query.user_message.content, str): - plain_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - # 转换为 data URI 形式 - b64 = getattr(ce, 'image_base64', '') - if b64: - if not b64.startswith('data:'): - b64 = f'data:image/png;base64,{b64}' - image_urls.append(b64) - elif ce.type == 'image_url': - url = getattr(ce, 'image_url', '') - if url: - image_urls.append(url) - - return plain_text, image_urls - - # ------------------------------------------------------------------ - # 请求构造 - # ------------------------------------------------------------------ - - def _build_messages( - self, - prompt: str, - image_urls: list[str], - system_prompt: str = '', - ) -> list[dict[str, typing.Any]]: - messages: list[dict[str, typing.Any]] = [] - if system_prompt: - messages.append({'role': 'system', 'content': system_prompt}) - messages.append( - { - 'role': 'user', - 'content': self._build_user_content(prompt, image_urls), - } - ) - return messages - - def _build_runtime_configurable(self, thread_id: str) -> dict[str, typing.Any]: - cfg: dict[str, typing.Any] = { - 'thread_id': thread_id, - 'thinking_enabled': self.thinking_enabled, - 'is_plan_mode': self.plan_mode, - 'subagent_enabled': self.subagent_enabled, - } - if self.subagent_enabled: - cfg['max_concurrent_subagents'] = self.max_concurrent_subagents - if self.model_name: - cfg['model_name'] = self.model_name - return cfg - - def _build_payload( - self, - thread_id: str, - prompt: str, - image_urls: list[str], - system_prompt: str = '', - ) -> dict[str, typing.Any]: - runtime_configurable = self._build_runtime_configurable(thread_id) - return { - 'assistant_id': self.assistant_id, - 'input': { - 'messages': self._build_messages(prompt, image_urls, system_prompt), - }, - 'stream_mode': ['values', 'messages-tuple', 'custom'], - # DeerFlow 2.0 从 config.configurable 读取运行时覆盖 - # 同时保留 context 字段做向后兼容 - 'context': dict(runtime_configurable), - 'config': { - 'recursion_limit': self.recursion_limit, - 'configurable': runtime_configurable, - }, - } - - # ------------------------------------------------------------------ - # Session/Thread 管理 - # ------------------------------------------------------------------ - - async def _ensure_thread_id(self, query: pipeline_query.Query) -> str: - """从 query.session 取/创建 deerflow thread_id - - LangBot 使用 `query.session.using_conversation.uuid` 持久化 conversation id, - 我们复用这个字段存储 deerflow thread_id(与 Dify Runner 同样做法)。 - """ - thread_id = query.session.using_conversation.uuid or '' - if thread_id: - return thread_id - - thread = await self.deerflow_client.create_thread(timeout=min(30, self.timeout)) - thread_id = thread.get('thread_id', '') - if not thread_id: - raise errors.DeerFlowAPIError(message=f'DeerFlow create thread 返回数据缺少 thread_id: {thread}') - - query.session.using_conversation.uuid = thread_id - return thread_id - - # ------------------------------------------------------------------ - # 流式事件处理 - # ------------------------------------------------------------------ - - def _handle_values_event( - self, - data: typing.Any, - state: _StreamState, - ) -> str | None: - """处理 values 事件,返回新的完整文本(增量基础上的全量)""" - values_messages = stream_utils.extract_messages_from_values_data(data) - if not values_messages: - return None - - new_messages: list[dict[str, typing.Any]] = [] - if not state.baseline_initialized: - state.baseline_initialized = True - for idx, msg in enumerate(values_messages): - if not isinstance(msg, dict): - continue - new_messages.append(msg) - msg_id = stream_utils.get_message_id(msg) - if msg_id: - self._remember_seen_message_id(state, msg_id) - continue - state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg) - else: - new_messages = self._extract_new_messages_from_values(values_messages, state) - - latest_text = '' - if new_messages: - state.run_values_messages.extend(new_messages) - if len(state.run_values_messages) > _MAX_VALUES_HISTORY: - state.run_values_messages = state.run_values_messages[-_MAX_VALUES_HISTORY:] - latest_text = stream_utils.extract_latest_ai_text(state.run_values_messages) - if latest_text: - state.has_values_text = True - latest_clarification = stream_utils.extract_latest_clarification_text( - state.run_values_messages, - ) - if latest_clarification: - state.clarification_text = latest_clarification - - return latest_text or None - - def _handle_message_event( - self, - data: typing.Any, - state: _StreamState, - ) -> str | None: - """处理 messages-tuple 事件,返回增量文本 - - 当 values 事件已经提供完整文本时,跳过 messages-tuple 的增量 - """ - delta = stream_utils.extract_ai_delta_from_event_data(data) - if delta and not state.has_values_text: - state.latest_text += delta - return delta - - maybe_clar = stream_utils.extract_clarification_from_event_data(data) - if maybe_clar: - state.clarification_text = maybe_clar - return None - - def _build_final_text(self, state: _StreamState) -> str: - """构建最终输出文本""" - if state.clarification_text: - return state.clarification_text - - # 优先使用最后一条 AI message 的文本 - latest_ai = stream_utils.extract_latest_ai_message(state.run_values_messages) - if latest_ai: - text = stream_utils.extract_text(latest_ai.get('content')) - if text: - if state.timed_out: - text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' - return text - - if state.latest_text: - text = state.latest_text - if state.timed_out: - text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。' - return text - - # 提取任务失败信息作兜底 - failure_text = stream_utils.build_task_failure_summary(state.task_failures) - if failure_text: - return failure_text - - return 'DeerFlow 返回空响应' - - # ------------------------------------------------------------------ - # 主流程 - # ------------------------------------------------------------------ - - async def _stream_messages_chunk( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """流式输出生成器""" - plain_text, image_urls = self._preprocess_user_message(query) - - system_prompt = '' - # LangBot 的 pipeline 通常通过 prompt-preprocess 已注入 system prompt - # 这里保持空,让 prompt-preprocess 的内容作为 user message 一并送给 deerflow - - thread_id = await self._ensure_thread_id(query) - payload = self._build_payload( - thread_id=thread_id, - prompt=plain_text or 'continue', - image_urls=image_urls, - system_prompt=system_prompt, - ) - - state = _StreamState() - prev_text = '' - message_idx = 0 - - try: - async for event in self.deerflow_client.stream_run( - thread_id=thread_id, - payload=payload, - timeout=self.timeout, - ): - event_type = event.get('event') - data = event.get('data') - - if event_type == 'values': - new_full = self._handle_values_event(data, state) - if new_full and new_full != prev_text: - delta = new_full[len(prev_text) :] if new_full.startswith(prev_text) else new_full - prev_text = new_full - if delta: - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=new_full, - is_final=False, - ) - continue - - if event_type in {'messages-tuple', 'messages', 'message'}: - delta = self._handle_message_event(data, state) - if delta: - prev_text = state.latest_text - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=prev_text, - is_final=False, - ) - continue - - if event_type == 'custom': - state.task_failures.extend( - stream_utils.extract_task_failures_from_custom_event(data), - ) - continue - - if event_type == 'error': - raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') - - if event_type == 'end': - break - except (asyncio.TimeoutError, TimeoutError): - self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') - state.timed_out = True - - # 最终消息 - final_text = self._build_final_text(state) - yield provider_message.MessageChunk( - role='assistant', - content=final_text, - is_final=True, - ) - - async def _messages( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """非流式聚合输出""" - plain_text, image_urls = self._preprocess_user_message(query) - - thread_id = await self._ensure_thread_id(query) - payload = self._build_payload( - thread_id=thread_id, - prompt=plain_text or 'continue', - image_urls=image_urls, - ) - - state = _StreamState() - - try: - async for event in self.deerflow_client.stream_run( - thread_id=thread_id, - payload=payload, - timeout=self.timeout, - ): - event_type = event.get('event') - data = event.get('data') - - if event_type == 'values': - self._handle_values_event(data, state) - continue - - if event_type in {'messages-tuple', 'messages', 'message'}: - self._handle_message_event(data, state) - continue - - if event_type == 'custom': - state.task_failures.extend( - stream_utils.extract_task_failures_from_custom_event(data), - ) - continue - - if event_type == 'error': - raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}') - - if event_type == 'end': - break - except (asyncio.TimeoutError, TimeoutError): - self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}') - state.timed_out = True - - final_text = self._build_final_text(state) - yield provider_message.Message( - role='assistant', - content=final_text, - ) - - async def run( - self, - query: pipeline_query.Query, - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """主入口:根据 adapter 是否支持流式输出,选择流式或非流式""" - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - async for msg in self._stream_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - async for msg in self._messages(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py deleted file mode 100644 index 039bf33ad..000000000 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ /dev/null @@ -1,775 +0,0 @@ -from __future__ import annotations - -import typing -import json -import uuid -import base64 -import mimetypes - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -from langbot.pkg.utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.dify_service_api.v1 import client, errors -import httpx - - -@runner.runner_class('dify-service-api') -class DifyServiceAPIRunner(runner.RequestRunner): - """Dify Service API 对话请求器""" - - dify_client: client.AsyncDifyServiceClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - valid_app_types = ['chat', 'agent', 'workflow'] - if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) - - api_key = self.pipeline_config['ai']['dify-service-api']['api-key'] - - self.dify_client = client.AsyncDifyServiceClient( - api_key=api_key, - base_url=self.pipeline_config['ai']['dify-service-api']['base-url'], - ) - - def _process_thinking_content( - self, - content: str, - ) -> tuple[str, str]: - """处理思维链内容 - - Args: - content: 原始内容 - Returns: - (处理后的内容, 提取的思维链内容) - """ - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - thinking_content = '' - # 从 content 中提取 标签内容 - if content and '' in content and '' in content: - import re - - think_pattern = r'(.*?)' - think_matches = re.findall(think_pattern, content, re.DOTALL) - if think_matches: - thinking_content = '\n'.join(think_matches) - # 移除 content 中的 标签 - content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip() - - # 3. 根据 remove_think 参数决定是否保留思维链 - if remove_think: - return content, '' - else: - # 如果有思维链内容,将其以 格式添加到 content 开头 - if thinking_content: - content = f'\n{thinking_content}\n\n{content}'.strip() - return content, thinking_content - - def _extract_dify_text_output(self, value: typing.Any) -> str: - """Extract text content from Dify output payload.""" - if value is None: - return '' - if isinstance(value, dict): - content = value.get('content') - if isinstance(content, str): - return content - return json.dumps(value, ensure_ascii=False) - if isinstance(value, str): - text = value.strip() - if not text: - return '' - try: - parsed = json.loads(text) - except json.JSONDecodeError: - return value - if isinstance(parsed, dict) and isinstance(parsed.get('content'), str): - return parsed['content'] - return value - return str(value) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]: - """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务 - - Returns: - tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id) - """ - plain_text = '' - upload_files: list[dict] = [] - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str: - file_name = file_name or 'file' - content_type = content_type or 'application/octet-stream' - file = (file_name, file_bytes, content_type) - resp = await self.dify_client.upload_file(file, user_tag) - return resp['id'] - - async def download_file(file_url: str) -> tuple[bytes, str]: - """Download file from url (supports data url).""" - - async with httpx.AsyncClient() as client_session: - resp = await client_session.get(file_url) - resp.raise_for_status() - content_type = ( - resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream' - ) - return resp.content, content_type - - def _detect_file_type(content_type: str) -> str: - """Map MIME to dify file type.""" - if content_type and content_type.startswith('image/'): - return 'image' - if content_type and content_type.startswith('audio/'): - return 'audio' - if content_type and content_type.startswith('video/'): - return 'video' - return 'document' - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - file_bytes = base64.b64decode(image_b64) - image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}') - upload_files.append({'type': 'image', 'id': image_id}) - elif ce.type == 'file_url': - file_url = getattr(ce, 'file_url', None) - file_name = getattr(ce, 'file_name', None) or 'file' - try: - file_bytes, content_type = await download_file(file_url) - file_id = await upload_file_bytes(file_name, file_bytes, content_type) - file_type = _detect_file_type(content_type) - upload_files.append({'type': file_type, 'id': file_id}) - except Exception as e: - self.ap.logger.warning(f'dify file upload failed: {e}') - elif ce.type == 'file_base64': - file_name = getattr(ce, 'file_name', None) or 'file' - - header, b64_data = ce.file_base64.split(',', 1) - content_type = 'application/octet-stream' - if ';' in header: - content_type = header.split(';')[0][5:] or content_type - file_bytes = base64.b64decode(b64_data) - file_id = await upload_file_bytes(file_name, file_bytes, content_type) - file_type = _detect_file_type(content_type) - upload_files.append({'type': file_type, 'id': file_id}) - - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt'] - - return plain_text, upload_files - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - mode = 'basic' # 标记是基础编排还是工作流编排 - - basic_mode_pending_chunk = '' - - inputs = {} - - inputs.update(query.variables) - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - - if mode == 'workflow': - if chunk['event'] == 'node_finished': - if chunk['data']['node_type'] == 'answer': - answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) - content, _ = self._process_thinking_content(answer) - - yield provider_message.Message( - role='assistant', - content=content, - ) - elif mode == 'basic': - if chunk['event'] == 'message': - basic_mode_pending_chunk += chunk['answer'] - elif chunk['event'] == 'message_end': - content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.Message( - role='assistant', - content=content, - ) - basic_mode_pending_chunk = '' - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _agent_chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = [] - - inputs = {} - - inputs.update(query.variables) - - pending_agent_message = '' - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message' or chunk['event'] == 'message': - pending_agent_message += chunk['answer'] - else: - if pending_agent_message.strip() != '': - pending_agent_message = pending_agent_message.replace('Action:', '') - content, _ = self._process_thinking_content(pending_agent_message) - yield provider_message.Message( - role='assistant', - content=content, - ) - pending_agent_message = '' - - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - - if chunk['tool']: - msg = provider_message.Message( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - image_url = base_url + chunk['url'] - - yield provider_message.Message( - role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], - ) - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _workflow_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用工作流""" - - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - query.variables['conversation_id'] = query.session.using_conversation.uuid - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = ['text_chunk', 'workflow_started'] - - inputs = { # these variables are legacy variables, we need to keep them for compatibility - 'langbot_user_message_text': plain_text, - 'langbot_session_id': query.variables['session_id'], - 'langbot_conversation_id': query.variables['conversation_id'], - 'langbot_msg_create_time': query.variables['msg_create_time'], - } - - inputs.update(query.variables) - - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': - continue - - msg = provider_message.Message( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) - - yield msg - - elif chunk['event'] == 'workflow_finished': - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) - - msg = provider_message.Message( - role='assistant', - content=content, - ) - - yield msg - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - mode = 'basic' - basic_mode_pending_chunk = '' - - inputs = {} - - inputs.update(query.variables) - message_idx = 0 - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - - is_final = False - think_start = False - think_end = False - yielded_final = False - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) - - if chunk['event'] == 'workflow_started': - mode = 'workflow' - elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): - # Some Dify deployments may omit workflow_started in streamed chunks. - mode = 'workflow' - - if chunk['event'] == 'message': - message_idx += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - basic_mode_pending_chunk += content - think_end = True - elif think_end: - basic_mode_pending_chunk += chunk['answer'] - if think_start: - continue - - else: - basic_mode_pending_chunk += chunk['answer'] - - if chunk['event'] == 'message_end': - is_final = True - elif chunk['event'] == 'workflow_finished': - is_final = True - if chunk['data'].get('error'): - raise errors.DifyAPIError(chunk['data']['error']) - - if mode == 'workflow' and chunk['event'] == 'node_finished': - if chunk['data'].get('node_type') == 'answer': - answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) - if answer: - basic_mode_pending_chunk = answer - - if ( - not yielded_final - and (is_final or message_idx % 8 == 0) - and (basic_mode_pending_chunk != '' or is_final) - ): - # content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.MessageChunk( - role='assistant', - content=basic_mode_pending_chunk, - is_final=is_final, - ) - if is_final: - yielded_final = True - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _agent_chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用聊天助手""" - cov_id = query.session.using_conversation.uuid or None - query.variables['conversation_id'] = cov_id - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = [] - - inputs = {} - - inputs.update(query.variables) - - pending_agent_message = '' - - chunk = None # 初始化chunk变量,防止在没有响应时引用错误 - message_idx = 0 - is_final = False - think_start = False - think_end = False - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - - async for chunk in self.dify_client.chat_messages( - inputs=inputs, - query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - response_mode='streaming', - conversation_id=cov_id, - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-agent-chunk: ' + str(chunk)) - - if chunk['event'] in ignored_events: - continue - - if chunk['event'] == 'agent_message': - message_idx += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - pending_agent_message += content - think_end = True - elif think_end or not think_start: - pending_agent_message += chunk['answer'] - if think_start and not think_end: - continue - - else: - pending_agent_message += chunk['answer'] - elif chunk['event'] == 'message_end': - is_final = True - else: - if chunk['event'] == 'agent_thought': - if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过 - continue - message_idx += 1 - if chunk['tool']: - msg = provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk['id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - message_idx += 1 - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - # 检查URL是否已经是完整的连接 - if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'): - image_url = chunk['url'] - else: - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - image_url = base_url + chunk['url'] - - yield provider_message.MessageChunk( - role='assistant', - content=[provider_message.ContentElement.from_image_url(image_url)], - is_final=is_final, - ) - - if chunk['event'] == 'error': - raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_agent_message, - is_final=is_final, - ) - - if chunk is None: - raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') - - query.session.using_conversation.uuid = chunk['conversation_id'] - - async def _workflow_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用工作流""" - - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - query.variables['conversation_id'] = query.session.using_conversation.uuid - - plain_text, upload_files = await self._preprocess_user_message(query) - - files = [ - { - 'type': f['type'], - 'transfer_method': 'local_file', - 'upload_file_id': f['id'], - } - for f in upload_files - ] - - ignored_events = ['workflow_started'] - - inputs = { # these variables are legacy variables, we need to keep them for compatibility - 'langbot_user_message_text': plain_text, - 'langbot_session_id': query.variables['session_id'], - 'langbot_conversation_id': query.variables['conversation_id'], - 'langbot_msg_create_time': query.variables['msg_create_time'], - } - - inputs.update(query.variables) - messsage_idx = 0 - is_final = False - think_start = False - think_end = False - workflow_contents = '' - - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') - async for chunk in self.dify_client.workflow_run( - inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', - files=files, - timeout=120, - ): - self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) - if chunk['event'] in ignored_events: - continue - if chunk['event'] == 'workflow_finished': - is_final = True - if chunk['data']['error']: - raise errors.DifyAPIError(chunk['data']['error']) - - if chunk['event'] == 'text_chunk': - messsage_idx += 1 - if remove_think: - if '' in chunk['data']['text'] and not think_start: - think_start = True - continue - if '' in chunk['data']['text'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['data']['text']) - workflow_contents += content - think_end = True - elif think_end: - workflow_contents += chunk['data']['text'] - if think_start: - continue - - else: - workflow_contents += chunk['data']['text'] - - if chunk['event'] == 'node_started': - if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': - continue - messsage_idx += 1 - msg = provider_message.MessageChunk( - role='assistant', - content=None, - tool_calls=[ - provider_message.ToolCall( - id=chunk['data']['node_id'], - type='function', - function=provider_message.FunctionCall( - name=chunk['data']['title'], - arguments=json.dumps({}), - ), - ) - ], - ) - - yield msg - - if messsage_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=workflow_contents, - is_final=is_final, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': - async for msg in self._chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent': - async for msg in self._agent_chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow': - async for msg in self._workflow_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) - else: - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': - async for msg in self._chat_messages(query): - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent': - async for msg in self._agent_chat_messages(query): - yield msg - elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'workflow': - async for msg in self._workflow_messages(query): - yield msg - else: - raise errors.DifyAPIError( - f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' - ) diff --git a/src/langbot/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py deleted file mode 100644 index 8995476d3..000000000 --- a/src/langbot/pkg/provider/runners/langflowapi.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import typing -import json -import httpx -import uuid -import traceback - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -@runner.runner_class('langflow-api') -class LangflowAPIRunner(runner.RequestRunner): - """Langflow API 对话请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - async def _build_request_payload(self, query: pipeline_query.Query) -> dict: - """构建请求负载 - - Args: - query: 用户查询对象 - - Returns: - dict: 请求负载 - """ - # 获取用户消息文本 - user_message_text = '' - if isinstance(query.user_message.content, str): - user_message_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for item in query.user_message.content: - if item.type == 'text': - user_message_text += item.text - - # 从配置中获取 input_type 和 output_type,如果未配置则使用默认值 - input_type = self.pipeline_config['ai']['langflow-api'].get('input_type', 'chat') - output_type = self.pipeline_config['ai']['langflow-api'].get('output_type', 'chat') - - # 构建基本负载 - payload = { - 'output_type': output_type, - 'input_type': input_type, - 'input_value': user_message_text, - 'session_id': str(uuid.uuid4()), - } - - # 如果配置中有tweaks,则添加到负载中 - tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks')) - if tweaks: - payload['tweaks'] = tweaks - - return payload - - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """运行请求 - - Args: - query: 用户查询对象 - - Yields: - Message: 回复消息 - """ - # 检查是否支持流式输出 - is_stream = False - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - # 从配置中获取API参数 - base_url = self.pipeline_config['ai']['langflow-api']['base-url'] - api_key = self.pipeline_config['ai']['langflow-api']['api-key'] - flow_id = self.pipeline_config['ai']['langflow-api']['flow-id'] - - # 构建API URL - url = f'{base_url.rstrip("/")}/api/v1/run/{flow_id}' - - # 构建请求负载 - payload = await self._build_request_payload(query) - - # 设置请求头 - headers = {'Content-Type': 'application/json', 'x-api-key': api_key} - - # 发送请求 - async with httpx.AsyncClient() as client: - if is_stream: - # 流式请求 - async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response: - response.raise_for_status() - - accumulated_content = '' - message_count = 0 - - async for line in response.aiter_lines(): - data_str = line - - if data_str.startswith('data: '): - data_str = data_str[6:] # 移除 "data: " 前缀 - - try: - data = json.loads(data_str) - - # 提取消息内容 - message_text = '' - if 'outputs' in data and len(data['outputs']) > 0: - output = data['outputs'][0] - if 'outputs' in output and len(output['outputs']) > 0: - inner_output = output['outputs'][0] - if 'outputs' in inner_output and 'message' in inner_output['outputs']: - message_data = inner_output['outputs']['message'] - if 'message' in message_data: - message_text = message_data['message'] - - # 如果没有找到消息,尝试其他可能的路径 - if not message_text and 'messages' in data: - messages = data['messages'] - if messages and len(messages) > 0: - message_text = messages[0].get('message', '') - - if message_text: - # 更新累积内容 - accumulated_content = message_text - message_count += 1 - - # 每8条消息或有新内容时生成一个chunk - if message_count % 8 == 0 or len(message_text) > 0: - yield provider_message.MessageChunk( - role='assistant', content=accumulated_content, is_final=False - ) - except json.JSONDecodeError: - # 如果不是JSON,跳过这一行 - traceback.print_exc() - continue - - # 发送最终消息 - yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True) - else: - # 非流式请求 - response = await client.post(url, json=payload, headers=headers, timeout=120.0) - response.raise_for_status() - - # 解析响应 - response_data = response.json() - - # 提取消息内容 - # 根据Langflow API文档,响应结构可能在outputs[0].outputs[0].outputs.message.message中 - message_text = '' - if 'outputs' in response_data and len(response_data['outputs']) > 0: - output = response_data['outputs'][0] - if 'outputs' in output and len(output['outputs']) > 0: - inner_output = output['outputs'][0] - if 'outputs' in inner_output and 'message' in inner_output['outputs']: - message_data = inner_output['outputs']['message'] - if 'message' in message_data: - message_text = message_data['message'] - - # 如果没有找到消息,尝试其他可能的路径 - if not message_text and 'messages' in response_data: - messages = response_data['messages'] - if messages and len(messages) > 0: - message_text = messages[0].get('message', '') - - # 如果仍然没有找到消息,返回完整响应的字符串表示 - if not message_text: - message_text = json.dumps(response_data, ensure_ascii=False, indent=2) - - # 生成回复消息 - if is_stream: - yield provider_message.MessageChunk(role='assistant', content=message_text, is_final=True) - else: - reply_message = provider_message.Message(role='assistant', content=message_text) - yield reply_message diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py deleted file mode 100644 index 3417c6671..000000000 --- a/src/langbot/pkg/provider/runners/localagent.py +++ /dev/null @@ -1,611 +0,0 @@ -from __future__ import annotations - -import json -import copy -import typing -from .. import runner -from ...telemetry import features as telemetry_features -from ..modelmgr import requester as modelmgr_requester -from ..tools.loaders.native import EXEC_TOOL_NAME -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.rag.context as rag_context - - -rag_combined_prompt_template = """ -The following are relevant context entries retrieved from the knowledge base. -Please use them to answer the user's message. -Respond in the same language as the user's input. - - -{rag_context} - - - -{user_message} - -""" - -SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec' -SANDBOX_EXEC_SYSTEM_GUIDANCE = ( - 'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, ' - 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' - 'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec ' - 'and then answer from the tool result.' -) - - -# Hard cap on tool-call rounds within a single agent turn. A looping or -# adversarial model can otherwise emit tool calls indefinitely (each potentially -# a sandbox exec), yielding a non-terminating request and runaway cost. Set -# generously so it never interrupts legitimate multi-step agentic workflows. -MAX_TOOL_CALL_ROUNDS = 128 - - -def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str) -> bool: - return ability in (model.model_entity.abilities or []) - - -class _StreamAccumulator: - """Accumulate streamed content and fragmented OpenAI-style tool calls.""" - - def __init__(self, msg_sequence: int = 0, initial_content: str | None = None): - self.tool_calls_map: dict[str, provider_message.ToolCall] = {} - self.msg_idx = 0 - self.accumulated_content = initial_content or '' - self.last_role = 'assistant' - self.msg_sequence = msg_sequence - - def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None: - self.msg_idx += 1 - - if msg.role: - self.last_role = msg.role - - if msg.content: - self.accumulated_content += msg.content - - if msg.tool_calls: - for tool_call in msg.tool_calls: - if tool_call.id not in self.tool_calls_map: - self.tool_calls_map[tool_call.id] = provider_message.ToolCall( - id=tool_call.id, - type=tool_call.type, - function=provider_message.FunctionCall( - name=tool_call.function.name if tool_call.function else '', - arguments='', - ), - ) - if tool_call.function and tool_call.function.arguments: - self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments - - if self.msg_idx % 8 == 0 or msg.is_final: - self.msg_sequence += 1 - return provider_message.MessageChunk( - role=self.last_role, - content=self.accumulated_content, - tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None, - is_final=msg.is_final, - msg_sequence=self.msg_sequence, - ) - - return None - - def final_message(self) -> provider_message.MessageChunk: - return provider_message.MessageChunk( - role=self.last_role, - content=self.accumulated_content, - tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None, - msg_sequence=self.msg_sequence, - ) - - -@runner.runner_class('local-agent') -class LocalAgentRunner(runner.RequestRunner): - """Local agent request runner""" - - async def _inject_inbound_attachments( - self, - query: pipeline_query.Query, - user_message: provider_message.Message, - ) -> None: - """Persist inbound attachments into the sandbox and tell the model. - - No-op when the box service is unavailable or there are no attachments. - On success, appends an extra text ContentElement to the user message - listing the in-sandbox paths and the outbox convention, and stashes the - descriptors in ``query.variables['_sandbox_inbound_attachments']``. - """ - box_service = getattr(self.ap, 'box_service', None) - if box_service is None or not getattr(box_service, 'available', False): - return - try: - attachments = await box_service.materialize_inbound_attachments(query) - except Exception as e: # never break the chat turn over attachment IO - self.ap.logger.warning(f'Inbound attachment materialization failed: {e}') - return - if not attachments: - return - - query.variables['_sandbox_inbound_attachments'] = attachments - - lines = [ - 'The user sent attachments. They have been saved into the sandbox and are ' - 'available to the exec/read/write tools at these paths:' - ] - for att in attachments: - lines.append(f'- {att["type"]}: {att["path"]} ({att["size"]} bytes)') - outbox_dir = f'{box_service.OUTBOX_MOUNT_DIR}/{query.query_id}' - lines.append( - 'If you produce any file (image, audio, document, etc.) that should be sent ' - f'back to the user, write it into {outbox_dir}/ (create the directory if ' - 'needed). Every file placed there will be delivered to the user automatically.' - ) - note = '\n'.join(lines) - - # Voice/File attachments are now available to the agent via the sandbox - # (exec/read/write tools). Their raw bytes must NOT be forwarded to the - # chat model as multimodal content: providers reject non-image file - # parts ("Invalid user message ... ensure all user messages are valid - # OpenAI chat completion messages"). Strip those content elements and - # rely on the sandbox-path note instead. Images are kept so vision - # models can still see them. - _model_unsafe_types = {'file_base64', 'file_url'} - if isinstance(user_message.content, list): - user_message.content = [ - ce for ce in user_message.content if getattr(ce, 'type', None) not in _model_unsafe_types - ] - - if isinstance(user_message.content, str): - user_message.content = [ - provider_message.ContentElement.from_text(user_message.content), - provider_message.ContentElement.from_text(note), - ] - elif isinstance(user_message.content, list): - user_message.content.append(provider_message.ContentElement.from_text(note)) - else: - user_message.content = [provider_message.ContentElement.from_text(note)] - - def _build_request_messages( - self, - query: pipeline_query.Query, - user_message: provider_message.Message, - ) -> list[provider_message.Message]: - req_messages = query.prompt.messages.copy() + query.messages.copy() - - if any(getattr(tool, 'name', None) == EXEC_TOOL_NAME for tool in query.use_funcs or []): - req_messages.append( - provider_message.Message( - role='system', - content=self.ap.box_service.get_system_guidance(query.query_id), - ) - ) - - req_messages.append(user_message) - return req_messages - - async def _get_model_candidates( - self, - query: pipeline_query.Query, - ) -> list[modelmgr_requester.RuntimeLLMModel]: - """Build ordered list of models to try: primary model + fallback models.""" - candidates = [] - - # Primary model - if query.use_llm_model_uuid: - try: - primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) - candidates.append(primary) - except ValueError: - self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found') - - # Fallback models - fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', []) - for fb_uuid in fallback_uuids: - try: - fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid) - candidates.append(fb_model) - except ValueError: - self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') - - return candidates - - async def _invoke_with_fallback( - self, - query: pipeline_query.Query, - candidates: list[modelmgr_requester.RuntimeLLMModel], - messages: list, - funcs: list, - remove_think: bool, - ) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]: - """Try non-streaming invocation with sequential fallback. Returns (message, model_used).""" - last_error = None - for model in candidates: - try: - msg = await model.provider.invoke_llm( - query, - model, - messages, - funcs if _model_has_ability(model, 'func_call') else [], - extra_args=model.model_entity.extra_args, - remove_think=remove_think, - ) - return msg, model - except Exception as e: - last_error = e - self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...') - raise last_error or RuntimeError('No model candidates available') - - async def _invoke_stream_with_fallback( - self, - query: pipeline_query.Query, - candidates: list[modelmgr_requester.RuntimeLLMModel], - messages: list, - funcs: list, - remove_think: bool, - ) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]: - """Try streaming invocation with sequential fallback. Returns (stream_generator, model_used). - - Fallback is only possible before any chunks have been yielded to the client. - Once streaming starts, the model is committed. - """ - last_error = None - for model in candidates: - try: - stream = model.provider.invoke_llm_stream( - query, - model, - messages, - funcs if _model_has_ability(model, 'func_call') else [], - extra_args=model.model_entity.extra_args, - remove_think=remove_think, - ) - # Attempt to get the first chunk to verify the stream works - first_chunk = await stream.__anext__() - - async def _chain_stream(first, rest): - yield first - async for chunk in rest: - yield chunk - - return _chain_stream(first_chunk, stream), model - except StopAsyncIteration: - # Empty stream — treat as success (model returned nothing) - async def _empty_stream(): - return - yield # make it a generator - - return _empty_stream(), model - except Exception as e: - last_error = e - self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...') - raise last_error or RuntimeError('No model candidates available') - - async def run( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: - """Run request""" - pending_tool_calls = [] - initial_response_emitted = False - - # Get knowledge bases list from query variables (set by PreProcessor, - # may have been modified by plugins during PromptPreProcessing) - kb_uuids = query.variables.get('_knowledge_base_uuids', []) - - user_message = copy.deepcopy(query.user_message) - - # Materialize inbound attachments (images / voices / files) into the - # sandbox so the agent's exec/read/write tools can operate on the real - # bytes — not just the multimodal copy the model sees. The exact - # in-sandbox paths are announced to the model as a system note. - await self._inject_inbound_attachments(query, user_message) - - user_message_text = '' - - if isinstance(user_message.content, str): - user_message_text = user_message.content - elif isinstance(user_message.content, list): - for ce in user_message.content: - if ce.type == 'text': - user_message_text += ce.text - break - - if kb_uuids and user_message_text: - # only support text for now - all_results: list[rag_context.RetrievalResultEntry] = [] - - kb_engine_plugins: set[str] = set() - - # Retrieve from each knowledge base - for kb_uuid in kb_uuids: - kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) - - if not kb: - self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') - continue - - try: - engine_plugin_id = kb.get_knowledge_engine_plugin_id() or 'builtin' - except Exception: - engine_plugin_id = 'builtin' - kb_engine_plugins.add(engine_plugin_id) - - result = await kb.retrieve( - user_message_text, - settings={ - 'bot_uuid': query.bot_uuid or '', - 'sender_id': str(query.sender_id), - 'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - }, - ) - - if result: - all_results.extend(result) - - # Telemetry: knowledge base usage (counts and engine categories only) - telemetry_features.set_value( - query, - 'kb', - { - 'kb_count': len(kb_uuids), - 'engine_plugins': sorted(kb_engine_plugins), - 'retrieved_entries': len(all_results), - }, - ) - - # Rerank step: re-score results using a rerank model if configured - local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) - rerank_model_uuid = local_agent_config.get('rerank-model', '') - if rerank_model_uuid == '__none__': - rerank_model_uuid = '' - self.ap.logger.info( - f'Rerank config: model_uuid={rerank_model_uuid!r}, ' - f'results={len(all_results)}, ' - f'local_agent_keys={list(local_agent_config.keys())}' - ) - if all_results and rerank_model_uuid: - try: - rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid) - rerank_top_k = int(local_agent_config.get('rerank-top-k', 5)) - - doc_texts = [] - for entry in all_results: - text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text) - doc_texts.append(text) - - doc_texts_capped = doc_texts[:64] - scores = await rerank_model.provider.invoke_rerank( - model=rerank_model, - query=user_message_text, - documents=doc_texts_capped, - ) - - scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True) - top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)] - all_results = [all_results[i] for i in top_indices] - - self.ap.logger.info( - f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})' - ) - except ValueError: - self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank') - except Exception as e: - self.ap.logger.warning(f'Rerank failed, using original order: {e}') - - final_user_message_text = '' - - if all_results: - texts = [] - idx = 1 - for entry in all_results: - for content in entry.content: - if content.type == 'text' and content.text is not None: - texts.append(f'[{idx}] {content.text}') - idx += 1 - rag_context_text = '\n\n'.join(texts) - final_user_message_text = rag_combined_prompt_template.format( - rag_context=rag_context_text, user_message=user_message_text - ) - - else: - final_user_message_text = user_message_text - - self.ap.logger.debug(f'Final user message text: {final_user_message_text}') - - for ce in user_message.content: - if ce.type == 'text': - ce.text = final_user_message_text - break - - mcp_loader = getattr(getattr(self.ap, 'tool_mgr', None), 'mcp_tool_loader', None) - if mcp_loader is not None: - resource_context = await mcp_loader.build_resource_context_for_query(query) - if resource_context: - resource_addition = ( - '\n\nMCP resource context selected by LangBot host:\n' - f'{resource_context}\n\n' - 'Use this context as read-only reference material. If it conflicts with the user message, ' - 'ask for clarification before taking external actions.' - ) - if isinstance(user_message.content, str): - user_message.content += resource_addition - elif isinstance(user_message.content, list): - appended = False - for ce in user_message.content: - if ce.type == 'text': - ce.text = (ce.text or '') + resource_addition - appended = True - break - if not appended: - user_message.content.append( - provider_message.ContentElement.from_text(resource_addition.strip()) - ) - - req_messages = self._build_request_messages(query, user_message) - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think') - - # Build ordered candidate list (primary + fallbacks) - candidates = await self._get_model_candidates(query) - if not candidates: - raise RuntimeError('No LLM model configured for local-agent runner') - - self.ap.logger.debug( - f'localagent req: query={query.query_id} req_messages={req_messages} ' - f'candidates={[m.model_entity.name for m in candidates]}' - ) - - if not is_stream: - # Non-streaming: invoke with fallback - msg, use_llm_model = await self._invoke_with_fallback( - query, - candidates, - req_messages, - query.use_funcs, - remove_think, - ) - final_msg = msg - else: - # Streaming: invoke with fallback - stream_accumulator = _StreamAccumulator(msg_sequence=1) - - stream_src, use_llm_model = await self._invoke_stream_with_fallback( - query, - candidates, - req_messages, - query.use_funcs, - remove_think, - ) - async for msg in stream_src: - chunk = stream_accumulator.add(msg) - if chunk: - yield chunk - initial_response_emitted = True - - final_msg = stream_accumulator.final_message() - - pending_tool_calls = final_msg.tool_calls - first_content = final_msg.content - if isinstance(final_msg, provider_message.MessageChunk): - first_end_sequence = final_msg.msg_sequence - - if not is_stream: - yield final_msg - elif not initial_response_emitted: - yield final_msg - initial_response_emitted = True - - req_messages.append(final_msg) - - # Once a model succeeds, commit to it for the tool call loop - # (no fallback mid-conversation — different models may interpret tool results differently) - tool_call_round = 0 - while pending_tool_calls: - tool_call_round += 1 - telemetry_features.set_value(query, 'tool_call_rounds', tool_call_round) - if tool_call_round > MAX_TOOL_CALL_ROUNDS: - self.ap.logger.warning( - f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap ' - f'(query_id={query.query_id}); stopping to avoid a non-terminating request.' - ) - break - for tool_call in pending_tool_calls: - try: - func = tool_call.function - - if func.arguments: - parameters = json.loads(func.arguments) - else: - parameters = {} - - func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query) - - # Handle return value content - tool_content = None - if ( - isinstance(func_ret, list) - and len(func_ret) > 0 - and isinstance(func_ret[0], provider_message.ContentElement) - ): - tool_content = func_ret - else: - tool_content = json.dumps(func_ret, ensure_ascii=False) - - if is_stream: - msg = provider_message.MessageChunk( - role='tool', - content=tool_content, - tool_call_id=tool_call.id, - ) - else: - msg = provider_message.Message( - role='tool', - content=tool_content, - tool_call_id=tool_call.id, - ) - - yield msg - - req_messages.append(msg) - except Exception as e: - if is_stream: - err_msg = provider_message.MessageChunk( - role='tool', - content=f'err: {e}', - tool_call_id=tool_call.id, - is_final=True, - ) - else: - err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id) - - yield err_msg - - req_messages.append(err_msg) - - self.ap.logger.debug( - f'localagent req: query={query.query_id} req_messages={req_messages} ' - f'use_llm_model={use_llm_model.model_entity.name}' - ) - - if is_stream: - stream_accumulator = _StreamAccumulator( - msg_sequence=first_end_sequence, - initial_content=first_content, - ) - - tool_stream_src = use_llm_model.provider.invoke_llm_stream( - query, - use_llm_model, - req_messages, - query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], - extra_args=use_llm_model.model_entity.extra_args, - remove_think=remove_think, - ) - async for msg in tool_stream_src: - chunk = stream_accumulator.add(msg) - if chunk: - yield chunk - - final_msg = stream_accumulator.final_message() - else: - # Non-streaming: use committed model directly (no fallback in tool loop) - msg = await use_llm_model.provider.invoke_llm( - query, - use_llm_model, - req_messages, - query.use_funcs if _model_has_ability(use_llm_model, 'func_call') else [], - extra_args=use_llm_model.model_entity.extra_args, - remove_think=remove_think, - ) - - yield msg - final_msg = msg - - pending_tool_calls = final_msg.tool_calls - - req_messages.append(final_msg) diff --git a/src/langbot/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py deleted file mode 100644 index 543fd7ef9..000000000 --- a/src/langbot/pkg/provider/runners/n8nsvapi.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import typing -import json -import uuid -import aiohttp - -from langbot.pkg.utils import httpclient - -from .. import runner -from ...core import app -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class N8nAPIError(Exception): - """N8n API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('n8n-service-api') -class N8nServiceAPIRunner(runner.RequestRunner): - """N8n Service API 工作流请求器""" - - def __init__(self, ap: app.Application, pipeline_config: dict): - self.ap = ap - self.pipeline_config = pipeline_config - - # 获取webhook URL - self.webhook_url = self.pipeline_config['ai']['n8n-service-api']['webhook-url'] - - # 获取超时设置,默认为120秒 - self.timeout = self.pipeline_config['ai']['n8n-service-api'].get('timeout', 120) - - # 获取输出键名,默认为response - self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response') - - # 获取认证类型,默认为none - self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none') - - # 根据认证类型获取相应的认证信息 - if self.auth_type == 'basic': - self.basic_username = self.pipeline_config['ai']['n8n-service-api'].get('basic-username', '') - self.basic_password = self.pipeline_config['ai']['n8n-service-api'].get('basic-password', '') - elif self.auth_type == 'jwt': - self.jwt_secret = self.pipeline_config['ai']['n8n-service-api'].get('jwt-secret', '') - self.jwt_algorithm = self.pipeline_config['ai']['n8n-service-api'].get('jwt-algorithm', 'HS256') - elif self.auth_type == 'header': - self.header_name = self.pipeline_config['ai']['n8n-service-api'].get('header-name', '') - self.header_value = self.pipeline_config['ai']['n8n-service-api'].get('header-value', '') - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> str: - """预处理用户消息,提取纯文本 - - Returns: - str: 纯文本消息 - """ - plain_text = '' - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - # 注意:n8n webhook目前不支持直接处理图片,如需支持可在此扩展 - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text - - async def _process_response( - self, response: aiohttp.ClientResponse - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """处理响应——支持流式格式和普通 JSON 格式""" - full_content = '' - full_text = '' - chunk_idx = 0 - is_final = False - message_idx = 0 - - buffer = '' - decoder = json.JSONDecoder() - - async for raw_chunk in response.content.iter_chunked(1024): - if not raw_chunk: - continue - - try: - # 将 bytes 解码为字符串(容忍错误) - if isinstance(raw_chunk, (bytes, bytearray)): - chunk_str = raw_chunk.decode('utf-8', errors='replace') - else: - chunk_str = str(raw_chunk) - - full_text += chunk_str - buffer += chunk_str - - # 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象) - while buffer: - buffer = buffer.lstrip() - if not buffer: - break - try: - obj, idx = decoder.raw_decode(buffer) - buffer = buffer[idx:] - - if not isinstance(obj, dict): - # 忽略非字典类型的顶级 JSON - continue - - if obj.get('type') == 'item' and 'content' in obj: - chunk_idx += 1 - content = obj['content'] - full_content += content - elif obj.get('type') == 'end': - is_final = True - - if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0): - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=full_content, - is_final=is_final, - msg_sequence=message_idx, - ) - except json.JSONDecodeError: - # buffer 末尾可能是一个不完整的 JSON,等待更多数据 - break - except Exception as e: - # 记录解析失败并继续接收后续 chunk - try: - preview = chunk_str[:200] - except Exception: - preview = '' - self.ap.logger.warning(f'Failed to process chunk: {e}; chunk preview: {preview}') - - # 流结束后,尝试解析残余 buffer - if buffer: - try: - buffer = buffer.strip() - if buffer: - obj, _ = decoder.raw_decode(buffer) - if isinstance(obj, dict): - if obj.get('type') == 'item' and 'content' in obj: - chunk_idx += 1 - full_content += obj['content'] - elif obj.get('type') == 'end': - is_final = True - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - content=full_content, - is_final=is_final, - msg_sequence=message_idx, - ) - except Exception as e: - preview = buffer[:200] - self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}') - - # n8n 返回普通 JSON 格式(无任何流式 type:item 内容) - if chunk_idx == 0: - output_content = '' - try: - response_data = json.loads(full_text.strip()) - if isinstance(response_data, dict): - if self.output_key in response_data: - output_content = response_data[self.output_key] - else: - output_content = json.dumps(response_data, ensure_ascii=False) - else: - output_content = full_text - except json.JSONDecodeError: - output_content = full_text - self.ap.logger.debug(f'n8n webhook response (non-stream): {full_text[:200]}') - yield provider_message.MessageChunk( - role='assistant', - content=output_content, - is_final=True, - msg_sequence=message_idx + 1, - ) - - async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用n8n webhook""" - # 生成会话ID(如果不存在) - if not query.session.using_conversation.uuid: - query.session.using_conversation.uuid = str(uuid.uuid4()) - - # Keep query variables in sync with the generated/new conversation id. - # query.variables is later merged into payload and would otherwise - # overwrite the generated conversation_id with the stale preprocessor - # value (usually None for a new conversation). - query.variables['conversation_id'] = query.session.using_conversation.uuid - - # 预处理用户消息 - plain_text = await self._preprocess_user_message(query) - - # 准备请求数据 - payload = { - # 基本消息内容 - 'chatInput': plain_text, # 考虑到之前用户直接用的message model这里添加新键 - 'message': plain_text, - 'user_message_text': plain_text, - 'conversation_id': query.session.using_conversation.uuid, - 'session_id': query.variables.get('session_id', ''), - 'user_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', - 'msg_create_time': query.variables.get('msg_create_time', ''), - } - - # 添加所有变量到payload - payload.update(query.variables) - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - try: - # 准备请求头和认证信息 - headers = {} - auth = None - - # 根据认证类型设置相应的认证信息 - if self.auth_type == 'basic': - # 使用Basic认证 - auth = aiohttp.BasicAuth(self.basic_username, self.basic_password) - self.ap.logger.debug(f'using basic auth: {self.basic_username}') - elif self.auth_type == 'jwt': - # 使用JWT认证 - import jwt - import time - - # 创建JWT令牌 - payload_jwt = { - 'exp': int(time.time()) + 3600, # 1小时过期 - 'iat': int(time.time()), - 'sub': 'n8n-webhook', - } - token = jwt.encode(payload_jwt, self.jwt_secret, algorithm=self.jwt_algorithm) - - # 添加到Authorization头 - headers['Authorization'] = f'Bearer {token}' - self.ap.logger.debug('using jwt auth') - elif self.auth_type == 'header': - # 使用自定义请求头认证 - headers[self.header_name] = self.header_value - self.ap.logger.debug(f'using header auth: {self.header_name}') - else: - self.ap.logger.debug('no auth') - - # 调用webhook - session = httpclient.get_session() - async with session.post( - self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout - ) as response: - if response.status != 200: - error_text = await response.text() - self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') - raise Exception(f'n8n webhook call failed: {response.status}, {error_text}') - - async for chunk in self._process_response(response): - if is_stream: - yield chunk - elif chunk.is_final: - yield provider_message.Message( - role='assistant', - content=chunk.content, - ) - except Exception as e: - self.ap.logger.error(f'n8n webhook call exception: {str(e)}') - raise N8nAPIError(f'n8n webhook call exception: {str(e)}') - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - async for msg in self._call_webhook(query): - yield msg diff --git a/src/langbot/pkg/provider/runners/tboxapi.py b/src/langbot/pkg/provider/runners/tboxapi.py deleted file mode 100644 index 0fb22a642..000000000 --- a/src/langbot/pkg/provider/runners/tboxapi.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -import typing -import json -import base64 -import tempfile -import os - -from tboxsdk.tbox import TboxClient -from tboxsdk.model.file import File, FileType - -from .. import runner -from ...core import app -from ...utils import image -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -class TboxAPIError(Exception): - """TBox API 请求失败""" - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - -@runner.runner_class('tbox-app-api') -class TboxAPIRunner(runner.RequestRunner): - "蚂蚁百宝箱API对话请求器" - - # 运行器内部使用的配置 - app_id: str # 蚂蚁百宝箱平台中的应用ID - api_key: str # 在蚂蚁百宝箱平台中申请的令牌 - - def __init__(self, ap: app.Application, pipeline_config: dict): - """初始化""" - self.ap = ap - self.pipeline_config = pipeline_config - - # 初始化Tbox 参数配置 - self.app_id = self.pipeline_config['ai']['tbox-app-api']['app-id'] - self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key'] - - # 初始化Tbox client - self.tbox_client = TboxClient(authorization=self.api_key) - - async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]: - """预处理用户消息,提取纯文本,并将图片上传到 Tbox 服务 - - Returns: - tuple[str, list[str]]: 纯文本和图片的 Tbox 文件ID - """ - plain_text = '' - image_ids = [] - - if isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - elif ce.type == 'image_base64': - image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) - # 创建临时文件 - file_bytes = base64.b64decode(image_b64) - try: - with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file: - tmp_file.write(file_bytes) - tmp_file_path = tmp_file.name - file_upload_resp = self.tbox_client.upload_file(tmp_file_path) - image_id = file_upload_resp.get('data', '') - image_ids.append(image_id) - finally: - # 清理临时文件 - if os.path.exists(tmp_file_path): - os.unlink(tmp_file_path) - elif isinstance(query.user_message.content, str): - plain_text = query.user_message.content - - return plain_text, image_ids - - async def _agent_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """TBox 智能体对话请求""" - - plain_text, image_ids = await self._preprocess_user_message(query) - remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') - - try: - is_stream = await query.adapter.is_stream_output_supported() - except AttributeError: - is_stream = False - - # 获取Tbox的conversation_id - conversation_id = query.session.using_conversation.uuid or None - - files = None - if image_ids: - files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids] - - # 发送对话请求 - response = self.tbox_client.chat( - app_id=self.app_id, # Tbox中智能体应用的ID - user_id=query.bot_uuid, # 用户ID - query=plain_text, # 用户输入的文本信息 - stream=is_stream, # 是否流式输出 - conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话 - files=files, # 图片内容 - ) - - if is_stream: - # 解析Tbox流式输出内容,并发送给上游 - for chunk in self._process_stream_message(response, query, remove_think): - yield chunk - else: - message = self._process_non_stream_message(response, query, remove_think) - yield provider_message.Message( - role='assistant', - content=message, - ) - - def _process_non_stream_message(self, response: typing.Dict, query: pipeline_query.Query, remove_think: bool): - if response.get('errorCode') != '0': - raise TboxAPIError(f'Tbox API 请求失败: {response.get("errorMsg", "")}') - payload = response.get('data', {}) - conversation_id = payload.get('conversationId', '') - query.session.using_conversation.uuid = conversation_id - thinking_content = payload.get('reasoningContent', []) - result = '' - if thinking_content and not remove_think: - result += f'\n{thinking_content[0].get("text", "")}\n\n' - content = payload.get('result', []) - if content: - result += content[0].get('chunk', '') - return result - - def _process_stream_message( - self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool - ): - idx_msg = 0 - pending_content = '' - conversation_id = None - think_start = False - think_end = False - for chunk in response: - if chunk.get('type', '') == 'chunk': - """ - Tbox返回的消息内容chunk结构 - {'lane': 'default', 'payload': {'conversationId': '20250918tBI947065406', 'messageId': '20250918TB1f53230954', 'text': '️'}, 'type': 'chunk'} - """ - # 如果包含思考过程,拼接 - if think_start and not think_end: - pending_content += '\n\n' - think_end = True - - payload = chunk.get('payload', {}) - if not conversation_id: - conversation_id = payload.get('conversationId') - query.session.using_conversation.uuid = conversation_id - if payload.get('text'): - idx_msg += 1 - pending_content += payload.get('text') - elif chunk.get('type', '') == 'thinking' and not remove_think: - """ - Tbox返回的思考过程chunk结构 - {'payload': '{"ext_data":{"text":"日期"},"event":"flow.node.llm.thinking","entity":{"node_type":"text-completion","execute_id":"6","group_id":0,"parent_execute_id":"6","node_name":"模型推理","node_id":"TC_5u6gl0"}}', 'type': 'thinking'} - """ - payload = json.loads(chunk.get('payload', '{}')) - if payload.get('ext_data', {}).get('text'): - idx_msg += 1 - content = payload.get('ext_data', {}).get('text') - if not think_start: - think_start = True - pending_content += f'\n{content}' - else: - pending_content += content - elif chunk.get('type', '') == 'error': - raise TboxAPIError( - f'Tbox API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} ' - ) - - if idx_msg % 8 == 0: - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=False, - ) - - # Tbox不返回END事件,默认发一个最终消息 - yield provider_message.MessageChunk( - role='assistant', - content=pending_content, - is_final=True, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行""" - msg_seq = 0 - async for msg in self._agent_messages(query): - if isinstance(msg, provider_message.MessageChunk): - msg_seq += 1 - msg.msg_sequence = msg_seq - yield msg diff --git a/src/langbot/pkg/provider/runners/weknoraapi.py b/src/langbot/pkg/provider/runners/weknoraapi.py deleted file mode 100644 index 9d46eebb7..000000000 --- a/src/langbot/pkg/provider/runners/weknoraapi.py +++ /dev/null @@ -1,351 +0,0 @@ -from __future__ import annotations - -import typing -import json - - -from langbot.pkg.provider import runner -from langbot.pkg.core import app -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -from langbot.libs.weknora_api import client, errors - - -@runner.runner_class('weknora-api') -class WeKnoraAPIRunner(runner.RequestRunner): - """WeKnora API 对话请求器""" - - weknora_client: client.AsyncWeKnoraClient - - def __init__(self, ap: app.Application, pipeline_config: dict): - super().__init__(ap, pipeline_config) - - valid_app_types = ['chat', 'agent'] - if self.pipeline_config['ai']['weknora-api']['app-type'] not in valid_app_types: - raise errors.WeKnoraAPIError( - f'不支持的 WeKnora 应用类型: {self.pipeline_config["ai"]["weknora-api"]["app-type"]}' - ) - - api_key = self.pipeline_config['ai']['weknora-api'].get('api-key', '').strip() - if not api_key: - raise errors.WeKnoraAPIError( - 'WeKnora API Key 未配置,请在流水线的 WeKnora API 配置中填入 API Key ' - '(从 WeKnora 前端 设置 → API Keys 生成)' - ) - - base_url = self.pipeline_config['ai']['weknora-api'].get('base-url', '').strip() - if not base_url: - raise errors.WeKnoraAPIError('WeKnora Base URL 未配置,请填入服务器地址,例如 http://localhost:8080/api/v1') - - self.weknora_client = client.AsyncWeKnoraClient( - api_key=api_key, - base_url=base_url, - ) - - async def _extract_plain_text(self, query: pipeline_query.Query) -> str: - """从用户消息中提取纯文本内容""" - plain_text = '' - if isinstance(query.user_message.content, str): - plain_text = query.user_message.content - elif isinstance(query.user_message.content, list): - for ce in query.user_message.content: - if ce.type == 'text': - plain_text += ce.text - - if not plain_text: - plain_text = self.pipeline_config['ai']['weknora-api'].get('base-prompt', '') - - return plain_text - - async def _ensure_session(self, query: pipeline_query.Query) -> str: - """确保会话存在,如果不存在则创建""" - session_id = query.session.using_conversation.uuid or '' - - if not session_id: - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - session_id = await self.weknora_client.create_session(title=f'IM Chat - {user_tag}') - query.session.using_conversation.uuid = session_id - - return session_id - - async def _agent_chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用 Agent 智能对话(非流式聚合输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-smart-reasoning') - knowledge_base_ids = config.get('knowledge-base-ids', []) - web_search_enabled = config.get('web-search-enabled', False) - timeout = config.get('timeout', 120) - - full_answer = '' - chunk = None - - async for chunk in self.weknora_client.agent_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - web_search_enabled=web_search_enabled, - timeout=timeout, - ): - self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - - if response_type == 'tool_call': - # 工具调用 - tool_data = chunk.get('data', {}) - tool_name = tool_data.get('tool_name', '') - if tool_name: - yield provider_message.Message( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk.get('id', ''), - type='function', - function=provider_message.FunctionCall( - name=tool_name, - arguments=json.dumps(tool_data.get('arguments', {})), - ), - ) - ], - ) - - elif response_type == 'answer': - if content: - full_answer += content - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if full_answer: - yield provider_message.Message( - role='assistant', - content=full_answer, - ) - - async def _chat_messages( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.Message, None]: - """调用知识库 RAG 问答(非流式聚合输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-quick-answer') - knowledge_base_ids = config.get('knowledge-base-ids', []) - timeout = config.get('timeout', 120) - - full_answer = '' - chunk = None - - async for chunk in self.weknora_client.knowledge_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - timeout=timeout, - ): - self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - - if response_type == 'answer': - if content: - full_answer += content - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if full_answer: - yield provider_message.Message( - role='assistant', - content=full_answer, - ) - - async def _agent_chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用 Agent 智能对话(流式输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-smart-reasoning') - knowledge_base_ids = config.get('knowledge-base-ids', []) - web_search_enabled = config.get('web-search-enabled', False) - timeout = config.get('timeout', 120) - - pending_answer = '' - message_idx = 0 - is_final = False - chunk = None - - async for chunk in self.weknora_client.agent_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - web_search_enabled=web_search_enabled, - timeout=timeout, - ): - self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - done = chunk.get('done', False) - - if response_type == 'tool_call': - tool_data = chunk.get('data', {}) - tool_name = tool_data.get('tool_name', '') - if tool_name: - message_idx += 1 - yield provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id=chunk.get('id', ''), - type='function', - function=provider_message.FunctionCall( - name=tool_name, - arguments=json.dumps(tool_data.get('arguments', {})), - ), - ) - ], - ) - - elif response_type == 'answer': - message_idx += 1 - if content: - pending_answer += content - - if done: - is_final = True - - # 每 8 个 chunk 输出一次,或最终输出 - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=is_final, - ) - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - # 确保最终消息已发出 - if not is_final and pending_answer: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=True, - ) - - async def _chat_messages_chunk( - self, query: pipeline_query.Query - ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: - """调用知识库 RAG 问答(流式输出)""" - session_id = await self._ensure_session(query) - plain_text = await self._extract_plain_text(query) - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' - - config = self.pipeline_config['ai']['weknora-api'] - agent_id = config.get('agent-id', 'builtin-quick-answer') - knowledge_base_ids = config.get('knowledge-base-ids', []) - timeout = config.get('timeout', 120) - - pending_answer = '' - message_idx = 0 - is_final = False - chunk = None - - async for chunk in self.weknora_client.knowledge_chat( - session_id=session_id, - query=plain_text, - user=user_tag, - agent_id=agent_id, - knowledge_base_ids=knowledge_base_ids, - timeout=timeout, - ): - self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)) - - response_type = chunk.get('response_type', '') - content = chunk.get('content', '') - done = chunk.get('done', False) - - if response_type == 'answer': - message_idx += 1 - if content: - pending_answer += content - - if done: - is_final = True - - if message_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=is_final, - ) - - elif response_type == 'error': - raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}') - - if chunk is None: - raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置') - - if not is_final and pending_answer: - yield provider_message.MessageChunk( - role='assistant', - content=pending_answer, - is_final=True, - ) - - async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: - """运行请求""" - app_type = self.pipeline_config['ai']['weknora-api']['app-type'] - - if await query.adapter.is_stream_output_supported(): - msg_idx = 0 - if app_type == 'agent': - async for msg in self._agent_chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - elif app_type == 'chat': - async for msg in self._chat_messages_chunk(query): - msg_idx += 1 - msg.msg_sequence = msg_idx - yield msg - else: - raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') - else: - if app_type == 'agent': - async for msg in self._agent_chat_messages(query): - yield msg - elif app_type == 'chat': - async for msg in self._chat_messages(query): - yield msg - else: - raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}') diff --git a/src/langbot/pkg/provider/tools/errors.py b/src/langbot/pkg/provider/tools/errors.py index d44b39ba8..7206fe252 100644 --- a/src/langbot/pkg/provider/tools/errors.py +++ b/src/langbot/pkg/provider/tools/errors.py @@ -4,3 +4,12 @@ class ToolNotFoundError(ValueError): def __init__(self, name: str): self.name = name super().__init__(f'Tool not found: {name}') + + +class ToolExecutionDeniedError(PermissionError): + """Raised when a known tool is not allowed in the current execution context.""" + + def __init__(self, name: str, reason: str): + self.name = name + self.reason = reason + super().__init__(f'Tool execution denied for {name}: {reason}') diff --git a/src/langbot/pkg/provider/tools/loader.py b/src/langbot/pkg/provider/tools/loader.py index f97e82164..22b4a305b 100644 --- a/src/langbot/pkg/provider/tools/loader.py +++ b/src/langbot/pkg/provider/tools/loader.py @@ -4,14 +4,14 @@ import typing from typing import TYPE_CHECKING -from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.entities.events import pipeline_query import langbot_plugin.api.entities.builtin.resource.tool as resource_tool if TYPE_CHECKING: from ...core import app -ToolLookupResult = resource_tool.LLMTool | ComponentManifest +# All loaders normalize their tools to resource_tool.LLMTool. +ToolLookupResult = resource_tool.LLMTool preregistered_loaders: list[typing.Type[ToolLoader]] = [] diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 5b0a30610..7007042ce 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -22,6 +22,7 @@ from pydantic import AnyUrl from .. import loader +from ..errors import ToolExecutionDeniedError from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -1417,35 +1418,67 @@ async def get_tool_catalog( return items - async def has_tool(self, name: str) -> bool: + def _session_by_source_id(self, source_id: str) -> RuntimeMCPSession | None: + return next( + (session for session in self.sessions.values() if session.server_uuid == source_id), + None, + ) + + async def has_tool(self, name: str, source_id: str | None = None) -> bool: """检查工具是否存在""" - if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): return bool(self._eligible_resource_sessions_for_bound(None)) - for session in self.sessions.values(): + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: return True return False - async def get_tool(self, name: str) -> resource_tool.LLMTool | None: - for session in self.sessions.values(): + async def get_tool( + self, + name: str, + source_id: str | None = None, + ) -> resource_tool.LLMTool | None: + if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + if not self._eligible_resource_sessions_for_bound(None): + return None + return next( + (tool for tool in self._mcp_synthetic_resource_tools() if tool.name == name), + None, + ) + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: return function return None - async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def invoke_tool( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_id: str | None = None, + ) -> typing.Any: """执行工具调用""" - if name == MCP_TOOL_LIST_RESOURCES: - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: - return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + if source_id is None and name == MCP_TOOL_LIST_RESOURCES: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: + raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') return await self._invoke_mcp_list_resources(parameters, query) - if name == MCP_TOOL_READ_RESOURCE: - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: - return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + if source_id is None and name == MCP_TOOL_READ_RESOURCE: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: + raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') return await self._invoke_mcp_read_resource(parameters, query) - for session in self.sessions.values(): + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') @@ -1525,7 +1558,7 @@ async def build_resource_context_for_query( default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES, ) -> str: """Build host-controlled MCP resource context for the current query.""" - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: return '' attachments = (query.variables or {}).get('_pipeline_mcp_resource_attachments', []) @@ -1543,7 +1576,7 @@ async def build_resource_context_for_query( for raw_attachment in attachments: if remaining_tokens <= 0: break - if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False: + if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled', True) is not True: continue attachment = raw_attachment.copy() @@ -1561,8 +1594,23 @@ async def build_resource_context_for_query( if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name: continue - max_tokens = min(int(attachment.get('max_tokens') or remaining_tokens), remaining_tokens) - max_bytes = int(attachment.get('max_bytes') or default_max_bytes) + raw_max_tokens = attachment.get('max_tokens') + raw_max_bytes = attachment.get('max_bytes') + if raw_max_tokens is None: + max_tokens = remaining_tokens + elif isinstance(raw_max_tokens, bool) or not isinstance(raw_max_tokens, int) or raw_max_tokens <= 0: + self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_tokens must be a positive integer') + continue + else: + max_tokens = min(raw_max_tokens, remaining_tokens) + + if raw_max_bytes is None: + max_bytes = default_max_bytes + elif isinstance(raw_max_bytes, bool) or not isinstance(raw_max_bytes, int) or raw_max_bytes <= 0: + self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_bytes must be a positive integer') + continue + else: + max_bytes = raw_max_bytes try: envelope = await session.read_resource_envelope( diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py index baac91d1d..e444044d4 100644 --- a/src/langbot/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -3,7 +3,6 @@ import typing import traceback -from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.entities.events import pipeline_query from .. import loader @@ -51,23 +50,35 @@ async def get_tool_catalog(self, bound_plugins: list[str] | None = None) -> list return catalog - async def has_tool(self, name: str) -> bool: + async def get_tool( + self, + name: str, + source_id: str | None = None, + ) -> resource_tool.LLMTool | None: + tools = await self.get_tools([source_id] if source_id else None) + return next((tool for tool in tools if tool.name == name), None) + + async def has_tool(self, name: str, source_id: str | None = None) -> bool: """检查工具是否存在""" - for tool in await self.ap.plugin_connector.list_tools(): + for tool in await self.ap.plugin_connector.list_tools([source_id] if source_id else None): if tool.metadata.name == name: return True return False - async def get_tool(self, name: str) -> ComponentManifest | None: - for tool in await self.ap.plugin_connector.list_tools(): - if tool.metadata.name == name: - return tool - return None - - async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def invoke_tool( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_id: str | None = None, + ) -> typing.Any: try: return await self.ap.plugin_connector.call_tool( - name, parameters, session=query.session, query_id=query.query_id + name, + parameters, + session=query.session, + query_id=query.query_id, + bound_plugins=[source_id] if source_id else None, ) except Exception as e: self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}') diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py index b62f3e7d5..d013e25e4 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill.py +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -10,6 +10,7 @@ from langbot_plugin.api.entities.events import pipeline_query ACTIVATED_SKILLS_KEY = '_activated_skills' +ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills' SKILL_MOUNT_PREFIX = '/workspace/.skills' _SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)') @@ -90,19 +91,21 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]: return normalize_skill_names(list(get_activated_skills(query).keys())) -def restore_activated_skills( +def restore_activated_skills_from_state( ap: app.Application, query: pipeline_query.Query, - skill_names: typing.Any, + state: dict[str, dict[str, typing.Any]], ) -> list[str]: - """Restore caller-provided activated skill names into Query variables. + """Restore persisted activated skill names into Query variables. - Persistence and state scope ownership belong to higher-level flows. This - helper only rebuilds current Query state from pipeline-visible skills, so - removed or unbound skills stay unavailable to native exec/write/edit. + The state value stores names only. Full skill metadata is rebuilt from the + current pipeline-visible skill cache so removed or unbound skills remain + unavailable to native exec/write/edit. """ + conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {} + skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY)) restored: list[str] = [] - for skill_name in normalize_skill_names(skill_names): + for skill_name in skill_names: skill_data = get_visible_skill(ap, query, skill_name) if skill_data is None: continue @@ -111,6 +114,59 @@ def restore_activated_skills( return restored +async def persist_activated_skill( + ap: app.Application, + query: pipeline_query.Query, + skill_name: str, +) -> None: + """Persist activated skill names into host-owned conversation state. + + ``activate`` runs host-side. This writes the run's current activated skill + names to the conversation-scope ``host.activated_skills`` snapshot so a later + run can restore them via ``restore_activated_skills_from_state``. Host writes + here and a runner ``state.updated`` to the same key follow last-write-wins. + + Best-effort: a persistence failure must not fail the activation itself. No-op + when the call is not inside an authorized agent run, or when conversation + state is unavailable (state disabled / scope not enabled / no conversation). + """ + session = getattr(query, '_agent_run_session', None) + if not isinstance(session, dict): + return + + authorization = session.get('authorization') + if not isinstance(authorization, dict): + return + + state_context = authorization.get('state_context') + if not isinstance(state_context, dict): + return + + scope_keys = state_context.get('scope_keys') + conversation_scope_key = scope_keys.get('conversation') if isinstance(scope_keys, dict) else None + if not conversation_scope_key: + return + + try: + from ....agent.runner.persistent_state_store import get_persistent_state_store + + store = get_persistent_state_store(ap.persistence_mgr.get_db_engine()) + await store.state_set( + scope_key=conversation_scope_key, + state_key=ACTIVATED_SKILL_NAMES_STATE_KEY, + value=get_activated_skill_names(query), + runner_id=str(session.get('runner_id', '') or ''), + binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'), + scope='conversation', + context=state_context, + logger=getattr(ap, 'logger', None), + ) + except Exception as e: # noqa: BLE001 - persistence is best-effort, must not break activation + logger = getattr(ap, 'logger', None) + if logger is not None: + logger.warning(f'Failed to persist activated skill "{skill_name}": {e}') + + def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]: normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace' if normalized_path == SKILL_MOUNT_PREFIX: diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index d53721785..e6c5ecef5 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -92,6 +92,7 @@ async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any: # Register activated skill for sandbox mount path resolution skill_loader.register_activated_skill(query, skill_data) + await skill_loader.persist_activated_skill(self.ap, query, skill_name) # Return SKILL.md content as Tool Result (injects into context) instructions = skill_data.get('instructions', '') diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 60a16ce7e..24c1ff20f 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -20,6 +20,16 @@ ) +TOOL_SOURCE_REFS_QUERY_KEY = '_host_tool_source_refs' + + +class ToolSourceRef(typing.TypedDict): + """Stable Host-side identity for one tool implementation.""" + + source: str + source_id: str | None + + class ToolManager: """LLM工具管理器""" @@ -59,14 +69,16 @@ async def get_all_tools( self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None, - include_skill_authoring: bool = False, include_mcp_resource_tools: bool = True, ) -> list[resource_tool.LLMTool]: all_functions: list[resource_tool.LLMTool] = [] all_functions.extend(await self.native_tool_loader.get_tools()) - if include_skill_authoring: - all_functions.extend(await self.skill_tool_loader.get_tools()) + # Skill tools (activate / register_skill) are exposed like native tools: + # the SkillToolLoader gates itself on sandbox + skill_mgr availability, so + # skill is just a group of authorized tools rather than a separate + # capability-gated surface. + all_functions.extend(await self.skill_tool_loader.get_tools()) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) all_functions.extend( await self.mcp_tool_loader.get_tools( @@ -113,6 +125,121 @@ def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMToo return catalog + async def get_resolved_tool_catalog( + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = True, + include_mcp_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + """Return scoped tools with one unambiguous implementation per name. + + LLM tool calls only carry a function name. If two implementations with + the same name remain inside the current Host scope, choosing one by + loader or registration order would authorize one resource and execute + another. Such names are therefore omitted until the scope is narrowed. + """ + catalog = await self.get_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + tools_by_name: dict[str, list[dict[str, typing.Any]]] = {} + for item in catalog: + name = item.get('name') + if isinstance(name, str) and name: + tools_by_name.setdefault(name, []).append(item) + + resolved: list[dict[str, typing.Any]] = [] + for name, candidates in tools_by_name.items(): + implementations = { + (str(item.get('source') or ''), self._normalize_source_id(item.get('source_id'))) for item in candidates + } + if len(implementations) != 1: + self.ap.logger.warning( + f'Tool {name} is hidden because multiple implementations are visible: ' + f'{sorted(implementations, key=lambda item: (item[0], item[1] or ""))}' + ) + continue + resolved.append(candidates[0]) + return resolved + + @staticmethod + def _normalize_source_id(source_id: typing.Any) -> str | None: + return source_id if isinstance(source_id, str) and source_id else None + + @classmethod + def source_ref_from_catalog_item(cls, item: dict[str, typing.Any]) -> ToolSourceRef | None: + source = item.get('source') + if not isinstance(source, str) or not source: + return None + return { + 'source': source, + 'source_id': cls._normalize_source_id(item.get('source_id')), + } + + @classmethod + def source_refs_from_catalog( + cls, + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> dict[str, ToolSourceRef]: + refs: dict[str, ToolSourceRef] = {} + for item in catalog: + name = item.get('name') + ref = cls.source_ref_from_catalog_item(item) + if isinstance(name, str) and name and ref is not None: + refs[name] = ref + return refs + + @staticmethod + def tools_from_catalog( + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> list[resource_tool.LLMTool]: + """Materialize LLM schemas from an already authorized Host catalog.""" + return [ + resource_tool.LLMTool( + name=item['name'], + human_desc=item.get('human_desc') or item.get('description') or item['name'], + description=item.get('description') or '', + parameters=item.get('parameters') or {}, + func=lambda parameters: {}, + ) + for item in catalog + ] + + @classmethod + def bind_query_tool_sources( + cls, + query: pipeline_query.Query, + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> None: + query.variables = query.variables or {} + query.variables[TOOL_SOURCE_REFS_QUERY_KEY] = cls.source_refs_from_catalog(catalog) + + @staticmethod + def get_query_tool_source( + query: pipeline_query.Query, + name: str, + ) -> ToolSourceRef | None: + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + return None + refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY) + if not isinstance(refs, dict): + return None + ref = refs.get(name) + if not isinstance(ref, dict): + return None + source = ref.get('source') + if not isinstance(source, str) or not source: + return None + source_id = ref.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None: """Get tool by name from any active loader.""" for active_loader in ( @@ -127,6 +254,64 @@ async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | No return None + async def get_tool_schema( + self, + name: str, + source_ref: ToolSourceRef | None = None, + ) -> tuple[str | None, dict | None]: + """Return (description, parameters JSON schema) for a tool by name. + + Used by the host to prefill ToolResource so a runner can build LLM tool + definitions without a separate get_tool_detail round-trip. All loaders + return resource_tool.LLMTool, so no per-shape branching is needed. + Returns (None, None) when the tool is not found. + """ + tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name) + if tool is None: + return None, None + return tool.description, (tool.parameters or None) + + async def get_tool_detail( + self, + name: str, + source_ref: ToolSourceRef | None = None, + ) -> dict | None: + """Return the host-level tool detail shape for a tool by name. + + All loaders return resource_tool.LLMTool, so the shape is uniform: + {name, description, human_desc, parameters}. Returns None when the tool + is not found. + """ + tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name) + if tool is None: + return None + return { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters or {}, + } + + async def get_tool_by_source( + self, + name: str, + source_ref: ToolSourceRef, + ) -> tool_loader.ToolLookupResult | None: + """Resolve a tool only from the implementation frozen at authorization.""" + source = source_ref['source'] + source_id = source_ref.get('source_id') + if source in {'builtin', 'native'}: + return await self.native_tool_loader.get_tool(name) + if source == 'skill': + return await self.skill_tool_loader.get_tool(name) + if source == 'plugin': + if not source_id: + return None + return await self.plugin_tool_loader.get_tool(name, source_id=source_id) + if source == 'mcp': + return await self.mcp_tool_loader.get_tool(name, source_id=source_id) + return None + async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: tools = [] @@ -228,9 +413,58 @@ async def _invoke_tool_with_monitoring( ) return result - async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def execute_func_call( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_ref: ToolSourceRef | None = None, + ) -> typing.Any: from langbot.pkg.telemetry import features as telemetry_features + source_ref = source_ref or self.get_query_tool_source(query, name) + if source_ref is not None: + source = source_ref['source'] + source_id = source_ref.get('source_id') + uses_source_id = False + if source in {'builtin', 'native'}: + loader = self.native_tool_loader + telemetry_source = 'native' + exists = await loader.has_tool(name) + elif source == 'skill': + loader = self.skill_tool_loader + telemetry_source = 'skill' + exists = await loader.has_tool(name) + elif source == 'plugin' and source_id: + loader = self.plugin_tool_loader + telemetry_source = 'plugin' + uses_source_id = True + exists = await loader.has_tool(name, source_id=source_id) + elif source == 'mcp': + loader = self.mcp_tool_loader + telemetry_source = 'mcp' + uses_source_id = True + exists = await loader.has_tool(name, source_id=source_id) + else: + raise ToolNotFoundError(name) + + if not exists: + raise ToolNotFoundError(name) + + async def invoke_selected_tool() -> typing.Any: + if uses_source_id: + return await loader.invoke_tool(name, parameters, query, source_id=source_id) + return await loader.invoke_tool(name, parameters, query) + + telemetry_features.increment(query, 'tool_calls', telemetry_source) + return await self._invoke_tool_with_monitoring( + source=telemetry_source, + name=name, + parameters=parameters, + query=query, + invoke=invoke_selected_tool, + ) + if await self.native_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'native') return await self._invoke_tool_with_monitoring( diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py index ddb2125c3..48839a861 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -93,50 +93,3 @@ def refresh_skill_from_disk(self, skill_name: str) -> bool: def get_skill_by_name(self, name: str) -> dict | None: """Get skill data by name.""" return self.skills.get(name) - - def get_skill_index(self, bound_skills: list[str] | None = None) -> str: - """Render the pipeline-visible skills as a short ``name: description`` - index suitable for the system prompt. - - ``bound_skills`` follows the same convention as - ``query.variables['_pipeline_bound_skills']``: ``None`` means every - loaded skill is exposed; an explicit list filters to that subset. - Returns an empty string when no skills are visible. - """ - lines: list[str] = [] - for skill in self.skills.values(): - name = skill.get('name') - if not name: - continue - if bound_skills is not None and name not in bound_skills: - continue - display = skill.get('display_name') or name - description = (skill.get('description') or '').strip().replace('\n', ' ') - lines.append(f'- {name} ({display}): {description}') - - if not lines: - return '' - return 'Available Skills:\n' + '\n'.join(lines) - - def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str: - """Build the system-prompt addendum that makes the LLM aware of the - pipeline-visible skills. - - Only metadata (name + description) is injected — the full SKILL.md is - loaded later via the ``activate`` Tool Call, protecting KV cache and - matching Claude Code's progressive disclosure pattern. Returns an - empty string when no skills are visible (no prompt change at all). - """ - skill_index = self.get_skill_index(bound_skills) - if not skill_index: - return '' - return ( - '\n\n' - f'{skill_index}\n\n' - "When the user's request clearly matches one or more skills " - 'based on their descriptions above, call the `activate` tool with ' - 'the skill name to load its full instructions. Only the name and ' - 'description are visible here; the actual instructions arrive as ' - 'the tool result. If no skill is a clear match, respond normally ' - 'without activating any skill.' - ) diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index 2af16486f..59cf86ca8 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -2,15 +2,6 @@ semantic_version = f'v{langbot.__version__}' -required_database_version = 25 -"""Tag the version of the legacy (3.x) database schema migration chain. - -Frozen at 25: the legacy ``pkg/persistence/migrations`` system (DBMigration / -dbmXXX_*.py) is deprecated and no longer accepts new migrations. All schema -changes from here on are managed by Alembic (see -``pkg/persistence/alembic/versions``). This value only gates the one-time -upgrade of pre-existing 3.x databases up to the Alembic baseline.""" - debug_mode = False edition = 'community' diff --git a/src/langbot/pkg/utils/importutil.py b/src/langbot/pkg/utils/importutil.py index a35052a60..a114d2463 100644 --- a/src/langbot/pkg/utils/importutil.py +++ b/src/langbot/pkg/utils/importutil.py @@ -47,3 +47,10 @@ def read_resource_file_bytes(resource_path: str) -> bytes: def list_resource_files(resource_path: str) -> list[str]: return [f.name for f in importlib.resources.files('langbot').joinpath(resource_path).iterdir()] + + +def is_resource_dir(resource_path: str) -> bool: + try: + return importlib.resources.files('langbot').joinpath(resource_path).is_dir() + except (TypeError, FileNotFoundError): + return False diff --git a/src/langbot/templates/components.yaml b/src/langbot/templates/components.yaml index a95235aaa..924fd8a4e 100644 --- a/src/langbot/templates/components.yaml +++ b/src/langbot/templates/components.yaml @@ -10,6 +10,8 @@ spec: MessagePlatformAdapter: fromDirs: - path: pkg/platform/sources/ + - path: pkg/platform/adapters/ + maxDepth: 3 LLMAPIRequester: fromDirs: - path: pkg/provider/modelmgr/requesters/ diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index f4ae79bf8..099701ba1 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -37,12 +37,6 @@ system: max_bots: -1 max_pipelines: -1 max_extensions: -1 - # When set to a non-empty string, every pipeline is forced to use this - # Box sandbox-scope template regardless of its own configuration, and - # the per-pipeline "Sandbox Scope" selector is locked in the web UI. - # Used by SaaS deployments to confine a tenant to a single shared - # sandbox (set to '{global}'). Empty string = no restriction. - force_box_session_id_template: '' task_retention: # Keep at most this many completed async task records in memory completed_limit: 200 @@ -122,6 +116,19 @@ plugin: binary_storage: # Max bytes for a single plugin binary storage value max_value_bytes: 10485760 +agent_runner: + # Host-level admin permissions for trusted control plugins. These plugins + # can use existing plugin action handlers to inspect or manage AgentRunner + # infrastructure across runner/plugin boundaries. Keep empty unless you + # fully trust the plugin identity. + # + # Example: + # admin_plugins: + # - identity: langbot/agent-runner-control + # permissions: + # - agent_run:admin + # - runtime:admin + admin_plugins: [] monitoring: auto_cleanup: # Enable automatic cleanup of expired monitoring records diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 78e2ec958..23666ab03 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -38,58 +38,10 @@ }, "ai": { "runner": { - "runner": "local-agent", + "id": "", "expire-time": 0 }, - "local-agent": { - "model": { - "primary": "", - "fallbacks": [] - }, - "max-round": 10, - "prompt": [ - { - "role": "system", - "content": "You are a helpful assistant. When tools are available, use them for exact calculations, data processing, and code execution instead of guessing. Unless the user explicitly asks for code or a script, return the result directly instead of printing the generated code." - } - ], - "knowledge-bases": [], - "box-session-id-template": "{launcher_type}_{launcher_id}", - "rerank-model": "", - "rerank-top-k": 5 - }, - "dify-service-api": { - "base-url": "https://api.dify.ai/v1", - "app-type": "chat", - "api-key": "your-api-key", - "timeout": 30 - }, - "dashscope-app-api": { - "app-type": "agent", - "api-key": "your-api-key", - "app-id": "your-app-id", - "references-quote": "参考资料来自:" - }, - "n8n-service-api": { - "webhook-url": "http://your-n8n-webhook-url", - "auth-type": "none", - "basic-username": "", - "basic-password": "", - "jwt-secret": "", - "jwt-algorithm": "HS256", - "header-name": "", - "header-value": "", - "timeout": 120, - "output-key": "response" - }, - "langflow-api": { - "base-url": "http://localhost:7860", - "api-key": "your-api-key", - "flow-id": "your-flow-id", - "input-type": "chat", - "output-type": "chat", - "tweaks": "{}" - } + "runner_config": {} }, "output": { "long-text-processing": { diff --git a/src/langbot/templates/legacy/command.json b/src/langbot/templates/legacy/command.json deleted file mode 100644 index 7c93f6470..000000000 --- a/src/langbot/templates/legacy/command.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "privilege": {}, - "command-prefix": [ - "!", - "!" - ] -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/pipeline.json b/src/langbot/templates/legacy/pipeline.json deleted file mode 100644 index eb57f0232..000000000 --- a/src/langbot/templates/legacy/pipeline.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "access-control":{ - "mode": "blacklist", - "blacklist": [], - "whitelist": [] - }, - "respond-rules": { - "default": { - "at": true, - "prefix": [ - "/ai", "!ai", "!ai", "ai" - ], - "regexp": [], - "random": 0.0 - } - }, - "income-msg-check": true, - "ignore-rules": { - "prefix": [], - "regexp": [] - }, - "check-sensitive-words": true, - "baidu-cloud-examine": { - "enable": false, - "api-key": "", - "api-secret": "" - }, - "rate-limit": { - "strategy": "drop", - "algo": "fixwin", - "fixwin": { - "default": { - "window-size": 60, - "limit": 60 - } - } - }, - "msg-truncate": { - "method": "round", - "round": { - "max-round": 10 - } - } -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/platform.json b/src/langbot/templates/legacy/platform.json deleted file mode 100644 index 3fa4e3bf7..000000000 --- a/src/langbot/templates/legacy/platform.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "platform-adapters": [ - { - "adapter": "nakuru", - "enable": false, - "host": "127.0.0.1", - "ws_port": 8080, - "http_port": 5700, - "token": "" - }, - { - "adapter": "aiocqhttp", - "enable": true, - "host": "0.0.0.0", - "port": 2280, - "access-token": "" - }, - { - "adapter": "qq-botpy", - "enable": false, - "appid": "", - "secret": "", - "intents": [ - "public_guild_messages", - "direct_message" - ] - }, - { - "adapter": "qqofficial", - "enable": false, - "appid": "1234567890", - "secret": "xxxxxxx", - "port": 2284, - "token": "abcdefg" - }, - { - "adapter": "wecom", - "enable": false, - "host": "0.0.0.0", - "port": 2290, - "corpid": "", - "secret": "", - "token": "", - "EncodingAESKey": "", - "contacts_secret": "" - }, - { - "adapter": "lark", - "enable": false, - "app_id": "cli_abcdefgh", - "app_secret": "XXXXXXXXXX", - "bot_name": "LangBot", - "enable-webhook": false, - "port": 2285, - "encrypt-key": "xxxxxxxxx" - }, - { - "adapter": "discord", - "enable": false, - "client_id": "1234567890", - "token": "XXXXXXXXXX" - }, - { - "adapter": "gewechat", - "enable": false, - "gewechat_url": "http://your-gewechat-server:2531", - "gewechat_file_url": "http://your-gewechat-server:2532", - "port": 2286, - "callback_url": "http://your-callback-url:2286/gewechat/callback", - "app_id": "", - "token": "" - }, - { - "adapter": "officialaccount", - "enable": false, - "token": "", - "EncodingAESKey": "", - "AppID": "", - "AppSecret": "", - "Mode": "drop", - "LoadingMessage": "AI正在思考中,请发送任意内容获取回复。", - "host": "0.0.0.0", - "port": 2287 - }, - { - "adapter": "dingtalk", - "enable": false, - "client_id": "", - "client_secret": "", - "robot_code": "", - "robot_name": "", - "markdown_card": false - }, - { - "adapter": "telegram", - "enable": false, - "token": "", - "markdown_card": false - }, - { - "adapter": "slack", - "enable": false, - "bot_token": "", - "signing_secret": "", - "port": 2288 - }, - { - "adapter": "wecomcs", - "enable": false, - "port": 2289, - "corpid": "", - "secret": "", - "token": "", - "EncodingAESKey": "" - } - ], - "track-function-calls": true, - "quote-origin": false, - "at-sender": false, - "force-delay": { - "min": 0, - "max": 0 - }, - "long-text-process": { - "threshold": 2560, - "strategy": "forward", - "font-path": "" - }, - "hide-exception-info": true -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/provider.json b/src/langbot/templates/legacy/provider.json deleted file mode 100644 index 75b107f5d..000000000 --- a/src/langbot/templates/legacy/provider.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "enable-chat": true, - "enable-vision": true, - "keys": { - "openai": [ - "sk-1234567890" - ], - "anthropic": [ - "sk-1234567890" - ], - "moonshot": [ - "sk-1234567890" - ], - "deepseek": [ - "sk-1234567890" - ], - "gitee-ai": [ - "XXXXX" - ], - "xai": [ - "xai-1234567890" - ], - "zhipuai": [ - "xxxxxxx" - ], - "siliconflow": [ - "xxxxxxx" - ], - "bailian": [ - "sk-xxxxxxx" - ], - "volcark": [ - "xxxxxxxx" - ], - "modelscope": [ - "xxxxxxxx" - ], - "ppio": [ - "xxxxxxxx" - ] - }, - "requester": { - "openai-chat-completions": { - "base-url": "https://api.openai.com/v1", - "args": {}, - "timeout": 120 - }, - "anthropic-messages": { - "base-url": "https://api.anthropic.com", - "args": { - "max_tokens": 1024 - }, - "timeout": 120 - }, - "moonshot-chat-completions": { - "base-url": "https://api.moonshot.cn/v1", - "args": {}, - "timeout": 120 - }, - "deepseek-chat-completions": { - "base-url": "https://api.deepseek.com", - "args": {}, - "timeout": 120 - }, - "ollama-chat": { - "base-url": "http://127.0.0.1:11434", - "args": {}, - "timeout": 600 - }, - "gitee-ai-chat-completions": { - "base-url": "https://ai.gitee.com/v1", - "args": {}, - "timeout": 120 - }, - "xai-chat-completions": { - "base-url": "https://api.x.ai/v1", - "args": {}, - "timeout": 120 - }, - "zhipuai-chat-completions": { - "base-url": "https://open.bigmodel.cn/api/paas/v4", - "args": {}, - "timeout": 120 - }, - "lmstudio-chat-completions": { - "base-url": "http://127.0.0.1:1234/v1", - "args": {}, - "timeout": 120 - }, - "siliconflow-chat-completions": { - "base-url": "https://api.siliconflow.cn/v1", - "args": {}, - "timeout": 120 - }, - "bailian-chat-completions": { - "args": {}, - "base-url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "timeout": 120 - }, - "volcark-chat-completions": { - "args": {}, - "base-url": "https://ark.cn-beijing.volces.com/api/v3", - "timeout": 120 - }, - "modelscope-chat-completions": { - "base-url": "https://api-inference.modelscope.cn/v1", - "args": {}, - "timeout": 120 - }, - "ppio-chat-completions": { - "base-url": "https://api.ppinfra.com/v3/openai", - "args": {}, - "timeout": 120 - } - }, - "model": "gpt-4o", - "prompt-mode": "normal", - "prompt": { - "default": "You are a helpful assistant." - }, - "runner": "local-agent", - "dify-service-api": { - "base-url": "https://api.dify.ai/v1", - "app-type": "chat", - "options": { - "convert-thinking-tips": "plain" - }, - "chat": { - "api-key": "app-1234567890", - "timeout": 120 - }, - "agent": { - "api-key": "app-1234567890", - "timeout": 120 - }, - "workflow": { - "api-key": "app-1234567890", - "output-key": "summary", - "timeout": 120 - } - }, - "dashscope-app-api": { - "app-type": "agent", - "api-key": "sk-1234567890", - "agent": { - "app-id": "Your_app_id", - "references_quote": "参考资料来自:" - }, - "workflow": { - "app-id": "Your_app_id", - "references_quote": "参考资料来自:", - "biz_params": { - "city": "北京", - "date": "2023-08-10" - } - } - }, - "mcp": { - "servers": [] - } -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/system.json b/src/langbot/templates/legacy/system.json deleted file mode 100644 index f3e69feb5..000000000 --- a/src/langbot/templates/legacy/system.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "admin-sessions": [], - "network-proxies": { - "http": null, - "https": null - }, - "report-usage": true, - "logging-level": "info", - "session-concurrency": { - "default": 1 - }, - "pipeline-concurrency": 20, - "qcg-center-url": "https://api.qchatgpt.rockchin.top/api/v2", - "help-message": "LangBot - 😎高稳定性、🧩支持插件、🌏实时联网的 ChatGPT QQ 机器人🤖\n链接:https://q.rkcn.top", - "http-api": { - "enable": true, - "host": "0.0.0.0", - "port": 5300, - "jwt-expire": 604800 - }, - "persistence": { - "sqlite": { - "path": "data/langbot.db" - }, - "use": "sqlite" - } -} \ No newline at end of file diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index b5c5eb79f..6db180092 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -11,50 +11,13 @@ stages: en_US: Strategy to call AI to process messages zh_Hans: 调用 AI 处理消息的方式 config: - - name: runner + - name: id label: en_US: Runner zh_Hans: 运行器 type: select required: true - default: local-agent - options: - - name: local-agent - label: - en_US: Local Agent - zh_Hans: 内置 Agent - - name: dify-service-api - label: - en_US: Dify Service API - zh_Hans: Dify 服务 API - - name: n8n-service-api - label: - en_US: n8n Workflow API - zh_Hans: n8n 工作流 API - - name: coze-api - label: - en_US: Coze API - zh_Hans: 扣子 API - - name: tbox-app-api - label: - en_US: Tbox App API - zh_Hans: 蚂蚁百宝箱平台 API - - name: dashscope-app-api - label: - en_US: Aliyun Dashscope App API - zh_Hans: 阿里云百炼平台 API - - name: langflow-api - label: - en_US: Langflow API - zh_Hans: Langflow API - - name: weknora-api - label: - en_US: WeKnora API - zh_Hans: WeKnora API - - name: deerflow-api - label: - en_US: DeerFlow API - zh_Hans: DeerFlow API + # Options and default are dynamically populated from AgentRunnerRegistry - name: expire-time label: en_US: Conversation expire time (seconds) @@ -75,816 +38,6 @@ stages: type: integer required: true default: 0 - - name: local-agent - label: - en_US: Local Agent - zh_Hans: 内置 Agent - description: - en_US: Configure the embedded agent of the pipeline - zh_Hans: 配置内置 Agent - config: - - name: model - label: - en_US: Model - zh_Hans: 模型 - type: model-fallback-selector - required: true - default: - primary: '' - fallbacks: [] - - name: max-round - label: - en_US: Max Round - zh_Hans: 最大回合数 - description: - en_US: The maximum number of previous messages that the agent can remember - zh_Hans: 最大前文消息回合数 - type: integer - required: true - default: 10 - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: prompt - label: - en_US: Prompt - zh_Hans: 提示词 - description: - en_US: The prompt of the agent - zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词 - type: prompt-editor - required: true - default: - - role: system - content: "You are a helpful assistant." - - name: box-session-id-template - label: - en_US: Sandbox Scope - zh_Hans: 沙箱作用域 - zh_Hant: 沙箱作用域 - ja_JP: サンドボックススコープ - vi_VN: Phạm vi Sandbox - th_TH: ขอบเขต Sandbox - es_ES: Alcance del Sandbox - ru_RU: Область песочницы - description: - en_US: Determines how sandbox environments are shared across messages. - zh_Hans: 决定沙箱环境在不同消息间的共享方式。 - zh_Hant: 決定沙箱環境在不同訊息間的共享方式。 - ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。 - vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn. - th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ - es_ES: Determina cómo se comparten los entornos sandbox entre mensajes. - ru_RU: Определяет, как песочницы используются совместно между сообщениями. - disable_if: - field: __system.box_scope_editable - operator: eq - value: false - disabled_tooltip: - en_US: >- - Sandbox scope can't be changed: either the Box sandbox is disabled - or unavailable (enable it in config.yaml with box.enabled = true and - ensure the runtime is reachable), or this deployment pins all - pipelines to a fixed scope. - zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。" - zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。" - ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。" - vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi." - th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว" - es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único." - ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров." - type: select - required: false - default: "{launcher_type}_{launcher_id}" - options: - - name: "{global}" - label: - en_US: Global (shared by all) - zh_Hans: 全局(所有人共享) - zh_Hant: 全域(所有人共用) - ja_JP: グローバル(全員共有) - vi_VN: Toàn cục (chia sẻ cho tất cả) - th_TH: ทั่วไป (แชร์ทั้งหมด) - es_ES: Global (compartido por todos) - ru_RU: Глобальный (общий для всех) - - name: "{launcher_type}_{launcher_id}" - label: - en_US: Per chat (Recommended) - zh_Hans: 每个会话(推荐) - zh_Hant: 每個會話(推薦) - ja_JP: チャットごと(推奨) - vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị) - th_TH: ต่อแชท (แนะนำ) - es_ES: Por chat (Recomendado) - ru_RU: По чату (Рекомендуется) - - name: "{launcher_type}_{launcher_id}_{sender_id}" - label: - en_US: Per user in chat - zh_Hans: 会话中每个用户 - zh_Hant: 會話中每個用戶 - ja_JP: チャット内のユーザーごと - vi_VN: Mỗi người dùng trong cuộc trò chuyện - th_TH: ต่อผู้ใช้ในแชท - es_ES: Por usuario en chat - ru_RU: По пользователю в чате - - name: "{launcher_type}_{launcher_id}_{conversation_id}" - label: - en_US: Per conversation context - zh_Hans: 每个对话上下文 - zh_Hant: 每個對話上下文 - ja_JP: 会話コンテキストごと - vi_VN: Mỗi ngữ cảnh hội thoại - th_TH: ต่อบริบทการสนทนา - es_ES: Por contexto de conversación - ru_RU: По контексту разговора - - name: "{query_id}" - label: - en_US: Per message (isolated) - zh_Hans: 每条消息(完全隔离) - zh_Hant: 每條訊息(完全隔離) - ja_JP: メッセージごと(隔離) - vi_VN: Mỗi tin nhắn (cách ly) - th_TH: ต่อข้อความ (แยกส่วน) - es_ES: Por mensaje (aislado) - ru_RU: По сообщению (изолированно) - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: rerank-model - label: - en_US: Rerank Model - zh_Hans: 重排序模型 - description: - en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks - zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量 - type: rerank-model-selector - required: false - default: '' - show_if: - field: knowledge-bases - operator: neq - value: [] - - name: rerank-top-k - label: - en_US: Rerank Top K - zh_Hans: 重排序保留数量 - description: - en_US: Number of top results to keep after reranking - zh_Hans: 重排序后保留的最相关结果数量 - type: integer - required: false - default: 5 - show_if: - field: rerank-model - operator: neq - value: '' - - name: tools - label: - en_US: Tools - zh_Hans: 工具 - description: - en_US: Select plugin, MCP, skill, and built-in tools available to this Local Agent. - zh_Hans: 选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。 - type: rich-tools-selector - required: false - default: [] - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: knowledge-bases - label: - en_US: Resources - zh_Hans: 资源 - description: - en_US: Select MCP resources and knowledge bases available to this Local Agent. - zh_Hans: 选择此内置 Agent 可以读取的 MCP 资源和知识库。 - type: resources-selector - required: false - default: [] - show_if: - field: __system.is_wizard - operator: neq - value: true - - name: dify-service-api - label: - en_US: Dify Service API - zh_Hans: Dify 服务 API - description: - en_US: Configure the Dify service API of the pipeline - zh_Hans: 配置 Dify 服务 API - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - type: string - required: true - options: - - name: 'https://api.dify.ai/v1' - label: - en_US: Dify Cloud - zh_Hans: Dify 云服务 - default: 'https://api.dify.ai/v1' - - name: base-prompt - label: - en_US: Base PROMPT - zh_Hans: 基础提示词 - description: - en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it. - zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词 - type: string - required: true - default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: chat - options: - - name: chat - label: - en_US: Chat - zh_Hans: 聊天(包括Chatflow) - - name: agent - label: - en_US: Agent - zh_Hans: Agent - - name: workflow - label: - en_US: Workflow - zh_Hans: 工作流 - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: 'your-api-key' - - name: n8n-service-api - label: - en_US: n8n Workflow API - zh_Hans: n8n 工作流 API - description: - en_US: Configure the n8n workflow API of the pipeline - zh_Hans: 配置 n8n 工作流 API - config: - - name: webhook-url - label: - en_US: Webhook URL - zh_Hans: Webhook URL - description: - en_US: The webhook URL of the n8n workflow - zh_Hans: n8n 工作流的 webhook URL - type: string - required: true - default: 'http://your-n8n-webhook-url' - - name: auth-type - label: - en_US: Authentication Type - zh_Hans: 认证类型 - description: - en_US: The authentication type for the webhook call - zh_Hans: webhook 调用的认证类型 - type: select - required: true - default: 'none' - options: - - name: 'none' - label: - en_US: None - zh_Hans: 无认证 - - name: 'basic' - label: - en_US: Basic Auth - zh_Hans: 基本认证 - - name: 'jwt' - label: - en_US: JWT - zh_Hans: JWT认证 - - name: 'header' - label: - en_US: Header Auth - zh_Hans: 请求头认证 - - name: basic-username - label: - en_US: Username - zh_Hans: 用户名 - description: - en_US: The username for Basic Auth - zh_Hans: 基本认证的用户名 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'basic' - - name: basic-password - label: - en_US: Password - zh_Hans: 密码 - description: - en_US: The password for Basic Auth - zh_Hans: 基本认证的密码 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'basic' - - name: jwt-secret - label: - en_US: Secret - zh_Hans: 密钥 - description: - en_US: The secret for JWT authentication - zh_Hans: JWT认证的密钥 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'jwt' - - name: jwt-algorithm - label: - en_US: Algorithm - zh_Hans: 算法 - description: - en_US: The algorithm for JWT authentication - zh_Hans: JWT认证的算法 - type: string - required: false - default: 'HS256' - show_if: - field: auth-type - operator: eq - value: 'jwt' - - name: header-name - label: - en_US: Header Name - zh_Hans: 请求头名称 - description: - en_US: The header name for Header Auth - zh_Hans: 请求头认证的名称 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'header' - - name: header-value - label: - en_US: Header Value - zh_Hans: 请求头值 - description: - en_US: The header value for Header Auth - zh_Hans: 请求头认证的值 - type: string - required: false - default: '' - show_if: - field: auth-type - operator: eq - value: 'header' - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: The timeout in seconds for the webhook call - zh_Hans: webhook 调用的超时时间(秒) - type: integer - required: false - default: 120 - - name: output-key - label: - en_US: Output Key - zh_Hans: 输出键名 - description: - en_US: The key name of the output in the webhook response - zh_Hans: webhook 响应中输出内容的键名 - type: string - required: false - default: 'response' - - name: coze-api - label: - en_US: coze API - zh_Hans: 扣子 API - description: - en_US: Configure the Coze API of the pipeline - zh_Hans: 配置Coze API - config: - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for the Coze server - zh_Hans: Coze服务器的 API 密钥 - type: string - required: true - default: '' - - name: bot-id - label: - en_US: Bot ID - zh_Hans: 机器人 ID - description: - en_US: The ID of the bot to run - zh_Hans: 要运行的机器人 ID - type: string - required: true - default: '' - - name: api-base - label: - en_US: API Base URL - zh_Hans: API 基础 URL - description: - en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com). - zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com) - type: string - options: - - name: 'https://api.coze.cn' - label: - en_US: Coze China - zh_Hans: Coze 中国版 - - name: 'https://api.coze.com' - label: - en_US: Coze Global - zh_Hans: Coze 全球版 - default: "https://api.coze.cn" - - name: auto-save-history - label: - en_US: Auto Save History - zh_Hans: 自动保存历史 - description: - en_US: Whether to automatically save conversation history - zh_Hans: 是否自动保存对话历史 - type: boolean - default: true - - name: timeout - label: - en_US: Request Timeout - zh_Hans: 请求超时 - description: - en_US: Timeout in seconds for API requests - zh_Hans: API 请求超时时间(秒) - type: number - default: 120 - - name: tbox-app-api - label: - en_US: Tbox App API - zh_Hans: 蚂蚁百宝箱平台 API - description: - en_US: Configure the Tbox App API of the pipeline - zh_Hans: 配置蚂蚁百宝箱平台 API - config: - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: '' - - name: app-id - label: - en_US: App ID - zh_Hans: 应用 ID - type: string - required: true - default: '' - - name: dashscope-app-api - label: - en_US: Aliyun Dashscope App API - zh_Hans: 阿里云百炼平台 API - description: - en_US: Configure the Aliyun Dashscope App API of the pipeline - zh_Hans: 配置阿里云百炼平台 API - config: - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: agent - options: - - name: agent - label: - en_US: Agent - zh_Hans: Agent - - name: workflow - label: - en_US: Workflow - zh_Hans: 工作流 - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - type: string - required: true - default: 'your-api-key' - - name: app-id - label: - en_US: App ID - zh_Hans: 应用 ID - type: string - required: true - default: 'your-app-id' - - name: references_quote - label: - en_US: References Quote - zh_Hans: 引用文本 - description: - en_US: The text prompt when the references are included - zh_Hans: 包含引用资料时的文本提示 - type: string - required: false - default: '参考资料来自:' - - name: langflow-api - label: - en_US: Langflow API - zh_Hans: Langflow API - description: - en_US: Configure the Langflow API of the pipeline, call the Langflow flow through the `Simplified Run Flow` interface - zh_Hans: 配置 Langflow API,通过 `Simplified Run Flow` 接口调用 Langflow 的流程 - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - description: - en_US: The base URL of the Langflow server - zh_Hans: Langflow 服务器的基础 URL - type: string - required: true - default: 'http://localhost:7860' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for the Langflow server - zh_Hans: Langflow 服务器的 API 密钥 - type: string - required: true - default: 'your-api-key' - - name: flow-id - label: - en_US: Flow ID - zh_Hans: 流程 ID - description: - en_US: The ID of the flow to run - zh_Hans: 要运行的流程 ID - type: string - required: true - default: 'your-flow-id' - - name: input-type - label: - en_US: Input Type - zh_Hans: 输入类型 - description: - en_US: The input type for the flow - zh_Hans: 流程的输入类型 - type: string - required: false - default: 'chat' - - name: output-type - label: - en_US: Output Type - zh_Hans: 输出类型 - description: - en_US: The output type for the flow - zh_Hans: 流程的输出类型 - type: string - required: false - default: 'chat' - - name: tweaks - label: - en_US: Tweaks - zh_Hans: 调整参数 - description: - en_US: Optional tweaks to apply to the flow - zh_Hans: 可选的流程调整参数 - type: json - required: false - default: '{}' - - name: weknora-api - label: - en_US: WeKnora API - zh_Hans: WeKnora API - description: - en_US: Configure the WeKnora API of the pipeline - zh_Hans: 配置 WeKnora API - config: - - name: base-url - label: - en_US: Base URL - zh_Hans: 基础 URL - description: - en_US: The base URL of the WeKnora server (with /api/v1) - zh_Hans: WeKnora 服务器的基础 URL(包含 /api/v1) - type: string - required: true - default: 'http://localhost:8080/api/v1' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: The API key for WeKnora, generated from WeKnora frontend Settings → API Keys - zh_Hans: WeKnora 的 API 密钥,从 WeKnora 前端 设置 → API Keys 生成 - type: string - required: true - default: '' - - name: app-type - label: - en_US: App Type - zh_Hans: 应用类型 - type: select - required: true - default: agent - options: - - name: agent - label: - en_US: Agent (Smart Reasoning) - zh_Hans: Agent(智能推理) - - name: chat - label: - en_US: Chat (Knowledge Base RAG) - zh_Hans: 聊天(知识库 RAG) - - name: agent-id - label: - en_US: Agent ID - zh_Hans: 智能体 ID - description: - en_US: The Agent ID to use. Built-in agents include builtin-quick-answer, builtin-smart-reasoning, builtin-data-analyst - zh_Hans: 要使用的智能体 ID。内置智能体:builtin-quick-answer、builtin-smart-reasoning、builtin-data-analyst - type: string - required: true - default: 'builtin-smart-reasoning' - - name: knowledge-base-ids - label: - en_US: Knowledge Base IDs - zh_Hans: 知识库 ID 列表 - description: - en_US: List of WeKnora knowledge base IDs to use (one per line) - zh_Hans: 要使用的 WeKnora 知识库 ID 列表(每行一个) - type: array - required: false - default: [] - - name: web-search-enabled - label: - en_US: Enable Web Search - zh_Hans: 启用网络搜索 - description: - en_US: Whether to enable web search in agent mode - zh_Hans: 在 Agent 模式下是否启用网络搜索 - type: boolean - required: false - default: false - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: Request timeout in seconds - zh_Hans: 请求超时时间(秒) - type: integer - required: false - default: 120 - - name: base-prompt - label: - en_US: Base Prompt - zh_Hans: 基础提示词 - description: - en_US: Default prompt when user message is empty (e.g. only images) - zh_Hans: 当用户消息为空(例如仅图片)时使用的默认提示词 - type: string - required: false - default: '请回答用户的问题。' - - name: deerflow-api - label: - en_US: DeerFlow API - zh_Hans: DeerFlow API - description: - en_US: Configure the DeerFlow LangGraph API of the pipeline - zh_Hans: 配置 DeerFlow LangGraph API - config: - - name: api-base - label: - en_US: API Base URL - zh_Hans: API 基础 URL - description: - en_US: The base URL of the DeerFlow server (e.g. http://127.0.0.1:2026) - zh_Hans: DeerFlow 服务器的基础 URL(例如 http://127.0.0.1:2026) - type: string - required: true - default: 'http://127.0.0.1:2026' - - name: api-key - label: - en_US: API Key - zh_Hans: API 密钥 - description: - en_US: Optional API key for DeerFlow (leave empty if not required) - zh_Hans: DeerFlow 的 API 密钥(如果不需要可留空) - type: string - required: false - default: '' - - name: auth-header - label: - en_US: Auth Header Name - zh_Hans: 鉴权请求头名称 - description: - en_US: Custom auth header name. Leave empty to use "x-api-key" - zh_Hans: 自定义鉴权请求头名称,留空则使用 "x-api-key" - type: string - required: false - default: '' - - name: assistant-id - label: - en_US: Assistant ID - zh_Hans: 助手 ID - description: - en_US: The DeerFlow assistant/graph id (default lead_agent) - zh_Hans: DeerFlow 助手/图 ID(默认 lead_agent) - type: string - required: true - default: 'lead_agent' - - name: model-name - label: - en_US: Model Name - zh_Hans: 模型名称 - description: - en_US: Optional model override forwarded to DeerFlow configurable - zh_Hans: 可选的模型名称覆盖,会作为 configurable 转发给 DeerFlow - type: string - required: false - default: '' - - name: thinking-enabled - label: - en_US: Enable Thinking - zh_Hans: 启用思考 - description: - en_US: Whether to enable DeerFlow thinking mode - zh_Hans: 是否启用 DeerFlow 思考模式 - type: boolean - required: false - default: false - - name: plan-mode - label: - en_US: Plan Mode - zh_Hans: 规划模式 - description: - en_US: Whether to enable DeerFlow plan mode - zh_Hans: 是否启用 DeerFlow 规划模式 - type: boolean - required: false - default: false - - name: subagent-enabled - label: - en_US: Enable Subagents - zh_Hans: 启用子代理 - description: - en_US: Whether to enable parallel subagent execution - zh_Hans: 是否启用并行子代理执行 - type: boolean - required: false - default: false - - name: max-concurrent-subagents - label: - en_US: Max Concurrent Subagents - zh_Hans: 最大并发子代理数 - description: - en_US: Maximum number of concurrent subagents (only effective when subagents are enabled) - zh_Hans: 最大并发子代理数(仅在启用子代理时生效) - type: integer - required: false - default: 3 - - name: timeout - label: - en_US: Timeout - zh_Hans: 超时时间 - description: - en_US: Request timeout in seconds (DeerFlow runs may take a long time) - zh_Hans: 请求超时时间(秒),DeerFlow 运行可能耗时较长 - type: integer - required: false - default: 300 - - name: recursion-limit - label: - en_US: Recursion Limit - zh_Hans: 递归上限 - description: - en_US: LangGraph recursion limit for a single run - zh_Hans: 单次运行的 LangGraph 递归上限 - type: integer - required: false - default: 1000 + # Runner config stages are dynamically added from AgentRunnerRegistry + # Each plugin runner's config schema is added as a separate stage + # The stage name matches the runner id for frontend matching diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ddef1abdc..76aa7b1e1 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -61,7 +61,7 @@ def langbot_process(e2e_config_path, e2e_port, e2e_tmpdir): project_root=project_root, work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists port=e2e_port, - timeout=60, # Longer timeout for first startup + timeout=180, # Longer timeout for first startup collect_coverage=collect_coverage, ) diff --git a/tests/e2e/live_aiocqhttp_eba_probe.py b/tests/e2e/live_aiocqhttp_eba_probe.py new file mode 100644 index 000000000..493d208e3 --- /dev/null +++ b/tests/e2e/live_aiocqhttp_eba_probe.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import time +from collections import Counter +from pathlib import Path + +from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warn] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def dump_event(event: platform_events.Event) -> dict: + data = event.model_dump(exclude={'source_platform_object'}) + data['event_class'] = type(event).__name__ + return data + + +async def main(): + parser = argparse.ArgumentParser(description='Live OneBot v11 / aiocqhttp EBA probe for Matcha or a real endpoint.') + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=2280) + parser.add_argument('--access-token', default='') + parser.add_argument('--timeout', type=int, default=120) + parser.add_argument('--target-type', choices=['private', 'group'], default=None) + parser.add_argument('--target-id', default=None) + parser.add_argument( + '--component-sweep', action='store_true', help='Send text, mention, image, file, face, and forward samples.' + ) + parser.add_argument('--destructive', action='store_true', help='Enable delete/mute/kick/leave style APIs.') + parser.add_argument('--out', default='data/temp/aiocqhttp_eba_live_probe.jsonl') + args = parser.parse_args() + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_fp = out_path.open('a', encoding='utf-8') + + adapter = AiocqhttpAdapter( + {'host': args.host, 'port': args.port, 'access-token': args.access_token}, + ProbeLogger(), + ) + + observed: list[platform_events.Event] = [] + first_message = asyncio.Event() + + async def listener(event, adapter): + observed.append(event) + out_fp.write(json.dumps(dump_event(event), ensure_ascii=False, default=str) + '\n') + out_fp.flush() + print(f'[event] {type(event).__name__} {event.type}') + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + async def call_api(name: str, awaitable, timeout: int = 8): + try: + return await asyncio.wait_for(awaitable, timeout=timeout) + except Exception as exc: + api_results[name] = f'skip:{type(exc).__name__}:{exc}' + return None + + task = asyncio.create_task(adapter.run_async()) + print(f'Listening on ws://{args.host}:{args.port}/ws/ . Trigger events from Matcha now.') + + api_results: dict[str, str] = {} + try: + try: + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + first = next(event for event in observed if isinstance(event, platform_events.MessageReceivedEvent)) + target_type = args.target_type or ('group' if first.chat_type.value == 'group' else 'private') + target_id = args.target_id or str(first.chat_id) + + reply = await call_api( + 'reply_message', + adapter.reply_message( + first, + platform_message.MessageChain([platform_message.Plain(text='aiocqhttp EBA reply probe')]), + quote_origin=True, + ), + ) + if reply: + api_results['reply_message'] = f'ok:{reply.message_id}' + + sent = await call_api( + 'send_message', + adapter.send_message( + target_type, + target_id, + platform_message.MessageChain([platform_message.Plain(text='aiocqhttp EBA send probe')]), + ), + ) + if sent: + api_results['send_message'] = f'ok:{sent.message_id}' + + if args.component_sweep: + png_base64 = base64.b64encode( + base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFeAJ5mZtH5QAAAABJRU5ErkJggg==' + ) + ).decode() + component_cases = { + 'component:text_at_face': platform_message.MessageChain( + [ + platform_message.Plain(text='component sweep '), + platform_message.At(target=str(first.sender.id)), + platform_message.Plain(text=' '), + platform_message.AtAll(), + platform_message.Plain(text=' '), + platform_message.Face(face_id=14, face_name='微笑'), + ] + ), + 'component:image_base64': platform_message.MessageChain( + [ + platform_message.Plain(text='image component '), + platform_message.Image(base64=f'data:image/png;base64,{png_base64}'), + ] + ), + 'component:file': platform_message.MessageChain( + [ + platform_message.Plain(text='file component '), + platform_message.File(name='probe.txt', url='https://example.com/probe.txt'), + ] + ), + } + if target_type == 'group': + component_cases['component:forward'] = platform_message.MessageChain( + [ + platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id=adapter.bot_account_id or '960164003', + sender_name='LangBot', + message_chain=platform_message.MessageChain( + [platform_message.Plain(text='forward node 1')] + ), + ), + platform_message.ForwardMessageNode( + sender_id=str(first.sender.id), + sender_name=first.sender.nickname or 'Matcha', + message_chain=platform_message.MessageChain( + [platform_message.Plain(text='forward node 2')] + ), + ), + ] + ) + ] + ) + for name, chain in component_cases.items(): + result = await call_api(name, adapter.send_message(target_type, target_id, chain)) + if result: + api_results[name] = f'ok:{result.message_id}' + + if sent and sent.message_id: + fetched = await call_api('get_message', adapter.get_message(target_type, target_id, sent.message_id)) + if fetched: + api_results['get_message'] = f'ok:{fetched.message_id}' + if args.destructive: + deleted = await call_api( + 'delete_message', + adapter.delete_message(target_type, target_id, sent.message_id), + ) + if deleted is not None: + api_results['delete_message'] = 'ok' + + if target_type == 'group': + group = await call_api('get_group_info', adapter.get_group_info(target_id)) + if group: + api_results['get_group_info'] = f'ok:{group.id}' + members = await call_api('get_group_member_list', adapter.get_group_member_list(target_id)) + if members is not None: + api_results['get_group_member_list'] = f'ok:{len(members)}' + if members: + member = await call_api( + 'get_group_member_info', + adapter.get_group_member_info(target_id, members[0].user.id), + ) + if member: + api_results['get_group_member_info'] = f'ok:{member.user.id}' + + for action in ('get_login_info', 'get_status', 'get_version_info', 'can_send_image', 'can_send_record'): + result = await call_api( + f'call_platform_api:{action}', + adapter.call_platform_api(action, {}), + ) + if result is not None: + api_results[f'call_platform_api:{action}'] = 'ok' + except asyncio.TimeoutError: + api_results['first_message'] = 'timeout' + finally: + task.cancel() + try: + await asyncio.wait_for(task, timeout=3) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + out_fp.close() + + counts = Counter(event.type for event in observed) + print( + json.dumps( + { + 'output': str(out_path), + 'observed_events': counts, + 'api_results': api_results, + 'duration_seconds': round(time.monotonic(), 3), + }, + ensure_ascii=False, + default=str, + indent=2, + ) + ) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/tests/e2e/live_discord_eba_probe.py b/tests/e2e/live_discord_eba_probe.py new file mode 100644 index 000000000..68ce69abb --- /dev/null +++ b/tests/e2e/live_discord_eba_probe.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +from pathlib import Path +from typing import Any + +from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import errors as platform_errors +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +PNG_1X1 = base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +) + + +def summarize_event(event: platform_events.EBAEvent) -> dict[str, Any]: + data: dict[str, Any] = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'reaction', 'is_add', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + data[field] = value.value if hasattr(value, 'value') else value + for field in ('sender', 'user', 'member', 'group', 'operator', 'inviter'): + if hasattr(event, field): + value = getattr(event, field) + if value is not None and hasattr(value, 'model_dump'): + data[field] = value.model_dump() + return data + + +def chat_type_value(chat_type: platform_entities.ChatType | str) -> str: + return chat_type.value if hasattr(chat_type, 'value') else str(chat_type) + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry: dict[str, Any] = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = result + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('DISCORD_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +async def run_expected_error(results: list[dict[str, Any]], name: str, func, error_type: type[Exception]): + try: + await func() + except Exception as exc: + if isinstance(exc, error_type): + record_api(results, name, True, {'expected_error': type(exc).__name__}) + return + record_api(results, name, False, error=exc) + return + record_api(results, name, False, error=RuntimeError(f'expected {error_type.__name__}')) + + +async def wait_for_event(events: list[platform_events.EBAEvent], predicate, timeout: int) -> platform_events.EBAEvent: + deadline = asyncio.get_running_loop().time() + timeout + seen = 0 + while asyncio.get_running_loop().time() < deadline: + for event in events[seen:]: + if predicate(event): + return event + seen = len(events) + await asyncio.sleep(0.2) + raise TimeoutError('event was not observed before timeout') + + +async def run_probe( + token: str, + client_id: str, + channel_id: str, + log_path: Path, + timeout: int, + guild_id: str | None, + moderation_user_id: str | None, + kick_user_id: str | None, + allow_invite: bool, + allow_leave_group: bool, +): + adapter = DiscordAdapter({'client_id': client_id, 'token': token}, ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + + async def listener(event, adapter): + events.append(event) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n') + print('DISCORD_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + run_task = asyncio.create_task(adapter.run_async()) + + try: + print('READY: send a message in the Discord test channel now.') + await asyncio.wait_for(first_message.wait(), timeout=timeout) + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + source_chat_type = chat_type_value(source.chat_type) + target_chat_id = str(source.chat_id) + guild_id = guild_id or (str(source.group.id) if source.group else None) + actor_user_id = str(source.sender.id) + + await run_api( + api_results, + 'reply_message:text_image_file', + lambda: adapter.reply_message( + source, + platform_message.MessageChain( + [ + platform_message.Plain(text='Discord EBA live reply: text'), + platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), + platform_message.File( + name='discord-eba-live.txt', + size=16, + base64='data:text/plain;base64,' + base64.b64encode(b'discord-eba-live').decode(), + ), + ] + ), + quote_origin=True, + ), + ) + sent = await run_api( + api_results, + 'send_message:text_image_file', + lambda: adapter.send_message( + source_chat_type, + target_chat_id, + platform_message.MessageChain( + [ + platform_message.Plain(text='Discord EBA live send_message OK'), + platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), + ] + ), + ), + ) + + edit_probe = await run_api( + api_results, + 'send_message:edit_delete_probe', + lambda: adapter.send_message( + source_chat_type, + target_chat_id, + platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit/delete probe')]), + ), + ) + if edit_probe: + await run_api( + api_results, + 'edit_message', + lambda: adapter.edit_message( + source_chat_type, + target_chat_id, + edit_probe.message_id, + platform_message.MessageChain([platform_message.Plain(text='Discord EBA edit probe edited')]), + ), + ) + await run_api( + api_results, + 'delete_message', + lambda: adapter.delete_message(source_chat_type, target_chat_id, edit_probe.message_id), + ) + await run_api( + api_results, + 'event_observed:message.edited', + lambda: wait_for_event( + events, + lambda event: isinstance(event, platform_events.MessageEditedEvent) + and str(event.message_id) == str(edit_probe.message_id), + timeout=max(10, timeout // 3), + ), + ) + await run_api( + api_results, + 'event_observed:message.deleted', + lambda: wait_for_event( + events, + lambda event: isinstance(event, platform_events.MessageDeletedEvent) + and str(event.message_id) == str(edit_probe.message_id), + timeout=max(10, timeout // 3), + ), + ) + + if sent: + await run_api( + api_results, + 'forward_message', + lambda: adapter.forward_message( + source_chat_type, + target_chat_id, + sent.message_id, + source_chat_type, + target_chat_id, + ), + ) + await run_api( + api_results, + 'call_platform_api:add_reaction', + lambda: adapter.call_platform_api( + 'add_reaction', + {'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'}, + ), + ) + await run_api( + api_results, + 'call_platform_api:remove_reaction', + lambda: adapter.call_platform_api( + 'remove_reaction', + {'channel_id': target_chat_id, 'message_id': sent.message_id, 'emoji': '👍'}, + ), + ) + + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(actor_user_id)) + await run_expected_error( + api_results, + 'upload_file:not_supported', + lambda: adapter.upload_file(b'discord-eba-upload', 'discord-eba-upload.txt'), + platform_errors.NotSupportedError, + ) + await run_api(api_results, 'get_file_url', lambda: adapter.get_file_url('https://cdn.discordapp.com/file.txt')) + await run_api( + api_results, + 'call_platform_api:get_channel', + lambda: adapter.call_platform_api('get_channel', {'channel_id': target_chat_id}), + ) + await run_api( + api_results, + 'call_platform_api:typing', + lambda: adapter.call_platform_api('typing', {'channel_id': target_chat_id}), + ) + + pin_probe = await run_api( + api_results, + 'send_message:pin_probe', + lambda: adapter.send_message( + source_chat_type, + target_chat_id, + platform_message.MessageChain([platform_message.Plain(text='Discord EBA pin probe')]), + ), + ) + if pin_probe: + await run_api( + api_results, + 'call_platform_api:pin_message', + lambda: adapter.call_platform_api( + 'pin_message', + {'channel_id': target_chat_id, 'message_id': pin_probe.message_id}, + ), + ) + await run_api( + api_results, + 'call_platform_api:unpin_message', + lambda: adapter.call_platform_api( + 'unpin_message', + {'channel_id': target_chat_id, 'message_id': pin_probe.message_id}, + ), + ) + + if guild_id: + await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(guild_id)) + await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(guild_id)) + await run_api( + api_results, + 'get_group_member_info', + lambda: adapter.get_group_member_info(guild_id, actor_user_id), + ) + await run_api( + api_results, + 'call_platform_api:get_guild', + lambda: adapter.call_platform_api('get_guild', {'guild_id': guild_id}), + ) + await run_api( + api_results, + 'call_platform_api:get_guild_channels', + lambda: adapter.call_platform_api('get_guild_channels', {'guild_id': guild_id}), + ) + await run_api( + api_results, + 'call_platform_api:get_guild_roles', + lambda: adapter.call_platform_api('get_guild_roles', {'guild_id': guild_id}), + ) + if allow_invite: + await run_api( + api_results, + 'call_platform_api:create_invite', + lambda: adapter.call_platform_api('create_invite', {'channel_id': target_chat_id, 'max_age': 300}), + ) + else: + record_api(api_results, 'call_platform_api:create_invite', False, error=RuntimeError('skipped')) + + if moderation_user_id: + await run_api( + api_results, + 'mute_member', + lambda: adapter.mute_member(guild_id, moderation_user_id, duration=30), + ) + await run_api(api_results, 'unmute_member', lambda: adapter.unmute_member(guild_id, moderation_user_id)) + else: + record_api(api_results, 'mute_member', False, error=RuntimeError('skipped')) + record_api(api_results, 'unmute_member', False, error=RuntimeError('skipped')) + + if kick_user_id: + await run_api(api_results, 'kick_member', lambda: adapter.kick_member(guild_id, kick_user_id)) + else: + record_api(api_results, 'kick_member', False, error=RuntimeError('skipped')) + + if allow_leave_group: + await run_api(api_results, 'leave_group', lambda: adapter.leave_group(guild_id)) + else: + record_api(api_results, 'leave_group', False, error=RuntimeError('skipped')) + else: + for name in ( + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'call_platform_api:get_guild', + 'call_platform_api:get_guild_channels', + 'call_platform_api:get_guild_roles', + 'call_platform_api:create_invite', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + ): + record_api(api_results, name, False, error=RuntimeError('skipped: no guild id')) + + await asyncio.sleep(3) + finally: + await adapter.kill() + run_task.cancel() + summary = { + 'events': [summarize_event(event) for event in events], + 'event_types': [event.type for event in events], + 'api_results': api_results, + 'api_passed': [result['name'] for result in api_results if result['ok']], + 'api_failed': [result for result in api_results if not result['ok']], + } + print('SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--token', default=os.getenv('DISCORD_BOT_TOKEN', '')) + parser.add_argument('--client-id', default=os.getenv('DISCORD_CLIENT_ID', '')) + parser.add_argument('--channel-id', default=os.getenv('DISCORD_EBA_CHANNEL_ID', '')) + parser.add_argument('--guild-id', default=os.getenv('DISCORD_EBA_GUILD_ID')) + parser.add_argument('--moderation-user-id', default=os.getenv('DISCORD_EBA_MODERATION_USER_ID')) + parser.add_argument('--kick-user-id', default=os.getenv('DISCORD_EBA_KICK_USER_ID')) + parser.add_argument('--log', default='data/temp/live_discord_eba_probe.jsonl') + parser.add_argument('--timeout', type=int, default=90) + parser.add_argument('--allow-invite', action='store_true') + parser.add_argument('--allow-leave-group', action='store_true') + args = parser.parse_args() + + if not args.token: + raise SystemExit('Set DISCORD_BOT_TOKEN or pass --token') + if not args.client_id: + raise SystemExit('Set DISCORD_CLIENT_ID or pass --client-id') + if not args.channel_id: + raise SystemExit('Set DISCORD_EBA_CHANNEL_ID or pass --channel-id') + + log_path = Path(args.log) + if log_path.exists(): + log_path.unlink() + asyncio.run( + run_probe( + args.token, + args.client_id, + args.channel_id, + log_path, + args.timeout, + args.guild_id, + args.moderation_user_id, + args.kick_user_id, + args.allow_invite, + args.allow_leave_group, + ) + ) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_officialaccount_eba_probe.py b/tests/e2e/live_officialaccount_eba_probe.py new file mode 100644 index 000000000..a163a8da6 --- /dev/null +++ b/tests/e2e/live_officialaccount_eba_probe.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.officialaccount.adapter import OfficialAccountAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: '' if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'appsecret'} else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('OFFICIALACCOUNT_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env() -> dict: + config = { + 'token': os.getenv('OFFICIALACCOUNT_TOKEN', ''), + 'EncodingAESKey': os.getenv('OFFICIALACCOUNT_ENCODING_AES_KEY', ''), + 'AppSecret': os.getenv('OFFICIALACCOUNT_APP_SECRET', ''), + 'AppID': os.getenv('OFFICIALACCOUNT_APP_ID', ''), + 'Mode': os.getenv('OFFICIALACCOUNT_MODE', 'drop'), + 'LoadingMessage': os.getenv('OFFICIALACCOUNT_LOADING_MESSAGE', 'AI正在思考中,请发送任意内容获取回复。'), + 'api_base_url': os.getenv('OFFICIALACCOUNT_API_BASE_URL', 'https://api.weixin.qq.com'), + } + missing = [key for key in ('token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode') if not config.get(key)] + if missing: + raise RuntimeError(f'Missing required OfficialAccount env vars for fields: {missing}') + return config + + +async def run_probe(args: argparse.Namespace): + adapter = OfficialAccountAdapter(config_from_env(), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n') + print('OFFICIALACCOUNT_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + app = Quart(__name__) + + @app.route('/callback', methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook('probe', '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + try: + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + first = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + await run_api(api_results, 'reply_message', lambda: adapter.reply_message(first, platform_message.MessageChain([platform_message.Plain(text=args.reply_text)]))) + await run_api(api_results, 'get_message', lambda: adapter.get_message(first.chat_type.value, first.chat_id, first.message_id)) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(first.sender.id)) + await run_api(api_results, 'get_friend_list', adapter.get_friend_list) + await run_api(api_results, 'call_platform_api.get_mode', lambda: adapter.call_platform_api('get_mode', {})) + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log': str(log_path), + } + print('OFFICIALACCOUNT_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + + +def main(): + parser = argparse.ArgumentParser(description='Live OfficialAccount EBA adapter probe') + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=5311) + parser.add_argument('--timeout', type=float, default=300) + parser.add_argument('--log', default='data/temp/officialaccount_eba_probe.jsonl') + parser.add_argument('--reply-text', default='OfficialAccount EBA probe reply') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_qqofficial_eba_probe.py b/tests/e2e/live_qqofficial_eba_probe.py new file mode 100644 index 000000000..5c45a82c5 --- /dev/null +++ b/tests/e2e/live_qqofficial_eba_probe.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + secret_keys = {'secret', 'token', 'authorization', 'access_token', 'clientsecret'} + return {key: '' if key.lower() in secret_keys else redact(item) for key, item in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'group') and event.group is not None: + data['group'] = event.group.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('QQOFFICIAL_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env(enable_webhook: bool) -> dict: + config = { + 'appid': os.getenv('QQOFFICIAL_APPID', ''), + 'secret': os.getenv('QQOFFICIAL_SECRET', ''), + 'token': os.getenv('QQOFFICIAL_TOKEN', ''), + 'enable-webhook': enable_webhook, + 'enable-stream-reply': os.getenv('QQOFFICIAL_ENABLE_STREAM_REPLY', '').lower() in {'1', 'true', 'yes'}, + } + missing = [key for key in ('appid', 'secret', 'token') if not config.get(key)] + if missing: + raise RuntimeError(f'Missing required QQOfficial env vars for fields: {missing}') + return config + + +async def run_probe(args: argparse.Namespace): + adapter = QQOfficialAdapter(config_from_env(args.webhook), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + summary = summarize_event(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summary, ensure_ascii=False, default=str) + '\n') + print('QQOFFICIAL_EBA_EVENT', json.dumps(summary, ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + server_task = None + if args.webhook: + app = Quart(__name__) + + @app.route('/callback', methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook('probe', '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + else: + server_task = asyncio.create_task(adapter.run_async()) + + try: + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + first = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + await run_api(api_results, 'reply_message', lambda: adapter.reply_message(first, platform_message.MessageChain([platform_message.Plain(text=args.reply_text)]))) + await run_api(api_results, 'get_message', lambda: adapter.get_message(first.chat_type.value, first.chat_id, first.message_id)) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(first.sender.id)) + await run_api(api_results, 'get_friend_list', adapter.get_friend_list) + if getattr(first, 'group', None): + await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(first.group.id)) + await run_api(api_results, 'get_group_member_info', lambda: adapter.get_group_member_info(first.group.id, first.sender.id)) + await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(first.group.id)) + await run_api(api_results, 'call_platform_api.get_mode', lambda: adapter.call_platform_api('get_mode', {})) + await run_api(api_results, 'call_platform_api.check_access_token', lambda: adapter.call_platform_api('check_access_token', {})) + finally: + if server_task: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + await adapter.kill() + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log': str(log_path), + } + print('QQOFFICIAL_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + + +def main(): + parser = argparse.ArgumentParser(description='Live QQOfficial EBA adapter probe') + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=5312) + parser.add_argument('--timeout', type=float, default=300) + parser.add_argument('--log', default='data/temp/qqofficial_eba_probe.jsonl') + parser.add_argument('--reply-text', default='QQOfficial EBA probe reply') + parser.add_argument('--webhook', action='store_true', help='Run a local webhook callback server instead of the WebSocket gateway') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() + diff --git a/tests/e2e/live_slack_eba_probe.py b/tests/e2e/live_slack_eba_probe.py new file mode 100644 index 000000000..bba4466a3 --- /dev/null +++ b/tests/e2e/live_slack_eba_probe.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.slack.adapter import SlackAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + secret_keys = {'token', 'signing_secret', 'authorization', 'access_token'} + return {key: '' if key.lower() in secret_keys else redact(item) for key, item in value.items()} + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'group') and event.group is not None: + data['group'] = event.group.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('SLACK_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env() -> dict: + config = { + 'bot_token': os.getenv('SLACK_BOT_TOKEN', ''), + 'signing_secret': os.getenv('SLACK_SIGNING_SECRET', ''), + 'bot_user_id': os.getenv('SLACK_BOT_USER_ID', ''), + } + missing = [key for key in ('bot_token', 'signing_secret') if not config.get(key)] + if missing: + raise RuntimeError(f'Missing required Slack env vars for fields: {missing}') + return config + + +async def run_probe(args: argparse.Namespace): + adapter = SlackAdapter(config_from_env(), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + summary = summarize_event(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summary, ensure_ascii=False, default=str) + '\n') + print('SLACK_EBA_EVENT', json.dumps(summary, ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + app = Quart(__name__) + + @app.route('/callback', methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook('probe', '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + try: + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + first = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + await run_api(api_results, 'reply_message', lambda: adapter.reply_message(first, platform_message.MessageChain([platform_message.Plain(text=args.reply_text)]))) + await run_api(api_results, 'get_message', lambda: adapter.get_message(first.chat_type.value, first.chat_id, first.message_id)) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(first.sender.id)) + await run_api(api_results, 'get_friend_list', adapter.get_friend_list) + if getattr(first, 'group', None): + await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(first.group.id)) + await run_api(api_results, 'get_group_member_info', lambda: adapter.get_group_member_info(first.group.id, first.sender.id)) + await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(first.group.id)) + await run_api(api_results, 'call_platform_api.get_mode', lambda: adapter.call_platform_api('get_mode', {})) + await run_api(api_results, 'call_platform_api.auth_test', lambda: adapter.call_platform_api('auth_test', {})) + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + await adapter.kill() + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log': str(log_path), + } + print('SLACK_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + + +def main(): + parser = argparse.ArgumentParser(description='Live Slack EBA adapter probe') + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', type=int, default=5313) + parser.add_argument('--timeout', type=float, default=300) + parser.add_argument('--log', default='data/temp/slack_eba_probe.jsonl') + parser.add_argument('--reply-text', default='Slack EBA probe reply') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_telegram_eba_probe.py b/tests/e2e/live_telegram_eba_probe.py new file mode 100644 index 000000000..0f7a2a148 --- /dev/null +++ b/tests/e2e/live_telegram_eba_probe.py @@ -0,0 +1,588 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import re +from pathlib import Path +from typing import Any + +import telegram + +from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +PNG_1X1 = base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +) + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ( + 'message_id', + 'chat_id', + 'chat_type', + 'reaction', + 'is_add', + 'action', + 'data', + ): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = value + if hasattr(event, 'member') and event.member is not None: + data['member'] = event.member.model_dump() + if hasattr(event, 'group') and event.group is not None: + data['group'] = event.group.model_dump() + if hasattr(event, 'operator') and event.operator is not None: + data['operator'] = event.operator.model_dump() + return data + + +def chat_type_value(chat_type: platform_entities.ChatType | str) -> str: + return chat_type.value if hasattr(chat_type, 'value') else str(chat_type) + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact_sensitive(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('TELEGRAM_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +def redact_sensitive(value: Any) -> Any: + if isinstance(value, str): + return re.sub(r'bot\d+:[A-Za-z0-9_-]+', 'bot', value) + if isinstance(value, dict): + return {key: redact_sensitive(item) for key, item in value.items()} + if isinstance(value, list): + return [redact_sensitive(item) for item in value] + if isinstance(value, int | float | bool) or value is None: + return value + return redact_sensitive(str(value)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +async def run_expected_error(results: list[dict[str, Any]], name: str, func, error_type_name: str): + try: + await func() + except Exception as exc: + if type(exc).__name__ == error_type_name: + record_api(results, name, True, {'expected_error': error_type_name}) + return + record_api(results, name, False, error=exc) + return + record_api(results, name, False, error=RuntimeError(f'expected {error_type_name}')) + + +async def run_probe( + token: str, + log_path: Path, + timeout: int, + group_chat_id: str | None, + moderation_user_id: str | None, + kick_user_id: str | None, + allow_group_mutation: bool, + allow_unpin_all: bool, + allow_leave_group: bool, +): + adapter = TelegramAdapter( + { + 'token': token, + 'markdown_card': False, + 'enable-stream-reply': False, + }, + ProbeLogger(), + ) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + callback_event = asyncio.Event() + callback_query_id: str | None = None + callback_probe_message_id: int | None = None + awaiting_callback = False + + async def listener(event, adapter): + nonlocal callback_query_id + events.append(event) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False) + '\n') + print('TELEGRAM_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'callback_query': + message_id = event.data.get('message_id') + if awaiting_callback and message_id == callback_probe_message_id: + callback_query_id = event.data.get('callback_query_id') + callback_event.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + await adapter.run_async() + + try: + print('READY: send a private or group message to the Telegram test bot now.') + await asyncio.wait_for(first_message.wait(), timeout=timeout) + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + source_chat_type = chat_type_value(source.chat_type) + group_id = group_chat_id or (str(source.chat_id) if source_chat_type == 'group' else None) + actor_user_id = str(source.sender.id) + target_chat_id = str(source.chat_id) + + await run_api( + api_results, + 'reply_message:text_image_file', + lambda: adapter.reply_message( + source, + platform_message.MessageChain( + [ + platform_message.Plain(text='EBA live reply: text'), + platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), + platform_message.File( + name='eba-live.txt', + size=8, + base64='data:text/plain;base64,' + base64.b64encode(b'eba-live').decode(), + ), + ] + ), + quote_origin=True, + ), + ) + await run_api( + api_results, + 'send_message:text_image_file', + lambda: adapter.send_message( + source_chat_type, + target_chat_id, + platform_message.MessageChain( + [ + platform_message.Plain(text='EBA live send_message OK'), + platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), + platform_message.File( + name='eba-send-live.txt', + size=13, + base64='data:text/plain;base64,' + base64.b64encode(b'eba-send-live').decode(), + ), + ] + ), + ), + ) + + edit_probe = await run_api( + api_results, + 'bot.send_message:edit_delete_probe', + lambda: adapter.bot.send_message(chat_id=target_chat_id, text='EBA edit/delete probe'), + ) + if edit_probe: + await run_api( + api_results, + 'edit_message', + lambda: adapter.edit_message( + source_chat_type, + target_chat_id, + edit_probe.message_id, + platform_message.MessageChain([platform_message.Plain(text='EBA edit probe edited')]), + ), + ) + await run_api( + api_results, + 'delete_message', + lambda: adapter.delete_message(source_chat_type, target_chat_id, edit_probe.message_id), + ) + + forward_probe = await run_api( + api_results, + 'bot.send_message:forward_probe', + lambda: adapter.bot.send_message(chat_id=target_chat_id, text='EBA forward probe'), + ) + if forward_probe: + forwarded = await run_api( + api_results, + 'forward_message', + lambda: adapter.forward_message( + source_chat_type, + target_chat_id, + forward_probe.message_id, + source_chat_type, + target_chat_id, + ), + ) + if forwarded: + await run_api( + api_results, + 'delete_message:forwarded_cleanup', + lambda: adapter.delete_message(source_chat_type, target_chat_id, forwarded.message_id), + ) + await run_api( + api_results, + 'delete_message:forward_source_cleanup', + lambda: adapter.delete_message(source_chat_type, target_chat_id, forward_probe.message_id), + ) + + document_probe = await run_api( + api_results, + 'bot.send_document:get_file_url_probe', + lambda: adapter.bot.send_document( + chat_id=target_chat_id, + document=telegram.InputFile(b'eba-file-url', filename='eba-file-url.txt'), + ), + ) + if document_probe and document_probe.document: + await run_api( + api_results, + 'get_file_url', + lambda: adapter.get_file_url(document_probe.document.file_id), + ) + + await run_api( + api_results, + 'get_user_info', + lambda: adapter.get_user_info(actor_user_id), + ) + await run_expected_error( + api_results, + 'upload_file:not_supported', + lambda: adapter.upload_file(b'eba-upload-live', 'eba-upload-live.txt'), + 'NotSupportedError', + ) + await run_api( + api_results, + 'call_platform_api:send_chat_action', + lambda: adapter.call_platform_api('send_chat_action', {'chat_id': target_chat_id, 'action': 'typing'}), + ) + + callback_probe = await run_api( + api_results, + 'bot.send_message:callback_probe', + lambda: adapter.bot.send_message( + chat_id=target_chat_id, + text='EBA callback probe', + reply_markup=telegram.InlineKeyboardMarkup( + [[telegram.InlineKeyboardButton('callback probe', callback_data='eba-callback-probe')]] + ), + ), + ) + if callback_probe: + callback_probe_message_id = callback_probe.message_id + awaiting_callback = True + callback_event.clear() + print('READY: click the callback button to test answer_callback_query.') + try: + await asyncio.wait_for(callback_event.wait(), timeout=max(15, timeout // 3)) + except asyncio.TimeoutError: + record_api( + api_results, + 'call_platform_api:answer_callback_query', + False, + error=TimeoutError('callback button was not clicked before timeout'), + ) + else: + await run_api( + api_results, + 'call_platform_api:answer_callback_query', + lambda: adapter.call_platform_api( + 'answer_callback_query', + {'callback_query_id': callback_query_id, 'text': 'EBA callback answered'}, + ), + ) + finally: + awaiting_callback = False + + if group_id: + await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(group_id)) + await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(group_id)) + await run_api( + api_results, + 'get_group_member_info', + lambda: adapter.get_group_member_info(group_id, actor_user_id), + ) + await run_api( + api_results, + 'call_platform_api:get_chat_administrators', + lambda: adapter.call_platform_api('get_chat_administrators', {'chat_id': group_id}), + ) + await run_api( + api_results, + 'call_platform_api:get_chat_member_count', + lambda: adapter.call_platform_api('get_chat_member_count', {'chat_id': group_id}), + ) + await run_api( + api_results, + 'call_platform_api:create_chat_invite_link', + lambda: adapter.call_platform_api( + 'create_chat_invite_link', {'chat_id': group_id, 'name': 'eba-probe'} + ), + ) + + pin_probe = await run_api( + api_results, + 'bot.send_message:pin_probe', + lambda: adapter.bot.send_message(chat_id=group_id, text='EBA pin probe'), + ) + if pin_probe: + await run_api( + api_results, + 'call_platform_api:pin_message', + lambda: adapter.call_platform_api( + 'pin_message', + {'chat_id': group_id, 'message_id': pin_probe.message_id, 'disable_notification': True}, + ), + ) + await run_api( + api_results, + 'call_platform_api:unpin_message', + lambda: adapter.call_platform_api( + 'unpin_message', + {'chat_id': group_id, 'message_id': pin_probe.message_id}, + ), + ) + await run_api( + api_results, + 'delete_message:pin_probe_cleanup', + lambda: adapter.delete_message('group', group_id, pin_probe.message_id), + ) + + if allow_unpin_all: + await run_api( + api_results, + 'call_platform_api:unpin_all_messages', + lambda: adapter.call_platform_api('unpin_all_messages', {'chat_id': group_id}), + ) + else: + record_api(api_results, 'call_platform_api:unpin_all_messages', False, error=RuntimeError('skipped')) + + if allow_group_mutation: + chat = await adapter.bot.get_chat(chat_id=group_id) + original_title = chat.title or 'EBA Probe Group' + original_description = chat.description or '' + await run_api( + api_results, + 'call_platform_api:set_chat_title', + lambda: adapter.call_platform_api( + 'set_chat_title', + {'chat_id': group_id, 'title': f'{original_title} EBA'}, + ), + ) + await run_api( + api_results, + 'call_platform_api:set_chat_title:restore', + lambda: adapter.call_platform_api('set_chat_title', {'chat_id': group_id, 'title': original_title}), + ) + await run_api( + api_results, + 'call_platform_api:set_chat_description', + lambda: adapter.call_platform_api( + 'set_chat_description', + {'chat_id': group_id, 'description': 'EBA probe temporary description'}, + ), + ) + await run_api( + api_results, + 'call_platform_api:set_chat_description:restore', + lambda: adapter.call_platform_api( + 'set_chat_description', + {'chat_id': group_id, 'description': original_description}, + ), + ) + else: + record_api(api_results, 'call_platform_api:set_chat_title', False, error=RuntimeError('skipped')) + record_api(api_results, 'call_platform_api:set_chat_description', False, error=RuntimeError('skipped')) + + if moderation_user_id: + await run_api( + api_results, + 'mute_member', + lambda: adapter.mute_member(group_id, moderation_user_id, duration=30), + ) + await run_api( + api_results, + 'unmute_member', + lambda: adapter.unmute_member(group_id, moderation_user_id), + ) + else: + record_api(api_results, 'mute_member', False, error=RuntimeError('skipped')) + record_api(api_results, 'unmute_member', False, error=RuntimeError('skipped')) + + if kick_user_id: + await run_api(api_results, 'kick_member', lambda: adapter.kick_member(group_id, kick_user_id)) + else: + record_api(api_results, 'kick_member', False, error=RuntimeError('skipped')) + + if allow_leave_group: + await run_api(api_results, 'leave_group', lambda: adapter.leave_group(group_id)) + else: + record_api(api_results, 'leave_group', False, error=RuntimeError('skipped')) + else: + for name in ( + 'get_group_info', + 'get_group_member_list', + 'get_group_member_info', + 'mute_member', + 'unmute_member', + 'kick_member', + 'leave_group', + 'call_platform_api:get_chat_administrators', + 'call_platform_api:get_chat_member_count', + 'call_platform_api:create_chat_invite_link', + 'call_platform_api:pin_message', + 'call_platform_api:unpin_message', + 'call_platform_api:unpin_all_messages', + 'call_platform_api:set_chat_title', + 'call_platform_api:set_chat_description', + ): + record_api(api_results, name, False, error=RuntimeError('skipped: no group chat id')) + + await asyncio.sleep(3) + finally: + await adapter.kill() + summary = { + 'events': [summarize_event(event) for event in events], + 'event_types': [event.type for event in events], + 'api_results': api_results, + 'api_passed': [result['name'] for result in api_results if result['ok']], + 'api_failed': [result for result in api_results if not result['ok']], + } + print('SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + + +async def run_callback_probe(token: str, chat_id: str, timeout: int): + adapter = TelegramAdapter( + { + 'token': token, + 'markdown_card': False, + 'enable-stream-reply': False, + }, + ProbeLogger(), + ) + api_results: list[dict[str, Any]] = [] + + callback_probe = await adapter.bot.send_message( + chat_id=chat_id, + text='EBA callback-only probe', + reply_markup=telegram.InlineKeyboardMarkup( + [[telegram.InlineKeyboardButton('callback probe', callback_data='eba-callback-only-probe')]] + ), + ) + deadline = asyncio.get_running_loop().time() + timeout + offset: int | None = None + try: + print('READY: click the callback-only probe button.') + callback_query_id: str | None = None + while asyncio.get_running_loop().time() < deadline and callback_query_id is None: + updates = await adapter.bot.get_updates( + offset=offset, + timeout=2, + allowed_updates=['callback_query'], + ) + for update in updates: + offset = update.update_id + 1 + callback_query = update.callback_query + if callback_query is None or callback_query.message is None: + continue + if callback_query.message.message_id == callback_probe.message_id: + callback_query_id = callback_query.id + break + if callback_query_id is None: + raise TimeoutError('callback button was not clicked before timeout') + await run_api( + api_results, + 'call_platform_api:answer_callback_query', + lambda: adapter.call_platform_api( + 'answer_callback_query', + {'callback_query_id': callback_query_id, 'text': 'EBA callback answered'}, + ), + ) + finally: + print( + 'SUMMARY', + json.dumps( + { + 'api_results': api_results, + 'api_passed': [result['name'] for result in api_results if result['ok']], + 'api_failed': [result for result in api_results if not result['ok']], + }, + ensure_ascii=False, + default=str, + ), + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--token', default=os.getenv('TELEGRAM_BOT_TOKEN', '')) + parser.add_argument('--log', default='data/temp/live_telegram_eba_probe.jsonl') + parser.add_argument('--timeout', type=int, default=90) + parser.add_argument('--group-chat-id', default=os.getenv('TELEGRAM_EBA_GROUP_CHAT_ID')) + parser.add_argument('--moderation-user-id', default=os.getenv('TELEGRAM_EBA_MODERATION_USER_ID')) + parser.add_argument('--kick-user-id', default=os.getenv('TELEGRAM_EBA_KICK_USER_ID')) + parser.add_argument('--allow-group-mutation', action='store_true') + parser.add_argument('--allow-unpin-all', action='store_true') + parser.add_argument('--allow-leave-group', action='store_true') + parser.add_argument('--callback-chat-id', default=os.getenv('TELEGRAM_EBA_CALLBACK_CHAT_ID')) + args = parser.parse_args() + + if not args.token: + raise SystemExit('Set TELEGRAM_BOT_TOKEN or pass --token') + + log_path = Path(args.log) + if log_path.exists(): + log_path.unlink() + if args.callback_chat_id: + asyncio.run(run_callback_probe(args.token, args.callback_chat_id, args.timeout)) + return + asyncio.run( + run_probe( + args.token, + log_path, + args.timeout, + args.group_chat_id, + args.moderation_user_id, + args.kick_user_id, + args.allow_group_mutation, + args.allow_unpin_all, + args.allow_leave_group, + ) + ) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_wecom_eba_probe.py b/tests/e2e/live_wecom_eba_probe.py new file mode 100644 index 000000000..2801e1fc2 --- /dev/null +++ b/tests/e2e/live_wecom_eba_probe.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.wecom.adapter import WecomAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +TINY_PNG = ( + 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +) + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + redacted = {} + for key, item in value.items(): + if key.lower() in {'secret', 'token', 'encodingaeskey', 'access_token'}: + redacted[key] = '' + else: + redacted[key] = redact(item) + return redacted + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('WECOM_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env() -> dict: + required = { + 'corpid': os.getenv('WECOM_CORPID', ''), + 'secret': os.getenv('WECOM_SECRET', ''), + 'token': os.getenv('WECOM_TOKEN', ''), + 'EncodingAESKey': os.getenv('WECOM_ENCODING_AES_KEY', ''), + } + missing = [key for key, value in required.items() if not value] + if missing: + raise RuntimeError(f'Missing required WeCom env vars for fields: {missing}') + return { + **required, + 'contacts_secret': os.getenv('WECOM_CONTACTS_SECRET', ''), + 'api_base_url': os.getenv('WECOM_API_BASE_URL', 'https://qyapi.weixin.qq.com/cgi-bin'), + } + + +async def run_probe(args: argparse.Namespace): + adapter = WecomAdapter(config_from_env(), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n') + print('WECOM_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + app = Quart(__name__) + + @app.route(args.path, methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook(args.bot_uuid, '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + try: + print(f'READY: configure WeCom callback URL to http://{args.host}:{args.port}{args.path}') + print('READY: send a real WeCom application message to the bot now.') + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + raw_event = source.source_platform_object + target_id = f'{source.chat_id}|{raw_event.agent_id}' + + if not args.skip_api: + await run_api( + api_results, + 'reply_message:text', + lambda: adapter.reply_message( + source, + platform_message.MessageChain([platform_message.Plain(text='WeCom EBA probe reply')]), + ), + ) + await run_api( + api_results, + 'send_message:text', + lambda: adapter.send_message( + 'person', + target_id, + platform_message.MessageChain([platform_message.Plain(text='WeCom EBA probe send')]), + ), + ) + await run_api( + api_results, + 'send_message:image', + lambda: adapter.send_message( + 'person', + target_id, + platform_message.MessageChain( + [ + platform_message.Plain(text='WeCom EBA probe image'), + platform_message.Image(base64=TINY_PNG), + ] + ), + ), + ) + await run_api( + api_results, + 'get_message', + lambda: adapter.get_message('private', source.chat_id, source.message_id), + ) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) + await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list()) + await run_api(api_results, 'call_platform_api:check_access_token', lambda: adapter.call_platform_api('check_access_token', {})) + await run_api( + api_results, + 'call_platform_api:get_user_info', + lambda: adapter.call_platform_api('get_user_info', {'user_id': source.sender.id}), + ) + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log_path': str(log_path), + } + print('WECOM_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + return summary + finally: + server_task.cancel() + await adapter.kill() + + +def main(): + parser = argparse.ArgumentParser(description='Live WeCom EBA adapter probe.') + parser.add_argument('--host', default='0.0.0.0') + parser.add_argument('--port', type=int, default=5312) + parser.add_argument('--path', default='/wecom/callback') + parser.add_argument('--timeout', type=int, default=180) + parser.add_argument('--bot-uuid', default='wecom-eba-live-probe') + parser.add_argument('--log', default='data/temp/wecom_eba_live_probe.jsonl') + parser.add_argument('--skip-api', action='store_true') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_wecombot_eba_probe.py b/tests/e2e/live_wecombot_eba_probe.py new file mode 100644 index 000000000..4818dba5a --- /dev/null +++ b/tests/e2e/live_wecombot_eba_probe.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: '' if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'aeskey'} else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data', 'feedback_id', 'feedback_type'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'group') and event.group is not None: + data['group'] = event.group.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('WECOMBOT_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env(enable_webhook: bool) -> dict: + config = { + 'BotId': os.getenv('WECOMBOT_BOT_ID', ''), + 'robot_name': os.getenv('WECOMBOT_ROBOT_NAME', ''), + 'enable-webhook': enable_webhook, + 'Secret': os.getenv('WECOMBOT_SECRET', ''), + 'Token': os.getenv('WECOMBOT_TOKEN', ''), + 'EncodingAESKey': os.getenv('WECOMBOT_ENCODING_AES_KEY', ''), + 'Corpid': os.getenv('WECOMBOT_CORPID', ''), + 'enable-stream-reply': os.getenv('WECOMBOT_ENABLE_STREAM_REPLY', '1') != '0', + } + required = ['BotId', 'Secret'] if not enable_webhook else ['Token', 'EncodingAESKey', 'Corpid'] + missing = [key for key in required if not config.get(key)] + if missing: + raise RuntimeError(f'Missing required WeComBot env vars for fields: {missing}') + return config + + +async def run_probe(args: argparse.Namespace): + adapter = WecomBotAdapter(config_from_env(args.webhook), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n') + print('WECOMBOT_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + run_task = None + server_task = None + if args.webhook: + app = Quart(__name__) + + @app.route(args.path, methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook(args.bot_uuid, '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + print(f'READY: configure WeComBot callback URL to http://{args.host}:{args.port}{args.path}') + else: + run_task = asyncio.create_task(adapter.run_async()) + print('READY: WeComBot WebSocket long connection started; no webhook URL is required.') + + try: + print('READY: send a real WeComBot message to the bot now.') + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + + if not args.skip_api: + await run_api( + api_results, + 'reply_message:text', + lambda: adapter.reply_message( + source, + platform_message.MessageChain([platform_message.Plain(text='WeComBot EBA probe reply')]), + ), + ) + if not args.webhook: + await run_api( + api_results, + 'send_message:text', + lambda: adapter.send_message( + 'group' if source.chat_type.value == 'group' else 'person', + source.chat_id, + platform_message.MessageChain([platform_message.Plain(text='WeComBot EBA probe send')]), + ), + ) + await run_api(api_results, 'get_message', lambda: adapter.get_message(source.chat_type.value, source.chat_id, source.message_id)) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) + if source.group: + await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(source.group.id)) + await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(source.group.id)) + await run_api(api_results, 'call_platform_api:is_websocket_mode', lambda: adapter.call_platform_api('is_websocket_mode', {})) + await run_api( + api_results, + 'call_platform_api:get_stream_session_status', + lambda: adapter.call_platform_api('get_stream_session_status', {'message_id': source.message_id}), + ) + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log_path': str(log_path), + 'mode': 'webhook' if args.webhook else 'websocket', + } + print('WECOMBOT_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + return summary + finally: + if server_task: + server_task.cancel() + if run_task: + run_task.cancel() + await adapter.kill() + + +def main(): + parser = argparse.ArgumentParser(description='Live WeComBot EBA adapter probe.') + parser.add_argument('--webhook', action='store_true', help='Use webhook mode. Default is WebSocket long connection mode.') + parser.add_argument('--host', default='0.0.0.0') + parser.add_argument('--port', type=int, default=5313) + parser.add_argument('--path', default='/wecombot/callback') + parser.add_argument('--timeout', type=int, default=180) + parser.add_argument('--bot-uuid', default='wecombot-eba-live-probe') + parser.add_argument('--log', default='data/temp/wecombot_eba_live_probe.jsonl') + parser.add_argument('--skip-api', action='store_true') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/live_wecomcs_eba_probe.py b/tests/e2e/live_wecomcs_eba_probe.py new file mode 100644 index 000000000..5a4b6f67a --- /dev/null +++ b/tests/e2e/live_wecomcs_eba_probe.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from quart import Quart, request + +from langbot.pkg.platform.adapters.wecomcs.adapter import WecomCSAdapter +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +TINY_PNG = ( + 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +) + + +class ProbeLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[info] {text}') + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[debug] {text}') + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[warning] {text}') + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + print(f'[error] {text}') + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + redacted = {} + for key, item in value.items(): + if key.lower() in {'secret', 'token', 'encodingaeskey', 'access_token'}: + redacted[key] = '' + else: + redacted[key] = redact(item) + return redacted + if isinstance(value, list): + return [redact(item) for item in value] + return value + + +def summarize_event(event: platform_events.EBAEvent) -> dict: + data = { + 'type': event.type, + 'adapter_name': event.adapter_name, + 'timestamp': event.timestamp, + } + for field in ('message_id', 'chat_id', 'chat_type', 'action', 'data'): + if hasattr(event, field): + value = getattr(event, field) + if hasattr(value, 'value'): + value = value.value + data[field] = redact(value) + if hasattr(event, 'sender') and event.sender is not None: + data['sender'] = event.sender.model_dump() + if hasattr(event, 'message_chain') and event.message_chain is not None: + data['message_chain'] = event.message_chain.model_dump() + return data + + +def record_api(results: list[dict[str, Any]], name: str, ok: bool, result: Any = None, error: Exception | None = None): + entry = {'name': name, 'ok': ok} + if result is not None: + entry['result'] = redact(result) + if error is not None: + entry['error'] = repr(error) + results.append(entry) + print('WECOMCS_EBA_API', json.dumps(entry, ensure_ascii=False, default=str)) + + +async def run_api(results: list[dict[str, Any]], name: str, func): + try: + result = await func() + record_api(results, name, True, result) + return result + except Exception as exc: + record_api(results, name, False, error=exc) + return None + + +def config_from_env() -> dict: + required = { + 'corpid': os.getenv('WECOMCS_CORPID') or os.getenv('WECOM_CORPID', ''), + 'secret': os.getenv('WECOMCS_SECRET') or os.getenv('WECOMCS_KF_SECRET', ''), + 'token': os.getenv('WECOMCS_TOKEN', ''), + 'EncodingAESKey': os.getenv('WECOMCS_ENCODING_AES_KEY', ''), + } + missing = [key for key, value in required.items() if not value] + if missing: + raise RuntimeError(f'Missing required WeComCS env vars for fields: {missing}') + return { + **required, + 'api_base_url': os.getenv('WECOMCS_API_BASE_URL', 'https://qyapi.weixin.qq.com/cgi-bin'), + } + + +async def run_probe(args: argparse.Namespace): + adapter = WecomCSAdapter(config_from_env(), ProbeLogger()) + events: list[platform_events.EBAEvent] = [] + api_results: list[dict[str, Any]] = [] + first_message = asyncio.Event() + log_path = Path(args.log) + log_path.parent.mkdir(parents=True, exist_ok=True) + + async def listener(event, adapter): + events.append(event) + with log_path.open('a', encoding='utf-8') as fp: + fp.write(json.dumps(summarize_event(event), ensure_ascii=False, default=str) + '\n') + print('WECOMCS_EBA_EVENT', json.dumps(summarize_event(event), ensure_ascii=False, default=str)) + if isinstance(event, platform_events.MessageReceivedEvent): + first_message.set() + + adapter.register_listener(platform_events.EBAEvent, listener) + + app = Quart(__name__) + + @app.route(args.path, methods=['GET', 'POST']) + async def callback(): + return await adapter.handle_unified_webhook(args.bot_uuid, '', request) + + server_task = asyncio.create_task(app.run_task(host=args.host, port=args.port)) + try: + print(f'READY: configure WeCom Customer Service callback URL to http://{args.host}:{args.port}{args.path}') + print('READY: send a real customer-service message from WeCom/WeChat UI to the bot now.') + await asyncio.wait_for(first_message.wait(), timeout=args.timeout) + + source = next(event for event in events if isinstance(event, platform_events.MessageReceivedEvent)) + + if not args.skip_api: + await run_api( + api_results, + 'reply_message:text', + lambda: adapter.reply_message( + source, + platform_message.MessageChain([platform_message.Plain(text='WeComCS EBA probe reply')]), + ), + ) + await run_api( + api_results, + 'send_message:text', + lambda: adapter.send_message( + 'person', + source.chat_id, + platform_message.MessageChain([platform_message.Plain(text='WeComCS EBA probe send')]), + ), + ) + await run_api( + api_results, + 'send_message:image', + lambda: adapter.send_message( + 'person', + source.chat_id, + platform_message.MessageChain( + [ + platform_message.Plain(text='WeComCS EBA probe image'), + platform_message.Image(base64=TINY_PNG), + ] + ), + ), + ) + await run_api(api_results, 'get_message', lambda: adapter.get_message('private', source.chat_id, source.message_id)) + await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) + await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list()) + await run_api( + api_results, + 'call_platform_api:check_access_token', + lambda: adapter.call_platform_api('check_access_token', {}), + ) + await run_api( + api_results, + 'call_platform_api:get_customer_info', + lambda: adapter.call_platform_api('get_customer_info', {'external_userid': source.sender.id}), + ) + + summary = { + 'events': [event.type for event in events], + 'api_results': api_results, + 'log_path': str(log_path), + } + print('WECOMCS_EBA_SUMMARY', json.dumps(summary, ensure_ascii=False, default=str)) + return summary + finally: + server_task.cancel() + await adapter.kill() + + +def main(): + parser = argparse.ArgumentParser(description='Live WeCom Customer Service EBA adapter probe.') + parser.add_argument('--host', default='0.0.0.0') + parser.add_argument('--port', type=int, default=5313) + parser.add_argument('--path', default='/wecomcs/callback') + parser.add_argument('--timeout', type=int, default=180) + parser.add_argument('--bot-uuid', default='wecomcs-eba-live-probe') + parser.add_argument('--log', default='data/temp/wecomcs_eba_live_probe.jsonl') + parser.add_argument('--skip-api', action='store_true') + args = parser.parse_args() + asyncio.run(run_probe(args)) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/test_agent_runner_plugin_runtime.py b/tests/e2e/test_agent_runner_plugin_runtime.py new file mode 100644 index 000000000..abeb0da8d --- /dev/null +++ b/tests/e2e/test_agent_runner_plugin_runtime.py @@ -0,0 +1,473 @@ +"""E2E tests for pluginized AgentRunner execution. + +This module starts the real LangBot backend with the plugin system enabled and +loads a deterministic AgentRunner plugin through the real SDK Plugin Runtime. +""" + +from __future__ import annotations + +import shutil +import socket +import sqlite3 +import subprocess +import tempfile +import textwrap +import time +from pathlib import Path + +import httpx +import pytest + +from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories +from tests.e2e.utils.process_manager import LangBotProcess, find_project_root + +pytestmark = pytest.mark.e2e + + +QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default' +QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa' + + +@pytest.fixture(scope='session') +def agent_runner_e2e_port(): + """Port for the AgentRunner plugin-runtime E2E process.""" + return 15310 + + +@pytest.fixture(scope='session') +def agent_runner_e2e_tmpdir(): + """Create temporary directory for AgentRunner E2E testing.""" + tmpdir = Path(tempfile.mkdtemp(prefix='langbot_agent_runner_e2e_')) + yield tmpdir + shutil.rmtree(tmpdir, ignore_errors=True) + + +def _write_qa_agent_runner_plugin(plugin_root: Path) -> None: + """Write a deterministic AgentRunner plugin used by this E2E.""" + runner_dir = plugin_root / 'components' / 'agent_runner' + runner_dir.mkdir(parents=True, exist_ok=True) + (plugin_root / 'assets').mkdir(parents=True, exist_ok=True) + (plugin_root / 'assets' / 'icon.svg').write_text( + '', + encoding='utf-8', + ) + (plugin_root / 'manifest.yaml').write_text( + textwrap.dedent( + """ + apiVersion: langbot/v1 + kind: Plugin + metadata: + author: e2e + name: agent-runner-qa + version: 0.1.0 + label: + en_US: AgentRunner QA + zh_Hans: AgentRunner QA + description: + en_US: Deterministic AgentRunner E2E probe. + zh_Hans: 确定性的 AgentRunner E2E 探针。 + icon: assets/icon.svg + spec: + version: 0.1.0 + config: [] + components: + AgentRunner: + fromDirs: + - path: components/agent_runner/ + pages: [] + execution: + python: + path: main.py + attr: AgentRunnerQAPlugin + """ + ).strip() + + '\n', + encoding='utf-8', + ) + (plugin_root / 'main.py').write_text( + textwrap.dedent( + """ + from __future__ import annotations + + from langbot_plugin.api.definition.plugin import BasePlugin + + + class AgentRunnerQAPlugin(BasePlugin): + async def initialize(self) -> None: + pass + """ + ).strip() + + '\n', + encoding='utf-8', + ) + (runner_dir / 'default.yaml').write_text( + textwrap.dedent( + """ + apiVersion: langbot/v1 + kind: AgentRunner + metadata: + name: default + label: + en_US: QA Echo Runner + zh_Hans: QA Echo Runner + description: + en_US: Echoes input and exercises run-scoped state APIs. + zh_Hans: 回显输入并验证运行级状态 API。 + spec: + config: [] + capabilities: + streaming: false + permissions: {} + execution: + python: + path: default.py + attr: DefaultAgentRunner + """ + ).strip() + + '\n', + encoding='utf-8', + ) + (runner_dir / 'default.py').write_text( + textwrap.dedent( + """ + from __future__ import annotations + + from typing import AsyncGenerator + + from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner + from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext + from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult + from langbot_plugin.api.entities.builtin.provider.message import Message + + + class DefaultAgentRunner(AgentRunner): + async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]: + text = ctx.input.to_text() + yield AgentRunResult.message_completed( + ctx.run_id, + Message(role='assistant', content=f'e2e echo: {text}'), + ) + yield AgentRunResult.state_updated( + ctx.run_id, + 'e2e.echo_count', + {'count': 1}, + scope='conversation', + ) + yield AgentRunResult.run_completed(ctx.run_id, finish_reason='stop') + """ + ).strip() + + '\n', + encoding='utf-8', + ) + + +def _free_port() -> int: + """Reserve a currently-free localhost TCP port for this E2E process.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('127.0.0.1', 0)) + return int(sock.getsockname()[1]) + + +@pytest.fixture(scope='session') +def agent_runner_runtime_ports(): + """Control/debug ports for the standalone plugin runtime.""" + control_port = _free_port() + debug_port = _free_port() + while debug_port == control_port: + debug_port = _free_port() + return control_port, debug_port + + +@pytest.fixture(scope='session') +def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, agent_runner_runtime_ports): + """Create a plugin-enabled config and deterministic AgentRunner fixture.""" + config_path = create_minimal_config(agent_runner_e2e_tmpdir, port=agent_runner_e2e_port) + create_test_directories(agent_runner_e2e_tmpdir) + + import yaml + + with open(config_path, encoding='utf-8') as f: + config = yaml.safe_load(f) + config['api']['global_api_key'] = 'e2e-agent-runner-key' + runtime_control_port, _runtime_debug_port = agent_runner_runtime_ports + config['plugin']['enable'] = True + config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws' + config['plugin']['enable_marketplace'] = False + config['box']['enabled'] = False + config['system']['jwt']['secret'] = 'e2e-agent-runner-secret-key' + with open(config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(config, f, default_flow_style=False) + + _write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME) + return config_path + + +@pytest.fixture(scope='session') +def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_ports): + """Start the real SDK plugin runtime over WebSocket.""" + control_port, debug_port = agent_runner_runtime_ports + stdout_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log' + stderr_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log' + stdout_file = open(stdout_path, 'wb') + stderr_file = open(stderr_path, 'wb') + proc = subprocess.Popen( + [ + str(find_project_root() / '.venv' / 'bin' / 'python'), + '-m', + 'langbot_plugin.cli.__init__', + 'rt', + '--ws-control-port', + str(control_port), + '--ws-debug-port', + str(debug_port), + ], + cwd=agent_runner_e2e_tmpdir, + stdout=stdout_file, + stderr=stderr_file, + start_new_session=True, + ) + yield proc + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + stdout_file.close() + stderr_file.close() + + +@pytest.fixture(scope='session') +def agent_runner_langbot_process( + agent_runner_e2e_config_path, + agent_runner_e2e_port, + agent_runner_e2e_tmpdir, + agent_runner_runtime_process, +): + """Start real LangBot with plugin runtime enabled.""" + project_root = find_project_root() + proc = LangBotProcess( + project_root=project_root, + work_dir=agent_runner_e2e_tmpdir, + port=agent_runner_e2e_port, + timeout=180, + debug=True, + cli_args=['--standalone-runtime'], + ) + + success = proc.start() + if not success: + stdout, stderr = proc.get_logs() + pytest.fail(f'LangBot failed to start with AgentRunner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}') + + yield proc + + proc.stop() + + +@pytest.fixture +def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process): + """HTTP client for the AgentRunner E2E backend.""" + with httpx.Client( + base_url=f'http://127.0.0.1:{agent_runner_e2e_port}', + timeout=90.0, + trust_env=False, + ) as client: + yield client + + +def _init_and_auth(client: httpx.Client) -> str: + """Initialize the test admin user and return a bearer token.""" + init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'}) + assert init_resp.status_code == 200 + assert init_resp.json()['code'] in [0, 1] + + auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'}) + assert auth_resp.status_code == 200 + payload = auth_resp.json() + assert payload['code'] == 0 + return payload['data']['token'] + + +def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process): + """Pipeline metadata should include the real runtime-discovered QA runner.""" + token = _init_and_auth(agent_runner_client) + start = time.time() + while time.time() - start < 60: + response = agent_runner_client.get( + '/api/v1/pipelines/_/metadata', + headers={'Authorization': f'Bearer {token}'}, + ) + + assert response.status_code == 200 + data = response.json() + assert data['code'] == 0 + metadata_groups = data['data']['configs'] + ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai') + + runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner') + runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id') + option_names = {option['name'] for option in runner_select['options']} + if QA_RUNNER_ID in option_names: + return + time.sleep(2) + + assert QA_RUNNER_ID in option_names + +def test_host_orchestrator_runs_agent_runner_and_records_ledger( + agent_runner_e2e_config_path, + agent_runner_e2e_tmpdir, + agent_runner_runtime_process, +): + """The Host orchestrator should run the pluginized runner and persist run side effects.""" + import asyncio + import os + + from langbot.pkg.agent.runner.host_models import ( + AgentBinding, + AgentEventEnvelope, + BindingScope, + DeliveryPolicy, + StatePolicy, + ) + from langbot.pkg.core import boot + from langbot.pkg.utils import platform as platform_utils + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + + async def _run_probe(): + previous_cwd = Path.cwd() + previous_standalone_runtime = platform_utils.standalone_runtime + os.chdir(agent_runner_e2e_tmpdir) + platform_utils.standalone_runtime = True + ap = None + try: + ap = await boot.make_app(asyncio.get_running_loop()) + for _ in range(60): + handler = getattr(ap.plugin_connector, 'handler', None) + if handler is not None: + await handler.ping() + break + await asyncio.sleep(1) + else: + raise AssertionError('Plugin runtime did not connect') + + for _ in range(60): + runners = await ap.agent_runner_registry.list_runners(use_cache=False) + if any(runner.id == QA_RUNNER_ID for runner in runners): + break + await asyncio.sleep(1) + else: + raise AssertionError(f'{QA_RUNNER_ID} was not discovered') + + event = AgentEventEnvelope( + event_id='e2e-orchestrator-event-001', + event_type='message.received', + source='api', + conversation_id='e2e-conversation', + thread_id='e2e-thread', + actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'), + subject=SubjectContext(subject_type='chat', subject_id='chat-001'), + input=AgentInput(text='hello from orchestrator e2e'), + delivery=DeliveryContext(surface='e2e'), + ) + binding = AgentBinding( + binding_id='e2e-binding', + scope=BindingScope(scope_type='global'), + runner_id=QA_RUNNER_ID, + state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']), + delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), + ) + return [message async for message in ap.agent_run_orchestrator.run(event, binding)] + finally: + if ap is not None: + ap.dispose() + platform_utils.standalone_runtime = previous_standalone_runtime + os.chdir(previous_cwd) + + messages = asyncio.run(_run_probe()) + + assert len(messages) == 1 + assert messages[0].role == 'assistant' + assert messages[0].content == 'e2e echo: hello from orchestrator e2e' + + db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'" + ).fetchone() + assert run_row == ('completed', QA_RUNNER_ID) + + event_types = { + row[0] + for row in conn.execute( + "SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')" + ).fetchall() + } + assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types) + + state_row = conn.execute( + "SELECT value_json FROM agent_runner_state WHERE state_key = 'e2e.echo_count'" + ).fetchone() + assert state_row is not None + assert '"count": 1' in state_row[0] + finally: + conn.close() + + +def test_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process): + """The Host debug surface should invoke the QA runner through the real Plugin Runtime.""" + token = _init_and_auth(agent_runner_client) + start = time.time() + while time.time() - start < 60: + metadata_response = agent_runner_client.get( + '/api/v1/pipelines/_/metadata', + headers={'Authorization': f'Bearer {token}'}, + ) + assert metadata_response.status_code == 200 + metadata = metadata_response.json()['data']['configs'] + ai_metadata = next(group for group in metadata if group.get('name') == 'ai') + runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner') + runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id') + if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}: + break + time.sleep(2) + else: + pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent') + + response = agent_runner_client.post( + '/api/v1/system/debug/plugin/action', + headers={'Authorization': f'Bearer {token}'}, + json={ + 'action': 'run_agent', + 'timeout': 60, + 'data': { + 'plugin_author': 'e2e', + 'plugin_name': 'agent-runner-qa', + 'runner_name': 'default', + 'context': { + 'run_id': 'e2e-run-001', + 'trigger': {'type': 'message.received'}, + 'event': { + 'event_id': 'e2e-event-001', + 'event_type': 'message.received', + 'source': 'api', + }, + 'input': {'text': 'hello from real e2e'}, + 'delivery': {'surface': 'e2e'}, + 'resources': {}, + 'runtime': {}, + }, + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['code'] == 0 + result = payload['data'] + assert result['type'] == 'message.completed', result + assert result['data']['message']['role'] == 'assistant' + assert result['data']['message']['content'] == 'e2e echo: hello from real e2e' diff --git a/tests/e2e/test_local_agent_runner_fake_provider.py b/tests/e2e/test_local_agent_runner_fake_provider.py new file mode 100644 index 000000000..0a4e9bc09 --- /dev/null +++ b/tests/e2e/test_local_agent_runner_fake_provider.py @@ -0,0 +1,885 @@ +"""E2E coverage for the official Local Agent runner with fake Host resources. + +These tests start the real LangBot application and the real SDK Plugin Runtime, +load the sibling ``langbot-local-agent`` plugin, and verify Local Agent paths +that must cross Host run-scoped APIs without calling any external provider. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import socket +import sqlite3 +import subprocess +import tempfile +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories +from tests.e2e.utils.process_manager import find_project_root + +pytestmark = pytest.mark.e2e + + +LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' +FAKE_PROVIDER_UUID = 'e2e-fake-provider' +FAKE_MODEL_UUID = 'e2e-fake-local-agent-model' +LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent' +E2E_TOOL_NAME = 'e2e_lookup' +E2E_KB_UUID = 'e2e-kb-local-agent' + + +def _free_port() -> int: + """Reserve a currently-free localhost TCP port for this E2E process.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('127.0.0.1', 0)) + return int(sock.getsockname()[1]) + + +def _local_agent_repo() -> Path: + """Return the sibling local-agent repository used by this workspace E2E.""" + project_root = find_project_root() + return project_root.parent / 'langbot-local-agent' + + +def _copy_local_agent_plugin(tmpdir: Path) -> None: + """Copy the sibling Local Agent plugin into the temporary LangBot data dir.""" + local_agent_src = _local_agent_repo() + if not (local_agent_src / 'manifest.yaml').exists(): + pytest.skip(f'local-agent repository not found at {local_agent_src}') + + plugin_dst = tmpdir / 'data' / 'plugins' / LOCAL_AGENT_PLUGIN_DIRNAME + ignore = shutil.ignore_patterns( + '.git', + '.venv', + '__pycache__', + '.pytest_cache', + '.ruff_cache', + 'build', + 'dist', + ) + shutil.copytree(local_agent_src, plugin_dst, ignore=ignore) + + +def _content_text(content: Any) -> str: + """Flatten provider message content into text for assertions.""" + if content is None: + return '' + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + text = item.get('text') if isinstance(item, dict) else getattr(item, 'text', None) + if text: + parts.append(str(text)) + return ''.join(parts) + return str(content) + + +def _message_text(message: Any) -> str: + """Flatten a provider message into text for assertions.""" + return _content_text(getattr(message, 'content', None)) + + +def _invoke_payload_texts(fake_requester: Any) -> list[list[str]]: + """Return model-facing text for every fake LLM invocation.""" + payloads: list[list[str]] = [] + for payload in fake_requester._invoke_payloads: + payloads.append([_message_text(message) for message in payload['messages']]) + return payloads + + +def _event( + *, + event_id: str, + conversation_id: str, + text: str, + thread_id: str = 'e2e-local-agent-thread', +): + """Build an AgentRunner event envelope for Local Agent E2E probes.""" + from langbot.pkg.agent.runner.host_models import AgentEventEnvelope + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + + return AgentEventEnvelope( + event_id=event_id, + event_type='message.received', + source='api', + conversation_id=conversation_id, + thread_id=thread_id, + actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'), + subject=SubjectContext(subject_type='chat', subject_id='chat-001'), + input=AgentInput(text=text), + delivery=DeliveryContext(surface='e2e', supports_streaming=False), + ) + + +def _binding( + *, + binding_id: str = 'e2e-local-agent-binding', + runner_config: dict[str, Any] | None = None, + allowed_tool_names: list[str] | None = None, + allowed_kb_uuids: list[str] | None = None, +): + """Build a Local Agent binding with fake model access.""" + from langbot.pkg.agent.runner.host_models import ( + AgentBinding, + BindingScope, + DeliveryPolicy, + ResourcePolicy, + StatePolicy, + ) + + config = { + 'model': {'primary': FAKE_MODEL_UUID, 'fallbacks': []}, + 'timeout': 60, + 'prompt': [{'role': 'system', 'content': 'You are a concise test assistant.'}], + 'knowledge-bases': [], + 'context-window-tokens': 8192, + 'context-reserve-tokens': 1024, + 'context-keep-recent-tokens': 1000, + 'context-summary-tokens': 500, + } + if runner_config: + config.update(runner_config) + + return AgentBinding( + binding_id=binding_id, + scope=BindingScope(scope_type='global'), + runner_id=LOCAL_AGENT_RUNNER_ID, + runner_config=config, + resource_policy=ResourcePolicy( + allowed_model_uuids=[FAKE_MODEL_UUID], + allowed_tool_names=allowed_tool_names, + allowed_kb_uuids=allowed_kb_uuids, + ), + state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']), + delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), + ) + + +class _FakeToolManager: + """Deterministic tool manager used behind the real Host CALL_TOOL action.""" + + def __init__(self): + self.calls: list[dict[str, Any]] = [] + + async def get_tool_schema(self, tool_name: str): + if tool_name != E2E_TOOL_NAME: + return None, None + return ( + 'Lookup a deterministic E2E value.', + { + 'type': 'object', + 'properties': { + 'query': {'type': 'string'}, + }, + 'required': ['query'], + }, + ) + + async def get_tool_detail(self, tool_name: str): + description, parameters = await self.get_tool_schema(tool_name) + if parameters is None: + return None + return {'name': tool_name, 'description': description, 'parameters': parameters} + + async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None): + del query + self.calls.append({'name': name, 'parameters': dict(parameters)}) + return { + 'value': f"tool-result:{parameters.get('query')}", + 'source': 'fake-tool-manager', + } + + +class _FakeKnowledgeBase: + """Minimal KB object used behind the real Host RETRIEVE_KNOWLEDGE action.""" + + def __init__(self): + self.knowledge_base_entity = SimpleNamespace(kb_type='fake') + self.retrieve_calls: list[dict[str, Any]] = [] + + def get_uuid(self) -> str: + return E2E_KB_UUID + + def get_name(self) -> str: + return 'E2E Fake KB' + + async def retrieve(self, query_text: str, settings: dict[str, Any]): + self.retrieve_calls.append({'query_text': query_text, 'settings': settings}) + return [ + SimpleNamespace( + content='RAG_SENTINEL Local Agent retrieved this deterministic chunk.', + metadata={'source': 'fake-kb'}, + id='fake-kb-chunk-1', + score=0.99, + model_dump=lambda mode='json': { + 'content': 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.', + 'metadata': {'source': 'fake-kb'}, + 'id': 'fake-kb-chunk-1', + 'score': 0.99, + }, + ) + ] + + +class _FakeRagManager: + """Deterministic RAG manager used by resource builder and retrieval action.""" + + def __init__(self, kb: _FakeKnowledgeBase): + self.kb = kb + self.knowledge_bases = {E2E_KB_UUID: kb} + + async def get_knowledge_base_by_uuid(self, kb_uuid: str): + if kb_uuid == E2E_KB_UUID: + return self.kb + return None + + +@pytest.fixture(scope='session') +def local_agent_e2e_tmpdir(): + """Create temporary directory for Local Agent E2E testing.""" + tmpdir = Path(tempfile.mkdtemp(prefix='langbot_local_agent_e2e_')) + yield tmpdir + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture(scope='session') +def local_agent_e2e_port() -> int: + """HTTP port for the real LangBot app used by this E2E.""" + return _free_port() + + +@pytest.fixture(scope='session') +def local_agent_runtime_ports() -> tuple[int, int]: + """Control/debug ports for the standalone plugin runtime.""" + control_port = _free_port() + debug_port = _free_port() + while debug_port == control_port: + debug_port = _free_port() + return control_port, debug_port + + +@pytest.fixture(scope='session') +def local_agent_e2e_config_path(local_agent_e2e_tmpdir, local_agent_e2e_port, local_agent_runtime_ports): + """Create a plugin-enabled config and install the Local Agent plugin fixture.""" + config_path = create_minimal_config(local_agent_e2e_tmpdir, port=local_agent_e2e_port) + create_test_directories(local_agent_e2e_tmpdir) + + import yaml + + with open(config_path, encoding='utf-8') as f: + config = yaml.safe_load(f) + runtime_control_port, _runtime_debug_port = local_agent_runtime_ports + config['api']['global_api_key'] = 'e2e-local-agent-key' + config['plugin']['enable'] = True + config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws' + config['plugin']['enable_marketplace'] = False + config['box']['enabled'] = False + config['system']['jwt']['secret'] = 'e2e-local-agent-secret-key' + with open(config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(config, f, default_flow_style=False) + + _copy_local_agent_plugin(local_agent_e2e_tmpdir) + return config_path + + +@pytest.fixture(scope='session') +def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_ports, local_agent_e2e_config_path): + """Start the real SDK plugin runtime over WebSocket.""" + del local_agent_e2e_config_path + control_port, debug_port = local_agent_runtime_ports + stdout_path = local_agent_e2e_tmpdir / 'plugin-runtime.stdout.log' + stderr_path = local_agent_e2e_tmpdir / 'plugin-runtime.stderr.log' + stdout_file = open(stdout_path, 'wb') + stderr_file = open(stderr_path, 'wb') + proc = subprocess.Popen( + [ + str(find_project_root() / '.venv' / 'bin' / 'python'), + '-m', + 'langbot_plugin.cli.__init__', + 'rt', + '--ws-control-port', + str(control_port), + '--ws-debug-port', + str(debug_port), + ], + cwd=local_agent_e2e_tmpdir, + stdout=stdout_file, + stderr=stderr_file, + start_new_session=True, + ) + yield proc + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + stdout_file.close() + stderr_file.close() + + +def _inject_fake_llm_model(ap) -> Any: + """Register a runtime-only fake model that supports count_tokens/invoke.""" + from langbot.pkg.entity.persistence import model as persistence_model + from langbot.pkg.provider.modelmgr import requester, token + from tests.unit_tests.provider.conftest import FakeProviderAPIRequester + + provider_entity = persistence_model.ModelProvider( + uuid=FAKE_PROVIDER_UUID, + name='E2E Fake Provider', + requester='fake-requester', + base_url='https://fake.invalid', + api_keys=['fake-key'], + ) + fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url}) + runtime_provider = requester.RuntimeProvider( + provider_entity=provider_entity, + token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys), + requester=fake_requester, + ) + runtime_model = requester.RuntimeLLMModel( + model_entity=persistence_model.LLMModel( + uuid=FAKE_MODEL_UUID, + name=FAKE_MODEL_UUID, + provider_uuid=provider_entity.uuid, + abilities=['func_call'], + context_length=8192, + extra_args={}, + ), + provider=runtime_provider, + ) + ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider + ap.model_mgr.llm_models.append(runtime_model) + return fake_requester + + +def _scripted_tool_call( + tool_name: str = E2E_TOOL_NAME, + *, + call_id: str = 'call-e2e-lookup', + query: str = 'alpha', +): + """Build an assistant message requesting a deterministic tool call.""" + from langbot_plugin.api.entities.builtin.provider import message as provider_message + + return provider_message.Message( + role='assistant', + content='', + tool_calls=[ + provider_message.ToolCall( + id=call_id, + type='function', + function=provider_message.FunctionCall( + name=tool_name, + arguments=json.dumps({'query': query}, ensure_ascii=False), + ), + ) + ], + ) + + +async def _boot_local_agent_app(tmpdir: Path): + """Boot LangBot and wait until the Local Agent runner is discoverable.""" + from langbot.pkg.core import boot + + ap = await boot.make_app(asyncio.get_running_loop()) + for _ in range(60): + handler = getattr(ap.plugin_connector, 'handler', None) + if handler is not None: + await handler.ping() + break + await asyncio.sleep(1) + else: + raise AssertionError(f'Plugin runtime did not connect; tmpdir={tmpdir}') + + for _ in range(60): + runners = await ap.agent_runner_registry.list_runners(use_cache=False) + if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners): + break + await asyncio.sleep(1) + else: + raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered') + + return ap + + +def _run_local_agent_probe(tmpdir: Path, probe): + """Run one Local Agent probe inside the temporary LangBot app.""" + from langbot.pkg.utils import platform as platform_utils + + async def _run(): + previous_cwd = Path.cwd() + previous_standalone_runtime = platform_utils.standalone_runtime + os.chdir(tmpdir) + platform_utils.standalone_runtime = True + ap = None + try: + ap = await _boot_local_agent_app(tmpdir) + return await probe(ap) + finally: + if ap is not None: + ap.dispose() + platform_utils.standalone_runtime = previous_standalone_runtime + os.chdir(previous_cwd) + + return asyncio.run(_run()) + + +def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Local Agent should execute through Host APIs with a token-free fake provider.""" + del local_agent_e2e_config_path, local_agent_runtime_process + + async def _run_probe(ap): + fake_requester = _inject_fake_llm_model(ap) + event = _event( + event_id='e2e-local-agent-event-001', + conversation_id='e2e-local-agent-conversation', + text='Say pong through the fake provider.', + ) + messages = [message async for message in ap.agent_run_orchestrator.run(event, _binding())] + return messages, list(fake_requester._count_tokens_payloads) + + messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) + + assert len(messages) == 1 + assert messages[0].role == 'assistant' + assert _content_text(messages[0].content) == 'Fake LLM response' + assert token_payloads + flattened_token_payloads = [item for payload in token_payloads for item in payload] + assert any(item.get('role') == 'system' for item in flattened_token_payloads) + assert any('Say pong through the fake provider.' in item.get('content', '') for item in flattened_token_payloads) + + db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT run_id, status, runner_id, status_reason FROM agent_run WHERE event_id = ?", + ('e2e-local-agent-event-001',), + ).fetchone() + assert run_row is not None + run_id, status, runner_id, status_reason = run_row + assert status == 'completed' + assert runner_id == LOCAL_AGENT_RUNNER_ID + assert status_reason == 'stop' + + event_rows = conn.execute( + 'SELECT sequence, type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence', + (run_id,), + ).fetchall() + event_types = [row[1] for row in event_rows] + assert event_types == ['message.completed', 'run.completed'] + assert 'Fake LLM response' in event_rows[0][2] + + transcript_rows = conn.execute( + 'SELECT role, content, run_id, runner_id FROM transcript WHERE conversation_id = ? ORDER BY seq', + ('e2e-local-agent-conversation',), + ).fetchall() + assert [row[0] for row in transcript_rows] == ['user', 'assistant'] + assert transcript_rows[0][1] == 'Say pong through the fake provider.' + assert transcript_rows[1][1] == 'Fake LLM response' + assert transcript_rows[1][2] == run_id + assert transcript_rows[1][3] == LOCAL_AGENT_RUNNER_ID + finally: + conn.close() + + +def test_local_agent_runner_executes_authorized_tool_loop_through_host_action( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Local Agent should execute a model tool call through Host CALL_TOOL and finish.""" + del local_agent_e2e_config_path, local_agent_runtime_process + + async def _run_probe(ap): + fake_requester = _inject_fake_llm_model(ap) + fake_requester.queue_llm_responses( + _scripted_tool_call(), + 'Tool loop final answer after tool-result:alpha', + ) + tool_mgr = _FakeToolManager() + ap.tool_mgr = tool_mgr + + event = _event( + event_id='e2e-local-agent-tool-event-001', + conversation_id='e2e-local-agent-tool-conversation', + text='Use the e2e lookup tool before answering.', + ) + binding = _binding( + binding_id='e2e-local-agent-tool-binding', + allowed_tool_names=[E2E_TOOL_NAME], + runner_config={ + 'max-tool-iterations': 2, + 'tool-execution-mode': 'serial', + }, + ) + messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + return messages, tool_mgr.calls, _invoke_payload_texts(fake_requester) + + messages, tool_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) + + assert len(messages) == 1 + assert _content_text(messages[0].content) == 'Tool loop final answer after tool-result:alpha' + assert tool_calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}] + assert any('tool-result:alpha' in text for text in invoke_payload_texts[-1]) + + db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?", + ('e2e-local-agent-tool-event-001',), + ).fetchone() + assert run_row is not None + run_id, status, status_reason = run_row + assert status == 'completed' + assert status_reason == 'stop' + + event_rows = conn.execute( + 'SELECT type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence', + (run_id,), + ).fetchall() + event_types = [row[0] for row in event_rows] + assert event_types == [ + 'tool.call.started', + 'tool.call.completed', + 'message.completed', + 'run.completed', + ] + assert E2E_TOOL_NAME in event_rows[0][1] + assert 'tool-result:alpha' in event_rows[1][1] + finally: + conn.close() + + +def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Local Agent should retrieve configured KB context through Host RETRIEVE_KNOWLEDGE.""" + del local_agent_e2e_config_path, local_agent_runtime_process + + async def _run_probe(ap): + fake_requester = _inject_fake_llm_model(ap) + fake_requester.queue_llm_responses('RAG final answer with RAG_SENTINEL') + fake_kb = _FakeKnowledgeBase() + ap.rag_mgr = _FakeRagManager(fake_kb) + + event = _event( + event_id='e2e-local-agent-rag-event-001', + conversation_id='e2e-local-agent-rag-conversation', + text='Answer with the retrieved RAG sentinel.', + ) + binding = _binding( + binding_id='e2e-local-agent-rag-binding', + allowed_kb_uuids=[E2E_KB_UUID], + runner_config={ + 'knowledge-bases': [E2E_KB_UUID], + 'retrieval-top-k': 1, + }, + ) + messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + return messages, fake_kb.retrieve_calls, _invoke_payload_texts(fake_requester) + + messages, retrieve_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) + + assert len(messages) == 1 + assert _content_text(messages[0].content) == 'RAG final answer with RAG_SENTINEL' + assert retrieve_calls == [ + { + 'query_text': 'Answer with the retrieved RAG sentinel.', + 'settings': {'top_k': 1, 'filters': {}}, + } + ] + assert any( + 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text + for payload_texts in invoke_payload_texts + for text in payload_texts + ) + + db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT run_id, status FROM agent_run WHERE event_id = ?", + ('e2e-local-agent-rag-event-001',), + ).fetchone() + assert run_row is not None + run_id, status = run_row + assert status == 'completed' + event_types = [ + row[0] + for row in conn.execute( + 'SELECT type FROM agent_run_event WHERE run_id = ? ORDER BY sequence', + (run_id,), + ) + ] + assert event_types == ['message.completed', 'run.completed'] + finally: + conn.close() + + +def test_local_agent_runner_compacts_history_and_persists_checkpoint( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Local Agent should compact old Host history and write conversation checkpoint state.""" + del local_agent_e2e_config_path, local_agent_runtime_process + + async def _run_probe(ap): + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + fake_requester = _inject_fake_llm_model(ap) + fake_requester.queue_llm_responses( + 'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL', + 'Compaction final answer', + ) + + store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + for index in range(12): + await store.append_transcript( + transcript_id=None, + event_id=f'e2e-local-agent-history-{index}', + conversation_id='e2e-local-agent-compaction-conversation', + role='user' if index % 2 == 0 else 'assistant', + content=( + f'HIST_SENTINEL-{index} ' + 'This is intentionally long deterministic history for compaction. ' * 10 + ), + thread_id='e2e-local-agent-thread', + item_type='message', + ) + + event = _event( + event_id='e2e-local-agent-compaction-event-001', + conversation_id='e2e-local-agent-compaction-conversation', + text='Use compacted context and answer.', + ) + binding = _binding( + binding_id='e2e-local-agent-compaction-binding', + runner_config={ + 'context-window-tokens': 900, + 'context-reserve-tokens': 300, + 'context-keep-recent-tokens': 160, + 'context-summary-tokens': 240, + 'context-history-fetch-limit': 20, + }, + ) + messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + return messages, _invoke_payload_texts(fake_requester), fake_requester._invoke_count + + messages, invoke_payload_texts, invoke_count = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe) + + assert len(messages) == 1 + assert _content_text(messages[0].content) == 'Compaction final answer' + assert invoke_count == 2 + assert any('HIST_SENTINEL' in text for text in invoke_payload_texts[0]) + assert any('SUMMARY_SENTINEL compacted older history' in text for text in invoke_payload_texts[-1]) + + db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT run_id, status FROM agent_run WHERE event_id = ?", + ('e2e-local-agent-compaction-event-001',), + ).fetchone() + assert run_row is not None + run_id, status = run_row + assert status == 'completed' + + event_types = [ + row[0] + for row in conn.execute( + 'SELECT type FROM agent_run_event WHERE run_id = ? ORDER BY sequence', + (run_id,), + ) + ] + assert event_types == ['message.completed', 'run.completed'] + + state_row = conn.execute( + "SELECT value_json FROM agent_runner_state WHERE state_key = 'runner.compaction.checkpoint'" + ).fetchone() + assert state_row is not None + checkpoint = json.loads(state_row[0]) + assert checkpoint['schema_version'] == 'langbot.local_agent.compaction_checkpoint.v1' + assert 'SUMMARY_SENTINEL compacted older history' in checkpoint['summary'] + assert checkpoint['conversation_id'] == 'e2e-local-agent-compaction-conversation' + assert checkpoint['covers_until'] + assert checkpoint['tokens_before'] > 600 + finally: + conn.close() + + +def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop( + local_agent_e2e_tmpdir, + local_agent_e2e_config_path, + local_agent_runtime_process, +): + """Local Agent should preserve RAG, compressed history, and multi-turn tool results together.""" + del local_agent_e2e_config_path, local_agent_runtime_process + + async def _run_probe(ap): + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + fake_requester = _inject_fake_llm_model(ap) + + async def scripted_response(**kwargs): + messages = kwargs['messages'] + text = '\n'.join(_message_text(message) for message in messages) + if 'context summarization assistant' in text or '' in text: + return 'SUMMARY_COMBO compacted older history including HIST_COMBO_SENTINEL and RAG_TOOL_COMBO_GOAL' + if 'tool-result:alpha' not in text: + return _scripted_tool_call(call_id='call-combo-alpha', query='alpha') + if 'tool-result:beta' not in text: + return _scripted_tool_call(call_id='call-combo-beta', query='beta') + assert 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text + assert 'SUMMARY_COMBO compacted older history' in text + assert 'tool-result:alpha' in text + assert 'tool-result:beta' in text + assert 'current combo request must survive' in text + return 'COMBO_FINAL RAG_SENTINEL HIST_COMBO_SENTINEL tool-result:alpha tool-result:beta' + + fake_requester.queue_llm_responses(*(scripted_response for _ in range(20))) + tool_mgr = _FakeToolManager() + fake_kb = _FakeKnowledgeBase() + ap.tool_mgr = tool_mgr + ap.rag_mgr = _FakeRagManager(fake_kb) + + store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + for index in range(16): + await store.append_transcript( + transcript_id=None, + event_id=f'e2e-local-agent-combo-history-{index}', + conversation_id='e2e-local-agent-combo-conversation', + role='user' if index % 2 == 0 else 'assistant', + content=( + f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL ' + 'This old message intentionally creates pressure for combo compaction. ' * 8 + ), + thread_id='e2e-local-agent-thread', + item_type='message', + ) + + event = _event( + event_id='e2e-local-agent-combo-event-001', + conversation_id='e2e-local-agent-combo-conversation', + text='current combo request must survive; use RAG and tools before answering.', + ) + binding = _binding( + binding_id='e2e-local-agent-combo-binding', + allowed_tool_names=[E2E_TOOL_NAME], + allowed_kb_uuids=[E2E_KB_UUID], + runner_config={ + 'knowledge-bases': [E2E_KB_UUID], + 'retrieval-top-k': 1, + 'max-tool-iterations': 4, + 'tool-execution-mode': 'serial', + 'context-window-tokens': 950, + 'context-reserve-tokens': 300, + 'context-keep-recent-tokens': 140, + 'context-summary-tokens': 260, + 'context-history-fetch-limit': 25, + }, + ) + messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)] + return ( + messages, + tool_mgr.calls, + fake_kb.retrieve_calls, + _invoke_payload_texts(fake_requester), + fake_requester._invoke_count, + ) + + messages, tool_calls, retrieve_calls, invoke_payload_texts, invoke_count = _run_local_agent_probe( + local_agent_e2e_tmpdir, + _run_probe, + ) + + assert len(messages) == 1 + assert _content_text(messages[0].content) == ( + 'COMBO_FINAL RAG_SENTINEL HIST_COMBO_SENTINEL tool-result:alpha tool-result:beta' + ) + assert tool_calls == [ + {'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}, + {'name': E2E_TOOL_NAME, 'parameters': {'query': 'beta'}}, + ] + assert retrieve_calls == [ + { + 'query_text': 'current combo request must survive; use RAG and tools before answering.', + 'settings': {'top_k': 1, 'filters': {}}, + } + ] + assert invoke_count >= 4 + assert any( + 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text + for payload_texts in invoke_payload_texts + for text in payload_texts + ) + assert any('SUMMARY_COMBO compacted older history' in text for text in invoke_payload_texts[-1]) + assert any('tool-result:alpha' in text for text in invoke_payload_texts[-1]) + assert any('tool-result:beta' in text for text in invoke_payload_texts[-1]) + assert any('current combo request must survive' in text for text in invoke_payload_texts[-1]) + + db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db' + conn = sqlite3.connect(str(db_path)) + try: + run_row = conn.execute( + "SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?", + ('e2e-local-agent-combo-event-001',), + ).fetchone() + assert run_row is not None + run_id, status, status_reason = run_row + assert status == 'completed' + assert status_reason == 'stop' + + event_rows = conn.execute( + 'SELECT type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence', + (run_id,), + ).fetchall() + event_types = [row[0] for row in event_rows] + assert event_types == [ + 'tool.call.started', + 'tool.call.completed', + 'tool.call.started', + 'tool.call.completed', + 'message.completed', + 'run.completed', + ] + assert 'tool-result:alpha' in event_rows[1][1] + assert 'tool-result:beta' in event_rows[3][1] + assert 'COMBO_FINAL' in event_rows[4][1] + + state_rows = conn.execute( + "SELECT value_json FROM agent_runner_state WHERE state_key = 'runner.compaction.checkpoint'" + ).fetchall() + checkpoints = [json.loads(row[0]) for row in state_rows] + checkpoint = next( + ( + item + for item in checkpoints + if item.get('conversation_id') == 'e2e-local-agent-combo-conversation' + ), + None, + ) + assert checkpoint is not None + assert checkpoint['conversation_id'] == 'e2e-local-agent-combo-conversation' + assert 'SUMMARY_COMBO compacted older history' in checkpoint['summary'] + finally: + conn.close() diff --git a/tests/e2e/utils/config_factory.py b/tests/e2e/utils/config_factory.py index 2df35b903..a047ff22a 100644 --- a/tests/e2e/utils/config_factory.py +++ b/tests/e2e/utils/config_factory.py @@ -152,6 +152,27 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path: 'delete_batch_size': 1000, }, }, + 'box': { + 'enabled': False, + 'backend': 'local', + 'runtime': { + 'endpoint': '', + }, + 'local': { + 'profile': 'default', + 'image': '', + 'host_root': str(tmpdir / 'box'), + 'default_workspace': '', + 'skills_root': 'skills', + 'allowed_mount_roots': [str(tmpdir / 'box'), '/tmp'], + 'workspace_quota_mb': None, + }, + 'e2b': { + 'api_key': '', + 'api_url': '', + 'template': '', + }, + }, 'space': { 'url': 'https://space.langbot.app', 'models_gateway_api_url': 'https://api.langbot.cloud/v1', @@ -179,8 +200,12 @@ def create_test_directories(tmpdir: Path) -> dict[str, Path]: """Create necessary directories for LangBot testing.""" directories = { 'data': tmpdir / 'data', + 'labels': tmpdir / 'data' / 'labels', + 'metadata': tmpdir / 'data' / 'metadata', 'logs': tmpdir / 'logs', + 'data_logs': tmpdir / 'data' / 'logs', 'storage': tmpdir / 'storage', + 'box': tmpdir / 'box', 'chroma': tmpdir / 'chroma', } diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index 44c6719e5..b8b5816ab 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -9,6 +9,7 @@ import time import signal import os +import sys from pathlib import Path from typing import Optional import logging @@ -26,13 +27,21 @@ def __init__( port: int = 15300, timeout: int = 30, collect_coverage: bool = True, + debug: bool = False, + cli_args: list[str] | None = None, ): self.project_root = project_root self.work_dir = work_dir # Directory containing data/config.yaml self.port = port self.timeout = timeout self.collect_coverage = collect_coverage + self.debug = debug + self.cli_args = cli_args or [] self.process: Optional[subprocess.Popen] = None + self._stdout_file = None + self._stderr_file = None + self._stdout_path = self.work_dir / 'langbot.stdout.log' + self._stderr_path = self.work_dir / 'langbot.stderr.log' self._stdout_data: bytes = b'' self._stderr_data: bytes = b'' self._coverage_file: Optional[Path] = None @@ -63,13 +72,14 @@ def start(self) -> bool: # Disable telemetry env['SPACE__DISABLE_TELEMETRY'] = 'true' env['SPACE__DISABLE_MODELS_SERVICE'] = 'true' + if self.debug: + env['DEBUG'] = 'true' # Build command if self.collect_coverage: # Use coverage.py to collect coverage data # Set COVERAGE_PROCESS_START to enable coverage in subprocess self._coverage_file = self.work_dir / '.coverage.e2e' - env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc') env['COVERAGE_FILE'] = str(self._coverage_file) # Create .coveragerc for subprocess @@ -88,27 +98,33 @@ def start(self) -> bool: coveragerc_path = self.work_dir / '.coveragerc' with open(coveragerc_path, 'w') as f: f.write(coveragerc_content) + env['COVERAGE_PROCESS_START'] = str(coveragerc_path) cmd = [ + sys.executable, + '-m', 'coverage', 'run', '--rcfile=' + str(coveragerc_path), '-m', 'langbot', + *self.cli_args, ] else: - cmd = ['uv', 'run', 'python', '-m', 'langbot'] + cmd = [sys.executable, '-m', 'langbot', *self.cli_args] logger.info(f'Starting LangBot in: {self.work_dir}') logger.info(f'Command: {cmd}') # Start process (run in work_dir so it finds data/config.yaml) + self._stdout_file = open(self._stdout_path, 'wb') + self._stderr_file = open(self._stderr_path, 'wb') self.process = subprocess.Popen( cmd, cwd=self.work_dir, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=self._stdout_file, + stderr=self._stderr_file, preexec_fn=os.setsid if os.name != 'nt' else None, ) @@ -117,7 +133,14 @@ def start(self) -> bool: while time.time() - start_time < self.timeout: # Check if process died if self.process.poll() is not None: - self._stdout_data, self._stderr_data = self.process.communicate() + if self._stdout_file: + self._stdout_file.close() + self._stdout_file = None + if self._stderr_file: + self._stderr_file.close() + self._stderr_file = None + self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b'' + self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b'' logger.error(f'LangBot process died: {self._stderr_data.decode()}') return False @@ -170,8 +193,14 @@ def stop(self) -> None: self.process.wait() # Collect output for debugging - if self.process.stdout or self.process.stderr: - self._stdout_data, self._stderr_data = self.process.communicate() + if self._stdout_file: + self._stdout_file.close() + self._stdout_file = None + if self._stderr_file: + self._stderr_file.close() + self._stderr_file = None + self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b'' + self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b'' self.process = None @@ -183,6 +212,10 @@ def get_logs(self) -> tuple[str, str]: """Get stdout and stderr logs.""" stdout = self._stdout_data.decode('utf-8', errors='replace') stderr = self._stderr_data.decode('utf-8', errors='replace') + if not stdout and self._stdout_path.exists(): + stdout = self._stdout_path.read_text(encoding='utf-8', errors='replace') + if not stderr and self._stderr_path.exists(): + stderr = self._stderr_path.read_text(encoding='utf-8', errors='replace') return stdout, stderr def get_coverage_file(self) -> Optional[Path]: diff --git a/tests/factories/app.py b/tests/factories/app.py index d1edf56a2..9b316ffe4 100644 --- a/tests/factories/app.py +++ b/tests/factories/app.py @@ -122,11 +122,9 @@ def _create_mock_cmd_mgr(self): return cmd_mgr def _create_mock_skill_mgr(self): - """Mock SkillManager that returns no skill index addition by default.""" + """Mock SkillManager with no loaded skills by default.""" skill_mgr = Mock() skill_mgr.skills = {} - skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') - skill_mgr.get_skill_index = Mock(return_value=[]) return skill_mgr def _create_mock_pipeline_service(self): diff --git a/tests/factories/message.py b/tests/factories/message.py index 9b3cc3602..ec23877d9 100644 --- a/tests/factories/message.py +++ b/tests/factories/message.py @@ -169,10 +169,12 @@ def _base_query( 'bot_uuid': 'test-bot-uuid', 'pipeline_config': { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, - 'prompt': 'test-prompt', + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': 'test-prompt', + }, }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, @@ -417,10 +419,12 @@ def query_with_config( if pipeline_config is None: pipeline_config = { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, - 'prompt': 'test-prompt', + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': 'test-prompt', + }, }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 0e6854bf9..4238294d2 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -97,6 +97,60 @@ def fake_bot_app(): app.bot_service.update_bot = AsyncMock(return_value={}) app.bot_service.delete_bot = AsyncMock() app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) + app.bot_service.list_event_route_statuses = AsyncMock( + return_value={ + 'routes': [ + { + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'last_status': 'delivered', + 'failure_code': None, + 'reason': 'delivered', + 'timestamp': 100, + 'seq_id': 1, + 'current': True, + } + ], + 'unmatched_events': [], + 'stale_routes': [], + } + ) + app.bot_service.dry_run_event_route = AsyncMock( + return_value={ + 'matched': True, + 'binding_id': 'binding-1', + 'matched_binding_id': 'binding-1', + 'matched_binding_index': 0, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'target': { + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'target_name': 'Member Agent', + 'kind': 'agent', + }, + 'reason': 'Event route matched agent target', + 'failure_code': None, + 'diagnostic_steps': ['Route 1 (platform.member.joined) selected: Selected by priority and order'], + 'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}], + } + ) + app.bot_service.dispatch_test_event_route = AsyncMock( + return_value={ + 'dispatched': True, + 'event_type': 'message.received', + 'suppressed_outputs': [], + 'route_status': { + 'routes': [], + 'unmatched_events': [], + 'stale_routes': [], + }, + } + ) app.bot_service.send_message = AsyncMock() # Platform manager @@ -199,6 +253,102 @@ async def test_get_bot_logs_success(self, quart_test_client): assert 'total_count' in data['data'] +@pytest.mark.usefixtures('mock_circular_import_chain') +class TestBotEventRouteDryRunEndpoint: + """Tests for bot event route dry-run endpoint.""" + + @pytest.mark.asyncio + async def test_dry_run_event_route_success(self, quart_test_client, fake_bot_app): + """POST dry_run returns route diagnostics.""" + response = await quart_test_client.post( + '/api/v1/platform/bots/test-bot-uuid/event-routes/dry-run', + headers={'Authorization': 'Bearer test_token'}, + json={ + 'event_type': 'platform.member.joined', + 'payload': {'room': {'id': 'room-1'}}, + 'context': {'source': 'ui'}, + 'event_bindings': [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ], + }, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['matched'] is True + assert data['data']['binding_id'] == 'binding-1' + assert data['data']['target']['target_name'] == 'Member Agent' + assert data['data']['diagnostic_steps'][0].startswith('Route 1') + fake_bot_app.bot_service.dry_run_event_route.assert_awaited_with( + bot_uuid='test-bot-uuid', + event_type='platform.member.joined', + event_data={'room': {'id': 'room-1'}}, + context={'source': 'ui'}, + event_bindings=[ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ], + ) + + +@pytest.mark.usefixtures('mock_circular_import_chain') +class TestBotEventRouteStatusEndpoint: + """Tests for bot event route runtime status endpoint.""" + + @pytest.mark.asyncio + async def test_get_event_route_status_success(self, quart_test_client, fake_bot_app): + """GET event route status returns recent route traces.""" + response = await quart_test_client.get( + '/api/v1/platform/bots/test-bot-uuid/event-routes/status', + headers={'Authorization': 'Bearer test_token'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['routes'][0]['binding_id'] == 'binding-1' + assert data['data']['routes'][0]['last_status'] == 'delivered' + fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid') + + +@pytest.mark.usefixtures('mock_circular_import_chain') +class TestBotEventRouteTestEndpoint: + """Tests for bot event route synthetic dispatch endpoint.""" + + @pytest.mark.asyncio + async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app): + """POST test route dispatches a synthetic event.""" + response = await quart_test_client.post( + '/api/v1/platform/bots/test-bot-uuid/event-routes/test', + headers={'Authorization': 'Bearer test_token'}, + json={ + 'event_type': 'message.received', + 'payload': {'message_text': 'hello'}, + }, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['dispatched'] is True + assert data['data']['event_type'] == 'message.received' + fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with( + bot_uuid='test-bot-uuid', + event_type='message.received', + payload={'message_text': 'hello'}, + ) + + @pytest.mark.usefixtures('mock_circular_import_chain') class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 50ac37bc5..21441596f 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -278,3 +278,17 @@ async def test_get_extensions(self, quart_test_client): assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 + + @pytest.mark.asyncio + async def test_get_extensions_tolerates_skill_list_failure(self, quart_test_client, fake_pipeline_app): + """Pipeline plugin/MCP binding UI should not fail when Box skill listing is unavailable.""" + fake_pipeline_app.skill_service.list_skills = AsyncMock(side_effect=TimeoutError('box_list_skills timeout')) + + response = await quart_test_client.get( + '/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'} + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['available_skills'] == [] diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 25d3e5c82..8084c805c 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -9,34 +9,60 @@ from __future__ import annotations +import json + import pytest +import sqlalchemy as sa +from alembic.config import Config +from alembic.script import ScriptDirectory from sqlalchemy.ext.asyncio import create_async_engine +from langbot.pkg.entity.persistence import ( + agent as agent_models, + agent_run as agent_run_models, + agent_runner_state as agent_runner_state_models, + bot as bot_models, + metadata as metadata_models, + monitoring as monitoring_models, +) from langbot.pkg.entity.persistence.base import Base from langbot.pkg.persistence.alembic_runner import ( - run_alembic_upgrade, - run_alembic_stamp, - get_alembic_current, _ALEMBIC_DIR, + get_alembic_current, + run_alembic_stamp, + run_alembic_upgrade, ) -from alembic.config import Config -from alembic.script import ScriptDirectory -def _get_script_head() -> str: - """Resolve the current Alembic head revision from the script directory. - - Avoids hardcoding a revision number in assertions so adding a new - migration doesn't require editing the migration tests. - """ +def _get_script_directory() -> ScriptDirectory: + """Load the repository's Alembic revision graph.""" cfg = Config() cfg.set_main_option('script_location', _ALEMBIC_DIR) - return ScriptDirectory.from_config(cfg).get_current_head() + return ScriptDirectory.from_config(cfg) + + +def _get_script_head() -> str: + """Resolve the only Alembic head without hardcoding a revision.""" + return _get_script_directory().get_current_head() pytestmark = pytest.mark.integration +class TestAlembicRevisionGraph: + """Static release gates for the Alembic graph.""" + + def test_revision_ids_fit_alembic_version_column_and_graph_has_one_head(self): + script = _get_script_directory() + revisions = list(script.walk_revisions()) + + assert script.get_bases() == ['0001_baseline'] + assert script.get_heads() == ['0012_monitoring_tool_calls'] + assert all(len(item.revision) <= 32 for item in revisions), { + item.revision: len(item.revision) for item in revisions if len(item.revision) > 32 + } + + @pytest.fixture def sqlite_db_url(tmp_path): """Create SQLite URL with temporary database file.""" @@ -149,6 +175,174 @@ async def test_upgrade_idempotent(self, sqlite_engine): rev2 = await get_alembic_current(sqlite_engine) assert rev2 == rev1, f'Expected {rev1}, got {rev2}' + @pytest.mark.asyncio + async def test_upgrade_from_mcp_resource_branch_creates_agent_and_monitoring_schema(self, sqlite_engine): + """The MCP branch can converge into the Agent branch and the current head.""" + await run_alembic_stamp(sqlite_engine, '0008_mcp_resource_prefs') + await run_alembic_upgrade(sqlite_engine, 'head') + + def inspect_schema(sync_conn): + inspector = sa.inspect(sync_conn) + tables = set(inspector.get_table_names()) + monitoring_indexes = { + index['name'] for index in inspector.get_indexes(monitoring_models.MonitoringToolCall.__tablename__) + } + return tables, monitoring_indexes + + async with sqlite_engine.connect() as conn: + tables, monitoring_indexes = await conn.run_sync(inspect_schema) + + expected_agent_tables = { + agent_models.Agent.__tablename__, + agent_run_models.AgentRun.__tablename__, + agent_run_models.AgentRunEvent.__tablename__, + agent_run_models.AgentRuntime.__tablename__, + agent_runner_state_models.AgentRunnerState.__tablename__, + } + expected_monitoring_indexes = {index.name for index in monitoring_models.MonitoringToolCall.__table__.indexes} + + assert expected_agent_tables <= tables + assert monitoring_models.MonitoringToolCall.__tablename__ in tables + assert expected_monitoring_indexes <= monitoring_indexes + assert await get_alembic_current(sqlite_engine) == _get_script_head() + + @pytest.mark.asyncio + async def test_bot_admin_data_migrates_when_create_all_already_created_table(self, sqlite_engine): + """0007 must migrate config admins even when the ORM created its table first.""" + config = { + 'admins': ['group_admin-1', 'person_user_with_underscore', 'malformed'], + 'preserved': True, + } + + async with sqlite_engine.begin() as conn: + await conn.run_sync(bot_models.Bot.__table__.create) + await conn.run_sync(bot_models.BotAdmin.__table__.create) + await conn.run_sync(metadata_models.Metadata.__table__.create) + await conn.execute( + sa.insert(bot_models.Bot).values( + uuid='bot-1', + name='Bot', + description='', + adapter='test', + adapter_config={}, + enable=True, + ) + ) + await conn.execute( + sa.insert(bot_models.BotAdmin).values( + bot_uuid='bot-1', + launcher_type='group', + launcher_id='admin-1', + ) + ) + await conn.execute( + sa.insert(metadata_models.Metadata).values( + key='instance_config', + value=json.dumps(config), + ) + ) + + await run_alembic_stamp(sqlite_engine, '0006_normalize_mcp_remote_mode') + await run_alembic_upgrade(sqlite_engine, '0007_add_bot_admins') + + async with sqlite_engine.connect() as conn: + admin_rows = ( + await conn.execute( + sa.select( + bot_models.BotAdmin.launcher_type, + bot_models.BotAdmin.launcher_id, + ).order_by(bot_models.BotAdmin.launcher_type, bot_models.BotAdmin.launcher_id) + ) + ).all() + stored_config = ( + await conn.execute( + sa.select(metadata_models.Metadata.value).where(metadata_models.Metadata.key == 'instance_config') + ) + ).scalar_one() + + assert admin_rows == [('group', 'admin-1'), ('person', 'user_with_underscore')] + assert json.loads(stored_config) == {'preserved': True} + + @pytest.mark.asyncio + async def test_pipeline_routing_rules_preserve_message_filters(self, sqlite_engine): + """Every legacy routing rule keeps its matching semantics in event bindings.""" + routing_rules = [ + { + 'type': 'launcher_type', + 'operator': 'eq', + 'value': 'group', + 'pipeline_uuid': 'pipeline-group', + }, + { + 'type': 'launcher_id', + 'operator': 'regex', + 'value': '^room-', + 'pipeline_uuid': 'pipeline-room', + }, + { + 'type': 'message_content', + 'operator': 'contains', + 'value': 'urgent', + 'pipeline_uuid': 'pipeline-content', + }, + { + 'type': 'message_has_element', + 'operator': 'eq', + 'value': 'Image', + 'pipeline_uuid': 'pipeline-image', + }, + { + 'type': 'message_has_element', + 'operator': 'neq', + 'value': 'Voice', + 'pipeline_uuid': 'pipeline-no-voice', + }, + ] + + async with sqlite_engine.begin() as conn: + await conn.execute( + sa.text( + 'CREATE TABLE bots (' + 'uuid VARCHAR(255) PRIMARY KEY, ' + 'use_pipeline_uuid VARCHAR(255), ' + 'pipeline_routing_rules JSON NOT NULL, ' + 'event_bindings JSON NOT NULL' + ')' + ) + ) + await conn.execute( + sa.text( + 'INSERT INTO bots ' + '(uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings) ' + 'VALUES (:uuid, :default_pipeline, :rules, :bindings)' + ), + { + 'uuid': 'bot-routing', + 'default_pipeline': 'pipeline-default', + 'rules': json.dumps(routing_rules), + 'bindings': '[]', + }, + ) + + await run_alembic_stamp(sqlite_engine, '0008_agent_product_surface') + await run_alembic_upgrade(sqlite_engine, '0009_migrate_event_bindings') + + async with sqlite_engine.connect() as conn: + raw_bindings = ( + await conn.execute(sa.text("SELECT event_bindings FROM bots WHERE uuid = 'bot-routing'")) + ).scalar_one() + + bindings = json.loads(raw_bindings) + filters_by_pipeline = {binding['target_uuid']: binding['filters'] for binding in bindings} + assert filters_by_pipeline == { + 'pipeline-group': [{'field': 'chat_type', 'operator': 'eq', 'value': 'group'}], + 'pipeline-room': [{'field': 'chat_id', 'operator': 'regex', 'value': '^room-'}], + 'pipeline-content': [{'field': 'message_text', 'operator': 'contains', 'value': 'urgent'}], + 'pipeline-image': [{'field': 'message_element_types', 'operator': 'contains', 'value': 'Image'}], + 'pipeline-no-voice': [{'field': 'message_element_types', 'operator': 'not_contains', 'value': 'Voice'}], + 'pipeline-default': [], + } + class TestSQLiteMigrationFreshDatabase: """Tests for fresh database workflow.""" diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py index 767594c33..42efad609 100644 --- a/tests/integration/pipeline/test_full_flow.py +++ b/tests/integration/pipeline/test_full_flow.py @@ -14,7 +14,6 @@ import pytest import asyncio from unittest.mock import AsyncMock, Mock -import sys from tests.factories import FakeApp, text_query, mock_platform_adapter from tests.factories.provider import FakeProvider @@ -32,7 +31,7 @@ def mock_circular_import_chain(): """ Break circular import chain for pipeline modules using isolated_sys_modules. - Chain: pipeline → core.app → provider.runner → http_controller → groups/plugins + Chain: pipeline → core.app → http_controller → groups/plugins We mock minimal modules to allow importing RuntimePipeline, StageInstContainer, and stage classes without triggering full application initialization. @@ -48,11 +47,7 @@ def mock_circular_import_chain(): # Mock core.app - Application class is referenced but not instantiated mock_core_app = Mock() - # Mock provider.runner with preregistered_runners list - mock_runner = Mock() - mock_runner.preregistered_runners = [] # Will be populated in tests - - # Mock utils.importutil - prevents auto-import of runners + # Mock utils.importutil to avoid unrelated import-time registrations. mock_importutil = Mock() mock_importutil.import_modules_in_pkg = lambda pkg: None mock_importutil.import_modules_in_pkgs = lambda pkgs: None @@ -68,14 +63,12 @@ def mock_circular_import_chain(): 'langbot.pkg.pipeline.process.handlers.chat', 'langbot.pkg.pipeline.process.handlers.command', 'langbot.pkg.pipeline.respback.respback', - 'langbot.pkg.provider.runner', ] with isolated_sys_modules( mocks={ 'langbot.pkg.core.entities': mock_core_entities, 'langbot.pkg.core.app': mock_core_app, - 'langbot.pkg.provider.runner': mock_runner, 'langbot.pkg.utils.importutil': mock_importutil, 'langbot.pkg.pipeline.controller': Mock(), 'langbot.pkg.pipeline.pipelinemgr': Mock(), @@ -108,8 +101,7 @@ def mock_circular_import_chain(): class FakeRunner: """Minimal fake runner class for pipeline integration tests. - Note: preregistered_runners expects a CLASS, not an instance. - The handler calls runner_cls(self.ap, query.pipeline_config) to instantiate. + The test orchestrator instantiates this class for each configured run. """ name = 'local-agent' @@ -243,12 +235,21 @@ def fake_platform_adapter(): @pytest.fixture -def set_fake_runner(): - """Factory fixture to set a fake runner CLASS in preregistered_runners.""" +def set_fake_runner(pipeline_app): + """Attach a minimal AgentRunOrchestrator-compatible test double.""" def _set_runner(runner_cls): - # preregistered_runners expects a list of runner classes - sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_cls] + runner = runner_cls() + orchestrator = Mock() + orchestrator.try_claim_steering_from_query = AsyncMock(return_value=False) + + async def run_from_query(query): + async for result in runner.run(query): + yield result + + orchestrator.run_from_query = run_from_query + orchestrator.resolve_runner_id_for_telemetry = Mock(return_value='plugin:langbot-team/LocalAgent/default') + pipeline_app.agent_run_orchestrator = orchestrator return _set_runner @@ -260,11 +261,13 @@ def create_minimal_pipeline_config(): """Create minimal pipeline configuration for tests.""" return { 'ai': { - 'runner': {'runner': 'local-agent', 'expire-time': None}, - 'local-agent': { - 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, - 'prompt': 'default', - 'knowledge-bases': [], + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default', 'expire-time': 0}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': 'default', + 'knowledge-bases': [], + }, }, }, 'output': { @@ -366,7 +369,7 @@ async def test_preproc_continues_on_valid_query(self, pipeline_app, fake_platfor result = await preproc_stage.process(query, 'PreProcessor') - assert result.result_type == entities.ResultType.CONTINUE + assert result.result_type.value == entities.ResultType.CONTINUE.value assert result.new_query.session is not None assert result.new_query.user_message is not None @@ -393,7 +396,7 @@ async def test_preproc_sets_user_message(self, pipeline_app, fake_platform_adapt result = await preproc_stage.process(query, 'PreProcessor') - assert result.result_type == entities.ResultType.CONTINUE + assert result.result_type.value == entities.ResultType.CONTINUE.value # Check user_message content assert result.new_query.user_message is not None assert result.new_query.user_message.role == 'user' @@ -466,7 +469,7 @@ async def test_processor_prevent_default_without_reply_interrupts(self, pipeline results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.INTERRUPT + assert results[0].result_type.value == entities.ResultType.INTERRUPT.value @pytest.mark.asyncio async def test_processor_prevent_default_with_reply_continues(self, pipeline_app, fake_platform_adapter): @@ -501,7 +504,7 @@ async def test_processor_prevent_default_with_reply_continues(self, pipeline_app results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.CONTINUE + assert results[0].result_type.value == entities.ResultType.CONTINUE.value assert len(query.resp_messages) == 1 assert query.resp_messages[0] == reply_chain @@ -546,7 +549,7 @@ async def test_runner_exception_yields_interrupt(self, pipeline_app, fake_platfo results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.INTERRUPT + assert results[0].result_type.value == entities.ResultType.INTERRUPT.value assert results[0].user_notice == 'Request failed.' assert results[0].error_notice is not None @@ -585,7 +588,7 @@ async def test_runner_exception_show_error_mode(self, pipeline_app, fake_platfor results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.INTERRUPT + assert results[0].result_type.value == entities.ResultType.INTERRUPT.value assert 'Custom runtime error' in results[0].user_notice @pytest.mark.asyncio @@ -623,7 +626,7 @@ async def test_runner_exception_hide_mode(self, pipeline_app, fake_platform_adap results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.INTERRUPT + assert results[0].result_type.value == entities.ResultType.INTERRUPT.value assert results[0].user_notice is None @@ -655,7 +658,7 @@ async def test_send_response_calls_adapter(self, pipeline_app, fake_platform_ada result = await respback_stage.process(query, 'SendResponseBackStage') - assert result.result_type == entities.ResultType.CONTINUE + assert result.result_type.value == entities.ResultType.CONTINUE.value # Check that adapter was called outbound = platform.get_outbound_messages() @@ -815,7 +818,7 @@ async def test_full_chain_text_message_flow(self, pipeline_app, fake_platform_ad # Run PreProcessor result1 = await preproc_stage.process(query, 'PreProcessor') - assert result1.result_type == entities.ResultType.CONTINUE + assert result1.result_type.value == entities.ResultType.CONTINUE.value query = result1.new_query # Run Processor @@ -831,7 +834,7 @@ async def test_full_chain_text_message_flow(self, pipeline_app, fake_platform_ad # Run SendResponseBackStage result3 = await respback_stage.process(query, 'SendResponseBackStage') - assert result3.result_type == entities.ResultType.CONTINUE + assert result3.result_type.value == entities.ResultType.CONTINUE.value # Verify adapter was called outbound = platform.get_outbound_messages() @@ -879,14 +882,14 @@ async def test_chain_stops_on_interrupt(self, pipeline_app, fake_platform_adapte # Run PreProcessor result1 = await preproc_stage.process(query, 'PreProcessor') - assert result1.result_type == entities.ResultType.CONTINUE + assert result1.result_type.value == entities.ResultType.CONTINUE.value query = result1.new_query # Run Processor - should INTERRUPT results = await collect_processor_results(processor_stage, query, 'MessageProcessor') assert len(results) == 1 - assert results[0].result_type == entities.ResultType.INTERRUPT + assert results[0].result_type.value == entities.ResultType.INTERRUPT.value # Chain stops here - no resp_messages assert len(query.resp_messages) == 0 diff --git a/tests/manual/mcp_smoke.py b/tests/manual/mcp_smoke.py index 197724fff..1827df9d2 100644 --- a/tests/manual/mcp_smoke.py +++ b/tests/manual/mcp_smoke.py @@ -37,7 +37,20 @@ async def verify_api_key(key: str) -> bool: ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key) ap.bot_service = SimpleNamespace( - get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]) + get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]), + list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}), + dispatch_test_event_route=AsyncMock( + return_value={ + 'dispatched': True, + 'event_type': 'message.received', + 'suppressed_outputs': [], + 'route_status': { + 'routes': [], + 'unmatched_events': [], + 'stale_routes': [], + }, + } + ), ) ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}])) ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[])) @@ -96,7 +109,7 @@ async def main() -> int: tools = await session.list_tools() names = [t.name for t in tools.tools] print(f'PASS: listed {len(names)} tools') - for required in ('list_bots', 'get_system_info', 'list_skills'): + for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'): if required not in names: failures.append(f'missing tool {required}') @@ -114,6 +127,20 @@ async def main() -> int: else: print('PASS: get_system_info returned version') + res3 = await session.call_tool( + 'test_bot_event_route', + { + 'bot_uuid': 'bot-1', + 'event_type': 'message.received', + 'payload': {'message_text': 'hello'}, + }, + ) + text3 = res3.content[0].text if res3.content else '' + if '"dispatched": true' not in text3: + failures.append(f'test_bot_event_route wrong: {text3!r}') + else: + print('PASS: test_bot_event_route returned dispatch result') + shutdown.set() with contextlib.suppress(Exception): await asyncio.wait_for(server_task, timeout=5) diff --git a/tests/unit_tests/COVERAGE_EXCLUSIONS.md b/tests/unit_tests/COVERAGE_EXCLUSIONS.md index f0e161158..662202cdf 100644 --- a/tests/unit_tests/COVERAGE_EXCLUSIONS.md +++ b/tests/unit_tests/COVERAGE_EXCLUSIONS.md @@ -18,14 +18,7 @@ - **测试方式**: 需要 mock HTTP 响应或使用 fake LLM server - **状态**: 后续可补充 mock HTTP 测试 -### 3. Agent Runner (`provider/runners/`) -- **路径**: `src/langbot/pkg/provider/runners/` -- **模块**: cozeapi, difysvapi, n8nsvapi, langflowapi, dashscopeapi, localagent, tboxapi -- **排除原因**: 需要真实 Agent 平台(Coze、Dify、n8n 等)的 API 连接 -- **测试方式**: 需要 mock Agent 平台响应 -- **状态**: 后续可补充 mock 测试 - -### 4. 向量数据库 (`vector/vdbs/`) +### 3. 向量数据库 (`vector/vdbs/`) - **路径**: `src/langbot/pkg/vector/vdbs/` - **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search - **排除原因**: 需要真实向量数据库实例运行 @@ -42,7 +35,7 @@ # 排除外部适配器后计算覆盖率 pytest tests/unit_tests/ --cov=langbot.pkg \ --cov-fail-under=0 \ - -o "cov_exclude_patterns=platform/sources/*,provider/modelmgr/requesters/*,provider/runners/*,vector/vdbs/*" + -o "cov_exclude_patterns=platform/sources/*,provider/modelmgr/requesters/*,vector/vdbs/*" ``` ### 当前覆盖率(排除后) @@ -77,15 +70,11 @@ pytest tests/unit_tests/ --cov=langbot.pkg \ - 使用 `httpx` mock 测试 API 响应解析 - 测试重试逻辑、错误处理 -2. **`provider/runners/`** (优先级:中) - - Mock Agent 平台响应 - - 测试 session 管理、错误处理 - -3. **`platform/sources/`** (优先级:低) +2. **`platform/sources/`** (优先级:低) - Mock 平台 webhook 事件 - 测试消息解析、事件处理 -4. **`vector/vdbs/`** (优先级:低) +3. **`vector/vdbs/`** (优先级:低) - Mock 向量数据库操作 - 测试 CRUD、查询逻辑 @@ -176,4 +165,4 @@ tests/unit_tests/ | `core` | **28%** | 1289 | 🔄 需补充 app 启动 | | `persistence` | **24%** | 1099 | 🔄 需补充 mgr | -外部适配器测试需要 mock 环境或集成测试,不属于纯单元测试范畴。 \ No newline at end of file +外部适配器测试需要 mock 环境或集成测试,不属于纯单元测试范畴。 diff --git a/tests/unit_tests/agent/__init__.py b/tests/unit_tests/agent/__init__.py new file mode 100644 index 000000000..ba10b285b --- /dev/null +++ b/tests/unit_tests/agent/__init__.py @@ -0,0 +1,2 @@ +"""Tests for agent runner subsystem.""" +from __future__ import annotations \ No newline at end of file diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py new file mode 100644 index 000000000..7a61101d7 --- /dev/null +++ b/tests/unit_tests/agent/conftest.py @@ -0,0 +1,124 @@ +"""Shared test fixtures for agent runner tests.""" + +from __future__ import annotations + +import typing + + +def make_resources( + models: list[dict] | None = None, + tools: list[dict] | None = None, + knowledge_bases: list[dict] | None = None, + skills: list[dict] | None = None, + storage: dict | None = None, +) -> dict[str, typing.Any]: + """Create a minimal AgentResources dict for testing. + + Args: + models: List of model dicts with 'model_id' key + tools: List of tool dicts with 'tool_name' key + knowledge_bases: List of KB dicts with 'kb_id' key + skills: List of skill dicts with 'skill_name' key + storage: Storage permissions dict + Returns: + AgentResources dict with all required fields + """ + return { + 'models': models or [], + 'tools': tools or [], + 'knowledge_bases': knowledge_bases or [], + 'skills': skills or [], + 'storage': storage or {'plugin_storage': False, 'workspace_storage': False}, + 'platform_capabilities': {}, + } + + +def make_session( + run_id: str = 'test-run-id', + runner_id: str = 'plugin:test/test-runner/default', + query_id: int | None = 1, + plugin_identity: str = 'test/test-runner', + resources: dict | None = None, + conversation_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + thread_id: str | None = None, + available_apis: dict[str, bool] | None = None, + state_policy: dict[str, typing.Any] | None = None, + state_context: dict[str, typing.Any] | None = None, + execution_query: typing.Any | None = None, +) -> dict[str, typing.Any]: + """Create a minimal AgentRunSession dict for testing. + + Args: + run_id: Unique run identifier + runner_id: Runner descriptor ID + query_id: Host entry query ID + plugin_identity: Plugin identifier (author/name) + resources: AgentResources dict (uses make_resources() default if None) + + Returns: + AgentRunSession dict with run-scoped authorization snapshot + """ + import time + + now = int(time.time()) + res = resources if resources is not None else make_resources() + apis = available_apis if available_apis is not None else {} + policy = ( + state_policy if state_policy is not None else {'enable_state': True, 'state_scopes': ['conversation', 'actor']} + ) + context = state_context if state_context is not None else {} + + authorized_ids: dict[str, set[str]] = { + 'model': {m.get('model_id') for m in res.get('models', [])}, + 'tool': {t.get('tool_name') for t in res.get('tools', [])}, + 'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])}, + 'skill': {s.get('skill_name') for s in res.get('skills', [])}, + } + authorized_operations: dict[str, dict[str, set[str]]] = { + 'model': { + m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank', 'count_tokens']) + for m in res.get('models', []) + if m.get('model_id') + }, + 'tool': { + t.get('tool_name'): set(t.get('operations') or ['detail', 'call']) + for t in res.get('tools', []) + if t.get('tool_name') + }, + 'knowledge_base': { + kb.get('kb_id'): set(kb.get('operations') or ['list', 'retrieve']) + for kb in res.get('knowledge_bases', []) + if kb.get('kb_id') + }, + 'skill': { + s.get('skill_name'): set(s.get('operations') or ['activate']) + for s in res.get('skills', []) + if s.get('skill_name') + }, + } + + return { + 'run_id': run_id, + 'runner_id': runner_id, + 'query_id': query_id, + 'execution_query': execution_query, + 'plugin_identity': plugin_identity, + 'authorization': { + 'resources': res, + 'available_apis': apis, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': policy, + 'state_context': context, + 'authorized_ids': authorized_ids, + 'authorized_operations': authorized_operations, + }, + 'status': { + 'started_at': now, + 'last_activity_at': now, + }, + } diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py new file mode 100644 index 000000000..db50984d1 --- /dev/null +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -0,0 +1,631 @@ +"""Tests for ChatMessageHandler behavior with AgentRunOrchestrator. + +Tests focus on: +- Streaming mode behavior (single resp_message_id, pop/append pattern) +- Non-streaming mode behavior (no pop) +- Orchestrator invocation +- Error handling for RunnerNotFoundError, RunnerExecutionError + +Avoids circular imports by using proper import structure. +""" + +from __future__ import annotations + +import uuid +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from langbot.pkg.agent.runner.errors import ( + RunnerNotFoundError, + RunnerExecutionError, + RunnerNotAuthorizedError, +) +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + +# Define mock classes in dependency order (no forward references needed) + + +class MockLauncherType: + value = 'person' + + +class MockConversation: + def __init__(self): + self.uuid = 'conv-uuid' + self.messages = [] + + +class MockMessage: + role = 'user' + content = 'Hello' + + +class MockAdapter: + is_stream = False + + async def is_stream_output_supported(self): + return self.is_stream + + async def create_message_card(self, resp_message_id, message_event): + pass + + +class MockSession: + launcher_type = MockLauncherType() + launcher_id = 'user123' + + def __init__(self): + self.using_conversation = MockConversation() + + +class MockQuery: + """Mock Query for testing.""" + + def __init__(self): + self.query_id = 1 + self.launcher_type = MockLauncherType() + self.launcher_id = 'user123' + self.sender_id = 'user123' + self.bot_uuid = 'bot-uuid' + self.pipeline_uuid = 'pipeline-uuid' + self.pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot-team/LocalAgent/default', + }, + 'runner_config': {}, + }, + 'output': { + 'misc': { + 'exception-handling': 'show-hint', + 'failure-hint': 'Request failed.', + }, + }, + } + self.variables = {} + self.session = MockSession() + self.user_message = MockMessage() + self.messages = [] + self.resp_messages = [] + self.resp_message_chain = None + self.adapter = MockAdapter() + self.message_event = MagicMock() + self.message_chain = MagicMock() + + +class MockMessageChunk: + """Mock MessageChunk for testing.""" + + def __init__(self, content, resp_message_id=None): + self.role = 'assistant' + self.content = content + self.resp_message_id = resp_message_id + self.tool_calls = [] + self.is_final = False + + def readable_str(self): + return self.content + + +class MockEventContext: + """Mock event context for testing.""" + + def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None): + self._prevented = prevented + self.event = MagicMock() + self.event.reply_message_chain = reply_message_chain + self.event.user_message_alter = user_message_alter + + def is_prevented_default(self): + return self._prevented + + +class MockAgentRunOrchestrator: + """Mock AgentRunOrchestrator for testing.""" + + def __init__(self, chunks=None, error=None): + self._chunks = chunks or [] + self._error = error + + async def run_from_query(self, query): + """Async generator that yields chunks or raises error.""" + if self._error: + raise self._error + for chunk in self._chunks: + yield chunk + + async def try_claim_steering_from_query(self, query): + return False + + def resolve_runner_id_for_telemetry(self, query): + return 'plugin:langbot-team/LocalAgent/default' + + +class MockApplication: + """Mock Application for testing.""" + + def __init__(self, orchestrator=None): + self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator() + self.logger = MagicMock() + self.logger.info = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.error = MagicMock() + + # Mock plugin_connector + self.plugin_connector = MagicMock() + self.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext()) + + # Mock telemetry + self.telemetry = MagicMock() + self.telemetry.start_send_task = AsyncMock() + + # Mock survey + self.survey = MagicMock() + self.survey.trigger_event = AsyncMock() + + # Mock model_mgr + self.model_mgr = MagicMock() + self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + + # Mock sess_mgr + self.sess_mgr = MagicMock() + self.sess_mgr.get_conversation = AsyncMock() + + +class TestStreamingBehavior: + """Tests for streaming mode behavior.""" + + def test_single_resp_message_id_for_streaming(self): + """Streaming mode should use single resp_message_id for entire response.""" + # Simulate the streaming logic: resp_message_id created outside loop + resp_message_id = uuid.uuid4() + + chunks = ['Hello', ' World', '!'] + resp_messages = [] + + for chunk in chunks: + result = MockMessageChunk(chunk) + result.resp_message_id = str(resp_message_id) + + # Pop old chunk (streaming behavior) + if resp_messages: + resp_messages.pop() + resp_messages.append(result) + + # All chunks should have same resp_message_id + assert len(resp_messages) == 1 # Only last chunk remains after pop/append + assert resp_messages[0].resp_message_id == str(resp_message_id) + + def test_pop_before_append_in_streaming(self): + """Streaming mode should pop old chunk before appending new.""" + resp_message_id = uuid.uuid4() + resp_messages = [] + + # First chunk - no pop + chunk1 = MockMessageChunk('Hello') + chunk1.resp_message_id = str(resp_message_id) + resp_messages.append(chunk1) + assert len(resp_messages) == 1 + + # Second chunk - pop first, then append + if resp_messages: + resp_messages.pop() + chunk2 = MockMessageChunk('Hello World') + chunk2.resp_message_id = str(resp_message_id) + resp_messages.append(chunk2) + assert len(resp_messages) == 1 + assert resp_messages[0].content == 'Hello World' + + def test_non_streaming_no_pop(self): + """Non-streaming mode should NOT pop previous responses.""" + resp_messages = [] + + # First message + msg1 = MockMessageChunk('Response 1') + resp_messages.append(msg1) + assert len(resp_messages) == 1 + + # Second message - should NOT pop in non-streaming + msg2 = MockMessageChunk('Response 2') + resp_messages.append(msg2) + assert len(resp_messages) == 2 + + +class TestRunnerConfigResolverInChatHandler: + """Tests for RunnerConfigResolver usage in chat handler context.""" + + def test_resolve_runner_id_from_pipeline_config(self): + """Chat handler should use RunnerConfigResolver to resolve runner ID.""" + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot-team/LocalAgent/default', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot-team/LocalAgent/default' + + def test_old_runner_field_is_not_resolved(self): + """The 4.x path requires ai.runner.id.""" + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id is None + + +class TestErrorHandling: + """Tests for orchestrator error handling.""" + + def test_runner_not_found_error_properties(self): + """RunnerNotFoundError should have runner_id property.""" + error = RunnerNotFoundError('plugin:notexist/unknown/default') + assert error.runner_id == 'plugin:notexist/unknown/default' + assert 'not found' in str(error) + + def test_runner_execution_error_retryable(self): + """RunnerExecutionError should have retryable property.""" + error = RunnerExecutionError( + 'plugin:langbot-team/LocalAgent/default', + 'Upstream timeout', + retryable=True, + ) + assert error.runner_id == 'plugin:langbot-team/LocalAgent/default' + assert error.retryable is True + assert 'timeout' in str(error) + + def test_runner_execution_error_not_retryable(self): + """RunnerExecutionError can be non-retryable.""" + error = RunnerExecutionError( + 'plugin:langbot-team/LocalAgent/default', + 'Configuration error', + retryable=False, + ) + assert error.retryable is False + + def test_runner_not_authorized_error_properties(self): + """RunnerNotAuthorizedError should have bound_plugins property.""" + error = RunnerNotAuthorizedError( + 'plugin:langbot-team/LocalAgent/default', + ['langbot-team/DifyAgent'], + ) + assert error.runner_id == 'plugin:langbot-team/LocalAgent/default' + assert error.bound_plugins == ['langbot-team/DifyAgent'] + + +class TestChatHandlerImports: + """Test that chat handler can be imported without circular import.""" + + def test_import_chat_handler_module(self): + """Import chat handler module should work.""" + # This test verifies the import works without circular dependency + from langbot.pkg.pipeline.process.handlers import chat + + assert chat.ChatMessageHandler is not None + + def test_chat_handler_class_exists(self): + """ChatMessageHandler class should be defined.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + + assert ChatMessageHandler.__name__ == 'ChatMessageHandler' + + def test_chat_handler_has_handle_method(self): + """ChatMessageHandler should have async generator handle method.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + + assert hasattr(ChatMessageHandler, 'handle') + # handle returns AsyncGenerator, so check for async generator function + import inspect + + assert inspect.isasyncgenfunction(ChatMessageHandler.handle) + + +class TestChatHandlerAsyncBehavior: + """Real async tests for ChatMessageHandler.handle() with mocked orchestrator.""" + + @pytest.mark.asyncio + async def test_streaming_single_resp_message_id(self): + """Streaming mode: all chunks should have same resp_message_id.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + # Create chunks for streaming + chunks = [ + MockMessageChunk('Hello'), + MockMessageChunk('Hello World'), + MockMessageChunk('Hello World!'), + ] + + orchestrator = MockAgentRunOrchestrator(chunks=chunks) + mock_ap = MockApplication(orchestrator=orchestrator) + + # Mock event context to not prevent default + event_ctx = MockEventContext(prevented=False) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=event_ctx) + + query = MockQuery() + query.adapter.is_stream = True # Enable streaming mode + + handler = ChatMessageHandler(mock_ap) + + # Mock event creation and StageProcessResult to bypass pydantic validation + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Verify single resp_message_id + resp_ids = [msg.resp_message_id for msg in query.resp_messages if hasattr(msg, 'resp_message_id')] + assert len(set(resp_ids)) == 1 # All same ID + + # Verify pop/append pattern: only last chunk remains + assert len(query.resp_messages) == 1 + assert query.resp_messages[0].content == 'Hello World!' + + @pytest.mark.asyncio + async def test_non_streaming_no_pop(self): + """Non-streaming mode: all chunks should remain.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + chunks = [ + MockMessageChunk('Response 1'), + MockMessageChunk('Response 2'), + ] + + orchestrator = MockAgentRunOrchestrator(chunks=chunks) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + query.adapter.is_stream = False # Disable streaming mode + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # No pop: all chunks should remain + assert len(query.resp_messages) == 2 + assert query.resp_messages[0].content == 'Response 1' + assert query.resp_messages[1].content == 'Response 2' + + @pytest.mark.asyncio + async def test_agent_turn_recreates_conversation_if_tool_resets_it(self): + """Agent turn bookkeeping should tolerate CREATE_NEW_CONVERSATION during runner execution.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + response = MockMessageChunk('Tool response') + new_conversation = MockConversation() + + class ResetConversationOrchestrator(MockAgentRunOrchestrator): + async def run_from_query(self, query): + query.session.using_conversation = None + yield response + + mock_ap = MockApplication(orchestrator=ResetConversationOrchestrator()) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + mock_ap.sess_mgr.get_conversation = AsyncMock(return_value=new_conversation) + + query = MockQuery() + query.adapter.is_stream = False + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.CONTINUE + mock_ap.sess_mgr.get_conversation.assert_awaited_once() + assert query.session.using_conversation is new_conversation + assert new_conversation.messages == [] + + @pytest.mark.asyncio + async def test_runner_not_found_error(self): + """Handler should catch RunnerNotFoundError and return INTERRUPT.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator(error=RunnerNotFoundError('plugin:notexist/unknown/default')) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Should return INTERRUPT with user_notice + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'not found' in results[0].user_notice + + @pytest.mark.asyncio + async def test_runner_not_authorized_error(self): + """Handler should catch RunnerNotAuthorizedError and return INTERRUPT.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator( + error=RunnerNotAuthorizedError('plugin:langbot-team/LocalAgent/default', ['other/plugin']) + ) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'not authorized' in results[0].user_notice + + @pytest.mark.asyncio + async def test_runner_execution_error_retryable(self): + """Handler should catch retryable RunnerExecutionError.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + orchestrator = MockAgentRunOrchestrator( + error=RunnerExecutionError('plugin:langbot-team/LocalAgent/default', 'timeout', retryable=True) + ) + mock_ap = MockApplication(orchestrator=orchestrator) + mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock( + result_type=kwargs.get('result_type'), + user_notice=kwargs.get('user_notice'), + ) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.INTERRUPT + assert 'temporarily unavailable' in results[0].user_notice + + @pytest.mark.asyncio + async def test_prevented_default_with_reply(self): + """When event prevented default with reply, use reply message.""" + from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + from langbot.pkg.pipeline import entities + + # Mock reply message chain + reply_chain = MockMessageChunk('Reply from plugin') + + mock_ap = MockApplication() + mock_ap.plugin_connector.emit_event = AsyncMock( + return_value=MockEventContext(prevented=True, reply_message_chain=reply_chain) + ) + + query = MockQuery() + + handler = ChatMessageHandler(mock_ap) + + mock_event = MagicMock() + mock_event.return_value = MagicMock() + + def make_result(*args, **kwargs): + return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) + + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): + mock_events_module.PersonNormalMessageReceived = mock_event + mock_events_module.GroupNormalMessageReceived = mock_event + + results = [] + async for result in handler.handle(query): + results.append(result) + + # Should return CONTINUE with reply message + assert len(results) == 1 + assert results[0].result_type == entities.ResultType.CONTINUE + assert len(query.resp_messages) == 1 diff --git a/tests/unit_tests/agent/test_config_resolver.py b/tests/unit_tests/agent/test_config_resolver.py new file mode 100644 index 000000000..19b163fd3 --- /dev/null +++ b/tests/unit_tests/agent/test_config_resolver.py @@ -0,0 +1,207 @@ +"""Tests for current AgentRunner config resolution.""" + +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + +class TestResolveRunnerId: + """Tests for RunnerConfigResolver.resolve_runner_id.""" + + def test_resolve_current_runner_id(self): + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot-team/LocalAgent/default', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot-team/LocalAgent/default' + + def test_does_not_resolve_legacy_runner_field(self): + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id is None + + def test_resolve_no_runner_config(self): + runner_id = RunnerConfigResolver.resolve_runner_id({}) + assert runner_id is None + + +class TestResolveRunnerConfig: + """Tests for RunnerConfigResolver.resolve_runner_config.""" + + def test_resolve_current_config(self): + pipeline_config = { + 'ai': { + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': 'uuid-123', + 'custom_option': 10, + }, + }, + }, + } + + config = RunnerConfigResolver.resolve_runner_config( + pipeline_config, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {'model': 'uuid-123', 'custom_option': 10} + + def test_does_not_read_legacy_runner_block(self): + pipeline_config = { + 'ai': { + 'local-agent': { + 'model': 'uuid-123', + }, + }, + } + + config = RunnerConfigResolver.resolve_runner_config( + pipeline_config, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {} + + def test_resolve_no_config(self): + config = RunnerConfigResolver.resolve_runner_config( + {}, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {} + + +class TestGetExpireTime: + """Tests for RunnerConfigResolver.get_expire_time.""" + + def test_get_expire_time_zero(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 0, + }, + }, + } + + expire_time = RunnerConfigResolver.get_expire_time(pipeline_config) + assert expire_time == 0 + + def test_get_expire_time_positive(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 3600, + }, + }, + } + + expire_time = RunnerConfigResolver.get_expire_time(pipeline_config) + assert expire_time == 3600 + + +class TestValidateCurrentConfig: + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + def test_agent_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value): + config = { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + field_name: invalid_value, + } + }, + } + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + RunnerConfigResolver.validate_agent_config(config) + + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + def test_pipeline_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + field_name: invalid_value, + } + }, + } + } + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + RunnerConfigResolver.validate_pipeline_config(config) + + def test_only_selected_runner_security_fields_are_validated(self): + config = { + 'runner': {'id': 'plugin:test/selected/default'}, + 'runner_config': { + 'plugin:test/selected/default': {'enable-all-tools': False}, + 'plugin:test/unselected/default': {'enable-all-tools': 'preserved'}, + }, + } + + assert RunnerConfigResolver.validate_agent_config(config) is config + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + @pytest.mark.parametrize('config_kind', ['agent', 'pipeline']) + def test_rejects_non_boolean_mcp_resource_enabled(self, config_kind, invalid_value): + runner_id = 'plugin:test/runner/default' + runner_config = {'mcp-resources': [{'uri': 'file:///README.md', 'enabled': invalid_value}]} + if config_kind == 'agent': + config = { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: runner_config}, + } + validate = RunnerConfigResolver.validate_agent_config + else: + config = { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: runner_config}, + } + } + validate = RunnerConfigResolver.validate_pipeline_config + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + validate(config) + + @pytest.mark.parametrize('enabled', [True, False]) + def test_accepts_boolean_mcp_resource_enabled(self, enabled): + runner_config = {'mcp-resources': [{'enabled': enabled}]} + + assert RunnerConfigResolver.validate_runner_security_fields(runner_config) is runner_config + + def test_get_expire_time_default(self): + expire_time = RunnerConfigResolver.get_expire_time({}) + assert expire_time == 0 diff --git a/tests/unit_tests/agent/test_config_resolver_templates.py b/tests/unit_tests/agent/test_config_resolver_templates.py new file mode 100644 index 000000000..b8434b82b --- /dev/null +++ b/tests/unit_tests/agent/test_config_resolver_templates.py @@ -0,0 +1,72 @@ +"""Tests for persisted AgentRunner config templates.""" + +from __future__ import annotations + +import json + +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + +class TestDefaultPipelineConfig: + """Tests for default-pipeline-config.json format.""" + + def test_default_config_is_current_format(self): + from langbot.pkg.utils import paths as path_utils + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + assert 'ai' in config + assert 'runner' in config['ai'] + assert 'id' in config['ai']['runner'] + assert config['ai']['runner']['id'] == '' + assert 'runner_config' in config['ai'] + assert config['ai']['runner_config'] == {} + assert 'local-agent' not in config['ai'] + + +class TestResolveRunnerId: + """Tests for current runner id resolution.""" + + def test_resolve_current_id(self): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/my-runner/default'}, + }, + } + runner_id = RunnerConfigResolver.resolve_runner_id(config) + assert runner_id == 'plugin:test/my-runner/default' + + def test_old_runner_field_is_not_supported(self): + config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + }, + } + runner_id = RunnerConfigResolver.resolve_runner_id(config) + assert runner_id is None + + +class TestResolveRunnerConfig: + """Tests for runtime runner config resolution.""" + + def test_resolve_current_config(self): + config = { + 'ai': { + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': {'custom-option': 20}, + }, + }, + } + runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') + assert runner_config['custom-option'] == 20 + + def test_old_runner_block_is_not_supported(self): + config = { + 'ai': { + 'local-agent': {'custom-option': 20}, + }, + } + runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') + assert runner_config == {} diff --git a/tests/unit_tests/agent/test_context_builder_params_state.py b/tests/unit_tests/agent/test_context_builder_params_state.py new file mode 100644 index 000000000..05e868eb5 --- /dev/null +++ b/tests/unit_tests/agent/test_context_builder_params_state.py @@ -0,0 +1,162 @@ +"""Tests for Query entry adapter params packaging.""" +from __future__ import annotations + +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + +class TestBuildParams: + """Tests for QueryEntryAdapter.build_params filtering.""" + + def test_params_empty_when_no_variables(self): + query = type('Query', (), {'variables': None})() + assert QueryEntryAdapter.build_params(query) == {} + + def test_params_filters_underscore_prefix(self): + query = type('Query', (), { + 'variables': { + '_internal_var': 'should_be_excluded', + '_pipeline_bound_plugins': ['a/b'], + '_monitoring_bot_name': 'Bot', + 'public_var': 'should_be_included', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert '_internal_var' not in params + assert '_pipeline_bound_plugins' not in params + assert '_monitoring_bot_name' not in params + assert params['public_var'] == 'should_be_included' + + def test_params_filters_sensitive_naming(self): + query = type('Query', (), { + 'variables': { + 'api_key': 'secret123', + 'API_KEY': 'secret456', + 'token': 'tok123', + 'secret': 'sec123', + 'password': 'pass123', + 'credential': 'cred123', + 'user_api_key': 'should_be_excluded', + 'user_secret_key': 'should_be_excluded', + 'my_token_value': 'should_be_excluded', + 'user_password_hash': 'should_be_excluded', + 'public_name': 'should_be_included', + 'safe_value': 'should_be_included', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'api_key' not in params + assert 'API_KEY' not in params + assert 'token' not in params + assert 'secret' not in params + assert 'password' not in params + assert 'credential' not in params + assert 'user_api_key' not in params + assert 'user_secret_key' not in params + assert 'my_token_value' not in params + assert 'user_password_hash' not in params + assert 'public_name' in params + assert 'safe_value' in params + + def test_params_keeps_common_public_vars(self): + query = type('Query', (), { + 'variables': { + 'launcher_type': 'telegram', + 'launcher_id': 'group_123', + 'sender_id': 'user_001', + 'session_id': 'sess_abc', + 'msg_create_time': 1234567890, + 'group_name': 'Tech Group', + 'sender_name': 'John', + 'user_message_text': 'Hello world', + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert params['launcher_type'] == 'telegram' + assert params['launcher_id'] == 'group_123' + assert params['sender_id'] == 'user_001' + assert params['session_id'] == 'sess_abc' + assert params['msg_create_time'] == 1234567890 + assert params['group_name'] == 'Tech Group' + assert params['sender_name'] == 'John' + assert params['user_message_text'] == 'Hello world' + + def test_params_filters_non_json_serializable(self): + class CustomObject: + pass + + query = type('Query', (), { + 'variables': { + 'string_value': 'hello', + 'int_value': 42, + 'float_value': 3.14, + 'bool_value': True, + 'null_value': None, + 'list_value': ['a', 'b', 'c'], + 'dict_value': {'nested': 'value'}, + 'custom_object': CustomObject(), + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'string_value' in params + assert 'int_value' in params + assert 'float_value' in params + assert 'bool_value' in params + assert 'null_value' in params + assert 'list_value' in params + assert 'dict_value' in params + assert 'custom_object' not in params + + def test_params_filters_nested_non_serializable(self): + class CustomObject: + pass + + query = type('Query', (), { + 'variables': { + 'nested_list_with_bad': ['a', CustomObject(), 'c'], + 'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()}, + 'good_nested_list': ['a', ['b', 'c']], + 'good_nested_dict': {'outer': {'inner': 'value'}}, + }, + })() + + params = QueryEntryAdapter.build_params(query) + assert 'nested_list_with_bad' not in params + assert 'nested_dict_with_bad' not in params + assert 'good_nested_list' in params + assert 'good_nested_dict' in params + + def test_is_json_serializable_primitives_and_collections(self): + assert QueryEntryAdapter.is_json_serializable(None) is True + assert QueryEntryAdapter.is_json_serializable('string') is True + assert QueryEntryAdapter.is_json_serializable(42) is True + assert QueryEntryAdapter.is_json_serializable(['a', 'b']) is True + assert QueryEntryAdapter.is_json_serializable({'key': 'value'}) is True + assert QueryEntryAdapter.is_json_serializable((1, 2, 3)) is True + + def test_is_json_serializable_rejects_sets_and_objects(self): + class CustomObject: + pass + + assert QueryEntryAdapter.is_json_serializable(CustomObject()) is False + assert QueryEntryAdapter.is_json_serializable({1, 2, 3}) is False + assert QueryEntryAdapter.is_json_serializable([1, {2, 3}]) is False + assert QueryEntryAdapter.is_json_serializable({'key': {1, 2}}) is False + + +class TestBuildAdapterContext: + """Tests for QueryEntryAdapter.build_adapter_context.""" + + def test_adapter_context_does_not_push_prompt(self): + query = type('Query', (), { + 'variables': {}, + 'query_id': 123, + 'prompt': object(), + })() + + context = QueryEntryAdapter.build_adapter_context(query, binding=None) + + assert context == {'params': {}, 'query_id': 123} diff --git a/tests/unit_tests/agent/test_context_builder_state.py b/tests/unit_tests/agent/test_context_builder_state.py new file mode 100644 index 000000000..9c9f17cf1 --- /dev/null +++ b/tests/unit_tests/agent/test_context_builder_state.py @@ -0,0 +1,361 @@ +"""Tests for ContextAccess.state determination in AgentRunContextBuilder. + +Tests focus on: +- Event-first mode: state=True when enable_state=True and state_scopes non-empty +- Event-first mode: state=False when enable_state=False +- Legacy Query mode: state=False (no persistent state API) +""" +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock + +from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy +from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +class MockApplication: + """Mock Application for testing.""" + def __init__(self): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock() + + +def make_descriptor( + permissions: dict | None = None, +) -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id='plugin:test/runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + permissions=permissions + if permissions is not None + else { + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin'], + }, + ) + + +class TestContextAccessStateDetermination: + """Tests for ContextAccess.state field determination - real calls to _build_context_access.""" + + @pytest.fixture + def mock_app(self): + """Create mock application.""" + return MockApplication() + + @pytest.fixture + def mock_event(self): + """Create mock event envelope.""" + return AgentEventEnvelope( + event_id='evt_001', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id='ws_001', + conversation_id='conv_001', + thread_id=None, + actor=ActorContext(actor_type='user', actor_id='user_001'), + subject=None, + input=AgentInput(text='hello', contents=[], attachments=[]), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + + @pytest.fixture + def mock_descriptor(self): + """Create mock runner descriptor.""" + return make_descriptor() + + @pytest.mark.asyncio + async def test_enable_state_true_with_scopes_sets_state_true(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=True when enable_state=True and state_scopes non-empty.""" + # Create binding with state enabled and non-empty scopes + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor'], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call to _build_context_access + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=True based on binding.state_policy + assert context_access['available_apis']['state'] is True + + @pytest.mark.asyncio + async def test_enable_state_false_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when enable_state=False.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=False, + state_scopes=[], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=False + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_enable_state_true_empty_scopes_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when enable_state=True but state_scopes empty.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=[], # Empty scopes - state not available + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # Verify state=False (empty scopes means state not available) + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor): + """ContextAccess.state=False when no binding is provided.""" + builder = AgentRunContextBuilder(mock_app) + + # Real call without binding + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None) + + # Verify state=False (no binding = no state policy = state disabled) + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_runner_scope_available_without_conversation(self, mock_app, mock_descriptor): + """State API with runner scope is available even without conversation_id.""" + mock_event = AgentEventEnvelope( + event_id='evt_002', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id='ws_001', + conversation_id=None, # No conversation + thread_id=None, + actor=ActorContext(actor_type='user', actor_id='user_001'), + subject=None, + input=AgentInput(text='hello', contents=[], attachments=[]), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + + binding = AgentBinding( + binding_id='binding_002', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='workspace', scope_id='ws_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['runner'], # Runner scope doesn't need conversation_id + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # State should be True because runner scope is enabled + assert context_access['available_apis']['state'] is True + + @pytest.mark.asyncio + async def test_multiple_scopes_all_available(self, mock_app, mock_event, mock_descriptor): + """State API with multiple scopes enabled.""" + binding = AgentBinding( + binding_id='binding_003', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor', 'subject', 'runner'], + ), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + # State should be True with all scopes enabled + assert context_access['available_apis']['state'] is True + + +class TestStatePolicyFromBinding: + """Tests for state_policy extraction from binding.""" + + def test_state_policy_structure(self): + """State policy has correct structure.""" + policy = StatePolicy( + enable_state=True, + state_scopes=['conversation', 'actor', 'subject', 'runner'], + ) + + assert policy.enable_state is True + assert len(policy.state_scopes) == 4 + assert 'conversation' in policy.state_scopes + + def test_state_policy_disabled(self): + """State policy can be disabled.""" + policy = StatePolicy( + enable_state=False, + state_scopes=[], + ) + + assert policy.enable_state is False + assert len(policy.state_scopes) == 0 + + +class TestBindingWithStatePolicy: + """Tests for binding with state_policy.""" + + def test_binding_contains_state_policy(self): + """Binding contains state_policy field.""" + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy( + enable_state=True, + state_scopes=['conversation'], + ), + ) + + assert binding.state_policy is not None + assert binding.state_policy.enable_state is True + + +class TestContextAccessOtherAPIs: + """Tests for other available_apis fields based on run scope.""" + + @pytest.fixture + def mock_app(self): + """Create mock application.""" + return MockApplication() + + @pytest.mark.asyncio + async def test_history_apis_enabled_with_conversation(self, mock_app): + """History APIs are available when the run has a conversation scope.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['prompt_get'] is False + assert context_access['available_apis']['history_page'] is True + assert context_access['available_apis']['history_search'] is True + + @pytest.mark.asyncio + async def test_event_apis_enabled_by_default(self, mock_app): + """Event APIs are available based on current run scope.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['event_get'] is True + assert context_access['available_apis']['event_page'] is True + + @pytest.mark.asyncio + async def test_conversation_required_apis_disabled_without_conversation(self, mock_app): + """Conversation-scoped APIs are disabled when the run has no conversation.""" + mock_event = MagicMock() + mock_event.conversation_id = None + mock_event.thread_id = None + mock_descriptor = make_descriptor() + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + # Real call + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['history_page'] is False + assert context_access['available_apis']['history_search'] is False + assert context_access['available_apis']['event_get'] is True + assert context_access['available_apis']['event_page'] is False + assert context_access['available_apis']['state'] is False + + @pytest.mark.asyncio + async def test_manifest_permissions_disable_context_apis(self, mock_app): + """Pull APIs are disabled when manifest permissions omit them.""" + mock_event = MagicMock() + mock_event.conversation_id = 'conv_001' + mock_event.thread_id = None + mock_descriptor = make_descriptor(permissions={}) + + binding = AgentBinding( + binding_id='binding_001', + runner_id='plugin:test/runner/default', + scope=BindingScope(scope_type='agent', scope_id='conv_001'), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + ) + + builder = AgentRunContextBuilder(mock_app) + + context_access = await builder._build_context_access(mock_event, mock_descriptor, binding) + + assert context_access['available_apis']['history_page'] is False + assert context_access['available_apis']['history_search'] is False + assert context_access['available_apis']['event_get'] is False + assert context_access['available_apis']['event_page'] is False + assert context_access['available_apis']['storage'] is False diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py new file mode 100644 index 000000000..5188aefda --- /dev/null +++ b/tests/unit_tests/agent/test_context_validation.py @@ -0,0 +1,428 @@ +"""Test that LangBot context builder output validates against SDK AgentRunContext.""" +from __future__ import annotations + +import pytest +from types import SimpleNamespace +from unittest.mock import MagicMock, AsyncMock, patch + +# SDK imports for validation +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + +# LangBot imports +from langbot.pkg.agent.runner.context_builder import ( + AgentRunContextBuilder, + AgentResources as BuilderResources, +) +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope +from langbot.pkg.core import app + + +class TestContextValidation: + """Test that context builder output validates against SDK AgentRunContext.""" + + def _make_mock_app(self): + """Create a mock application.""" + mock_app = MagicMock(spec=app.Application) + mock_app.ver_mgr = MagicMock() + mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0") + mock_app.persistence_mgr = MagicMock() + mock_app.persistence_mgr.get_db_engine = MagicMock() + mock_app.logger = MagicMock() + return mock_app + + def _make_event_envelope(self) -> AgentEventEnvelope: + """Create a test event envelope.""" + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + return AgentEventEnvelope( + event_id="evt_1", + event_type="message.received", + event_time=1700000000, + source="platform", + source_event_type="platform.message", + bot_id="bot_1", + workspace_id="workspace_1", + conversation_id="conv_1", + thread_id=None, + actor=ActorContext( + actor_type="user", + actor_id="user_1", + actor_name="Test User", + ), + subject=None, + input=EventInput(text="Hello world"), + delivery=DeliveryContext(surface="test"), + data={"platform_event_id": "source_evt_1"}, + ) + + def _make_binding(self) -> AgentBinding: + """Create a test binding.""" + return AgentBinding( + binding_id="binding_1", + scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), + event_types=["message.received"], + runner_id="plugin:test/plugin/runner", + runner_config={"timeout": 300}, + agent_id="pipeline_1", + enabled=True, + ) + + def _make_resources(self) -> BuilderResources: + """Create test resources.""" + return { + 'models': [], + 'tools': [], + 'knowledge_bases': [], + 'skills': [], + 'files': [], + 'storage': {'plugin_storage': True, 'workspace_storage': True}, + 'platform_capabilities': {}, + } + + def _make_descriptor(self): + """Create a mock runner descriptor.""" + return AgentRunnerDescriptor( + id="plugin:test/plugin/runner", + source="plugin", + label={"en_US": "Test Runner"}, + plugin_author="test", + plugin_name="plugin", + runner_name="runner", + permissions={ + "history": ["page", "search"], + "events": ["get", "page"], + "storage": ["plugin", "workspace"], + }, + ) + + @pytest.mark.asyncio + async def test_build_context_from_event_validates(self): + """Test that build_context_from_event output validates against SDK AgentRunContext.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + # Build context + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # Validate it can be parsed by SDK AgentRunContext + # This will raise ValidationError if invalid + validated = AgentRunContext.model_validate(context_dict) + + # Verify required fields + assert validated.run_id is not None + assert validated.event is not None + assert isinstance(validated.event, AgentEventContext) + assert validated.delivery is not None + assert isinstance(validated.delivery, DeliveryContext) + assert validated.context is not None + assert isinstance(validated.context, ContextAccess) + assert validated.input is not None + assert isinstance(validated.input, AgentInput) + assert validated.resources is not None + assert isinstance(validated.resources, AgentResources) + assert validated.runtime is not None + assert isinstance(validated.runtime, AgentRuntimeContext) + assert "protocol_version" not in validated.runtime.model_dump() + assert "sdk_protocol_version" not in validated.runtime.model_dump() + assert "sdk_protocol_version" not in context_dict["runtime"] + + # Verify event context + assert validated.event.event_id == "evt_1" + assert validated.event.event_type == "message.received" + assert validated.event.source == "platform" + assert validated.event.source_event_type == "platform.message" + assert validated.event.data == {"platform_event_id": "source_evt_1"} + + # Verify conversation context uses SDK field names + assert validated.conversation is not None + assert validated.conversation.bot_id == "bot_1" + assert validated.conversation.workspace_id == "workspace_1" + + # Verify delivery context + assert validated.delivery.surface == "test" + + # Verify input + assert validated.input.text == "Hello world" + + @pytest.mark.asyncio + async def test_build_context_from_event_populates_model_context_window(self): + """Runtime metadata should expose the selected LLM model context window.""" + mock_app = self._make_mock_app() + mock_app.model_mgr = MagicMock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock( + return_value=SimpleNamespace( + model_entity=SimpleNamespace(context_length=128000), + ) + ) + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + resources['models'] = [ + { + 'model_id': 'rerank-model', + 'model_type': 'rerank', + 'provider': 'test-provider', + 'operations': ['rerank'], + }, + { + 'model_id': 'llm-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + ] + descriptor = self._make_descriptor() + + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + assert context_dict['runtime']['metadata']['model_context_window_tokens'] == 128000 + mock_app.model_mgr.get_model_by_uuid.assert_awaited_once_with('llm-model') + + @pytest.mark.asyncio + async def test_model_context_window_uses_primary_llm_only(self): + """Fallback model windows should not replace missing primary model metadata.""" + mock_app = self._make_mock_app() + mock_app.model_mgr = MagicMock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock( + return_value=SimpleNamespace( + model_entity=SimpleNamespace(context_length=None), + ) + ) + builder = AgentRunContextBuilder(mock_app) + resources = self._make_resources() + resources['models'] = [ + { + 'model_id': 'primary-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + { + 'model_id': 'fallback-model', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream'], + }, + ] + + assert await builder._build_model_context_window_tokens(resources) is None + mock_app.model_mgr.get_model_by_uuid.assert_awaited_once_with('primary-model') + + @pytest.mark.asyncio + async def test_build_context_preserves_subject_data_for_non_message_events(self): + """Non-message EBA events keep subject.data instead of relying on message text.""" + from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + event = AgentEventEnvelope( + event_id="evt_recall_1", + event_type="message.recalled", + event_time=1700000001, + source="platform", + source_event_type="platform.message.recall", + bot_id="bot_1", + workspace_id="workspace_1", + conversation_id="conv_1", + actor=ActorContext(actor_type="user", actor_id="user_1"), + subject=SubjectContext( + subject_type="message", + subject_id="message_1", + data={"recalled_message_id": "message_1", "reason": "user_recall"}, + ), + input=EventInput(text=None), + delivery=DeliveryContext(surface="test"), + data={"source_event_id": "source_recall_1"}, + ) + binding = self._make_binding() + binding.event_types = ["message.recalled"] + resources = self._make_resources() + descriptor = self._make_descriptor() + + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + validated = AgentRunContext.model_validate(context_dict) + + assert validated.event.event_type == "message.recalled" + assert validated.input.text is None + assert validated.subject is not None + assert validated.subject.subject_type == "message" + assert validated.subject.subject_id == "message_1" + assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"} + + @pytest.mark.asyncio + async def test_build_context_from_event_has_no_legacy_top_level_fields(self): + """Test that build_context_from_event does NOT have top-level messages/prompt/params.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # Protocol v1 does NOT have these as core fields + assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1" + assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1" + assert 'params' not in context_dict, "params should not be top-level in Protocol v1" + + # Protocol v1 DOES have these + assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1" + assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1" + assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows" + assert 'adapter' in context_dict, "adapter should exist" + assert 'metadata' in context_dict, "metadata should exist" + + @pytest.mark.asyncio + async def test_build_context_from_event_event_is_not_none(self): + """Test that event field is NOT None in Protocol v1.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # event is REQUIRED in Protocol v1 + assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1" + + # Validate + validated = AgentRunContext.model_validate(context_dict) + assert validated.event is not None + + @pytest.mark.asyncio + async def test_build_context_from_event_delivery_is_not_none(self): + """Test that delivery field is NOT None in Protocol v1.""" + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + + event = self._make_event_envelope() + binding = self._make_binding() + resources = self._make_resources() + descriptor = self._make_descriptor() + + # Mock persistent state store to return empty state snapshot + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock(return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + }) + mock_get_store.return_value = mock_store + + context_dict = await builder.build_context_from_event( + event=event, + binding=binding, + descriptor=descriptor, + resources=resources, + ) + + # delivery is REQUIRED in Protocol v1 + assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1" + + # Validate + validated = AgentRunContext.model_validate(context_dict) + assert validated.delivery is not None diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py new file mode 100644 index 000000000..6eee1fce9 --- /dev/null +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -0,0 +1,374 @@ +"""Tests for event-first Protocol v1 entities and Query entry adapter. + +Tests cover: +1. Query -> AgentEventEnvelope conversion +2. Current config -> AgentConfig projection and single-binding resolution +3. AgentRunContext not inlining full history by default +4. LangBot Host not defining context-window controls +5. Event-first run() entry point +""" +from __future__ import annotations + +import pytest +from unittest.mock import Mock + +# Import SDK entities +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + AgentEventContext, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.result import ( + AgentRunResult, +) + +# Import LangBot host models +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.binding_resolver import ( + AgentBindingResolver, + AgentBindingResolutionError, +) + + +class TestQueryToEventEnvelope: + """Test Query -> AgentEventEnvelope conversion.""" + + def test_query_to_event_basic_fields(self, mock_query): + """Test basic field conversion from Query to Event envelope.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.event_type == "message.received" + assert event.source == "host_adapter" + assert event.bot_id == mock_query.bot_uuid + assert event.actor is not None + assert event.actor.actor_type == "user" + + def test_query_to_event_input(self, mock_query): + """Test input conversion from Query.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.input is not None + assert event.input.text == "Hello world" + assert "message_chain" not in event.input.model_dump() + + def test_query_to_event_conversation(self, mock_query): + """Test conversation context extraction.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "conv-uuid-123" + + def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query): + """Pipeline variables can provide the conversation identity for state scope.""" + mock_query.session.using_conversation.uuid = None + mock_query.variables["conversation_id"] = "conv-from-vars" + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "conv-from-vars" + + def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query): + """Debug Chat and legacy pipeline runs may not have a conversation UUID.""" + mock_query.session.using_conversation.uuid = None + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.conversation_id == "person_launcher-123" + + def test_query_to_event_delivery_context(self, mock_query): + """Test delivery context extraction.""" + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.delivery is not None + assert event.delivery.surface == "platform" + assert isinstance(event.delivery.supports_streaming, bool) + + def test_query_to_event_preserves_source_event_data(self, mock_query): + """Test source event metadata survives the adapter boundary.""" + source_event = Mock() + source_event.type = "platform.message.created" + source_event.time = 1700000000 + source_event.sender = None + source_event.model_dump = Mock(return_value={ + "type": "platform.message.created", + "message_id": "source-message-1", + "source_platform_object": {"large": "payload"}, + }) + mock_query.message_event = source_event + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.source_event_type == "platform.message.created" + assert event.event_time == 1700000000 + assert event.data == { + "type": "platform.message.created", + "message_id": "source-message-1", + } + + def test_query_to_event_keeps_large_payloads_out_of_event_data(self, mock_query): + """Large or nested platform payloads should not be duplicated into event.data.""" + source_event = Mock() + source_event.type = "platform.message.created" + source_event.time = 1700000000 + source_event.sender = None + source_event.model_dump = Mock(return_value={ + "type": "platform.message.created", + "message_id": "source-message-1", + "message_chain": [{"type": "Image", "base64": "data:image/png;base64," + ("a" * 1024)}], + "raw_text": "x" * 1024, + "source_platform_object": {"large": "payload"}, + }) + mock_query.message_event = source_event + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.data == { + "type": "platform.message.created", + "message_id": "source-message-1", + } + + def test_query_to_event_handles_missing_message_chain(self, mock_query): + """Test delivery context building when Query has no message_chain.""" + delattr(mock_query, "message_chain") + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.delivery.reply_target == {"message_id": None} + + def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query): + """Pipeline-local message IDs must not become global audit IDs.""" + first = QueryEntryAdapter.query_to_event(mock_query) + + mock_query.launcher_id = "launcher-456" + second = QueryEntryAdapter.query_to_event(mock_query) + + assert first.event_id.startswith("host:") + assert first.event_id != "789" + assert second.event_id != first.event_id + + +class TestQueryConfigToAgentConfig: + """Test current config projection and single-Agent binding resolution.""" + + def test_config_to_agent_config_runner_id(self, mock_query): + """Test AgentConfig runner_id extraction.""" + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:author/plugin/runner" + ) + + assert agent_config.runner_id == "plugin:author/plugin/runner" + + def test_config_to_agent_config_uses_current_runner_config(self, mock_query): + """Temporary query adapters use the current runner config container.""" + mock_query.pipeline_config = { + "ai": { + "runner": {"id": "plugin:langbot-team/LocalAgent/default"}, + "runner_config": { + "plugin:langbot-team/LocalAgent/default": { + "model": {"primary": "model-primary", "fallbacks": []}, + "knowledge-bases": ["kb-001"], + }, + }, + } + } + + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, + "plugin:langbot-team/LocalAgent/default", + ) + + assert agent_config.runner_config["model"] == {"primary": "model-primary", "fallbacks": []} + assert agent_config.runner_config["knowledge-bases"] == ["kb-001"] + + def test_resolver_projects_agent_scope(self, mock_query): + """Test binding scope projection through the resolver.""" + event = QueryEntryAdapter.query_to_event(mock_query) + agent_config = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:test/plugin/runner" + ) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + + assert binding.scope.scope_type == "agent" + assert binding.scope.scope_id == mock_query.pipeline_uuid + assert binding.agent_id == mock_query.pipeline_uuid + + def test_resolver_rejects_multiple_matching_agents(self, mock_query): + """Event dispatch is single-Agent in v1.""" + event = QueryEntryAdapter.query_to_event(mock_query) + first = QueryEntryAdapter.config_to_agent_config( + mock_query, "plugin:test/plugin/runner" + ) + second = first.model_copy(update={"agent_id": "agent_2"}) + + with pytest.raises(AgentBindingResolutionError): + AgentBindingResolver().resolve_one(event, [first, second]) + +class TestAgentRunContextProtocolV1: + """Test AgentRunContext Protocol v1 behavior.""" + + def test_sdk_context_event_required(self): + """Test that event is required in Protocol v1 context.""" + trigger = AgentTrigger(type="message.received") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) + input = AgentInput(text="Hello") + from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources + from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + ctx = AgentRunContext( + run_id="run_1", + trigger=trigger, + event=event, + input=input, + delivery=DeliveryContext(surface="platform"), + resources=AgentResources(), + runtime=AgentRuntimeContext(), + ) + + assert ctx.event is not None + assert ctx.event.event_type == "message.received" + + def test_sdk_context_has_no_history_message_fields(self): + """AgentRunContext should not expose inline history message fields.""" + trigger = AgentTrigger(type="message.received") + event = AgentEventContext( + event_id="evt_1", + event_type="message.received", + source="platform", + ) + input = AgentInput(text="Hello") + from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources + from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + ctx = AgentRunContext( + run_id="run_1", + trigger=trigger, + event=event, + input=input, + delivery=DeliveryContext(surface="platform"), + resources=AgentResources(), + runtime=AgentRuntimeContext(), + ) + + assert "messages" not in AgentRunContext.model_fields + assert "bootstrap" not in AgentRunContext.model_fields + assert not hasattr(ctx, "bootstrap") + + +class TestHostManagedHistoryNotInProtocol: + """Test that Host-managed history payloads are not in Protocol v1.""" + + def test_messages_not_in_sdk_context_top_level(self): + """AgentRunContext should not expose top-level history messages.""" + ctx_fields = AgentRunContext.model_fields.keys() + + assert "messages" not in ctx_fields + + +class TestSDKResultProtocolV1: + """Test SDK AgentRunResult for Protocol v1.""" + + def test_result_requires_run_id(self): + """Test result requires run_id for Protocol v1.""" + from langbot_plugin.api.entities.builtin.provider.message import Message + + result = AgentRunResult.message_completed( + run_id="run_1", + message=Message(role="assistant", content="Hello"), + ) + + assert result.run_id == "run_1" + +# Fixtures +@pytest.fixture +def mock_query(): + """Create a mock query for testing.""" + query = Mock() + query.query_id = 123 + query.bot_uuid = "bot-uuid-123" + query.pipeline_uuid = "pipeline-uuid-456" + query.launcher_type = Mock(value="person") + query.launcher_id = "launcher-123" + query.sender_id = "sender-123" + query.pipeline_config = { + "ai": { + "runner": "plugin:test/plugin/runner", + } + } + query.variables = {} + + # Create a proper content element mock + content_elem = Mock(spec=['type', 'text', 'model_dump']) + content_elem.type = 'text' + content_elem.text = 'Hello world' + content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Hello world'}) + + query.user_message = Mock() + query.user_message.content = [content_elem] + + # Create message_chain mock + message_chain = Mock() + message_chain.message_id = 789 + message_chain.model_dump = Mock(return_value={'message_id': 789, 'components': []}) + query.message_chain = message_chain + + query.message_event = None + + # Mock session with proper conversation + query.session = Mock() + query.session.launcher_type = Mock(value="person") + query.session.launcher_id = "launcher-123" + query.session.using_conversation = Mock() + query.session.using_conversation.uuid = "conv-uuid-123" + + # Mock use_funcs (empty list by default) + query.use_funcs = [] + query.use_llm_model_uuid = None + + return query + + +@pytest.fixture +def mock_query_no_session(): + """Create a mock Query without session.""" + query = Mock() + query.query_id = 456 + query.bot_uuid = "bot-uuid-456" + query.pipeline_uuid = "pipeline-uuid-789" + query.launcher_type = Mock(value="person") + query.launcher_id = "launcher-456" + query.sender_id = "sender-456" + query.pipeline_config = { + "ai": { + "runner": "plugin:test/plugin/runner", + } + } + query.variables = {} + + # Create a proper content element mock + content_elem = Mock(spec=['type', 'text', 'model_dump']) + content_elem.type = 'text' + content_elem.text = 'Test message' + content_elem.model_dump = Mock(return_value={'type': 'text', 'text': 'Test message'}) + + query.user_message = Mock() + query.user_message.content = [content_elem] + + message_chain = Mock() + message_chain.message_id = -1 + message_chain.model_dump = Mock(return_value={'message_id': -1, 'components': []}) + query.message_chain = message_chain + + query.message_event = None + query.session = None + + # Mock use_funcs + query.use_funcs = [] + query.use_llm_model_uuid = None + + return query diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py new file mode 100644 index 000000000..450b4111d --- /dev/null +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -0,0 +1,795 @@ +"""Tests for EventLog, Transcript, and history/event APIs.""" +from __future__ import annotations + +import datetime + +import pytest + +from langbot.pkg.agent.runner.host_models import ( + AgentEventEnvelope, + AgentBinding, + BindingScope, + ResourcePolicy, + StatePolicy, + DeliveryPolicy, +) +from langbot.pkg.agent.runner.event_log_store import EventLogStore +from langbot.pkg.agent.runner.transcript_store import TranscriptStore +from langbot.pkg.agent.runner.session_registry import get_session_registry +from langbot_plugin.api.entities.builtin.agent_runner.event import ( + ActorContext, +) +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + +def make_event_envelope( + event_id: str = "evt_1", + event_type: str = "message.received", + conversation_id: str | None = "conv_1", + actor_id: str | None = "user_1", + input_text: str = "Hello", +) -> AgentEventEnvelope: + """Create a test event envelope.""" + return AgentEventEnvelope( + event_id=event_id, + event_type=event_type, + event_time=1700000000, + source="platform", + bot_id="bot_1", + workspace_id=None, + conversation_id=conversation_id, + thread_id=None, + actor=ActorContext( + actor_type="user", + actor_id=actor_id, + actor_name="Test User", + ) if actor_id else None, + subject=None, + input=AgentInput(text=input_text), + delivery=DeliveryContext(surface="test"), + ) + + +def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding: + """Create a test binding.""" + return AgentBinding( + binding_id="binding_1", + scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), + event_types=["message.received"], + runner_id=runner_id, + runner_config={}, + resource_policy=ResourcePolicy(), + state_policy=StatePolicy(), + delivery_policy=DeliveryPolicy(), + enabled=True, + ) + + +class TestEventLogStore: + """Test EventLogStore operations.""" + + @pytest.mark.asyncio + async def test_append_event(self, mock_db_engine): + """Test appending an event to EventLog.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + event_id = await store.append_event( + event_id="evt_1", + event_type="message.received", + source="platform", + bot_id="bot_1", + conversation_id="conv_1", + actor_type="user", + actor_id="user_1", + input_summary="Hello world", + run_id="run_1", + runner_id="plugin:test/plugin/runner", + ) + + assert event_id == "evt_1" + stored_event = mock_session.add.call_args.args[0] + assert stored_event.metadata_json is None + + @pytest.mark.asyncio + async def test_append_event_stores_metadata_json(self, mock_db_engine): + """EventLog metadata records steering dispatch/audit facts.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + event_id = await store.append_event( + event_id="evt_steering", + event_type="message.received", + source="platform", + run_id="run_1", + runner_id="plugin:test/plugin/runner", + metadata={ + "steering": { + "status": "queued", + "claimed_by_run_id": "run_1", + } + }, + ) + + assert event_id == "evt_steering" + stored_event = mock_session.add.call_args.args[0] + assert '"status": "queued"' in stored_event.metadata_json + assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json + + @pytest.mark.asyncio + async def test_append_event_truncates_input_summary(self, mock_db_engine): + """Test that long input summaries are truncated.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + long_text = "x" * 2000 + event_id = await store.append_event( + event_id="evt_2", + event_type="message.received", + source="platform", + input_summary=long_text, + ) + + assert event_id == "evt_2" + + @pytest.mark.asyncio + async def test_page_events_with_conversation_filter(self, mock_db_engine): + """Test paging events with conversation_id filter.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = EventLogStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items, next_seq, has_more = await store.page_events( + conversation_id="conv_1", + limit=10, + ) + + assert isinstance(items, list) + + +class TestTranscriptStore: + """Test TranscriptStore operations.""" + + @pytest.mark.asyncio + async def test_append_transcript(self, mock_db_engine): + """Test appending a transcript item.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + # Mock _get_next_seq + with patch.object(store, '_get_next_seq', return_value=1): + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + transcript_id = await store.append_transcript( + transcript_id=None, # Auto-generate + event_id="evt_1", + conversation_id="conv_1", + role="user", + content="Hello", + ) + + assert transcript_id is not None + + @pytest.mark.asyncio + async def test_append_transcript_with_attachments(self, mock_db_engine): + """Test appending transcript with attachment refs.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + + with patch.object(store, '_get_next_seq', return_value=1): + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + transcript_id = await store.append_transcript( + transcript_id=None, # Auto-generate + event_id="evt_2", + conversation_id="conv_1", + role="assistant", + content="Here's an image", + attachment_refs=[ + {"id": "att_1", "type": "image", "url": "http://example.com/img.png"} + ], + ) + + assert transcript_id is not None + + @pytest.mark.asyncio + async def test_page_transcript_backward(self, mock_db_engine): + """Test paging transcript backward (older items).""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_1", + limit=10, + direction="backward", + ) + + assert isinstance(items, list) + + @pytest.mark.asyncio + async def test_page_transcript_has_hard_limit(self, mock_db_engine): + """Test that transcript paging has a hard limit.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + # Request more than the hard limit + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_1", + limit=200, # Request 200, but hard limit is 100 + ) + + # The store should cap at 100 + assert len(items) <= store.HARD_LIMIT + + @pytest.mark.asyncio + async def test_search_transcript(self, mock_db_engine): + """Test searching transcript.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + items = await store.search_transcript( + conversation_id="conv_1", + query_text="database", + top_k=10, + ) + + assert isinstance(items, list) + + +class TestHistoryPageAuthorization: + """Test history.page authorization.""" + + @pytest.mark.asyncio + async def test_history_page_requires_run_id(self, mock_handler, mock_db_engine): + """Test history.page requires run_id.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # Mock call_action to simulate the handler + result = await mock_handler.call_action( + PluginToRuntimeAction.HISTORY_PAGE, + {"run_id": None}, + ) + + # Should return error + assert result.get("ok") is False or "error" in str(result).lower() + + @pytest.mark.asyncio + async def test_history_page_validates_conversation_scope(self, mock_db_engine): + """Test history.page only allows access to run's conversation.""" + # This test verifies the authorization logic + # The actual implementation validates conversation_id matches session + session_registry = get_session_registry() + + await session_registry.register( + run_id="run_1", + runner_id="plugin:test/plugin/runner", + query_id=None, + plugin_identity="test/plugin", + resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}}, + conversation_id="conv_1", + ) + + session = await session_registry.get("run_1") + assert session is not None + assert session["authorization"]["conversation_id"] == "conv_1" + + # Cleanup + await session_registry.unregister("run_1") + + +class TestEventGetAuthorization: + """Test event.get authorization.""" + + @pytest.mark.asyncio + async def test_event_get_requires_run_id(self, mock_handler): + """Test event.get requires run_id.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + result = await mock_handler.call_action( + PluginToRuntimeAction.EVENT_GET, + {"run_id": None, "event_id": "evt_1"}, + ) + + # Should return error + assert result.get("ok") is False or "error" in str(result).lower() + + +class TestContextAccessPopulation: + """Test ContextAccess population in build_context_from_event.""" + + @pytest.mark.asyncio + async def test_context_access_has_history_apis_when_permitted(self, mock_db_engine): + """Test ContextAccess shows available APIs based on permissions.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = None + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + cursor = await store.get_latest_cursor("conv_1") + # Should return None or a cursor string + assert cursor is None or isinstance(cursor, str) + + @pytest.mark.asyncio + async def test_context_access_shows_has_history_before(self, mock_db_engine): + """Test ContextAccess indicates if history exists.""" + from unittest.mock import AsyncMock, MagicMock, patch + + store = TranscriptStore(mock_db_engine) + + mock_result = MagicMock() + mock_result.scalar.return_value = 0 + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + with patch.object(store, '_session_factory') as mock_factory: + mock_factory.return_value.__aenter__.return_value = mock_session + + has_history = await store.has_history_before("conv_1", 10) + assert isinstance(has_history, bool) + + +class TestEventLogStoreRealSQLite: + """Test EventLogStore with real SQLite database.""" + + @pytest.fixture + async def db_engine(self): + """Create an in-memory SQLite database for testing.""" + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + @pytest.mark.asyncio + async def test_append_get_event_round_trip(self, db_engine): + """Test append_event -> get_event round trip with real DB.""" + store = EventLogStore(db_engine) + + # Append event + event_id = await store.append_event( + event_id="evt_real_001", + event_type="message.received", + source="platform", + bot_id="bot_001", + conversation_id="conv_001", + actor_type="user", + actor_id="user_001", + actor_name="Test User", + input_summary="Hello world", + run_id="run_001", + runner_id="plugin:test/plugin/runner", + ) + + assert event_id == "evt_real_001" + + # Get event + event = await store.get_event(event_id) + assert event is not None + assert event["event_id"] == "evt_real_001" + assert event["event_type"] == "message.received" + assert event["source"] == "platform" + assert event["conversation_id"] == "conv_001" + assert event["actor_type"] == "user" + assert event["actor_id"] == "user_001" + + @pytest.mark.asyncio + async def test_page_events(self, db_engine): + """Test page_events with real DB.""" + store = EventLogStore(db_engine) + + # Append multiple events + for i in range(5): + await store.append_event( + event_id=f"evt_real_{i:03d}", + event_type="message.received", + source="platform", + conversation_id="conv_001", + input_summary=f"Message {i}", + ) + + # Page events + items, next_seq, has_more = await store.page_events( + conversation_id="conv_001", + limit=3, + ) + + assert len(items) == 3 + assert has_more is True + + @pytest.mark.asyncio + async def test_get_latest_cursor(self, db_engine): + """Test get_latest_cursor with real DB.""" + store = EventLogStore(db_engine) + + # Append events + for i in range(3): + await store.append_event( + event_id=f"evt_cursor_{i:03d}", + event_type="message.received", + source="platform", + conversation_id="conv_cursor", + ) + + # Get latest cursor + cursor = await store.get_latest_cursor("conv_cursor") + assert cursor is not None + assert int(cursor) > 0 + + @pytest.mark.asyncio + async def test_cleanup_events_older_than(self, db_engine): + """EventLog cleanup removes only rows older than the cutoff.""" + import sqlalchemy + from langbot.pkg.entity.persistence.event_log import EventLog + + store = EventLogStore(db_engine) + cutoff = datetime.datetime.utcnow() + await store.append_event( + event_id="evt_cleanup_old", + event_type="message.received", + source="platform", + conversation_id="conv_cleanup", + ) + await store.append_event( + event_id="evt_cleanup_new", + event_type="message.received", + source="platform", + conversation_id="conv_cleanup", + ) + async with store._session_factory() as session: + await session.execute( + sqlalchemy.update(EventLog) + .where(EventLog.event_id == "evt_cleanup_old") + .values(created_at=cutoff - datetime.timedelta(days=2)) + ) + await session.execute( + sqlalchemy.update(EventLog) + .where(EventLog.event_id == "evt_cleanup_new") + .values(created_at=cutoff + datetime.timedelta(days=2)) + ) + await session.commit() + + removed = await store.cleanup_events_older_than(cutoff) + + assert removed == 1 + assert await store.get_event("evt_cleanup_old") is None + assert await store.get_event("evt_cleanup_new") is not None + + +class TestTranscriptStoreRealSQLite: + """Test TranscriptStore with real SQLite database.""" + + @pytest.fixture + async def db_engine(self): + """Create an in-memory SQLite database for testing.""" + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + @pytest.mark.asyncio + async def test_append_page_transcript_round_trip(self, db_engine): + """Test append_transcript -> page_transcript round trip with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + for i in range(3): + await store.append_transcript( + transcript_id=f"trans_real_{i:03d}", + event_id=f"evt_{i:03d}", + conversation_id="conv_001", + role="user" if i % 2 == 0 else "assistant", + content=f"Message {i}", + ) + + # Page transcript + items, next_seq, prev_seq, has_more = await store.page_transcript( + conversation_id="conv_001", + limit=10, + ) + + assert len(items) == 3 + assert items[0]["conversation_id"] == "conv_001" + + @pytest.mark.asyncio + async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine): + """Transcript is the canonical source; legacy Pipeline readers get a Message view.""" + store = TranscriptStore(db_engine) + + await store.append_transcript( + transcript_id="trans_view_001", + event_id="evt_view_001", + conversation_id="conv_view", + role="user", + content="User text", + content_json={ + "role": "user", + "content": [{"type": "text", "text": "User structured text"}], + }, + ) + await store.append_transcript( + transcript_id="trans_view_002", + event_id="evt_view_002", + conversation_id="conv_view", + role="tool", + item_type="tool_result", + content="ignored tool result", + ) + await store.append_transcript( + transcript_id="trans_view_003", + event_id="evt_view_003", + conversation_id="conv_view", + role="assistant", + content="Assistant text", + ) + + messages = await store.get_legacy_provider_messages("conv_view") + + assert [message.role for message in messages] == ["user", "assistant"] + assert messages[0].content[0].text == "User structured text" + assert messages[1].content == "Assistant text" + + @pytest.mark.asyncio + async def test_get_legacy_provider_messages_filters_scope(self, db_engine): + """Legacy Pipeline history projection must stay inside the current run scope.""" + store = TranscriptStore(db_engine) + + await store.append_transcript( + transcript_id="trans_scope_001", + event_id="evt_scope_001", + conversation_id="conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_001", + role="user", + content="Current scope text", + ) + await store.append_transcript( + transcript_id="trans_scope_002", + event_id="evt_scope_002", + conversation_id="conv_scope", + bot_id="bot_002", + workspace_id="workspace_001", + thread_id="thread_001", + role="assistant", + content="Other bot text", + ) + await store.append_transcript( + transcript_id="trans_scope_003", + event_id="evt_scope_003", + conversation_id="conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_002", + role="assistant", + content="Other thread text", + ) + + messages = await store.get_legacy_provider_messages( + "conv_scope", + bot_id="bot_001", + workspace_id="workspace_001", + thread_id="thread_001", + strict_thread=True, + ) + + assert [message.content for message in messages] == ["Current scope text"] + + @pytest.mark.asyncio + async def test_search_transcript_real_db(self, db_engine): + """Test search_transcript with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + await store.append_transcript( + transcript_id="trans_search_001", + event_id="evt_search_001", + conversation_id="conv_search", + role="user", + content="I want to learn about databases", + ) + await store.append_transcript( + transcript_id="trans_search_002", + event_id="evt_search_002", + conversation_id="conv_search", + role="assistant", + content="Here is information about databases", + ) + + # Search for "database" + items = await store.search_transcript( + conversation_id="conv_search", + query_text="database", + ) + + # Should find at least one match + assert len(items) >= 1 + + @pytest.mark.asyncio + async def test_get_latest_cursor_real_db(self, db_engine): + """Test get_latest_cursor with real DB.""" + store = TranscriptStore(db_engine) + + # Append transcript items + for i in range(3): + await store.append_transcript( + transcript_id=f"trans_cursor_{i:03d}", + event_id=f"evt_cursor_{i:03d}", + conversation_id="conv_cursor", + role="user", + content=f"Message {i}", + ) + + # Get latest cursor + cursor = await store.get_latest_cursor("conv_cursor") + assert cursor is not None + assert int(cursor) > 0 + + @pytest.mark.asyncio + async def test_cleanup_transcripts_older_than(self, db_engine): + """Transcript cleanup removes only rows older than the cutoff.""" + import sqlalchemy + from langbot.pkg.entity.persistence.transcript import Transcript + + store = TranscriptStore(db_engine) + cutoff = datetime.datetime.utcnow() + await store.append_transcript( + transcript_id="trans_cleanup_old", + event_id="evt_cleanup_old", + conversation_id="conv_cleanup", + role="user", + content="old", + ) + await store.append_transcript( + transcript_id="trans_cleanup_new", + event_id="evt_cleanup_new", + conversation_id="conv_cleanup", + role="assistant", + content="new", + ) + async with store._session_factory() as session: + await session.execute( + sqlalchemy.update(Transcript) + .where(Transcript.transcript_id == "trans_cleanup_old") + .values(created_at=cutoff - datetime.timedelta(days=2)) + ) + await session.execute( + sqlalchemy.update(Transcript) + .where(Transcript.transcript_id == "trans_cleanup_new") + .values(created_at=cutoff + datetime.timedelta(days=2)) + ) + await session.commit() + + removed = await store.cleanup_transcripts_older_than(cutoff) + items, _, _, _ = await store.page_transcript("conv_cleanup", limit=10) + + assert removed == 1 + assert [item["content"] for item in items] == ["new"] + + +# Fixtures +@pytest.fixture +def mock_db_engine(): + """Create a mock database engine for AsyncSession-based stores.""" + from unittest.mock import MagicMock + from sqlalchemy.ext.asyncio import AsyncEngine + + engine = MagicMock(spec=AsyncEngine) + return engine + + +@pytest.fixture +def mock_handler(): + """Create a mock handler for testing actions.""" + from langbot_plugin.runtime.io.handler import Handler + + class MockHandler(Handler): + def __init__(self): + self._responses = {} + + async def call_action(self, action, data, timeout=30): + # Simulate error response for missing run_id + if not data.get("run_id"): + return {"ok": False, "message": "run_id is required"} + return {"ok": True, "data": {}} + + return MockHandler() diff --git a/tests/unit_tests/agent/test_execution_context.py b/tests/unit_tests/agent/test_execution_context.py new file mode 100644 index 000000000..512160f2b --- /dev/null +++ b/tests/unit_tests/agent/test_execution_context.py @@ -0,0 +1,225 @@ +"""Tests for Host-only AgentRunner tool execution context.""" + +from __future__ import annotations + +import json + +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.provider.message import ContentElement + +from langbot.pkg.agent.runner.execution_context import ( + append_mcp_resource_context_to_event, + build_execution_query, + build_host_box_scope, + prepare_box_scope, + prepare_execution_query, + project_mcp_resource_config, +) +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope +from langbot.pkg.utils import constants + + +class PlatformAdapter: + pass + + +def make_event( + *, + event_id: str = 'event-1', + conversation_id: str | None = 'person_user-1', + target_type: str | None = 'person', + target_id: str | None = 'user-1', + adapter: str = 'PlatformAdapter', +) -> AgentEventEnvelope: + reply_target = {} + if target_type is not None: + reply_target['target_type'] = target_type + if target_id is not None: + reply_target['target_id'] = target_id + return AgentEventEnvelope( + event_id=event_id, + event_type='message.received', + source='platform', + bot_id='bot-1', + workspace_id='workspace-1', + conversation_id=conversation_id, + input=AgentInput(text='hello'), + delivery=DeliveryContext( + surface='platform', + reply_target=reply_target, + platform_capabilities={'adapter': adapter}, + ), + ) + + +def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch): + monkeypatch.setattr(constants, 'instance_id', 'instance-1') + event = make_event() + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + sender_id='user-1', + adapter=PlatformAdapter(), + variables={}, + ) + + prepare_execution_query(query, event, ['pdf']) + event_query = build_execution_query(event, ['pdf']) + + assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope'] + assert query.variables['_pipeline_bound_skills'] == ['pdf'] + assert event_query.variables['_pipeline_bound_skills'] == ['pdf'] + scope = json.loads(query.variables['_host_box_scope']) + assert scope == { + 'instance_id': 'instance-1', + 'workspace_id': 'workspace-1', + 'bot_id': 'bot-1', + 'platform_adapter': 'PlatformAdapter', + 'target_type': 'person', + 'target_id': 'user-1', + 'thread_id': None, + } + + +def test_prepare_box_scope_does_not_change_existing_skill_projection(): + event = make_event() + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + variables={'_pipeline_bound_skills': ['existing']}, + ) + + variables = prepare_box_scope(query, event) + + assert variables['_host_box_scope'] + assert variables['_pipeline_bound_skills'] == ['existing'] + + +def test_prepare_box_scope_preserves_event_first_channel_scope(): + channel_event = make_event(target_type='channel', target_id='same') + channel_query = build_execution_query(channel_event, []) + original_scope = channel_query.variables['_host_box_scope'] + + prepare_execution_query(channel_query, channel_event, []) + + person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[ + '_host_box_scope' + ] + assert channel_query.variables['_host_box_scope'] == original_scope + assert json.loads(original_scope)['target_type'] == 'channel' + assert original_scope != person_scope + + +def test_prepare_box_scope_overwrites_untrusted_existing_scope(): + event = make_event(target_type='person', target_id='user-1') + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + variables={'_host_box_scope': 'forged-scope'}, + ) + + variables = prepare_box_scope(query, event) + + assert variables['_host_box_scope'] != 'forged-scope' + assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1' + + +def test_project_mcp_resource_config_uses_independent_agent_runner_settings(): + query = pipeline_query.Query.model_construct(variables={}) + attachments = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + + variables = project_mcp_resource_config( + query, + { + 'mcp-resources': attachments, + 'mcp-resource-agent-read-enabled': False, + }, + ) + + assert variables['_pipeline_mcp_resource_attachments'] == attachments + assert variables['_pipeline_mcp_resource_attachments'] is not attachments + assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False + + +def test_project_mcp_resource_config_fails_closed_for_non_boolean_read_flag(): + query = pipeline_query.Query.model_construct(variables={}) + + variables = project_mcp_resource_config( + query, + {'mcp-resource-agent-read-enabled': 0}, + ) + + assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False + + +def test_pinned_context_updates_text_and_structured_input(): + event = make_event() + event.input = AgentInput( + text='hello', + contents=[ContentElement.from_text('hello')], + ) + + append_mcp_resource_context_to_event(event, '\n\npinned-context') + + assert event.input.text == 'hello\n\npinned-context' + assert event.input.contents[0].text == 'hello\n\npinned-context' + + +def test_event_reply_target_populates_valid_session_identity(): + event = make_event(conversation_id='rotating-transcript-id', target_type='group', target_id='room-1') + + query = build_execution_query(event, []) + + assert query.launcher_type.value == 'group' + assert query.launcher_id == 'room-1' + assert query.sender_id == 'room-1' + assert query.session.launcher_type.value == 'group' + assert query.session.launcher_id == 'room-1' + scope = json.loads(query.variables['_host_box_scope']) + assert scope['target_type'] == 'group' + assert scope['target_id'] == 'room-1' + assert 'rotating-transcript-id' not in query.variables['_host_box_scope'] + + +def test_non_message_event_without_conversation_uses_event_scope(): + event = make_event( + event_id='scheduled/event/中文', + conversation_id=None, + target_type=None, + target_id=None, + adapter='scheduler', + ) + event.event_type = 'schedule.triggered' + + query = build_execution_query(event, []) + + scope = json.loads(query.variables['_host_box_scope']) + assert scope['target_type'] == 'event' + assert scope['target_id'] == event.event_id + assert query.pipeline_config is None + assert query.pipeline_uuid is None + + +def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch): + event = make_event(adapter='AdapterA') + monkeypatch.setattr(constants, 'instance_id', 'instance-a') + first = build_host_box_scope(event) + + monkeypatch.setattr(constants, 'instance_id', 'instance-b') + second = build_host_box_scope(event) + other_adapter = build_host_box_scope(make_event(adapter='AdapterB')) + + assert first != second + assert second != other_adapter diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py new file mode 100644 index 000000000..3926d1870 --- /dev/null +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -0,0 +1,2021 @@ +"""Tests for RuntimeConnectionHandler proxy action authorization. + +Tests focus on: +- INVOKE_LLM authorization +- INVOKE_LLM_STREAM authorization +- CALL_TOOL authorization +- RETRIEVE_KNOWLEDGE_BASE authorization + +Authorization paths: +1. AgentRunner calls: has run_id, validates against session_registry +2. Regular plugin calls: no run_id, unscoped plugin action path +""" + +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.plugin.handler import _get_pipeline_knowledge_base_uuids + +# Import shared test fixtures from conftest.py +from .conftest import make_resources, make_session + + +class MockModel: + """Mock LLM model for testing.""" + + def __init__(self, uuid: str): + self.uuid = uuid + self.provider = MagicMock() + self.provider.invoke_llm = AsyncMock(return_value=MagicMock(model_dump=lambda: {'content': 'response'})) + + +class MockEmbeddingModel: + """Mock embedding model for testing.""" + + def __init__(self, uuid: str): + self.uuid = uuid + self.provider = MagicMock() + + +class MockKnowledgeBase: + """Mock knowledge base for testing.""" + + def __init__(self, uuid: str, name: str = 'KB'): + self.knowledge_base_entity = MagicMock() + self.knowledge_base_entity.description = f'{name} description' + self._uuid = uuid + self._name = name + self.retrieve = AsyncMock(return_value=[]) + + def get_uuid(self): + return self._uuid + + def get_name(self): + return self._name + + +class MockQuery: + """Mock query for testing.""" + + def __init__(self, query_id: int = 1): + self.query_id = query_id + self.session = MagicMock() + self.session.launcher_type = MagicMock() + self.session.launcher_type.value = 'telegram' + self.session.launcher_id = 'group_123' + self.sender_id = 'user_001' + self.bot_uuid = 'bot_001' + self.pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + +class MockApplication: + """Mock Application for testing.""" + + def __init__(self): + self.logger = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.info = MagicMock() + self.logger.error = MagicMock() + + self.query_pool = MagicMock() + self.query_pool.cached_queries = {} + + self.model_mgr = MagicMock() + self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + self.model_mgr.get_embedding_model_by_uuid = AsyncMock(return_value=None) + + self.tool_mgr = MagicMock() + self.tool_mgr.execute_func_call = AsyncMock(return_value={'result': 'success'}) + + self.rag_mgr = MagicMock() + self.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None) + self.rag_mgr.knowledge_bases = {} + + self.persistence_mgr = MagicMock() + self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None)) + + +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + capabilities={'knowledge_retrieval': True}, + permissions={'knowledge_bases': ['list', 'retrieve']}, + ) + + +class MockConnection: + """Mock connection for testing.""" + + pass + + +class TestPipelineKnowledgeBaseScope: + """Tests for schema-driven pipeline KB scope resolution.""" + + @pytest.mark.asyncio + async def test_uses_preprocessed_query_scope(self): + app = MockApplication() + query = MockQuery() + query.variables = {'_knowledge_base_uuids': ['kb_var', '__none__', 'kb_var']} + + kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query) + + assert kb_uuids == ['kb_var'] + + @pytest.mark.asyncio + async def test_uses_runner_schema_when_query_scope_not_preprocessed(self): + app = MockApplication() + app.agent_runner_registry = FakeAgentRunnerRegistry() + query = MockQuery() + query.variables = {} + + kb_uuids = await _get_pipeline_knowledge_base_uuids(app, query) + + assert kb_uuids == ['kb_001', 'kb_002'] + + +class MockDisconnectCallback: + """Mock disconnect callback for testing.""" + + async def __call__(self): + return True + + +class TestInvokeLLMAuthorization: + """Tests for INVOKE_LLM authorization.""" + + @pytest.mark.asyncio + async def test_invoke_llm_authorized_with_run_id(self): + """INVOKE_LLM: authorized when model in session.resources.""" + # Setup registry with session + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Verify authorization logic directly + session = await registry.get('run_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + + # Cleanup + await registry.unregister('run_authorized') + + @pytest.mark.asyncio + async def test_invoke_llm_unauthorized_with_run_id(self): + """INVOKE_LLM: unauthorized when model not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Test authorization logic directly + session = await registry.get('run_unauthorized') + assert session is not None + # model_002 is not in resources + assert registry.is_resource_allowed(session, 'model', 'model_002') is False + + await registry.unregister('run_unauthorized') + + @pytest.mark.asyncio + async def test_invoke_llm_session_not_found(self): + """INVOKE_LLM: session not found should return error.""" + registry = AgentRunSessionRegistry() + + # No session registered for this run_id + session = await registry.get('run_nonexistent') + assert session is None + + @pytest.mark.asyncio + async def test_invoke_llm_no_run_id_unrestricted(self): + """INVOKE_LLM: no run_id should be unrestricted (backward compat).""" + # When no run_id is provided, the authorization check is skipped + # This is the unscoped path for regular plugin calls + + # Simulate: if not run_id, skip authorization + run_id = None + # Authorization check should NOT be triggered + assert run_id is None # No authorization check + + +class TestInvokeLLMStreamAuthorization: + """Tests for INVOKE_LLM_STREAM authorization.""" + + @pytest.mark.asyncio + async def test_invoke_llm_stream_authorized_with_run_id(self): + """INVOKE_LLM_STREAM: authorized when model in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_stream_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_stream_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + + await registry.unregister('run_stream_authorized') + + @pytest.mark.asyncio + async def test_invoke_llm_stream_unauthorized_with_run_id(self): + """INVOKE_LLM_STREAM: unauthorized when model not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_stream_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_stream_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'model', 'model_002') is False + + await registry.unregister('run_stream_unauthorized') + + @pytest.mark.asyncio + async def test_invoke_llm_stream_no_run_id_unrestricted(self): + """INVOKE_LLM_STREAM: no run_id should be unrestricted.""" + run_id = None + # No authorization check + assert run_id is None + + +@pytest.mark.asyncio +async def test_tool_manager_get_tool_detail_returns_uniform_schema(): + """ToolManager.get_tool_detail returns a uniform host-level tool detail shape. + + All loaders normalize to resource_tool.LLMTool, so GET_TOOL_DETAIL no longer + needs a handler-local adapter for plugin ComponentManifest objects. + """ + import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + from langbot.pkg.provider.tools.toolmgr import ToolManager + + tool = resource_tool.LLMTool( + name='search', + human_desc='Search public data', + description='Search test data', + parameters={'type': 'object', 'properties': {'q': {'type': 'string'}}}, + func=lambda **kwargs: {}, + ) + + mgr = ToolManager.__new__(ToolManager) + + async def fake_get_tool_by_name(name): + return tool if name == 'search' else None + + mgr.get_tool_by_name = fake_get_tool_by_name + + detail = await mgr.get_tool_detail('search') + assert detail == { + 'name': 'search', + 'description': 'Search test data', + 'human_desc': 'Search public data', + 'parameters': {'type': 'object', 'properties': {'q': {'type': 'string'}}}, + } + assert await mgr.get_tool_detail('missing') is None + + +class TestCallToolAuthorization: + """Tests for CALL_TOOL authorization.""" + + @pytest.mark.asyncio + async def test_call_tool_authorized_with_run_id(self): + """CALL_TOOL: authorized when tool in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + + await registry.unregister('run_tool_authorized') + + @pytest.mark.asyncio + async def test_call_tool_unauthorized_with_run_id(self): + """CALL_TOOL: unauthorized when tool not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False + + await registry.unregister('run_tool_unauthorized') + + @pytest.mark.asyncio + async def test_call_tool_no_run_id_unrestricted(self): + """CALL_TOOL: no run_id should be unrestricted.""" + run_id = None + # No authorization check + assert run_id is None + + +class TestRetrieveKnowledgeBaseAuthorization: + """Tests for RETRIEVE_KNOWLEDGE_BASE authorization.""" + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_authorized_with_run_id(self): + """RETRIEVE_KNOWLEDGE_BASE: authorized when kb in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_authorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_authorized') + assert session is not None + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + + await registry.unregister('run_kb_authorized') + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_unauthorized_with_run_id(self): + """RETRIEVE_KNOWLEDGE_BASE: unauthorized when kb not in session.resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_unauthorized', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_unauthorized') + assert session is not None + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + await registry.unregister('run_kb_unauthorized') + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): + """RETRIEVE_KNOWLEDGE_BASE: no run_id checks pipeline config.""" + # When no run_id, the handler checks against pipeline's configured KBs + # This is the unscoped path for regular plugin calls + + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + # Simulate pipeline config + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:test/runner/default' + + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + assert 'kb_001' in allowed_kbs + assert 'kb_999' not in allowed_kbs + + +class TestAuthorizationPathDifferentiation: + """Tests that verify AgentRunner vs regular plugin call differentiation.""" + + @pytest.mark.asyncio + async def test_agent_runner_path_with_run_id(self): + """AgentRunner calls provide run_id and use session_registry.""" + registry = AgentRunSessionRegistry() + + # AgentRunner call has run_id + run_id = 'run_agent_123' + + # Register session with resources + await registry.register( + run_id=run_id, + runner_id='plugin:test/agent/default', + query_id=1, + plugin_identity='test/agent', + resources=make_resources( + models=[{'model_id': 'model_xyz'}], + tools=[{'tool_name': 'agent_tool'}], + knowledge_bases=[{'kb_id': 'kb_agent'}], + ), + ) + + session = await registry.get(run_id) + assert session is not None + + # Authorization checks + assert registry.is_resource_allowed(session, 'model', 'model_xyz') is True + assert registry.is_resource_allowed(session, 'model', 'other_model') is False + assert registry.is_resource_allowed(session, 'tool', 'agent_tool') is True + assert registry.is_resource_allowed(session, 'tool', 'other_tool') is False + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_agent') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_other') is False + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_regular_plugin_path_no_run_id(self): + """Regular plugin calls have no run_id and skip session check.""" + # Regular plugin call has no run_id + run_id = None + + # Authorization check should be skipped when run_id is None. + # This is handled in handler.py with: if run_id: ... + assert run_id is None + + # For regular plugins: + # - INVOKE_LLM: unrestricted access to any model + # - CALL_TOOL: unrestricted access to any tool + # - RETRIEVE_KNOWLEDGE_BASE: checks pipeline config instead + + +class TestHandlerAuthorizationErrorMessages: + """Tests for error message content in authorization failures.""" + + def test_model_not_authorized_error_message(self): + """Error message should mention model not authorized.""" + expected_msg = 'Model model_999 is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'model_999' in expected_msg + + def test_tool_not_authorized_error_message(self): + """Error message should mention tool not authorized.""" + expected_msg = 'Tool image_gen is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'image_gen' in expected_msg + + def test_kb_not_authorized_error_message(self): + """Error message should mention kb not authorized.""" + expected_msg = 'Knowledge base kb_999 is not authorized for this agent run' + assert 'not authorized' in expected_msg + assert 'kb_999' in expected_msg + + def test_session_not_found_error_message(self): + """Error message should mention session not found.""" + expected_msg = 'Run session run_xyz not found or expired' + assert 'not found' in expected_msg + assert 'run_xyz' in expected_msg + + +class TestRETRIEVEKNOWLEDGEBASEBugFix: + """Tests for the RETRIEVE_KNOWLEDGE_BASE bug fix in handler.py. + + Bug: Previously, the handler directly accessed pipeline_config['ai']['local-agent'] + without first resolving the runner_id, causing issues when non-local-agent runners + were used. + + Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config. + """ + + def test_retrieve_kb_fix_local_agent_runner(self): + """Fix should work for local-agent runner.""" + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot-team/LocalAgent/default', + }, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'knowledge-bases': ['kb_001'], + }, + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + + assert 'kb_001' in allowed_kbs + + def test_retrieve_kb_fix_other_runner(self): + """Fix should work for non-local-agent runners.""" + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:custom/my-agent/default', + }, + 'runner_config': { + 'plugin:custom/my-agent/default': { + 'knowledge-bases': ['kb_custom'], + }, + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + allowed_kbs = runner_config.get('knowledge-bases', []) + + assert 'kb_custom' in allowed_kbs + + def test_retrieve_kb_does_not_read_old_runner_format(self): + """The 4.x authorization path ignores legacy Pipeline runner fields.""" + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + 'local-agent': { + 'knowledge-bases': ['kb_legacy'], + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + assert runner_id is None + assert runner_config == {} + + +class TestHandlerActionAuthorization: + """Tests for real handler action-level authorization. + + These tests simulate RuntimeConnectionHandler action handlers + to verify actual authorization behavior at the action level. + """ + + @pytest.mark.asyncio + async def test_invoke_llm_handler_authorized_path(self): + """INVOKE_LLM handler: authorized when model in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Simulate handler authorization logic + run_id = 'run_invoke_llm_auth' + llm_model_uuid = 'model_001' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (same as handler.py line 352) + is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_invoke_llm_handler_unauthorized_path(self): + """INVOKE_LLM handler: unauthorized when model not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_invoke_llm_unauth' + llm_model_uuid = 'model_999' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (same as handler.py line 352) + is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) + assert is_allowed is False + + # Should return error response (handler.py line 357) + expected_error = f'Model {llm_model_uuid} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_invoke_llm_handler_session_not_found(self): + """INVOKE_LLM handler: session not found returns error.""" + registry = AgentRunSessionRegistry() + + # No session registered + run_id = 'run_nonexistent' + session = await registry.get(run_id) + assert session is None + + # Handler should return error (handler.py line 348) + expected_error = f'Run session {run_id} not found or expired' + assert 'not found' in expected_error + + @pytest.mark.asyncio + async def test_invoke_llm_handler_no_run_id_unrestricted(self): + """INVOKE_LLM handler: no run_id skips authorization (backward compat).""" + # Simulate handler logic: if not run_id, skip authorization + run_id = None + + # In handler.py, authorization check is inside: if run_id: ... + # So when run_id is None, authorization is skipped. + assert run_id is None + + @pytest.mark.asyncio + async def test_call_tool_handler_authorized_path(self): + """CALL_TOOL handler: authorized when tool in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_call_tool_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_call_tool_auth' + tool_name = 'web_search' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (handler.py line 475) + is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_call_tool_handler_unauthorized_path(self): + """CALL_TOOL handler: unauthorized when tool not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_call_tool_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_call_tool_unauth' + tool_name = 'image_gen' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check + is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) + assert is_allowed is False + + # Should return error (handler.py line 480) + expected_error = f'Tool {tool_name} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_call_tool_handler_no_run_id_unrestricted(self): + """CALL_TOOL handler: no run_id skips authorization.""" + run_id = None + + # Authorization check is inside: if run_id: ... + assert run_id is None + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_handler_authorized_path(self): + """RETRIEVE_KNOWLEDGE_BASE handler: authorized when kb in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_kb_auth' + kb_id = 'kb_001' + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check (handler.py line 889) + is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) + assert is_allowed is True + + await registry.unregister(run_id) + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_handler_unauthorized_path(self): + """RETRIEVE_KNOWLEDGE_BASE handler: unauthorized when kb not in resources.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_unauth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + run_id = 'run_kb_unauth' + kb_id = 'kb_999' # Not in resources + + session_registry = registry + session = await session_registry.get(run_id) + assert session is not None + + # Authorization check + is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) + assert is_allowed is False + + # Should return error (handler.py line 894) + expected_error = f'Knowledge base {kb_id} is not authorized for this agent run' + assert 'not authorized' in expected_error + + await registry.unregister(run_id) + + +class TestSDKAgentRunAPIProxyFieldConsistency: + """Tests for SDK AgentRunAPIProxy field name consistency with Host handler. + + These tests verify that SDK sends field names that match what Host handler reads. + """ + + def test_call_tool_field_names_match(self): + """CALL_TOOL: SDK 'parameters' matches Host 'parameters'.""" + # SDK agent_run_api.py line 146: "parameters": parameters + # Host handler.py line 457: parameters = data['parameters'] + sdk_field = 'parameters' + host_field = 'parameters' + assert sdk_field == host_field + + def test_call_tool_run_id_field_present(self): + """CALL_TOOL: SDK includes 'run_id' field.""" + # SDK agent_run_api.py line 144: "run_id": self.run_id + # Host handler.py line 458: run_id = data.get('run_id') + sdk_fields = ['run_id', 'tool_name', 'parameters'] + host_expected_fields = ['tool_name', 'parameters', 'run_id'] + + for field in host_expected_fields: + assert field in sdk_fields + + def test_invoke_llm_field_names_match(self): + """INVOKE_LLM: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 77-82 + sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args', 'timeout'] + # Host handler.py lines 333-337 + host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] + + for field in host_fields: + assert field in sdk_fields + + def test_invoke_llm_stream_field_names_match(self): + """INVOKE_LLM_STREAM: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 111-116 + sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args'] + # Host handler.py lines 397-401 + host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] + + for field in host_fields: + assert field in sdk_fields + + def test_retrieve_knowledge_base_field_names_match(self): + """RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler.""" + # SDK agent_run_api.py lines 178-183 + sdk_fields = ['run_id', 'kb_id', 'query_text', 'top_k', 'filters'] + + # Note: query_id is from query context, not SDK proxy + for field in ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']: + assert field in sdk_fields + + def test_retrieve_knowledge_base_action_enum_correct(self): + """RETRIEVE_KNOWLEDGE_BASE: SDK uses correct action enum.""" + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # SDK agent_run_api.py line 178: PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + # Host handler.py line 851: @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE) + action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE + assert action.value == 'retrieve_knowledge_base' + + # Verify it's different from unrestricted RETRIEVE_KNOWLEDGE + unrestricted_action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE + assert unrestricted_action.value == 'retrieve_knowledge' + assert action != unrestricted_action + + +class TestNoRunIdBackwardCompatPath: + """Tests for unscoped plugin action path when no run_id is provided. + + Regular plugins (non-AgentRunner) don't have run_id and should + have unrestricted access to certain APIs. + """ + + @pytest.mark.asyncio + async def test_invoke_llm_no_run_id_unrestricted_access(self): + """INVOKE_LLM: no run_id means unrestricted model access.""" + # Handler.py line 340: if run_id: ... + # When run_id is None, the authorization block is skipped + run_id = None + llm_model_uuid = 'any_model' + + # Simulate handler logic: no run_id skips authorization. + assert run_id is None + + # Model can be any UUID (unrestricted) + assert llm_model_uuid == 'any_model' + + @pytest.mark.asyncio + async def test_call_tool_no_run_id_unrestricted_access(self): + """CALL_TOOL: no run_id means unrestricted tool access.""" + run_id = None + tool_name = 'any_tool' + + # Handler.py line 463: if run_id: ... + assert run_id is None + + assert tool_name == 'any_tool' + + @pytest.mark.asyncio + async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): + """RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check.""" + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + # When no run_id, handler.py lines 897-914 check pipeline config + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:test/runner/default', + }, + 'runner_config': { + 'plugin:test/runner/default': { + 'knowledge-bases': ['kb_001', 'kb_002'], + }, + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) + allowed_kb_uuids = runner_config.get('knowledge-bases', []) + + # kb_001 should be allowed + assert 'kb_001' in allowed_kb_uuids + # kb_999 should NOT be allowed + assert 'kb_999' not in allowed_kb_uuids + + +class TestSessionExpiryAndCleanup: + """Tests for session expiry and cleanup scenarios.""" + + @pytest.mark.asyncio + async def test_session_expiry_detection(self): + """Session expiry: old session should be considered expired.""" + import time + + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + # Register session + await registry.register( + run_id='run_expiry_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_expiry_test') + assert session is not None + + # Check session status + started_at = session['status']['started_at'] + last_activity = session['status']['last_activity_at'] + assert last_activity >= started_at + + # Session should be valid initially + current_time = int(time.time()) + assert current_time - started_at < 10 # Less than 10 seconds old + + await registry.unregister('run_expiry_test') + + @pytest.mark.asyncio + async def test_cleanup_stale_sessions(self): + """Cleanup: stale sessions should be removed.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + # Register session + await registry.register( + run_id='run_cleanup_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Session exists + session = await registry.get('run_cleanup_test') + assert session is not None + + # Cleanup with max_age=0 (immediate cleanup) + # Note: This won't actually cleanup because session is just created + # We need to manually test cleanup logic + cleaned = await registry.cleanup_stale_sessions(max_age_seconds=0) + assert isinstance(cleaned, int) + + # Session should still exist (it was just created) + # With max_age=0, sessions with last_activity > 0 seconds ago would be cleaned + # But since it's just created, last_activity_at is current time + session_after = await registry.get('run_cleanup_test') + assert session_after is not None + + await registry.unregister('run_cleanup_test') + + @pytest.mark.asyncio + async def test_unregister_removes_session(self): + """Unregister: session should be removed from registry.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unregister_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Session exists + session = await registry.get('run_unregister_test') + assert session is not None + + # Unregister + await registry.unregister('run_unregister_test') + + # Session should not exist + session_after = await registry.get('run_unregister_test') + assert session_after is None + + +class TestResourceTypeValidation: + """Tests for different resource type validation in is_resource_allowed.""" + + @pytest.mark.asyncio + async def test_model_resource_validation(self): + """Model resource: correct model_id validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001'}, + {'model_id': 'model_002'}, + ] + ) + + await registry.register( + run_id='run_model_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_model_validation') + + # Authorized models + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_002') is True + + # Unauthorized models + assert registry.is_resource_allowed(session, 'model', 'model_999') is False + + await registry.unregister('run_model_validation') + + @pytest.mark.asyncio + async def test_tool_resource_validation(self): + """Tool resource: correct tool_name validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search'}, + {'tool_name': 'image_gen'}, + ] + ) + + await registry.register( + run_id='run_tool_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_tool_validation') + + # Authorized tools + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is True + + # Unauthorized tools + assert registry.is_resource_allowed(session, 'tool', 'file_upload') is False + + await registry.unregister('run_tool_validation') + + @pytest.mark.asyncio + async def test_knowledge_base_resource_validation(self): + """Knowledge base resource: correct kb_id validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + knowledge_bases=[ + {'kb_id': 'kb_001'}, + {'kb_id': 'kb_002'}, + ] + ) + + await registry.register( + run_id='run_kb_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_kb_validation') + + # Authorized KBs + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True + + # Unauthorized KBs + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + await registry.unregister('run_kb_validation') + + @pytest.mark.asyncio + async def test_storage_resource_validation(self): + """Storage resource: boolean permission validation.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': False} + + await registry.register( + run_id='run_storage_validation', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_storage_validation') + + # Plugin storage allowed + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + + # Workspace storage not allowed + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_storage_validation') + + def test_unknown_resource_type_returns_false(self): + """Unknown resource type: should return False.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + + session = make_session(resources=resources) + + # Unknown resource type should return False + assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False + + +class TestBypassPrevention: + """Tests to ensure AgentRunAPIProxy cannot bypass authorization.""" + + @pytest.mark.asyncio + async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self): + """Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action.""" + # AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id) + # RETRIEVE_KNOWLEDGE is unrestricted and separate + # AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization + + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_bypass_test', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_bypass_test') + + # kb_002 is not authorized + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False + + # If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted), + # it would bypass authorization - but AgentRunAPIProxy correctly uses + # RETRIE_KNOWLEDGE_BASE which requires authorization + + from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + + # Verify SDK uses correct action + assert PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE.value == 'retrieve_knowledge_base' + + await registry.unregister('run_bypass_test') + + @pytest.mark.asyncio + async def test_cannot_bypass_via_missing_run_id_in_session(self): + """Cannot bypass by using run_id that doesn't exist in registry.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_valid', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Try to use a run_id that doesn't exist + fake_run_id = 'run_fake' + session = await registry.get(fake_run_id) + assert session is None + + # Handler should return error for non-existent run_id + # (handler.py line 348, 466, 881) + expected_error = f'Run session {fake_run_id} not found or expired' + assert 'not found' in expected_error + + await registry.unregister('run_valid') + + +class TestValidateRunAuthorizationHelper: + """Tests for _validate_run_authorization helper function. + + This helper is used by INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL, + and RETRIEVE_KNOWLEDGE_BASE handlers to validate run_id authorization. + + Note: This helper uses get_session_registry() which returns the global singleton. + Tests must use the same global registry. + """ + + @pytest.mark.asyncio + async def test_validate_returns_session_when_authorized(self): + """_validate_run_authorization returns session when resource is authorized.""" + # Use global session registry (same as _validate_run_authorization) + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_validate_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Import the helper + from langbot.pkg.plugin.handler import _validate_run_authorization + + # Create mock application + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_validate_test_helper', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should return session, no error + assert session is not None + assert error is None + assert session['run_id'] == 'run_validate_test_helper' + + await registry.unregister('run_validate_test_helper') + + @pytest.mark.asyncio + async def test_validate_returns_error_when_session_not_found(self): + """_validate_run_authorization returns error when session not found.""" + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization('run_nonexistent_helper', 'model', 'model_001', mock_ap) + + # Should return no session, error response + assert session is None + assert error is not None + assert 'not found' in error.message.lower() + assert mock_ap.logger.warning.called + + @pytest.mark.asyncio + async def test_validate_returns_error_when_resource_not_allowed(self): + """_validate_run_authorization returns error when resource not allowed.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_unauthorized_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_unauthorized_helper', + 'model', + 'model_999', # Not in resources + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should return no session, error response + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + assert mock_ap.logger.warning.called + + await registry.unregister('run_unauthorized_helper') + + @pytest.mark.asyncio + async def test_validate_for_tool_resource_type(self): + """_validate_run_authorization works for tool resource type.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + + await registry.register( + run_id='run_tool_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_tool_test_helper', + 'tool', + 'web_search', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_tool_test_helper') + + @pytest.mark.asyncio + async def test_validate_for_knowledge_base_resource_type(self): + """_validate_run_authorization works for knowledge_base resource type.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + + await registry.register( + run_id='run_kb_test_helper', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_kb_test_helper', + 'knowledge_base', + 'kb_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_kb_test_helper') + + +class TestStorageResourcePermissionHelper: + """Tests for session_registry.is_resource_allowed for storage resource type. + + The 'storage' resource type has different permission model: + - resource_id can be 'plugin' or 'workspace' + - Permission is boolean flag, not list membership + """ + + @pytest.mark.asyncio + async def test_plugin_storage_allowed_when_true(self): + """is_resource_allowed returns True when plugin_storage=True.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': False} + + await registry.register( + run_id='run_plugin_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_plugin_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_plugin_storage') + + @pytest.mark.asyncio + async def test_workspace_storage_allowed_when_true(self): + """is_resource_allowed returns True when workspace_storage=True.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': False, 'workspace_storage': True} + + await registry.register( + run_id='run_workspace_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_workspace_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is True + + await registry.unregister('run_workspace_storage') + + @pytest.mark.asyncio + async def test_both_storage_types_disabled(self): + """is_resource_allowed returns False when both storage types disabled.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': False, 'workspace_storage': False} + + await registry.register( + run_id='run_no_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_no_storage') + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + await registry.unregister('run_no_storage') + + @pytest.mark.asyncio + async def test_unknown_storage_resource_id_returns_false(self): + """is_resource_allowed returns False for unknown storage resource_id.""" + registry = AgentRunSessionRegistry() + resources = make_resources() + resources['storage'] = {'plugin_storage': True, 'workspace_storage': True} + + await registry.register( + run_id='run_unknown_storage', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + session = await registry.get('run_unknown_storage') + + # Unknown storage resource_id + assert registry.is_resource_allowed(session, 'storage', 'unknown_type') is False + + await registry.unregister('run_unknown_storage') + + def test_storage_permission_with_missing_storage_field(self): + """is_resource_allowed handles missing storage field gracefully.""" + registry = AgentRunSessionRegistry() + + session = make_session(resources={}) + + # Should return False for both storage types + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + +class TestRealActionHandlerSimulation: + """Tests that simulate real RuntimeConnectionHandler action registration and execution. + + These tests attempt to verify the actual handler behavior without full integration. + Uses global session registry to match _validate_run_authorization behavior. + """ + + @pytest.mark.asyncio + async def test_action_handler_invoke_llm_flow(self): + """Simulate INVOKE_LLM action handler authorization flow.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_invoke_llm_flow_sim', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + # Simulate handler logic + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + # Step 1: Validate authorization + session, error = await _validate_run_authorization( + 'run_invoke_llm_flow_sim', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should pass authorization + assert session is not None + assert error is None + + # Step 2: Handler would invoke LLM (not tested here, would need mock model) + + await registry.unregister('run_invoke_llm_flow_sim') + + @pytest.mark.asyncio + async def test_action_handler_rejects_unauthorized_model(self): + """Simulate INVOKE_LLM handler rejecting unauthorized model.""" + # Use global session registry + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_reject_model_sim', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + # Try to access unauthorized model + session, error = await _validate_run_authorization( + 'run_reject_model_sim', + 'model', + 'model_999', + mock_ap, + caller_plugin_identity='test/runner', + ) + + # Should reject + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + assert mock_ap.logger.warning.called + + await registry.unregister('run_reject_model_sim') + + @pytest.mark.asyncio + async def test_action_handler_session_not_found_flow(self): + """Simulate handler behavior when session not found.""" + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + # Try to validate with non-existent run_id + session, error = await _validate_run_authorization( + 'run_nonexistent_session_flow', 'model', 'model_001', mock_ap + ) + + # Should return error + assert session is None + assert error is not None + assert 'not found' in error.message.lower() + assert mock_ap.logger.warning.called + + +class TestStoragePermissionValidation: + """Tests for Host-side storage permission validation via _validate_run_authorization. + + Phase 6: Storage actions (SET/GET/DELETE_BINARY_STORAGE) now validate + storage permissions via _validate_run_authorization when run_id is present. + """ + + @pytest.mark.asyncio + async def test_plugin_storage_allowed_when_permitted(self): + """_validate_run_authorization allows 'plugin' storage when permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False}) + + await registry.register( + run_id='run_plugin_storage_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_plugin_storage_auth', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_plugin_storage_auth') + + @pytest.mark.asyncio + async def test_plugin_storage_denied_when_not_permitted(self): + """_validate_run_authorization denies 'plugin' storage when not permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + + await registry.register( + run_id='run_plugin_storage_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_plugin_storage_denied', + 'storage', + 'plugin', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + + await registry.unregister('run_plugin_storage_denied') + + @pytest.mark.asyncio + async def test_workspace_storage_allowed_when_permitted(self): + """_validate_run_authorization allows 'workspace' storage when permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True}) + + await registry.register( + run_id='run_workspace_storage_auth', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_workspace_storage_auth', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is not None + assert error is None + + await registry.unregister('run_workspace_storage_auth') + + @pytest.mark.asyncio + async def test_workspace_storage_denied_when_not_permitted(self): + """_validate_run_authorization denies 'workspace' storage when not permitted.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + + await registry.register( + run_id='run_workspace_storage_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_workspace_storage_denied', + 'storage', + 'workspace', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'not authorized' in error.message.lower() + + await registry.unregister('run_workspace_storage_denied') + + +class TestOperationPermissionValidation: + """Tests operation-level Host-side run authorization.""" + + @pytest.mark.asyncio + async def test_model_operation_denied_when_resource_only_allows_invoke(self): + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.plugin.handler import _validate_run_authorization + + registry = get_session_registry() + await registry.register( + run_id='run_model_operation_denied', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(models=[{'model_id': 'model_001', 'operations': ['invoke']}]), + ) + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_model_operation_denied', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + operation='stream', + ) + + assert session is None + assert error is not None + assert 'operation stream' in error.message + + await registry.unregister('run_model_operation_denied') + + +class TestCallerPluginIdentityValidation: + """Tests for caller_plugin_identity cross-plugin validation. + + Phase 6: _validate_run_authorization now validates that the caller plugin + identity matches the session's plugin_identity, preventing cross-plugin + unauthorized access if one plugin tries to use another's run_id. + """ + + @pytest.mark.asyncio + async def test_same_plugin_identity_allowed(self): + """_validate_run_authorization allows when caller matches session.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_identity_match', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', # Session owner + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_identity_match', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', # Caller is same plugin + ) + + assert session is not None + assert error is None + + await registry.unregister('run_identity_match') + + @pytest.mark.asyncio + async def test_different_plugin_identity_denied(self): + """_validate_run_authorization denies when caller differs from session.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_identity_mismatch', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', # Session owner + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + mock_ap.logger.warning = MagicMock() + + session, error = await _validate_run_authorization( + 'run_identity_mismatch', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='other/plugin', # Different plugin trying to use run_id + ) + + assert session is None + assert error is not None + assert 'mismatch' in error.message.lower() + + await registry.unregister('run_identity_mismatch') + + @pytest.mark.asyncio + async def test_run_id_requires_caller_identity(self): + """Run-scoped authorization requires caller_plugin_identity.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + + await registry.register( + run_id='run_no_caller_identity', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + ) + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_no_caller_identity', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity=None, + ) + + assert session is None + assert error is not None + assert 'caller_plugin_identity is required' in error.message + + await registry.unregister('run_no_caller_identity') + + @pytest.mark.asyncio + async def test_session_missing_plugin_identity_denied(self): + """Malformed legacy sessions without plugin_identity fail closed.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + session = make_session( + run_id='run_missing_session_identity', + runner_id='plugin:test/runner/default', + plugin_identity='', + resources=resources, + ) + async with registry._lock: + registry._sessions['run_missing_session_identity'] = session + + from langbot.pkg.plugin.handler import _validate_run_authorization + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_run_authorization( + 'run_missing_session_identity', + 'model', + 'model_001', + mock_ap, + caller_plugin_identity='test/runner', + ) + + assert session is None + assert error is not None + assert 'no plugin_identity' in error.message + + await registry.unregister('run_missing_session_identity') + + @pytest.mark.asyncio + async def test_pull_api_session_missing_plugin_identity_denied(self): + """Pull API validation also fails closed for missing session identity.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + registry = get_session_registry() + session = make_session( + run_id='run_missing_pull_identity', + runner_id='plugin:test/runner/default', + plugin_identity='', + available_apis={'history_page': True}, + ) + async with registry._lock: + registry._sessions['run_missing_pull_identity'] = session + + from langbot.pkg.plugin.handler import _validate_agent_run_session + + mock_ap = MagicMock() + mock_ap.logger = MagicMock() + + session, error = await _validate_agent_run_session( + 'run_missing_pull_identity', + 'test/runner', + mock_ap, + 'HISTORY_PAGE', + 'history_page', + ) + + assert session is None + assert error is not None + assert 'no plugin_identity' in error.message + + await registry.unregister('run_missing_pull_identity') + + +class TestBackwardCompatStorageNoRunId: + """Tests for unscoped storage actions without run_id. + + Regular plugins (non-AgentRunner) don't have run_id and should + have unrestricted access to storage APIs. + """ + + def test_storage_no_run_id_skips_validation(self): + """Storage actions without run_id skip Host-side validation.""" + # Handler.py: if run_id: ...validation... + # When run_id is None, validation is skipped + run_id = None + + # Simulate handler logic: no run_id skips validation. + assert run_id is None + + # Storage access unrestricted for regular plugins + assert run_id is None + + def test_file_no_run_id_skips_validation(self): + """GET_CONFIG_FILE without run_id skips Host-side validation.""" + run_id = None + + assert run_id is None + + # File access unrestricted for regular plugins + assert run_id is None diff --git a/tests/unit_tests/agent/test_history_event_api_auth.py b/tests/unit_tests/agent/test_history_event_api_auth.py new file mode 100644 index 000000000..ab5392d37 --- /dev/null +++ b/tests/unit_tests/agent/test_history_event_api_auth.py @@ -0,0 +1,323 @@ +"""Tests for AgentRunner history/event pull API authorization.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.event_log_store import EventLogStore +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.entity.persistence import event_log as event_log_model +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.api.entities.builtin.agent_runner.page_results import ( + AgentEventRecord, + EventPage, +) +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +from .conftest import make_resources + + +class FakeConnection: + pass + + +class FakeApplication: + def __init__(self, db_engine): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + +@pytest.fixture +def session_registry(monkeypatch): + registry = AgentRunSessionRegistry() + monkeypatch.setattr( + 'langbot.pkg.agent.runner.session_registry._global_registry', + registry, + ) + return registry + + +@pytest.fixture +async def db_engine(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + assert event_log_model.EventLog.__tablename__ == 'event_log' + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +def _handler(db_engine, session_registry): + async def fake_disconnect(): + return True + + fake_app = FakeApplication(db_engine) + return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + +async def _register_session( + session_registry, + *, + run_id='run_1', + conversation_id='conv_1', + bot_id=None, + workspace_id=None, + thread_id=None, + available_apis=None, +): + await session_registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_resources(), + conversation_id=conversation_id, + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + available_apis=available_apis or {}, + ) + + +@pytest.mark.asyncio +async def test_history_page_requires_runtime_capability(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_page': False}) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_history_page_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_page': True}) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_history_search_rejects_filter_conversation_override(session_registry, db_engine): + await _register_session(session_registry, available_apis={'history_search': True}) + handler = _handler(db_engine, session_registry) + history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value] + + result = await history_search({ + 'run_id': 'run_1', + 'query': 'hello', + 'filters': {'conversation_id': 'conv_other'}, + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_page_requires_runtime_capability(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': False}) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_page_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': True}) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_event_get_returns_sdk_record_projection(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_get': True}) + store = EventLogStore(db_engine) + event_id = await store.append_event( + event_id='evt_projection_1', + event_type='message.received', + source='platform', + conversation_id='conv_1', + actor_type='user', + actor_id='user_1', + input_summary='hello', + input_json={'internal': 'not part of AgentEventRecord'}, + run_id='run_1', + runner_id='plugin:test/runner/default', + ) + handler = _handler(db_engine, session_registry) + event_get = handler.actions[PluginToRuntimeAction.EVENT_GET.value] + + result = await event_get({ + 'run_id': 'run_1', + 'event_id': event_id, + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + AgentEventRecord.model_validate(result.data) + assert 'id' not in result.data + assert 'input_json' not in result.data + assert 'run_id' not in result.data + assert 'runner_id' not in result.data + assert result.data['seq'] is not None + assert result.data['cursor'] == str(result.data['seq']) + + +@pytest.mark.asyncio +async def test_event_page_returns_sdk_page_projection(session_registry, db_engine): + await _register_session(session_registry, available_apis={'event_page': True}) + store = EventLogStore(db_engine) + await store.append_event( + event_id='evt_projection_page_1', + event_type='message.received', + source='platform', + conversation_id='conv_1', + input_json={'internal': 'not part of AgentEventRecord'}, + run_id='run_other', + runner_id='plugin:test/runner/default', + ) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + page = EventPage.model_validate(result.data) + assert len(page.items) == 1 + item = result.data['items'][0] + assert 'id' not in item + assert 'input_json' not in item + assert 'run_id' not in item + assert 'runner_id' not in item + + +@pytest.mark.asyncio +async def test_history_page_filters_run_scope_thread_and_bot(session_registry, db_engine): + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + await _register_session( + session_registry, + bot_id='bot_1', + thread_id='thread_1', + available_apis={'history_page': True}, + ) + store = TranscriptStore(db_engine) + await store.append_transcript( + transcript_id='tr_visible', + event_id='evt_visible', + conversation_id='conv_1', + role='user', + bot_id='bot_1', + thread_id='thread_1', + content='visible', + ) + await store.append_transcript( + transcript_id='tr_other_bot', + event_id='evt_other_bot', + conversation_id='conv_1', + role='user', + bot_id='bot_2', + thread_id='thread_1', + content='hidden bot', + ) + await store.append_transcript( + transcript_id='tr_other_thread', + event_id='evt_other_thread', + conversation_id='conv_1', + role='user', + bot_id='bot_1', + thread_id='thread_2', + content='hidden thread', + ) + handler = _handler(db_engine, session_registry) + history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value] + + result = await history_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + assert [item['content'] for item in result.data['items']] == ['visible'] + + +@pytest.mark.asyncio +async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_engine): + await _register_session( + session_registry, + bot_id='bot_1', + thread_id='thread_1', + available_apis={'event_page': True}, + ) + store = EventLogStore(db_engine) + await store.append_event( + event_id='evt_visible', + event_type='message.received', + source='platform', + bot_id='bot_1', + conversation_id='conv_1', + thread_id='thread_1', + ) + await store.append_event( + event_id='evt_other_bot', + event_type='message.received', + source='platform', + bot_id='bot_2', + conversation_id='conv_1', + thread_id='thread_1', + ) + await store.append_event( + event_id='evt_other_thread', + event_type='message.received', + source='platform', + bot_id='bot_1', + conversation_id='conv_1', + thread_id='thread_2', + ) + handler = _handler(db_engine, session_registry) + event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value] + + result = await event_page({ + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code == 0 + assert [item['event_id'] for item in result.data['items']] == ['evt_visible'] diff --git a/tests/unit_tests/agent/test_id.py b/tests/unit_tests/agent/test_id.py new file mode 100644 index 000000000..90fe7bea6 --- /dev/null +++ b/tests/unit_tests/agent/test_id.py @@ -0,0 +1,137 @@ +"""Tests for agent runner ID parsing and formatting.""" +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.id import ( + parse_runner_id, + format_runner_id, + RunnerIdParts, + is_plugin_runner_id, +) + + +class TestRunnerIdParsing: + """Tests for parse_runner_id.""" + + def test_parse_plugin_runner_id(self): + """Parse valid plugin runner ID.""" + runner_id = 'plugin:langbot-team/LocalAgent/default' + parts = parse_runner_id(runner_id) + + assert parts.source == 'plugin' + assert parts.plugin_author == 'langbot-team' + assert parts.plugin_name == 'LocalAgent' + assert parts.runner_name == 'default' + + def test_parse_plugin_runner_id_complex_names(self): + """Parse plugin runner ID with complex names.""" + runner_id = 'plugin:alice/helpdesk-agent/ticket-handler' + parts = parse_runner_id(runner_id) + + assert parts.source == 'plugin' + assert parts.plugin_author == 'alice' + assert parts.plugin_name == 'helpdesk-agent' + assert parts.runner_name == 'ticket-handler' + + def test_parse_invalid_plugin_runner_id_missing_parts(self): + """Parse invalid plugin runner ID with missing parts.""" + runner_id = 'plugin:langbot-team/LocalAgent' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'Invalid plugin runner ID format' in str(exc_info.value) + + def test_parse_invalid_plugin_runner_id_empty_parts(self): + """Parse invalid plugin runner ID with empty parts.""" + runner_id = 'plugin://default' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'non-empty' in str(exc_info.value) + + def test_parse_invalid_runner_id_not_plugin(self): + """Parse invalid runner ID without plugin prefix.""" + runner_id = 'local-agent' + + with pytest.raises(ValueError) as exc_info: + parse_runner_id(runner_id) + + assert 'Invalid runner ID format' in str(exc_info.value) + + def test_parse_invalid_runner_id_empty_string(self): + """Parse empty runner ID.""" + runner_id = '' + + with pytest.raises(ValueError): + parse_runner_id(runner_id) + + +class TestRunnerIdFormatting: + """Tests for format_runner_id.""" + + def test_format_plugin_runner_id(self): + """Format plugin runner ID.""" + runner_id = format_runner_id( + source='plugin', + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + ) + + assert runner_id == 'plugin:langbot-team/LocalAgent/default' + + def test_format_invalid_source(self): + """Format runner ID with invalid source.""" + with pytest.raises(ValueError) as exc_info: + format_runner_id( + source='builtin', + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + ) + + assert 'Invalid runner source' in str(exc_info.value) + + +class TestRunnerIdParts: + """Tests for RunnerIdParts dataclass.""" + + def test_get_plugin_id(self): + """Get plugin ID from parts.""" + parts = RunnerIdParts( + source='plugin', + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + ) + + assert parts.to_plugin_id() == 'langbot-team/LocalAgent' + + def test_frozen_dataclass(self): + """RunnerIdParts should be immutable.""" + parts = RunnerIdParts( + source='plugin', + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + ) + + with pytest.raises(Exception): # FrozenInstanceError + parts.plugin_author = 'other' + + +class TestIsPluginRunnerId: + """Tests for is_plugin_runner_id.""" + + def test_is_plugin_runner_id_true(self): + """Check plugin runner ID returns True.""" + assert is_plugin_runner_id('plugin:langbot-team/LocalAgent/default') is True + + def test_is_plugin_runner_id_false(self): + """Check non-plugin runner ID returns False.""" + assert is_plugin_runner_id('local-agent') is False + assert is_plugin_runner_id('builtin:local-agent') is False + assert is_plugin_runner_id('') is False \ No newline at end of file diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py new file mode 100644 index 000000000..ba770a236 --- /dev/null +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -0,0 +1,1348 @@ +"""Integration-style tests for AgentRunOrchestrator with a fake plugin runner.""" + +from __future__ import annotations + +import asyncio +import datetime +import types +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError +from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver +from langbot.pkg.agent.runner.session_registry import get_session_registry +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import session as provider_session +from langbot_plugin.api.entities.builtin.resource import tool as resource_tool + + +RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' + + +class FakeLogger: + def __init__(self): + self.warnings: list[str] = [] + + def debug(self, msg): + pass + + def info(self, msg): + pass + + def warning(self, msg, *args, **kwargs): + self.warnings.append(str(msg)) + + def error(self, msg): + pass + + +class FakeVersionManager: + def get_current_version(self): + return 'test-version' + + +class FakeModel: + def __init__(self, model_type: str = 'chat'): + self.model_entity = types.SimpleNamespace(model_type=model_type) + self.provider_entity = types.SimpleNamespace(name='fake-provider') + + +class FakeKnowledgeBase: + def __init__(self, kb_id: str): + self.kb_id = kb_id + self.knowledge_base_entity = types.SimpleNamespace(kb_type='fake') + + def get_name(self): + return f'KB {self.kb_id}' + + +class FakePluginConnector: + is_enable_plugin = True + + def __init__(self, results=None, error: Exception | None = None, delay: float = 0): + self.results = results or [] + self.error = error + self.delay = delay + self.calls: list[dict] = [] + self.contexts: list[dict] = [] + self.sessions_during_run: list[dict | None] = [] + + async def run_agent(self, plugin_author, plugin_name, runner_name, context): + self.calls.append( + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + } + ) + self.contexts.append(context) + self.sessions_during_run.append(await get_session_registry().get(context['run_id'])) + + if self.error: + raise self.error + + for result in self.results: + if self.delay: + await asyncio.sleep(self.delay) + yield result + + +class FakeRegistry: + def __init__(self, descriptor: AgentRunnerDescriptor): + self.descriptor = descriptor + self.calls: list[dict] = [] + + async def get(self, runner_id, bound_plugins=None): + self.calls.append({'runner_id': runner_id, 'bound_plugins': bound_plugins}) + assert runner_id == self.descriptor.id + return self.descriptor + + +class FakePersistenceManager: + def __init__(self, db_engine: AsyncEngine): + self._db_engine = db_engine + + def get_db_engine(self): + return self._db_engine + + +class FakeApplication: + def __init__(self, plugin_connector: FakePluginConnector, db_engine: AsyncEngine): + self.logger = FakeLogger() + self.ver_mgr = FakeVersionManager() + self.plugin_connector = plugin_connector + self.persistence_mgr = FakePersistenceManager(db_engine) + + self.model_mgr = types.SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=FakeModel())) + self.rag_mgr = types.SimpleNamespace( + get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase('kb_001')) + ) + self.skill_mgr = types.SimpleNamespace( + skills={ + 'demo': { + 'name': 'demo', + 'display_name': 'Demo Skill', + 'description': 'Helps with demo tasks.', + }, + 'hidden': { + 'name': 'hidden', + 'display_name': 'Hidden Skill', + 'description': 'Not bound to this pipeline.', + }, + } + ) + + +class FakeConversation: + uuid = 'conv_existing' + create_time = datetime.datetime(2026, 5, 15, 12, 0, 0) + + +def make_descriptor() -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + capabilities={ + 'streaming': True, + 'tool_calling': True, + 'knowledge_retrieval': True, + 'skill_authoring': True, + }, + permissions={ + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin'], + }, + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + ) + + +def make_query(): + async def fake_func(**kwargs): + return kwargs + + message_chain = platform_message.MessageChain( + [ + platform_message.Source( + id='msg_001', + time=datetime.datetime(2026, 5, 15, 12, 0, 0), + ), + platform_message.Plain(text='hello'), + platform_message.File(name='spec.txt', url='https://example.com/spec.txt'), + ] + ) + sender = platform_entities.Friend(id='user_001', nickname='Alice', remark=None) + message_event = platform_events.FriendMessage(sender=sender, message_chain=message_chain, time=1_784_098_800.0) + session = types.SimpleNamespace( + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user_001', + sender_id='user_001', + using_conversation=FakeConversation(), + ) + + return types.SimpleNamespace( + query_id=1001, + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id='user_001', + sender_id='user_001', + message_event=message_event, + message_chain=message_chain, + bot_uuid='bot_001', + pipeline_uuid='pipeline_001', + pipeline_config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': { + RUNNER_ID: { + 'model': {'primary': 'model_primary', 'fallbacks': ['model_fallback']}, + 'knowledge-bases': ['kb_001'], + 'timeout': 30, + }, + }, + }, + }, + session=session, + messages=[], + user_message=provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_file_url('https://example.com/spec.txt', 'spec.txt'), + ], + ), + variables={ + '_pipeline_bound_plugins': ['langbot-team/LocalAgent'], + '_fallback_model_uuids': ['model_fallback'], + '_pipeline_bound_skills': ['demo'], + '_host_tool_source_refs': { + 'langbot/test-tool/search': { + 'source': 'plugin', + 'source_id': 'langbot/test-tool', + }, + }, + 'public_param': 'visible', + }, + use_llm_model_uuid='model_primary', + use_funcs=[ + resource_tool.LLMTool( + name='langbot/test-tool/search', + human_desc='Search', + description='Search test data', + parameters={'type': 'object', 'properties': {'q': {'type': 'string'}}}, + func=fake_func, + ) + ], + ) + + +def test_context_builder_includes_consumable_base64_attachments(): + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('see attached'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_file_base64('data:text/plain;base64,aGVsbG8=', 'hello.txt'), + ], + ) + query.message_chain = platform_message.MessageChain( + [platform_message.Image(base64='data:image/jpeg;base64,aGVsbG8=')] + ) + + input_data = QueryEntryAdapter._build_input(query) + + assert input_data.contents[0].text == 'see attached' + assert input_data.contents[1].image_base64 == 'data:image/png;base64,aGVsbG8=' + assert input_data.contents[2].file_base64 == 'data:text/plain;base64,aGVsbG8=' + + attachment_types = [attachment.type for attachment in input_data.attachments] + assert attachment_types == ['image', 'file', 'image'] + assert input_data.attachments[1].name == 'hello.txt' + + +def test_context_builder_deduplicates_message_chain_attachments(): + query = make_query() + query.user_message = None + query.message_chain = platform_message.MessageChain( + [platform_message.Image(base64='data:image/jpeg;base64,aGVsbG8=')] + ) + + input_data = QueryEntryAdapter._build_input(query) + + assert [content.type for content in input_data.contents] == ['image_base64'] + assert len(input_data.attachments) == 1 + assert input_data.attachments[0].type == 'image' + assert input_data.attachments[0].content == 'data:image/jpeg;base64,aGVsbG8=' + + +def test_context_builder_preserves_same_source_duplicate_attachments(): + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + query.message_chain = platform_message.MessageChain([]) + + input_data = QueryEntryAdapter._build_input(query) + + assert [attachment.type for attachment in input_data.attachments] == ['image', 'image'] + + +@pytest.fixture(autouse=True) +async def clean_agent_state(): + """Reset all singleton stores and create a test database engine.""" + from langbot.pkg.entity.persistence.base import Base + + reset_persistent_state_store() + registry = get_session_registry() + for session in await registry.list_active_runs(): + await registry.unregister(session['run_id']) + + # Create in-memory SQLite engine for tests + test_engine = create_async_engine('sqlite+aiosqlite:///:memory:') + + # Create tables + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield test_engine + + # Cleanup + for session in await registry.list_active_runs(): + await registry.unregister(session['run_id']) + reset_persistent_state_store() + await test_engine.dispose() + + +@pytest.mark.asyncio +async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent_state): + """Test that orchestrator properly builds and passes authorized context to runner.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert messages[0].content == 'fake response' + assert plugin_connector.calls == [ + { + 'plugin_author': 'langbot-team', + 'plugin_name': 'LocalAgent', + 'runner_name': 'default', + } + ] + + context = plugin_connector.contexts[0] + assert context['config']['timeout'] == 30 + assert context['runtime']['deadline_at'] is not None + # Protocol v1: params is in adapter.extra + assert context['adapter']['extra']['params'] == {'public_param': 'visible'} + assert context['event']['event_type'] == 'message.received' + # Note: source_event_type is in event.source_event_type, not event.data + # (event.data contains the raw event payload, not metadata) + assert context['actor']['actor_id'] == 'user_001' + assert context['actor']['actor_name'] == 'Alice' + assert context['subject']['subject_id'] == 'msg_001' + assert context['input']['attachments'] + assert context['context']['available_apis']['run_get'] is True + assert context['context']['available_apis']['run_list'] is True + assert context['context']['available_apis']['run_events_page'] is True + assert context['context']['available_apis']['run_cancel'] is True + assert context['context']['available_apis']['run_append_result'] is False + assert context['context']['available_apis']['run_finalize'] is False + assert context['context']['available_apis']['run_claim'] is False + assert context['context']['available_apis']['run_renew_claim'] is False + assert context['context']['available_apis']['run_release_claim'] is False + assert context['context']['available_apis']['runtime_register'] is False + assert context['context']['available_apis']['runtime_heartbeat'] is False + assert context['context']['available_apis']['runtime_list'] is False + + resources = context['resources'] + assert {m['model_id'] for m in resources['models']} == {'model_primary', 'model_fallback'} + assert resources['tools'][0]['tool_name'] == 'langbot/test-tool/search' + assert resources['knowledge_bases'][0]['kb_id'] == 'kb_001' + assert resources['skills'] == [ + { + 'skill_name': 'demo', + 'display_name': 'Demo Skill', + 'description': 'Helps with demo tasks.', + } + ] + assert resources['storage']['plugin_storage'] is True + + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['plugin_identity'] == 'langbot-team/LocalAgent' + assert session_during_run['authorization']['authorized_ids']['tool'] == {'langbot/test-tool/search'} + assert session_during_run['authorization']['authorized_ids']['skill'] == {'demo'} + assert await get_session_registry().get(context['run_id']) is None + + +@pytest.mark.asyncio +async def test_orchestrator_persists_run_ledger(clean_agent_state): + """AgentRunOrchestrator records Host-owned run and result events.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + }, + { + 'type': 'run.completed', + 'data': {'finish_reason': 'stop'}, + 'usage': {'prompt_tokens': 2, 'completion_tokens': 3, 'total_tokens': 5}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + messages = [message async for message in orchestrator.run_from_query(make_query())] + + assert len(messages) == 1 + run_id = plugin_connector.contexts[0]['run_id'] + store = RunLedgerStore(db_engine) + + run = await store.get_run(run_id) + assert run is not None + assert run['status'] == 'completed' + assert run['event_id'] == plugin_connector.contexts[0]['event']['event_id'] + assert run['runner_id'] == RUNNER_ID + assert run['usage'] == { + 'prompt_tokens': 2, + 'completion_tokens': 3, + 'total_tokens': 5, + } + + events, next_cursor, prev_cursor, has_more = await store.page_run_events( + run_id=run_id, + limit=10, + ) + assert [event['sequence'] for event in events] == [1, 2] + assert [event['type'] for event in events] == ['message.completed', 'run.completed'] + assert next_cursor is None + assert prev_cursor == 1 + assert has_more is False + + +@pytest.mark.asyncio +async def test_orchestrator_stops_after_cancel_request(clean_agent_state): + """A persisted cancel request stops further synchronous runner consumption.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'first'}}, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'second'}}, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + original_append_run_result = orchestrator.journal.append_run_result + cancel_requested = False + + async def append_and_cancel_once(*args, **kwargs): + nonlocal cancel_requested + event = await original_append_run_result(*args, **kwargs) + if not cancel_requested: + cancel_requested = True + await RunLedgerStore(db_engine).request_cancel( + run_id=kwargs['run_id'], + status_reason='user stopped', + ) + return event + + orchestrator.journal.append_run_result = append_and_cancel_once + + messages = [message async for message in orchestrator.run_from_query(make_query())] + + assert [message.content for message in messages] == ['first'] + run_id = plugin_connector.contexts[0]['run_id'] + run = await RunLedgerStore(db_engine).get_run(run_id) + assert run is not None + assert run['status'] == 'cancelled' + assert run['status_reason'] == 'user stopped' + + +@pytest.mark.asyncio +async def test_orchestrator_does_not_package_query_messages_into_context(clean_agent_state): + """Host should not build an agent working-context window from query.messages.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'fake response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.pipeline_config['ai']['runner_config'][RUNNER_ID]['custom-option'] = 2 + query.messages = [ + provider_message.Message(role='user', content='message 1'), + provider_message.Message(role='assistant', content='response 1'), + provider_message.Message(role='user', content='message 2'), + provider_message.Message(role='assistant', content='response 2'), + provider_message.Message(role='user', content='message 3'), + provider_message.Message(role='assistant', content='response 3'), + ] + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + context = plugin_connector.contexts[0] + assert context['config']['custom-option'] == 2 + assert 'bootstrap' not in context + assert set(context['adapter']) == {'extra'} + assert 'context_packaging' not in context['runtime']['metadata'] + assert [message.content for message in query.messages] == [ + 'message 1', + 'response 1', + 'message 2', + 'response 2', + 'message 3', + 'response 3', + ] + + +@pytest.mark.asyncio +async def test_orchestrator_streams_fake_plugin_deltas(clean_agent_state): + """Test that orchestrator properly streams message chunks.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + {'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'hel'}}}, + {'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'hello'}}}, + {'type': 'run.completed', 'data': {'finish_reason': 'stop'}}, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + + chunks = [message async for message in orchestrator.run_from_query(make_query())] + + assert [chunk.content for chunk in chunks] == ['hel', 'hello'] + + +@pytest.mark.asyncio +async def test_orchestrator_persists_run_completed_message_transcript(clean_agent_state): + """run.completed(message=...) should be treated as the final assistant transcript.""" + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'run.completed', + 'data': { + 'finish_reason': 'stop', + 'message': {'role': 'assistant', 'content': 'final response'}, + }, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert [message.content for message in messages] == ['final response'] + transcript_store = TranscriptStore(db_engine) + transcripts, _, _, _ = await transcript_store.page_transcript(query.session.using_conversation.uuid, limit=10) + assistant_items = [item for item in transcripts if item['role'] == 'assistant'] + assert len(assistant_items) == 1 + assert assistant_items[0]['content'] == 'final response' + + +@pytest.mark.asyncio +async def test_orchestrator_drops_duplicate_result_sequence(clean_agent_state): + """Duplicate runner result sequences are idempotently ignored.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.delta', + 'sequence': 1, + 'data': {'chunk': {'role': 'assistant', 'content': 'first'}}, + }, + { + 'type': 'message.delta', + 'sequence': 1, + 'data': {'chunk': {'role': 'assistant', 'content': 'duplicate'}}, + }, + { + 'type': 'message.delta', + 'sequence': 3, + 'data': {'chunk': {'role': 'assistant', 'content': 'after-gap'}}, + }, + {'type': 'run.completed', 'sequence': 4, 'data': {'finish_reason': 'stop'}}, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + chunks = [message async for message in orchestrator.run_from_query(make_query())] + + assert [chunk.content for chunk in chunks] == ['first', 'after-gap'] + assert any('duplicate result sequence 1' in warning for warning in ap.logger.warnings) + assert any('result sequence gap or out-of-order' in warning for warning in ap.logger.warnings) + + +@pytest.mark.asyncio +async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(clean_agent_state): + """Test that state.updated events are applied and not yielded to pipeline.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external.conversation_id', + 'value': 'external_conv_123', + }, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'state saved'}}, + }, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert [message.content for message in messages] == ['state saved'] + # State is persisted to the database via PersistentStateStore. + + +@pytest.mark.asyncio +async def test_orchestrator_unregisters_session_after_runner_failure(clean_agent_state): + """Test that session is unregistered even when runner fails.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'run.failed', + 'data': {'error': 'boom', 'code': 'fake.error', 'retryable': False}, + } + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + + with pytest.raises(RunnerExecutionError): + [message async for message in orchestrator.run_from_query(make_query())] + + context = plugin_connector.contexts[0] + assert plugin_connector.sessions_during_run[0] is not None + assert await get_session_registry().get(context['run_id']) is None + + +@pytest.mark.asyncio +async def test_orchestrator_unregisters_session_after_event_log_failure(clean_agent_state): + """Journal failures before runner invocation must not leave steerable sessions.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'unused'}}, + } + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + orchestrator.journal.write_event_log = AsyncMock(side_effect=RuntimeError('journal unavailable')) + + with pytest.raises(RuntimeError, match='journal unavailable'): + [message async for message in orchestrator.run_from_query(make_query())] + + assert plugin_connector.contexts == [] + assert await get_session_registry().list_active_runs() == [] + + +@pytest.mark.asyncio +async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_agent_state): + """Dropped steering audits retain routing metadata but never execution-only context.""" + from langbot.pkg.agent.runner.event_log_store import EventLogStore + + class BlockingPluginConnector(FakePluginConnector): + def __init__(self): + super().__init__() + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def run_agent(self, plugin_author, plugin_name, runner_name, context): + self.calls.append( + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + } + ) + self.contexts.append(context) + self.sessions_during_run.append(await get_session_registry().get(context['run_id'])) + self.started.set() + await self.release.wait() + yield { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + + db_engine = clean_agent_state + descriptor = make_descriptor() + descriptor.capabilities.steering = True + plugin_connector = BlockingPluginConnector() + ap = FakeApplication(plugin_connector, db_engine) + pinned_context = 'PINNED_CONTEXT_MUST_NOT_BE_PERSISTED' + + async def build_resource_context(query): + attachments = query.variables.get('_pipeline_mcp_resource_attachments', []) + return pinned_context if attachments else '' + + mcp_loader = types.SimpleNamespace(build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + active_query = make_query() + + async def consume_active_run(): + return [message async for message in orchestrator.run_from_query(active_query)] + + active_task = asyncio.create_task(consume_active_run()) + await asyncio.wait_for(plugin_connector.started.wait(), timeout=1) + + steering_query = make_query() + steering_query.query_id = 1002 + steering_query.message_chain[0].id = 'msg_002' + steering_query.variables['_pipeline_mcp_resource_attachments'] = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + steering_query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True + + try: + claimed = await orchestrator.try_claim_steering_from_query(steering_query) + finally: + plugin_connector.release.set() + + messages = await asyncio.wait_for(active_task, timeout=1) + assert claimed is True + assert len(messages) == 1 + + event_store = EventLogStore(db_engine) + event_logs, _, _ = await event_store.page_events( + conversation_id=steering_query.session.using_conversation.uuid, + limit=10, + ) + dropped = next(event for event in event_logs if event['event_type'] == 'steering.dropped') + queued = next( + event + for event in event_logs + if event['event_type'] == 'message.received' + and event.get('metadata', {}).get('steering', {}).get('status') == 'queued' + ) + + assert dropped['input_summary'] == 'Unconsumed steering input dropped' + assert dropped['input_json'] is None + assert dropped['metadata']['steering']['original_event_id'] == queued['event_id'] + assert pinned_context not in str(event_logs) + + +@pytest.mark.asyncio +async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state): + """Test that orchestrator enforces total runner timeout.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'too late'}}, + } + ], + delay=0.05, + ) + orchestrator = AgentRunOrchestrator(FakeApplication(plugin_connector, db_engine), FakeRegistry(descriptor)) + query = make_query() + query.pipeline_config['ai']['runner_config'][RUNNER_ID]['timeout'] = 0.01 + + with pytest.raises(RunnerExecutionError) as exc_info: + [message async for message in orchestrator.run_from_query(query)] + + assert exc_info.value.retryable is True + assert 'runner.timeout' in str(exc_info.value) + assert await get_session_registry().list_active_runs() == [] + + +class TestQueryEntrySessionQueryId: + """Tests for internal query_id entering session registry.""" + + @pytest.mark.asyncio + async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state): + """Inbound staging and later runner tools resolve to the same Box session.""" + from langbot.pkg.box.service import BoxService + + class CapturingBoxService: + available = True + + def __init__(self): + self.resolver = object.__new__(BoxService) + self.materialize_session_id = None + + async def materialize_inbound_attachments(self, query): + self.materialize_session_id = self.resolver.resolve_box_session_id(query) + return [] + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + box_service = CapturingBoxService() + ap.box_service = box_service + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + session = plugin_connector.sessions_during_run[0] + assert session is not None + assert session['execution_query'] is query + runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query']) + assert box_service.materialize_session_id == runner_session_id + assert query.variables['_host_box_scope'] + + @pytest.mark.asyncio + async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state): + """query_id from Query entry flow is registered internally in session.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + provider_message.ContentElement.from_file_base64('data:text/plain;base64,aGVsbG8=', 'hello.txt'), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + # Verify session during run had query_id + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['query_id'] == query.query_id + assert session_during_run['execution_query'] is query + assert query.pipeline_uuid == 'pipeline_001' + assert query.pipeline_config is not None + + @pytest.mark.asyncio + async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state): + """Pure event-first flow has query_id=None in session.""" + from langbot.pkg.agent.runner.host_models import ( + AgentEventEnvelope, + AgentBinding, + BindingScope, + StatePolicy, + DeliveryPolicy, + ResourcePolicy, + ) + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + mcp_loader = types.SimpleNamespace( + build_resource_context_for_query=AsyncMock(return_value='Pinned documentation') + ) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + # Create event and binding directly (not from Query) + event = AgentEventEnvelope( + event_id='evt_001', + event_type='message.received', + event_time=1234567890, + source='test', + bot_id='bot_001', + workspace_id=None, + conversation_id='conv_001', + thread_id=None, + actor=None, + subject=None, + input=AgentInput( + text='hello', + contents=[provider_message.ContentElement.from_text('hello')], + attachments=[], + ), + delivery=DeliveryContext(surface='test', supports_streaming=True), + ) + binding = AgentBinding( + binding_id='binding_001', + scope=BindingScope(scope_type='agent', scope_id='pipeline_001'), + event_types=['message.received'], + runner_id=RUNNER_ID, + runner_config={ + 'mcp-resources': [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ], + 'mcp-resource-agent-read-enabled': True, + }, + resource_policy=ResourcePolicy(), + state_policy=StatePolicy(enable_state=False, state_scopes=[]), + delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True), + enabled=True, + ) + + messages = [message async for message in orchestrator.run(event, binding)] + + assert len(messages) == 1 + # Verify session during run has query_id=None + session_during_run = plugin_connector.sessions_during_run[0] + assert session_during_run is not None + assert session_during_run['query_id'] is None + execution_query = session_during_run['execution_query'] + assert execution_query is not None + assert execution_query.pipeline_uuid is None + assert execution_query.pipeline_config is None + assert execution_query.bot_uuid == event.bot_id + assert execution_query.launcher_id == event.conversation_id + assert execution_query.sender_id == event.conversation_id + assert execution_query.session.launcher_id == event.conversation_id + assert execution_query.message_event.type == event.event_type + assert execution_query.variables['_host_box_scope'] + assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden'] + assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1' + assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True + assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text'] + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text'] + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text'] + assert event.input.text == 'hello' + assert event.input.contents[0].text == 'hello' + assert 'Pinned documentation' not in str(execution_query.user_message.content) + mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query) + + +class TestQueryEntryAdapterParams: + """Tests for params handling in Query entry adapter.""" + + @pytest.mark.asyncio + async def test_prompt_not_pushed_into_adapter_extra(self, clean_agent_state): + """Pipeline prompt is not pushed into adapter.extra; runners pull it through prompt_get.""" + from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + # Add prompt to query + query.prompt = provider_prompt.Prompt( + name='test_prompt', + messages=[ + provider_message.Message(role='system', content='You are a helpful assistant.'), + ], + ) + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + assert 'prompt' not in context + assert 'prompt' not in context['adapter']['extra'] + assert context['context']['available_apis']['prompt_get'] is True + + @pytest.mark.asyncio + async def test_params_filtering_keeps_public_param(self, clean_agent_state): + """Public params are kept.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'another_param': 123, + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + assert context['adapter']['extra']['params'] == { + 'public_param': 'visible', + 'another_param': 123, + } + + @pytest.mark.asyncio + async def test_params_filtering_removes_internal_vars(self, clean_agent_state): + """Internal variables (starting with _) are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + '_internal_var': 'should_be_filtered', + '_pipeline_bound_plugins': ['plugin1'], + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert '_internal_var' not in params + assert '_pipeline_bound_plugins' not in params + + @pytest.mark.asyncio + async def test_params_filtering_removes_sensitive_patterns(self, clean_agent_state): + """Sensitive naming patterns are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'api_token': 'secret123', + 'secret_key': 'secret456', + 'password': 'secret789', + 'credential': 'secret000', + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert 'api_token' not in params + assert 'secret_key' not in params + assert 'password' not in params + assert 'credential' not in params + + @pytest.mark.asyncio + async def test_params_filtering_removes_non_json_serializable(self, clean_agent_state): + """Non-JSON-serializable values are filtered.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables = { + 'public_param': 'visible', + 'a_set': {1, 2, 3}, # set is not JSON-serializable + 'a_lambda': lambda x: x, # function is not JSON-serializable + } + + _messages = [message async for message in orchestrator.run_from_query(query)] + + context = plugin_connector.contexts[0] + params = context['adapter']['extra']['params'] + assert 'public_param' in params + assert 'a_set' not in params + assert 'a_lambda' not in params + + +class TestQueryEntryAdapterHostCapabilities: + """Tests for event-first host capabilities via Query entry adapter path.""" + + @pytest.mark.asyncio + async def test_state_updated_writes_to_persistent_store(self, clean_agent_state): + """state.updated via Pipeline path writes to PersistentStateStore.""" + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external.test_key', + 'value': 'test_value', + }, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'state saved'}}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert messages[0].content == 'state saved' + + # Verify state was written to PersistentStateStore + persistent_store = get_persistent_state_store(db_engine) + # Build snapshot to check if state was written + # Note: We need to rebuild the event and binding to query the store + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['external.test_key'] == 'test_value' + + @pytest.mark.asyncio + async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state): + """Persisted activated skill names are restored into the next Query run.""" + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + from langbot.pkg.provider.tools.loaders.skill import ( + ACTIVATED_SKILL_NAMES_STATE_KEY, + ACTIVATED_SKILLS_KEY, + ) + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'restored'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + persistent_store = get_persistent_state_store(db_engine) + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + success, error = await persistent_store.apply_update_from_event( + event, + binding, + descriptor, + 'conversation', + ACTIVATED_SKILL_NAMES_STATE_KEY, + ['demo'], + None, + ) + assert success is True + assert error is None + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo' + + @pytest.mark.asyncio + async def test_event_log_and_transcript_written(self, clean_agent_state): + """EventLog and Transcript are written via Pipeline path.""" + from langbot.pkg.agent.runner.event_log_store import EventLogStore + from langbot.pkg.agent.runner.transcript_store import TranscriptStore + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'assistant response'}}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + mcp_loader = types.SimpleNamespace( + build_resource_context_for_query=AsyncMock(return_value='Pinned documentation') + ) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.variables['_pipeline_mcp_resource_attachments'] = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True + query.user_message = provider_message.Message( + role='user', + content=[ + provider_message.ContentElement.from_text('hello'), + provider_message.ContentElement.from_image_base64('data:image/png;base64,aGVsbG8='), + ], + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text'] + + # Check EventLog has incoming event + event_log_store = EventLogStore(db_engine) + event_logs, _, _ = await event_log_store.page_events( + conversation_id=query.session.using_conversation.uuid, + limit=10, + ) + assert len(event_logs) >= 1 + # First event should be the incoming message.received + assert event_logs[0]['event_type'] == 'message.received' + assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None + assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True + assert 'aGVsbG8=' not in str(event_logs[0]['input_json']) + assert 'Pinned documentation' not in str(event_logs[0]['input_json']) + + # Check Transcript has user and assistant messages + transcript_store = TranscriptStore(db_engine) + transcripts, _, _, _ = await transcript_store.page_transcript( + conversation_id=query.session.using_conversation.uuid, + limit=10, + include_attachments=True, + ) + assert len(transcripts) >= 2 + # Find user and assistant messages + roles = [t['role'] for t in transcripts] + assert 'user' in roles + assert 'assistant' in roles + user_item = next(t for t in transcripts if t['role'] == 'user') + assert user_item['content_json']['content'][1]['image_base64'] is None + assert user_item['attachment_refs'][0]['content'] is None + assert 'aGVsbG8=' not in str(user_item) + assert 'Pinned documentation' not in str(user_item) diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py new file mode 100644 index 000000000..af11647c4 --- /dev/null +++ b/tests/unit_tests/agent/test_registry.py @@ -0,0 +1,272 @@ +"""Tests for agent runner registry.""" + +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.registry import AgentRunnerRegistry +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError + + +class FakeApplication: + """Fake Application for testing.""" + + def __init__(self): + class FakeLogger: + def info(self, msg): + pass + + def debug(self, msg): + pass + + def warning(self, msg): + pass + + def error(self, msg): + pass + + self.logger = FakeLogger() + + class FakePluginConnector: + is_enable_plugin = True + + async def list_agent_runners(self, bound_plugins=None): + # Return sample runner data + return [ + { + 'plugin_author': 'langbot-team', + 'plugin_name': 'LocalAgent', + 'runner_name': 'default', + 'manifest': { + 'id': 'plugin:langbot-team/LocalAgent/default', + 'name': 'default', + 'label': {'en_US': 'Local Agent'}, + 'capabilities': {'streaming': True}, + 'permissions': {}, + 'config_schema': [], + }, + }, + { + 'plugin_author': 'alice', + 'plugin_name': 'my-agent', + 'runner_name': 'custom', + 'manifest': { + 'id': 'plugin:alice/my-agent/custom', + 'name': 'custom', + 'label': {'en_US': 'Custom Agent'}, + 'capabilities': {}, + 'permissions': {}, + 'config_schema': [{'name': 'param1', 'type': 'string'}], + }, + }, + # Invalid runner - wrong kind + { + 'plugin_author': 'bad', + 'plugin_name': 'wrong-kind', + 'runner_name': 'default', + 'manifest': { + 'kind': 'Tool', # Wrong kind + 'metadata': {}, + 'spec': {}, + }, + }, + # Invalid runner - missing name + { + 'plugin_author': 'bad', + 'plugin_name': 'missing-name', + 'runner_name': 'default', + 'manifest': { + 'kind': 'AgentRunner', + 'metadata': {}, # No name + 'spec': {}, + }, + }, + ] + + self.plugin_connector = FakePluginConnector() + + +class TestRegistryDiscovery: + """Tests for runner discovery.""" + + @pytest.mark.asyncio + async def test_discover_valid_runners(self): + """Discover valid runners from plugin runtime.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + runners = await registry.list_runners(use_cache=False) + + # Should find 2 valid runners (langbot-team/LocalAgent and alice/my-agent) + assert len(runners) == 2 + + ids = [r.id for r in runners] + assert 'plugin:langbot-team/LocalAgent/default' in ids + assert 'plugin:alice/my-agent/custom' in ids + + @pytest.mark.asyncio + async def test_discover_caches_results(self): + """Discovery should cache results.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # First discovery + runners1 = await registry.list_runners(use_cache=True) + + # Second call should use cache + runners2 = await registry.list_runners(use_cache=True) + + assert registry._cache is not None + assert len(runners1) == len(runners2) + + @pytest.mark.asyncio + async def test_discover_handles_plugin_disabled(self): + """Discovery returns empty when plugin system disabled.""" + ap = FakeApplication() + ap.plugin_connector.is_enable_plugin = False + registry = AgentRunnerRegistry(ap) + + runners = await registry.list_runners(use_cache=False) + + assert runners == [] + + @pytest.mark.asyncio + async def test_cache_not_polluted_by_bound_plugins(self): + """Cache should contain ALL runners, not filtered by bound_plugins. + + Regression test: get(bound_plugins=["a/b"]) should not pollute cache, + so subsequent list_runners(bound_plugins=None) should return all runners. + """ + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # First: get with bound_plugins filter (should not pollute cache) + descriptor = await registry.get( + 'plugin:langbot-team/LocalAgent/default', + bound_plugins=['langbot-team/LocalAgent'], + ) + assert descriptor.id == 'plugin:langbot-team/LocalAgent/default' + + # Cache should contain ALL runners (both langbot and alice) + assert registry._cache is not None + assert len(registry._cache) == 2 # Both runners in cache + assert 'plugin:langbot-team/LocalAgent/default' in registry._cache + assert 'plugin:alice/my-agent/custom' in registry._cache + + # Second: list_runners without filter should return ALL runners + all_runners = await registry.list_runners(bound_plugins=None, use_cache=True) + assert len(all_runners) == 2 # Both runners returned + + # Third: list_runners with different filter should work correctly + alice_runners = await registry.list_runners(bound_plugins=['alice/my-agent'], use_cache=True) + assert len(alice_runners) == 1 + assert alice_runners[0].id == 'plugin:alice/my-agent/custom' + + +class TestRegistryGet: + """Tests for getting specific runner.""" + + @pytest.mark.asyncio + async def test_get_existing_runner(self): + """Get existing runner by ID.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + descriptor = await registry.get('plugin:langbot-team/LocalAgent/default') + + assert descriptor.id == 'plugin:langbot-team/LocalAgent/default' + assert descriptor.plugin_author == 'langbot-team' + assert descriptor.plugin_name == 'LocalAgent' + assert descriptor.runner_name == 'default' + + @pytest.mark.asyncio + async def test_get_nonexistent_runner(self): + """Get nonexistent runner raises RunnerNotFoundError.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + with pytest.raises(RunnerNotFoundError) as exc_info: + await registry.get('plugin:notexist/unknown/default') + + assert exc_info.value.runner_id == 'plugin:notexist/unknown/default' + + @pytest.mark.asyncio + async def test_get_runner_with_bound_plugins_filter(self): + """Get runner with bound plugins authorization.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + # Authorized - langbot plugin in bound list + descriptor = await registry.get( + 'plugin:langbot-team/LocalAgent/default', + bound_plugins=['langbot-team/LocalAgent'], + ) + assert descriptor is not None + + # Not authorized - plugin not in bound list + with pytest.raises(RunnerNotAuthorizedError): + await registry.get( + 'plugin:alice/my-agent/custom', + bound_plugins=['langbot-team/LocalAgent'], + ) + + +class TestRegistryMetadataForPipeline: + """Tests for get_runner_metadata_for_pipeline.""" + + @pytest.mark.asyncio + async def test_get_metadata_options_and_stages(self): + """Get metadata options and stages for pipeline UI.""" + ap = FakeApplication() + registry = AgentRunnerRegistry(ap) + + options, stages = await registry.get_runner_metadata_for_pipeline() + + # Should have options for each runner + assert len(options) == 2 + option_ids = [o['name'] for o in options] + assert 'plugin:langbot-team/LocalAgent/default' in option_ids + assert 'plugin:alice/my-agent/custom' in option_ids + + # Config comes from the typed manifest. + assert len(stages) == 1 + assert stages[0]['name'] == 'plugin:alice/my-agent/custom' + assert stages[0]['config'][0]['name'] == 'param1' + assert stages[0]['config'][0]['type'] == 'string' + assert stages[0]['config'][0]['id'] == 'plugin:alice/my-agent/custom.param1' + + +class TestDescriptorValidation: + """Tests for descriptor validation.""" + + def test_validate_runner_descriptor(self): + """Validate correctly built descriptor.""" + descriptor = AgentRunnerDescriptor( + id='plugin:test/my-runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + ) + + assert descriptor.id == 'plugin:test/my-runner/default' + assert descriptor.get_plugin_id() == 'test/my-runner' + assert 'protocol_version' not in AgentRunnerDescriptor.model_fields + + def test_descriptor_capabilities(self): + """Descriptor capability helper methods.""" + descriptor = AgentRunnerDescriptor( + id='plugin:test/my-runner/default', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + capabilities={'streaming': True, 'tool_calling': False}, + ) + + assert descriptor.supports_streaming() is True + assert descriptor.supports_tool_calling() is False + assert descriptor.supports_knowledge_retrieval() is False diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py new file mode 100644 index 000000000..7f41fc705 --- /dev/null +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -0,0 +1,559 @@ +"""Tests for AgentResourceBuilder.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver +from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter +from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder +from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy + + +RUNNER_ID = 'plugin:test/runner/default' +FULL_PERMISSIONS = { + 'models': ['count_tokens', 'invoke', 'stream', 'rerank'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin', 'workspace'], +} + + +def make_descriptor( + *, + config_schema: list[dict] | None = None, + capabilities: dict | None = None, + permissions: dict | None = None, +) -> AgentRunnerDescriptor: + return AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + capabilities=capabilities or {}, + permissions=permissions if permissions is not None else FULL_PERMISSIONS, + config_schema=config_schema or [], + ) + + +def make_model(model_type='llm', provider='test-provider'): + return SimpleNamespace( + model_entity=SimpleNamespace(model_type=model_type), + provider_entity=SimpleNamespace(name=provider), + ) + + +def make_query( + runner_config: dict, + *, + variables: dict | None = None, + use_llm_model_uuid=None, + use_funcs: list | None = None, +): + return SimpleNamespace( + query_id=1, + bot_uuid='bot_001', + launcher_type='person', + launcher_id='launcher_001', + sender_id='sender_001', + message_event=None, + message_chain=None, + user_message=None, + session=None, + pipeline_config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': {RUNNER_ID: runner_config}, + }, + }, + variables=variables or {}, + use_llm_model_uuid=use_llm_model_uuid, + use_funcs=use_funcs or [], + pipeline_uuid='pipeline_001', + ) + + +async def build_resources(app, query, descriptor): + event = QueryEntryAdapter.query_to_event(query) + agent_config = QueryEntryAdapter.config_to_agent_config(query, descriptor.id) + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + return await AgentResourceBuilder(app).build_resources_from_binding( + event=event, + binding=binding, + descriptor=descriptor, + ) + + +@pytest.fixture +def app(): + mock_app = Mock() + mock_app.logger = Mock() + mock_app.model_mgr = Mock() + mock_app.rag_mgr = Mock() + mock_app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None) + mock_app.skill_mgr = None + mock_app.tool_mgr = Mock() + mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None)) + mock_app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + return mock_app + + +@pytest.mark.asyncio +async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app): + """DynamicForm model selectors should become run-scoped authorized models.""" + llm_models = { + 'primary': make_model(), + 'fallback': make_model(), + 'aux': make_model(provider='aux-provider'), + } + rerank_models = { + 'rerank': make_model(model_type='rerank', provider='rerank-provider'), + } + + async def get_model_by_uuid(model_uuid): + return llm_models.get(model_uuid) + + async def get_rerank_model_by_uuid(model_uuid): + return rerank_models.get(model_uuid) + + app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=get_model_by_uuid) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=get_rerank_model_by_uuid) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'aux-model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + ) + query = make_query( + { + 'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']}, + 'aux-model': 'aux', + 'rerank-model': 'rerank', + } + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + { + 'model_id': 'primary', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream', 'count_tokens'], + }, + { + 'model_id': 'fallback', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream', 'count_tokens'], + }, + { + 'model_id': 'aux', + 'model_type': 'llm', + 'provider': 'aux-provider', + 'operations': ['invoke', 'stream', 'count_tokens'], + }, + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_from_config_without_manifest_acl(app): + """Config-selected models are not projected without manifest model permissions.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=make_model(model_type='rerank')) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + permissions={}, + ) + query = make_query( + { + 'model': {'primary': 'primary', 'fallbacks': ['fallback']}, + 'rerank-model': 'rerank', + } + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [] + + +@pytest.mark.asyncio +async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app): + """Config-selected model references are projected regardless of method granularity.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock( + return_value=make_model(model_type='rerank', provider='rerank-provider') + ) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + ) + query = make_query( + { + 'model': 'llm', + 'rerank-model': 'rerank', + } + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + { + 'model_id': 'llm', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream', 'count_tokens'], + }, + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_resources_accepts_dynamic_form_type_aliases(app): + """Frontend DynamicForm aliases should resolve to runtime resource grants.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + + async def get_kb(kb_uuid): + return SimpleNamespace( + uuid=kb_uuid, + get_name=lambda: f'name-{kb_uuid}', + knowledge_base_entity=SimpleNamespace(kb_type='default'), + ) + + app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb) + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + config_schema=[ + {'name': 'model', 'type': 'select-llm-model'}, + {'name': 'knowledge-bases', 'type': 'select-knowledge-bases'}, + ], + ) + query = make_query( + { + 'model': 'llm_alias', + 'knowledge-bases': ['kb_alias'], + } + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + { + 'model_id': 'llm_alias', + 'model_type': 'llm', + 'provider': 'test-provider', + 'operations': ['invoke', 'stream', 'count_tokens'], + }, + ] + assert resources['knowledge_bases'] == [ + {'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_manifest_permission_narrows_binding(app): + """Manifest model permissions narrower than binding should remove LLM grants.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock( + return_value=make_model(model_type='rerank', provider='rerank-provider') + ) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'llm-model-selector'}, + {'name': 'rerank-model', 'type': 'rerank-model-selector'}, + ], + permissions={ + **FULL_PERMISSIONS, + 'models': ['rerank'], + }, + ) + query = make_query( + { + 'model': 'llm', + 'rerank-model': 'rerank', + } + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['models'] == [ + {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, + ] + + +@pytest.mark.asyncio +async def test_build_models_deduplicates_query_and_config_models(app): + """A model selected by both preproc and runner config should appear once.""" + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model()) + app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=None) + descriptor = make_descriptor( + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + ], + ) + query = make_query( + {'model': {'primary': 'primary', 'fallbacks': ['fallback']}}, + variables={'_fallback_model_uuids': ['fallback']}, + use_llm_model_uuid='primary', + ) + + resources = await build_resources(app, query, descriptor) + + assert [model['model_id'] for model in resources['models']] == ['primary', 'fallback'] + + +@pytest.mark.asyncio +async def test_build_tools_authorizes_query_declared_tools(app): + """Tools discovered by Pipeline preprocessing become run-scoped authorized + resources, with full parameters schema prefilled by the host.""" + app.tool_mgr.get_tool_schema = AsyncMock( + side_effect=lambda name, source_ref=None: { + 'qa_plugin_echo': ( + 'Echo test tool', + {'type': 'object', 'properties': {'text': {'type': 'string'}}}, + ), + }.get(name, (None, None)) + ) + descriptor = make_descriptor( + capabilities={'tool_calling': True}, + ) + query = make_query( + {}, + variables={ + '_host_tool_source_refs': { + 'qa_plugin_echo': {'source': 'plugin', 'source_id': 'test/plugin'}, + 'qa_mcp_echo': {'source': 'mcp', 'source_id': 'mcp-server'}, + }, + }, + use_funcs=[ + {'name': 'qa_plugin_echo', 'description': 'Echo test tool'}, + SimpleNamespace(name='qa_mcp_echo'), + ], + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['tools'] == [ + { + 'tool_name': 'qa_plugin_echo', + 'tool_type': 'plugin', + 'description': 'Echo test tool', + 'operations': ['detail', 'call'], + 'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}}, + 'source': 'plugin', + 'source_id': 'test/plugin', + }, + { + 'tool_name': 'qa_mcp_echo', + 'tool_type': 'mcp', + 'description': None, + 'operations': ['detail', 'call'], + 'parameters': None, + 'source': 'mcp', + 'source_id': 'mcp-server', + }, + ] + + +@pytest.mark.asyncio +async def test_build_tools_manifest_permission_denies_binding_tools(app): + """Binding tool grants should be removed when manifest does not request tools.""" + descriptor = make_descriptor( + capabilities={'tool_calling': True}, + permissions={ + **FULL_PERMISSIONS, + 'tools': [], + }, + ) + query = make_query( + {}, + use_funcs=[ + {'name': 'qa_plugin_echo', 'description': 'Echo test tool'}, + ], + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['tools'] == [] + + +@pytest.mark.asyncio +async def test_build_tools_materializes_independent_agent_all_tools_policy(app): + """Independent Agents resolve an all-tools grant against the live Host catalog.""" + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + {'name': 'exec', 'source': 'builtin'}, + {'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'}, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool'] + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(app): + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + {'name': 'exec', 'source': 'builtin'}, + {'name': 'langbot_mcp_list_resources', 'source': 'mcp'}, + {'name': 'langbot_mcp_read_resource', 'source': 'mcp'}, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={'mcp-resource-agent-read-enabled': False}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert [tool['tool_name'] for tool in resources['tools']] == ['exec'] + + +@pytest.mark.asyncio +async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads_disabled(app): + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + { + 'name': 'langbot_mcp_read_resource', + 'source': 'plugin', + 'source_id': 'test/resource-reader', + }, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={'mcp-resource-agent-read-enabled': False}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert resources['tools'] == [ + { + 'tool_name': 'langbot_mcp_read_resource', + 'tool_type': 'plugin', + 'description': None, + 'operations': ['detail', 'call'], + 'parameters': None, + 'source': 'plugin', + 'source_id': 'test/resource-reader', + } + ] + + +@pytest.mark.asyncio +async def test_build_knowledge_bases_unions_config_and_policy_grants(app): + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'}, + ], + ) + query = make_query( + {'knowledge-bases': ['kb_config']}, + variables={'_knowledge_base_uuids': ['kb_policy']}, + ) + + async def get_kb(kb_uuid): + return SimpleNamespace( + uuid=kb_uuid, + get_name=lambda: f'name-{kb_uuid}', + knowledge_base_entity=SimpleNamespace(kb_type='default'), + ) + + app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb) + + resources = await build_resources(app, query, descriptor) + + assert resources['knowledge_bases'] == [ + {'kb_id': 'kb_config', 'kb_name': 'name-kb_config', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + {'kb_id': 'kb_policy', 'kb_name': 'name-kb_policy', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, + ] + + +@pytest.mark.asyncio +async def test_build_knowledge_bases_manifest_permission_denies_binding_kbs(app): + descriptor = make_descriptor( + capabilities={'knowledge_retrieval': True}, + permissions={ + **FULL_PERMISSIONS, + 'knowledge_bases': [], + }, + config_schema=[ + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector'}, + ], + ) + query = make_query( + {'knowledge-bases': ['kb_config']}, + variables={'_knowledge_base_uuids': ['kb_policy']}, + ) + + resources = await build_resources(app, query, descriptor) + + assert resources['knowledge_bases'] == [] + + +@pytest.mark.asyncio +async def test_build_storage_intersects_manifest_and_binding_policy(app): + descriptor = make_descriptor( + permissions={ + **FULL_PERMISSIONS, + 'storage': ['plugin'], + }, + ) + query = make_query({}) + + resources = await build_resources(app, query, descriptor) + + assert resources['storage'] == { + 'plugin_storage': True, + 'workspace_storage': False, + } diff --git a/tests/unit_tests/agent/test_resource_policy.py b/tests/unit_tests/agent/test_resource_policy.py new file mode 100644 index 000000000..839d62fb2 --- /dev/null +++ b/tests/unit_tests/agent/test_resource_policy.py @@ -0,0 +1,91 @@ +"""Tests for generic AgentRunner resource-policy projection.""" + +from types import SimpleNamespace + +import pytest + +from langbot.pkg.agent.runner.resource_policy import ResourcePolicyProjector + + +def test_pipeline_projection_intersects_selected_tools_with_scoped_tools(): + policy = ResourcePolicyProjector.from_runner_config( + { + 'enable-all-tools': False, + 'tools': ['plugin_tool', 'missing_tool', 'plugin_tool'], + 'knowledge-bases': ['kb-config'], + }, + resolved_model_uuids=['model-1', 'model-1'], + resolved_tool_names=['exec', 'plugin_tool', 'mcp_tool'], + resolved_kb_uuids=['kb-runtime'], + resolved_skill_names=[], + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['plugin_tool'] + assert policy.allowed_model_uuids == ['model-1'] + assert policy.allowed_kb_uuids == ['kb-runtime'] + assert policy.allowed_skill_names == [] + + +def test_pipeline_projection_materializes_enable_all_tools_from_scoped_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': True, 'tools': ['ignored']}, + resolved_tool_names=['exec', 'mcp_tool'], + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['exec', 'mcp_tool'] + + +def test_pipeline_projection_keeps_sources_only_for_authorized_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['mcp_tool']}, + resolved_tool_names=['plugin_tool', 'mcp_tool'], + resolved_tool_sources={ + 'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'}, + 'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'}, + }, + ) + + assert policy.allowed_tool_sources == { + 'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'}, + } + + +def test_independent_agent_projection_preserves_all_tools_intent(): + policy = ResourcePolicyProjector.from_runner_config({}) + + assert policy.allow_all_tools is True + assert policy.allowed_tool_names is None + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_enable_all_tools_fails_closed_for_non_boolean_values(invalid_value): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': invalid_value, 'tools': ['explicit-tool']}, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['explicit-tool'] + + +def test_independent_agent_projection_preserves_selected_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['exec', None, 'exec', 123]}, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['exec'] + + +def test_filter_tools_supports_sdk_objects_and_dictionary_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']}, + ) + tools = [ + {'name': 'dict-tool'}, + SimpleNamespace(name='object-tool'), + SimpleNamespace(name='other-tool'), + ] + + assert ResourcePolicyProjector.filter_tools(tools, policy) == tools[:2] diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py new file mode 100644 index 000000000..e276bd059 --- /dev/null +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -0,0 +1,365 @@ +"""Tests for agent runner result normalizer.""" +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError + +from langbot_plugin.api.entities.builtin.provider import message as provider_message + + +class FakeApplication: + """Fake Application for testing.""" + def __init__(self): + class FakeLogger: + def __init__(self): + self.warnings = [] + + def info(self, msg): + pass + def debug(self, msg): + pass + def warning(self, msg): + self.warnings.append(msg) + def error(self, msg): + pass + + self.logger = FakeLogger() + + +def make_descriptor(): + """Create a test descriptor.""" + return AgentRunnerDescriptor( + id='plugin:langbot-team/LocalAgent/default', + source='plugin', + label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + capabilities={'streaming': True}, + ) + + +class TestNormalizeMessageDelta: + """Tests for normalizing message.delta results.""" + + @pytest.mark.asyncio + async def test_normalize_message_delta_text(self): + """Normalize message.delta with text chunk.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.delta', + 'data': { + 'chunk': { + 'role': 'assistant', + 'content': 'Hello', + }, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.MessageChunk) + assert result.role == 'assistant' + assert result.content == 'Hello' + + @pytest.mark.asyncio + async def test_normalize_message_delta_missing_chunk(self): + """Invalid message.delta payload is dropped.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.delta', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeMessageCompleted: + """Tests for normalizing message.completed results.""" + + @pytest.mark.asyncio + async def test_normalize_message_completed(self): + """Normalize message.completed with full message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.completed', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Complete response', + }, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.Message) + assert result.role == 'assistant' + assert result.content == 'Complete response' + + @pytest.mark.asyncio + async def test_normalize_message_completed_missing_message(self): + """Invalid message.completed payload is dropped.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'message.completed', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeRunCompleted: + """Tests for normalizing run.completed results.""" + + @pytest.mark.asyncio + async def test_normalize_run_completed_with_message(self): + """Normalize run.completed with final message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.completed', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Final response', + }, + 'finish_reason': 'stop', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is not None + assert isinstance(result, provider_message.Message) + + @pytest.mark.asyncio + async def test_normalize_run_completed_without_message(self): + """Normalize run.completed without message.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.completed', + 'data': { + 'finish_reason': 'stop', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + + assert result is None + + +class TestNormalizeRunFailed: + """Tests for normalizing run.failed results.""" + + @pytest.mark.asyncio + async def test_normalize_run_failed(self): + """Normalize run.failed raises RunnerExecutionError.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'run.failed', + 'data': { + 'error': 'Upstream timeout', + 'code': 'upstream.timeout', + 'retryable': True, + }, + } + + with pytest.raises(RunnerExecutionError) as exc_info: + await normalizer.normalize(result_dict, descriptor) + + assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default' + assert exc_info.value.retryable is True + assert 'timeout' in str(exc_info.value) + + +class TestNormalizeNonMessageResults: + """Tests for normalizing non-message results.""" + + @pytest.mark.asyncio + async def test_normalize_tool_call_started(self): + """Normalize tool.call.started returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'tool.call.started', + 'data': { + 'tool_call_id': 'call_1', + 'tool_name': 'weather', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_tool_call_completed(self): + """Normalize tool.call.completed returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'tool.call.completed', + 'data': { + 'tool_call_id': 'call_1', + 'tool_name': 'weather', + 'result': {'temp': 20}, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_state_updated(self): + """Normalize state.updated returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'state.updated', + 'data': { + 'scope': 'conversation', + 'key': 'external_conversation_id', + 'value': 'abc123', + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_action_requested(self): + """Normalize action.requested returns None (EBA reserved).""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'action.requested', + 'data': { + 'action': 'platform.message.edit', + 'payload': {}, + }, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_invalid_state_updated_payload_is_dropped(self): + """Invalid state.updated payload returns None with a warning.""" + app = FakeApplication() + normalizer = AgentResultNormalizer(app) + descriptor = make_descriptor() + + result = await normalizer.normalize( + { + 'type': 'state.updated', + 'data': { + 'scope': 'invalid', + 'key': 'k', + 'value': 'v', + }, + }, + descriptor, + ) + + assert result is None + assert app.logger.warnings + +class TestNormalizeInvalidResults: + """Tests for handling invalid results.""" + + @pytest.mark.asyncio + async def test_normalize_missing_type(self): + """Normalize result without type.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'data': {}, + } + + with pytest.raises(RunnerProtocolError) as exc_info: + await normalizer.normalize(result_dict, descriptor) + + assert 'Missing result type' in str(exc_info.value) + + @pytest.mark.asyncio + async def test_normalize_unknown_type(self): + """Normalize unknown type returns None.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + result_dict = { + 'type': 'unknown_type', + 'data': {}, + } + + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + @pytest.mark.asyncio + async def test_normalize_legacy_type_returns_none(self): + """Legacy types (chunk, text, finish) are now treated as unknown.""" + normalizer = AgentResultNormalizer(FakeApplication()) + descriptor = make_descriptor() + + # chunk is now unknown + result_dict = { + 'type': 'chunk', + 'data': { + 'message_chunk': { + 'role': 'assistant', + 'content': 'Legacy chunk', + }, + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + # text is now unknown + result_dict = { + 'type': 'text', + 'data': { + 'content': 'Legacy text', + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None + + # finish is now unknown + result_dict = { + 'type': 'finish', + 'data': { + 'message': { + 'role': 'assistant', + 'content': 'Legacy finish', + }, + }, + } + result = await normalizer.normalize(result_dict, descriptor) + assert result is None diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py new file mode 100644 index 000000000..d71ce4215 --- /dev/null +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -0,0 +1,1499 @@ +"""Tests for AgentRunner run ledger pull API authorization.""" + +from __future__ import annotations + +import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker + +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.entity.persistence import agent_run as agent_run_model +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import ( + AgentRun, + AgentRunEvent, + RunEventPage, + RunPage, +) +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +from .conftest import make_resources + + +class FakeConnection: + pass + + +class FakeApplication: + def __init__(self, db_engine, admin_plugins=None, runner_registry=None): + self.logger = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + self.agent_runner_registry = runner_registry + self.instance_config = SimpleNamespace( + data={ + 'agent_runner': { + 'admin_plugins': admin_plugins or [], + } + } + ) + + +@pytest.fixture +def session_registry(monkeypatch): + registry = AgentRunSessionRegistry() + monkeypatch.setattr( + 'langbot.pkg.agent.runner.session_registry._global_registry', + registry, + ) + return registry + + +@pytest.fixture +async def db_engine(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + assert agent_run_model.AgentRun.__tablename__ == 'agent_run' + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +class FakeRunnerRegistry: + def __init__(self, runners): + self.runners = runners + self.calls = [] + + async def list_runners(self, *, bound_plugins=None, use_cache=True): + self.calls.append({'bound_plugins': bound_plugins, 'use_cache': use_cache}) + return self.runners + + +def _handler(db_engine, admin_plugins=None, runner_registry=None): + async def fake_disconnect(): + return True + + fake_app = FakeApplication(db_engine, admin_plugins=admin_plugins, runner_registry=runner_registry) + return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + +async def _register_session( + session_registry, + *, + run_id='run_1', + conversation_id='conv_1', + available_apis=None, +): + await session_registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_resources(), + conversation_id=conversation_id, + bot_id='bot_1', + workspace_id='workspace_1', + thread_id=None, + available_apis=available_apis or {}, + ) + + +async def _create_run( + db_engine, + *, + run_id='run_1', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id=None, + plugin_identity='test/runner', + runner_id='plugin:test/runner/default', + available_apis=None, + status='running', + queue_name=None, + priority=0, + requested_runtime_id=None, +): + store = RunLedgerStore(db_engine) + await store.create_run( + run_id=run_id, + event_id='evt_1', + binding_id='binding_1', + runner_id=runner_id, + conversation_id=conversation_id, + bot_id=bot_id, + workspace_id=workspace_id, + thread_id=thread_id, + status=status, + queue_name=queue_name, + priority=priority, + requested_runtime_id=requested_runtime_id, + authorization={ + 'runner_id': runner_id, + 'binding_id': 'binding_1', + 'plugin_identity': plugin_identity, + 'resources': make_resources(), + 'available_apis': available_apis or {}, + 'conversation_id': conversation_id, + 'bot_id': bot_id, + 'workspace_id': workspace_id, + 'thread_id': thread_id, + 'state_policy': {'enable_state': True, 'state_scopes': ['conversation', 'actor']}, + 'state_context': {}, + }, + ) + await store.append_event( + run_id=run_id, + sequence=1, + event_type='message.completed', + data={'message': {'role': 'assistant', 'content': 'ok'}}, + ) + + +@pytest.mark.asyncio +async def test_run_get_returns_current_run(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_get': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + assert run.status == 'running' + + +@pytest.mark.asyncio +async def test_run_list_rejects_cross_conversation(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'conversation_id': 'conv_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_list_returns_scoped_runs(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + await _create_run(db_engine) + await _create_run(db_engine, run_id='run_other', conversation_id='conv_other') + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_run_list_filters_same_scope_different_runner_owner(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_list': True}) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='run_other_owner', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_list': True}, + ) + handler = _handler(db_engine) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner( + session_registry, + db_engine, +): + await _register_session( + session_registry, + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + await _create_run( + db_engine, + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + await _create_run( + db_engine, + run_id='run_other_owner', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={ + 'run_get': True, + 'run_events_page': True, + 'run_cancel': True, + 'run_append_result': True, + 'run_finalize': True, + }, + ) + handler = _handler(db_engine) + + calls = [ + ( + PluginToRuntimeAction.RUN_GET.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + }, + ), + ( + PluginToRuntimeAction.RUN_EVENTS_PAGE.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + }, + ), + ( + PluginToRuntimeAction.RUN_CANCEL.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'reason': 'not allowed', + }, + ), + ( + PluginToRuntimeAction.RUN_APPEND_RESULT.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + }, + ), + ( + PluginToRuntimeAction.RUN_FINALIZE.value, + { + 'run_id': 'run_1', + 'target_run_id': 'run_other_owner', + 'caller_plugin_identity': 'test/runner', + 'status': 'completed', + }, + ), + ] + + for action, payload in calls: + result = await handler.actions[action](payload) + assert result.code != 0, action + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_events_page_returns_events(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_events_page': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + result = await run_events_page( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunEventPage.model_validate(result.data) + assert [item.sequence for item in page.items] == [1] + assert page.items[0].type == 'message.completed' + + +@pytest.mark.asyncio +async def test_run_get_uses_persistent_authorization_after_session_expired(db_engine): + await _create_run(db_engine, available_apis={'run_get': True}) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + + +@pytest.mark.asyncio +async def test_persistent_run_get_rejects_cross_scope(db_engine): + await _create_run(db_engine, available_apis={'run_get': True}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + available_apis={'run_get': True}, + ) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not accessible' in result.message.lower() + + +@pytest.mark.asyncio +async def test_persistent_run_get_requires_capability(db_engine): + await _create_run(db_engine, available_apis={'run_get': False}) + handler = _handler(db_engine) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_list_all_runs_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_list': True}, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['running'], + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_other', 'run_1'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_permission_string_allows_without_run_id(db_engine): + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': 'agent_run:admin', + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code == 0 + page = RunPage.model_validate(result.data) + assert [run.run_id for run in page.items] == ['run_1'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_list_runner_registry_without_run_id(db_engine): + runner_registry = FakeRunnerRegistry( + [ + { + 'id': 'plugin:test/runner/default', + 'source': 'plugin', + 'plugin_author': 'test', + 'plugin_name': 'runner', + 'runner_name': 'default', + 'label': {'en_US': 'Default'}, + } + ] + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + runner_registry=runner_registry, + ) + runner_list = handler.actions['runner_list'] + + result = await runner_list( + { + 'caller_plugin_identity': 'langbot/control', + 'include_plugins': ['test/runner'], + } + ) + + assert result.code == 0 + assert result.data['items'][0]['id'] == 'plugin:test/runner/default' + assert runner_registry.calls == [ + { + 'bound_plugins': ['test/runner'], + 'use_cache': True, + } + ] + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_list_runner_registry(db_engine): + handler = _handler(db_engine, runner_registry=FakeRunnerRegistry([])) + runner_list = handler.actions['runner_list'] + + result = await runner_list({'caller_plugin_identity': 'test/runner'}) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_get_and_page_cross_scope_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_get': True, 'run_events_page': True}, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + run_result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + events_result = await run_events_page( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert run_result.code == 0 + assert AgentRun.model_validate(run_result.data).run_id == 'run_other' + assert events_result.code == 0 + page = RunEventPage.model_validate(events_result.data) + assert [event.type for event in page.items] == ['message.completed'] + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_get_and_page_cross_scope_without_run_id(db_engine): + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + run_events_page = handler.actions[PluginToRuntimeAction.RUN_EVENTS_PAGE.value] + + run_result = await run_get( + { + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'langbot/control', + } + ) + events_result = await run_events_page( + { + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'langbot/control', + } + ) + + assert run_result.code == 0 + assert AgentRun.model_validate(run_result.data).run_id == 'run_other' + assert events_result.code == 0 + page = RunEventPage.model_validate(events_result.data) + assert [event.type for event in page.items] == ['message.completed'] + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_use_admin_run_actions_without_run_id(db_engine): + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_list = handler.actions[PluginToRuntimeAction.RUN_LIST.value] + + result = await run_list( + { + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'run_id is required' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_can_cancel_cross_scope_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run( + db_engine, + run_id='run_other', + conversation_id='conv_other', + bot_id='bot_other', + workspace_id='workspace_other', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_cancel = handler.actions[PluginToRuntimeAction.RUN_CANCEL.value] + + result = await run_cancel( + { + 'run_id': 'run_1', + 'target_run_id': 'run_other', + 'caller_plugin_identity': 'test/runner', + 'reason': 'admin requested', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_other' + assert run.cancel_requested_at is not None + assert run.status_reason == 'admin requested' + events, _next_cursor, _prev_cursor, _has_more = await RunLedgerStore(db_engine).page_run_events( + run_id='run_other', + ) + assert [event['type'] for event in events] == ['message.completed', 'admin.run_cancel'] + assert events[1]['source'] == 'host' + assert events[1]['data']['caller_plugin_identity'] == 'test/runner' + assert events[1]['metadata'] == {'permission': 'agent_run:admin'} + + +@pytest.mark.asyncio +async def test_configured_admin_identity_cannot_be_spoofed_with_other_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['agent_run:admin'], + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_1', + 'caller_plugin_identity': 'langbot/control', + } + ) + + assert result.code != 0 + assert 'mismatch' in result.message.lower() + + +@pytest.mark.asyncio +async def test_agent_run_admin_permission_does_not_grant_runtime_admin(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin'], + } + ], + ) + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + + result = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_runtime_admin_can_register_list_and_claim_with_own_run_session(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + registered = await runtime_register( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'labels': {'region': 'test'}, + } + ) + page = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + + +@pytest.mark.asyncio +async def test_runtime_admin_can_register_list_and_claim_without_run_id(db_engine): + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + registered = await runtime_register( + { + 'caller_plugin_identity': 'langbot/control', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'labels': {'region': 'test'}, + } + ) + page = await runtime_list( + { + 'caller_plugin_identity': 'langbot/control', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + claimed = await run_claim( + { + 'caller_plugin_identity': 'langbot/control', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + + +@pytest.mark.asyncio +async def test_runtime_admin_can_reconcile_without_run_id(db_engine): + store = RunLedgerStore(db_engine) + await store.register_runtime( + runtime_id='runtime_stale', + display_name='Runtime Stale', + heartbeat_deadline_seconds=60, + ) + await store.create_run( + run_id='claimed_run', + event_id='evt_claimed', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + status='queued', + queue_name='default', + ) + claim = await store.claim_next_run(runtime_id='runtime_stale', queue_name='default', lease_seconds=60) + assert claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + expired_at = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=1) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(agent_run_model.AgentRun) + .where(agent_run_model.AgentRun.run_id == 'claimed_run') + .values(claim_lease_expires_at=expired_at) + ) + await session.execute( + sqlalchemy.update(agent_run_model.AgentRuntime) + .where(agent_run_model.AgentRuntime.runtime_id == 'runtime_stale') + .values( + last_heartbeat_at=expired_at, + heartbeat_deadline_at=expired_at, + ) + ) + await session.commit() + + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'langbot/control', + 'permissions': ['runtime:admin'], + } + ], + ) + runtime_reconcile = handler.actions['runtime_reconcile'] + + result = await runtime_reconcile({'caller_plugin_identity': 'langbot/control'}) + + assert result.code == 0 + assert result.data['stale_count'] == 1 + assert result.data['released_claim_count'] == 1 + assert result.data['stale_runtimes'][0]['runtime_id'] == 'runtime_stale' + assert result.data['released_claims'][0]['run_id'] == 'claimed_run' + assert (await store.get_runtime('runtime_stale'))['status'] == 'stale' + released_run = await store.get_run('claimed_run') + assert released_run is not None + assert released_run['status'] == 'queued' + assert released_run['claimed_by_runtime_id'] is None + assert 'claim_token' not in released_run + + +@pytest.mark.asyncio +async def test_unconfigured_plugin_cannot_reconcile_runtime(db_engine): + handler = _handler(db_engine) + runtime_reconcile = handler.actions['runtime_reconcile'] + + result = await runtime_reconcile({'caller_plugin_identity': 'test/runner'}) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_disabled_admin_plugin_entry_does_not_grant_access(session_registry, db_engine): + await _register_session(session_registry, available_apis={}) + await _create_run(db_engine) + handler = _handler( + db_engine, + admin_plugins=[ + { + 'identity': 'test/runner', + 'permissions': ['agent_run:admin', 'runtime:admin'], + 'enabled': False, + } + ], + ) + run_get = handler.actions[PluginToRuntimeAction.RUN_GET.value] + + result = await run_get( + { + 'run_id': 'run_1', + 'target_run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + } + ) + + assert result.code != 0 + assert 'not authorized' in result.message.lower() + + +@pytest.mark.asyncio +async def test_run_cancel_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_cancel': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_cancel = handler.actions[PluginToRuntimeAction.RUN_CANCEL.value] + + result = await run_cancel( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'reason': 'user requested', + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.run_id == 'run_1' + assert run.cancel_requested_at is not None + assert run.status_reason == 'user requested' + + +@pytest.mark.asyncio +async def test_run_append_result_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + 'usage': {'output_tokens': 1}, + }, + } + ) + + assert result.code == 0 + event = AgentRunEvent.model_validate(result.data) + assert event.run_id == 'run_1' + assert event.sequence == 2 + assert event.type == 'tool.call.started' + assert event.data == {'tool_call_id': 'call_1', 'tool_name': 'weather'} + assert event.usage == {'output_tokens': 1} + + +@pytest.mark.asyncio +async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'hello'}}, + }, + } + ) + + assert result.code != 0 + assert 'canonical runner result path' in result.message + + +@pytest.mark.asyncio +async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'unknown.event', + 'data': {}, + }, + } + ) + + assert result.code != 0 + assert 'unknown result type' in result.message + + +@pytest.mark.asyncio +async def test_run_append_result_validates_known_payloads(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + + result = await run_append_result( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'action.requested', + 'data': {'payload': {}}, + }, + } + ) + + assert result.code != 0 + assert 'invalid action.requested payload' in result.message + + +@pytest.mark.asyncio +async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_append_result': True, 'run_finalize': True}, + ) + await _create_run( + db_engine, + available_apis={'run_append_result': True, 'run_finalize': True}, + ) + await _create_run( + db_engine, + run_id='run_claimed', + conversation_id='conv_1', + available_apis={'run_append_result': True, 'run_finalize': True}, + status='queued', + queue_name='default', + ) + store = RunLedgerStore(db_engine) + claim = await store.claim_next_run(runtime_id='runtime_1', queue_name='default') + assert claim is not None + token = claim['claim_token'] + handler = _handler(db_engine) + run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + + missing_claim = await run_append_result( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + } + ) + wrong_claim = await run_finalize( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': 'wrong-token', + 'status': 'completed', + } + ) + appended = await run_append_result( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': token, + 'sequence': 2, + 'result': { + 'type': 'tool.call.started', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'weather'}, + }, + } + ) + finalized = await run_finalize( + { + 'run_id': 'run_1', + 'target_run_id': 'run_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': token, + 'status': 'completed', + } + ) + + assert missing_claim.code != 0 + assert 'active claim ownership' in missing_claim.message + assert wrong_claim.code != 0 + assert 'claim ownership is not active' in wrong_claim.message + assert appended.code == 0 + assert finalized.code == 0 + assert finalized.data['status'] == 'completed' + + +@pytest.mark.asyncio +async def test_run_finalize_basic_path(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_finalize': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + + result = await run_finalize( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'status': 'completed', + 'status_reason': 'done', + 'usage': {'total_tokens': 3}, + } + ) + + assert result.code == 0 + run = AgentRun.model_validate(result.data) + assert run.status == 'completed' + assert run.status_reason == 'done' + assert run.finished_at is not None + assert run.usage == {'total_tokens': 3} + + +@pytest.mark.asyncio +async def test_runtime_register_heartbeat_and_list_actions(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={ + 'runtime_register': True, + 'runtime_heartbeat': True, + 'runtime_list': True, + }, + ) + handler = _handler(db_engine) + runtime_register = handler.actions[PluginToRuntimeAction.RUNTIME_REGISTER.value] + runtime_heartbeat = handler.actions[PluginToRuntimeAction.RUNTIME_HEARTBEAT.value] + runtime_list = handler.actions[PluginToRuntimeAction.RUNTIME_LIST.value] + + registered = await runtime_register( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'display_name': 'Runtime 1', + 'capabilities': {'runner': True}, + 'labels': {'region': 'test'}, + 'metadata': {'slots': 2}, + } + ) + + assert registered.code == 0 + assert registered.data['runtime_id'] == 'runtime_1' + assert registered.data['capabilities'] == {'runner': True} + + heartbeat = await runtime_heartbeat( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'capabilities': {'runner': True, 'stream': True}, + 'labels': {'region': 'test'}, + 'metadata': {'active_runs': 1}, + } + ) + + assert heartbeat.code == 0 + assert heartbeat.data['capabilities'] == {'runner': True, 'stream': True} + assert heartbeat.data['metadata'] == {'slots': 2, 'active_runs': 1} + + page = await runtime_list( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'statuses': ['online'], + 'labels': {'region': 'test'}, + } + ) + + assert page.code == 0 + assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] + + +@pytest.mark.asyncio +async def test_run_claim_renew_and_release_actions(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={ + 'run_claim': True, + 'run_renew_claim': True, + 'run_release_claim': True, + }, + ) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=5, + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'lease_seconds': 30, + } + ) + + assert claimed.code == 0 + assert claimed.data['run_id'] == 'queued_run' + assert claimed.data['status'] == 'claimed' + assert claimed.data['claimed_by_runtime_id'] == 'runtime_1' + claim_token = claimed.data['claim_token'] + + renewed = await run_renew_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim_token, + 'lease_seconds': 60, + } + ) + + assert renewed.code == 0 + assert 'claim_token' not in renewed.data + + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim_token, + 'reason': 'done with lease', + } + ) + + assert released.code == 0 + assert released.data['status'] == 'queued' + assert released.data['claimed_by_runtime_id'] is None + assert 'claim_token' not in released.data + + +@pytest.mark.asyncio +async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_claim': True}, + ) + store = RunLedgerStore(db_engine) + await store.create_run( + run_id='other_runner_queued', + event_id='evt_other_runner', + binding_id='binding_1', + runner_id='plugin:other/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=30, + ) + await store.create_run( + run_id='other_conversation_queued', + event_id='evt_other_conversation', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_other', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=20, + ) + await store.create_run( + run_id='own_queued', + event_id='evt_own', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + priority=10, + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + + spoofed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + 'runner_ids': ['plugin:other/runner/default'], + } + ) + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + } + ) + + assert spoofed.code != 0 + assert 'not accessible' in spoofed.message.lower() + assert claimed.code == 0 + assert claimed.data['run_id'] == 'own_queued' + + +@pytest.mark.asyncio +async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_renew_claim': True, 'run_release_claim': True}, + ) + await _create_run(db_engine) + await _create_run( + db_engine, + run_id='other_claimed', + conversation_id='conv_1', + plugin_identity='other/runner', + runner_id='plugin:other/runner/default', + available_apis={'run_renew_claim': True, 'run_release_claim': True}, + status='queued', + queue_name='default', + ) + store = RunLedgerStore(db_engine) + claim = await store.claim_next_run(runtime_id='runtime_1', queue_name='default') + assert claim is not None + handler = _handler(db_engine) + run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + renewed = await run_renew_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'other_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim['claim_token'], + } + ) + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'other_claimed', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claim['claim_token'], + } + ) + + assert renewed.code != 0 + assert 'not accessible' in renewed.message.lower() + assert released.code != 0 + assert 'not accessible' in released.message.lower() + + +@pytest.mark.asyncio +async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine): + await _register_session( + session_registry, + available_apis={'run_claim': True, 'run_release_claim': True}, + ) + await RunLedgerStore(db_engine).create_run( + run_id='queued_run', + event_id='evt_queued', + binding_id='binding_1', + runner_id='plugin:test/runner/default', + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + status='queued', + queue_name='default', + ) + handler = _handler(db_engine) + run_claim = handler.actions[PluginToRuntimeAction.RUN_CLAIM.value] + run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + + claimed = await run_claim( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'queue_name': 'default', + } + ) + assert claimed.code == 0 + + released = await run_release_claim( + { + 'run_id': 'run_1', + 'target_run_id': 'queued_run', + 'caller_plugin_identity': 'test/runner', + 'runtime_id': 'runtime_1', + 'claim_token': claimed.data['claim_token'], + 'status': 'completed', + } + ) + + assert released.code != 0 + assert 'use run_finalize' in released.message diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py new file mode 100644 index 000000000..c3667fcde --- /dev/null +++ b/tests/unit_tests/agent/test_run_ledger_store.py @@ -0,0 +1,430 @@ +"""Tests for RunLedgerStore host primitives.""" + +from __future__ import annotations + +import datetime + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.entity.persistence.agent_run import AgentRun +from langbot.pkg.entity.persistence.base import Base + + +UTC = datetime.timezone.utc + + +@pytest.fixture +async def db_engine(tmp_path): + db_path = tmp_path / 'run_ledger_store.db' + engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + +@pytest.fixture +def store(db_engine): + return RunLedgerStore(db_engine) + + +@pytest.mark.asyncio +async def test_create_queued_run_claim_renew_release(store): + run = await store.create_run( + run_id='run-queued', + event_id='evt-1', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + priority=10, + requested_runtime_id='runtime-a', + ) + + assert run['status'] == 'queued' + assert run['started_at'] is None + assert run['queue_name'] == 'default' + assert run['priority'] == 10 + assert run['requested_runtime_id'] == 'runtime-a' + + assert await store.claim_next_run(runtime_id='runtime-b', queue_name='default') is None + + claimed = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=30) + + assert claimed is not None + assert claimed['run_id'] == 'run-queued' + assert claimed['status'] == 'claimed' + assert claimed['claimed_by_runtime_id'] == 'runtime-a' + assert claimed['claim_token'] + assert claimed['dispatch_attempts'] == 1 + assert claimed['claim_lease_expires_at'] is not None + assert claimed['last_claimed_at'] is not None + + token = claimed['claim_token'] + assert await store.renew_claim(run_id='run-queued', claim_token='wrong-token') is None + + renewed = await store.renew_claim(run_id='run-queued', claim_token=token, lease_seconds=90) + + assert renewed is not None + assert 'claim_token' not in renewed + assert renewed['claim_lease_expires_at'] >= claimed['claim_lease_expires_at'] + + released = await store.release_claim( + run_id='run-queued', + claim_token=token, + status='queued', + status_reason='runtime released capacity', + ) + + assert released is not None + assert released['status'] == 'queued' + assert released['status_reason'] == 'runtime released capacity' + assert released['claimed_by_runtime_id'] is None + assert 'claim_token' not in released + assert released['claim_lease_expires_at'] is None + assert released['dispatch_attempts'] == 1 + + +@pytest.mark.asyncio +async def test_claim_next_run_applies_scope_filters(store): + await store.create_run( + run_id='run-other-runner', + event_id='evt-other-runner', + binding_id='binding-1', + runner_id='runner-b', + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=30, + ) + await store.create_run( + run_id='run-other-conversation', + event_id='evt-other-conversation', + binding_id='binding-1', + runner_id='runner-a', + conversation_id='conv-b', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=20, + ) + await store.create_run( + run_id='run-allowed', + event_id='evt-allowed', + binding_id='binding-1', + runner_id='runner-a', + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + status='queued', + queue_name='default', + priority=10, + ) + + claimed = await store.claim_next_run( + runtime_id='runtime-a', + queue_name='default', + runner_ids=['runner-a'], + conversation_id='conv-a', + bot_id='bot-a', + workspace_id='workspace-a', + thread_id=None, + strict_thread=True, + ) + + assert claimed is not None + assert claimed['run_id'] == 'run-allowed' + + +@pytest.mark.asyncio +async def test_expired_claim_can_be_reclaimed(store, db_engine): + await store.create_run( + run_id='run-expired', + event_id='evt-2', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + first_claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + assert first_claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-expired') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + reclaimed = await store.claim_next_run(runtime_id='runtime-b', queue_name='default', lease_seconds=60) + + assert reclaimed is not None + assert reclaimed['run_id'] == 'run-expired' + assert reclaimed['claimed_by_runtime_id'] == 'runtime-b' + assert reclaimed['claim_token'] != first_claim['claim_token'] + assert reclaimed['dispatch_attempts'] == 2 + + +@pytest.mark.asyncio +async def test_release_expired_claims_requeues_runs(store, db_engine): + await store.create_run( + run_id='run-expired-release', + event_id='evt-3', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + await store.create_run( + run_id='run-active-claim', + event_id='evt-4', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + expired_claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + active_claim = await store.claim_next_run(runtime_id='runtime-b', queue_name='default', lease_seconds=60) + assert expired_claim is not None + assert active_claim is not None + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-expired-release') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + released = await store.release_expired_claims() + + assert [run['run_id'] for run in released] == ['run-expired-release'] + assert released[0]['status'] == 'queued' + assert released[0]['status_reason'] == 'claim lease expired' + assert released[0]['claimed_by_runtime_id'] is None + assert 'claim_token' not in released[0] + assert released[0]['claim_lease_expires_at'] is None + + active = await store.get_run('run-active-claim') + assert active is not None + assert active['status'] == 'claimed' + assert active['claimed_by_runtime_id'] == active_claim['claimed_by_runtime_id'] + assert 'claim_token' not in active + + +@pytest.mark.asyncio +async def test_expired_claim_cannot_renew_or_release(store, db_engine): + await store.create_run( + run_id='run-stale-claim', + event_id='evt-stale', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + claimed = await store.claim_next_run(runtime_id='runtime-a', queue_name='default', lease_seconds=60) + assert claimed is not None + token = claimed['claim_token'] + + session_factory = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + await session.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'run-stale-claim') + .values(claim_lease_expires_at=datetime.datetime.now(UTC) - datetime.timedelta(seconds=1)) + ) + await session.commit() + + assert await store.renew_claim(run_id='run-stale-claim', claim_token=token, runtime_id='runtime-a') is None + assert await store.release_claim(run_id='run-stale-claim', claim_token=token, runtime_id='runtime-a') is None + + +@pytest.mark.asyncio +async def test_run_status_validation_and_terminal_transition_rules(store): + with pytest.raises(ValueError, match='Unknown run status'): + await store.create_run( + run_id='run-invalid-create', + event_id='evt-invalid', + binding_id='binding-1', + runner_id='runner-a', + status='bogus', + ) + + await store.create_run( + run_id='run-invalid-release', + event_id='evt-release', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + claim = await store.claim_next_run(runtime_id='runtime-a', queue_name='default') + assert claim is not None + with pytest.raises(ValueError, match='Unknown run status'): + await store.release_claim( + run_id='run-invalid-release', + claim_token=claim['claim_token'], + runtime_id='runtime-a', + status='bogus', + ) + + await store.create_run( + run_id='run-terminal', + event_id='evt-terminal', + binding_id='binding-1', + runner_id='runner-a', + ) + with pytest.raises(ValueError, match='Unknown run status'): + await store.finalize_run(run_id='run-terminal', status='bogus') + + completed = await store.finalize_run( + run_id='run-terminal', + status='completed', + metadata={'attempt': 1}, + ) + assert completed is not None + assert completed['status'] == 'completed' + + merged = await store.finalize_run( + run_id='run-terminal', + status='completed', + metadata={'retry_observed': True}, + ) + assert merged is not None + assert merged['metadata'] == {'attempt': 1, 'retry_observed': True} + + with pytest.raises(ValueError, match='Cannot transition terminal run'): + await store.finalize_run(run_id='run-terminal', status='failed') + + +@pytest.mark.asyncio +async def test_append_audit_event_uses_next_sequence(store): + await store.create_run( + run_id='run-audit', + event_id='evt-5', + binding_id='binding-1', + runner_id='runner-a', + ) + await store.append_event( + run_id='run-audit', + sequence=1, + event_type='message.completed', + data={'ok': True}, + ) + + event = await store.append_audit_event( + run_id='run-audit', + event_type='admin.run_cancel', + data={'action': 'run_cancel'}, + metadata={'permission': 'agent_run:admin'}, + ) + + assert event is not None + assert event['sequence'] == 2 + assert event['type'] == 'admin.run_cancel' + assert event['source'] == 'host' + assert event['data'] == {'action': 'run_cancel'} + assert event['metadata'] == {'permission': 'agent_run:admin'} + assert await store.append_audit_event(run_id='missing', event_type='admin.missing') is None + + +@pytest.mark.asyncio +async def test_runtime_register_heartbeat_list_and_mark_stale(store): + registered = await store.register_runtime( + runtime_id='runtime-a', + display_name='Runtime A', + endpoint='http://runtime-a', + version='1.0.0', + capabilities={'stream': True}, + labels={'region': 'test'}, + metadata={'slot_count': 2}, + heartbeat_deadline_seconds=30, + ) + + assert registered['runtime_id'] == 'runtime-a' + assert registered['status'] == 'online' + assert registered['display_name'] == 'Runtime A' + assert registered['capabilities'] == {'stream': True} + assert registered['labels'] == {'region': 'test'} + assert registered['metadata'] == {'slot_count': 2} + assert registered['last_heartbeat_at'] is not None + assert registered['heartbeat_deadline_at'] is not None + + heartbeat = await store.heartbeat_runtime( + runtime_id='runtime-a', + metadata={'active_runs': 1}, + heartbeat_deadline_seconds=30, + ) + + assert heartbeat is not None + assert heartbeat['metadata'] == {'slot_count': 2, 'active_runs': 1} + + runtimes, total_count = await store.list_runtimes(statuses=['online']) + assert [runtime['runtime_id'] for runtime in runtimes] == ['runtime-a'] + assert total_count == 1 + + stale = await store.mark_stale_runtimes( + now=datetime.datetime.now(UTC) + datetime.timedelta(seconds=31), + ) + + assert [runtime['runtime_id'] for runtime in stale] == ['runtime-a'] + assert stale[0]['status'] == 'stale' + assert (await store.get_runtime('runtime-a'))['status'] == 'stale' + + +@pytest.mark.asyncio +async def test_runtime_stats_splits_active_and_claimed_runs(store): + await store.register_runtime(runtime_id='runtime-a') + await store.create_run( + run_id='run-running', + event_id='evt-running', + binding_id='binding-1', + runner_id='runner-a', + status='running', + ) + await store.create_run( + run_id='run-claimed', + event_id='evt-claimed', + binding_id='binding-1', + runner_id='runner-a', + status='queued', + queue_name='default', + ) + assert await store.claim_next_run(runtime_id='runtime-a', queue_name='default') is not None + + stats = await store.get_runtime_stats() + + assert stats['active_runs'] == 2 + assert stats['claimed_runs'] == 1 + + +@pytest.mark.asyncio +async def test_runner_stats_reports_zero_success_rate_for_failed_only_runner(store): + now = int(datetime.datetime.now(UTC).timestamp()) + await store.create_run( + run_id='run-failed', + event_id='evt-failed', + binding_id='binding-1', + runner_id='runner-a', + status='failed', + ) + + stats = await store.get_runner_stats(start_time=now - 10, end_time=now + 10) + + assert stats[0]['runner_id'] == 'runner-a' + assert stats[0]['failed_runs'] == 1 + assert stats[0]['success_rate'] == 0.0 diff --git a/tests/unit_tests/agent/test_session_registry.py b/tests/unit_tests/agent/test_session_registry.py new file mode 100644 index 000000000..6b476f56b --- /dev/null +++ b/tests/unit_tests/agent/test_session_registry.py @@ -0,0 +1,665 @@ +"""Tests for AgentRunSessionRegistry.""" + +from __future__ import annotations + +import pytest +import asyncio +import time + +from langbot.pkg.agent.runner.session_registry import ( + AgentRunSessionRegistry, + AgentRunSession, + MAX_STEERING_QUEUE_ITEMS, + get_session_registry, +) + +# Import shared test fixtures from conftest.py +from .conftest import make_resources, make_session + + +class TestSessionRegistryBasic: + """Tests for basic registry operations.""" + + @pytest.mark.asyncio + async def test_register_and_get(self): + """Register and retrieve a session.""" + registry = AgentRunSessionRegistry() + run_id = 'run_abc' + resources = make_resources( + models=[{'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}], + tools=[{'tool_name': 'web_search', 'tool_type': 'builtin'}], + ) + await registry.register( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=resources, + ) + + result = await registry.get(run_id) + assert result is not None + assert result['run_id'] == run_id + assert result['runner_id'] == 'plugin:test/my-runner/default' + assert result['query_id'] == 1 + assert result['plugin_identity'] == 'test/my-runner' + auth_resources = result['authorization']['resources'] + assert len(auth_resources['models']) == 1 + assert auth_resources['models'][0]['model_id'] == 'model_001' + assert 'resources' not in result + assert 'permissions' not in result + assert '_authorized_ids' not in result + + @pytest.mark.asyncio + async def test_register_keeps_host_execution_query_by_identity(self): + """The runtime registry keeps the Host Query view in memory only.""" + registry = AgentRunSessionRegistry() + execution_query = object() + + await registry.register( + run_id='run_execution_query', + runner_id='plugin:test/my-runner/default', + query_id=None, + plugin_identity='test/my-runner', + resources=make_resources(), + execution_query=execution_query, + ) + + session = await registry.get('run_execution_query') + + assert session is not None + assert session['query_id'] is None + assert session['execution_query'] is execution_query + + @pytest.mark.asyncio + async def test_register_requires_plugin_identity(self): + """Agent run sessions must always have an owning plugin identity.""" + registry = AgentRunSessionRegistry() + + with pytest.raises(ValueError, match='plugin_identity is required'): + await registry.register( + run_id='run_missing_identity', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='', + resources=make_resources(), + ) + + @pytest.mark.asyncio + async def test_register_freezes_authorization_snapshot(self): + """Register should freeze authorization data for the run.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[{'model_id': 'model_001'}], + storage={'plugin_storage': True, 'workspace_storage': False}, + ) + + await registry.register( + run_id='run_snapshot', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=resources, + available_apis={'history_page': True}, + conversation_id='conv_001', + ) + + resources['models'].append({'model_id': 'model_late'}) + resources['storage']['workspace_storage'] = True + + session = await registry.get('run_snapshot') + assert session is not None + authorization = session['authorization'] + assert authorization['conversation_id'] == 'conv_001' + assert authorization['available_apis'] == {'history_page': True} + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_late') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + @pytest.mark.asyncio + async def test_get_nonexistent_session(self): + """Get should return None for nonexistent run_id.""" + registry = AgentRunSessionRegistry() + result = await registry.get('nonexistent_run') + assert result is None + + @pytest.mark.asyncio + async def test_unregister(self): + """Unregister should remove session.""" + registry = AgentRunSessionRegistry() + run_id = 'run_xyz' + + await registry.register( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + ) + + # Verify registered + result = await registry.get(run_id) + assert result is not None + + # Unregister + await registry.unregister(run_id) + + # Verify unregistered + result = await registry.get(run_id) + assert result is None + + @pytest.mark.asyncio + async def test_unregister_nonexistent(self): + """Unregister nonexistent session should not raise error.""" + registry = AgentRunSessionRegistry() + # Should not raise + await registry.unregister('nonexistent_run') + + @pytest.mark.asyncio + async def test_update_activity(self): + """Update activity should update last_activity_at.""" + registry = AgentRunSessionRegistry() + run_id = 'run_activity' + + # Create session with manually set old timestamp + now = int(time.time()) + old_session: AgentRunSession = make_session( + run_id=run_id, + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + ) + old_session['status'] = { + 'started_at': now - 100, + 'last_activity_at': now - 100, + } + + async with registry._lock: + registry._sessions[run_id] = old_session + + # Get initial session + session1 = await registry.get(run_id) + initial_time = session1['status']['last_activity_at'] + + # Update activity + await registry.update_activity(run_id) + + # Verify updated - should be significantly different (100 seconds) + session2 = await registry.get(run_id) + assert session2['status']['last_activity_at'] > initial_time + assert session2['status']['last_activity_at'] - initial_time >= 100 + + @pytest.mark.asyncio + async def test_update_activity_nonexistent(self): + """Update activity on nonexistent session should not raise.""" + registry = AgentRunSessionRegistry() + # Should not raise + await registry.update_activity('nonexistent_run') + + @pytest.mark.asyncio + async def test_list_active_runs(self): + """List active runs should return all sessions.""" + registry = AgentRunSessionRegistry() + + await registry.register('run_1', 'plugin:a/b/default', 1, 'a/b', make_resources()) + await registry.register('run_2', 'plugin:c/d/default', 2, 'c/d', make_resources()) + + active_runs = await registry.list_active_runs() + assert len(active_runs) == 2 + run_ids = [r['run_id'] for r in active_runs] + assert 'run_1' in run_ids + assert 'run_2' in run_ids + + @pytest.mark.asyncio + async def test_cleanup_stale_sessions(self): + """Cleanup should remove old sessions.""" + registry = AgentRunSessionRegistry() + + # Create sessions with manually set old timestamp + now = int(time.time()) + old_session: AgentRunSession = make_session( + run_id='old_run', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + ) + old_session['status'] = { + 'started_at': now - 7200, + 'last_activity_at': now - 7200, + } + new_session: AgentRunSession = make_session( + run_id='new_run', + runner_id='plugin:test/runner/default', + query_id=2, + plugin_identity='test/runner', + ) + new_session['status'] = { + 'started_at': now, + 'last_activity_at': now, + } + + async with registry._lock: + registry._sessions['old_run'] = old_session + registry._sessions['new_run'] = new_session + + # Cleanup sessions older than 1 hour + cleaned = await registry.cleanup_stale_sessions(max_age_seconds=3600) + assert cleaned == 1 + + # Verify old session removed, new remains + assert await registry.get('old_run') is None + assert await registry.get('new_run') is not None + + @pytest.mark.asyncio + async def test_pull_steering_all_preserves_queue_order(self): + """Default all-mode steering returns every queued item in FIFO order.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_1'}, 'input': {'text': 'first'}}) + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_2'}, 'input': {'text': 'second'}}) + await registry.enqueue_steering('run_steering', {'event': {'event_id': 'event_3'}, 'input': {'text': 'third'}}) + + items = await registry.pull_steering('run_steering', mode='all') + assert [item['event']['event_id'] for item in items] == ['event_1', 'event_2', 'event_3'] + assert await registry.pull_steering('run_steering', mode='all') == [] + + @pytest.mark.asyncio + async def test_pull_steering_one_at_a_time_leaves_remaining_items(self): + """one-at-a-time is an explicit runner-side throttling mode.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_one', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + await registry.enqueue_steering('run_steering_one', {'event': {'event_id': 'event_1'}}) + await registry.enqueue_steering('run_steering_one', {'event': {'event_id': 'event_2'}}) + + first = await registry.pull_steering('run_steering_one', mode='one-at-a-time') + second = await registry.pull_steering('run_steering_one', mode='one-at-a-time') + + assert [item['event']['event_id'] for item in first] == ['event_1'] + assert [item['event']['event_id'] for item in second] == ['event_2'] + + @pytest.mark.asyncio + async def test_enqueue_steering_rejects_when_queue_is_full(self): + """A full steering queue does not claim more queries.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_full', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + + for index in range(MAX_STEERING_QUEUE_ITEMS): + assert await registry.enqueue_steering( + 'run_steering_full', + {'event': {'event_id': f'event_{index}'}}, + ) + + assert not await registry.enqueue_steering( + 'run_steering_full', + {'event': {'event_id': 'overflow'}}, + ) + + items = await registry.pull_steering('run_steering_full', mode='all') + assert len(items) == MAX_STEERING_QUEUE_ITEMS + assert all(item['event']['event_id'] != 'overflow' for item in items) + + @pytest.mark.asyncio + async def test_find_steering_target_requires_same_scope(self): + """Steering claims must not cross bot/workspace/thread boundaries.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_scoped', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_1', + available_apis={'steering_pull': True}, + ) + + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_1', + ) + == 'run_steering_scoped' + ) + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_2', + workspace_id='workspace_1', + thread_id='thread_1', + ) + is None + ) + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_2', + ) + is None + ) + + @pytest.mark.asyncio + async def test_unregister_returns_pending_steering_queue(self): + """Unregister returns the removed session so callers can audit pending steering.""" + registry = AgentRunSessionRegistry() + await registry.register( + run_id='run_steering_unregister', + runner_id='plugin:test/my-runner/default', + query_id=1, + plugin_identity='test/my-runner', + resources=make_resources(), + conversation_id='conv_1', + available_apis={'steering_pull': True}, + ) + await registry.enqueue_steering( + 'run_steering_unregister', + {'event': {'event_id': 'event_pending'}}, + ) + + session = await registry.unregister('run_steering_unregister') + + assert session is not None + assert session['steering_queue'][0]['event']['event_id'] == 'event_pending' + assert await registry.get('run_steering_unregister') is None + + +class TestIsResourceAllowed: + """Tests for is_resource_allowed validation.""" + + def test_model_allowed(self): + """Model in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}, + {'model_id': 'model_002', 'model_type': 'embedding', 'provider': 'anthropic'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001') is True + assert registry.is_resource_allowed(session, 'model', 'model_002') is True + + def test_model_operation_denied(self): + """Model resources should enforce operation-level grants.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + models=[ + {'model_id': 'model_001', 'operations': ['invoke']}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001', 'invoke') is True + assert registry.is_resource_allowed(session, 'model', 'model_001', 'stream') is False + + def test_model_not_allowed(self): + """Model not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[{'model_id': 'model_001'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_999') is False + + def test_model_empty_resources(self): + """Empty models list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(models=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'model', 'model_001') is False + + def test_tool_allowed(self): + """Tool in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search', 'tool_type': 'builtin'}, + {'tool_name': 'code_exec', 'tool_type': 'plugin'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search') is True + assert registry.is_resource_allowed(session, 'tool', 'code_exec') is True + + def test_tool_operation_denied(self): + """Tool resources should enforce detail/call grants.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + tools=[ + {'tool_name': 'web_search', 'operations': ['detail']}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search', 'detail') is True + assert registry.is_resource_allowed(session, 'tool', 'web_search', 'call') is False + + def test_tool_not_allowed(self): + """Tool not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[{'tool_name': 'web_search'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False + + def test_tool_empty_resources(self): + """Empty tools list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(tools=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'tool', 'web_search') is False + + def test_knowledge_base_allowed(self): + """Knowledge base in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + knowledge_bases=[ + {'kb_id': 'kb_001', 'kb_name': 'docs', 'kb_type': 'vector'}, + {'kb_id': 'kb_002', 'kb_name': 'faq', 'kb_type': 'keyword'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True + + def test_knowledge_base_not_allowed(self): + """Knowledge base not in resources should be denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False + + def test_knowledge_base_empty_resources(self): + """Empty knowledge bases list should deny all.""" + registry = AgentRunSessionRegistry() + resources = make_resources(knowledge_bases=[]) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False + + def test_skill_allowed(self): + """Skill in resources should be allowed.""" + registry = AgentRunSessionRegistry() + resources = make_resources( + skills=[ + {'skill_name': 'demo', 'display_name': 'Demo'}, + {'skill_name': 'writer', 'display_name': 'Writer'}, + ] + ) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'skill', 'demo') is True + assert registry.is_resource_allowed(session, 'skill', 'writer') is True + assert registry.is_resource_allowed(session, 'skill', 'hidden') is False + + def test_storage_plugin_allowed(self): + """Plugin storage permission should be checked.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is True + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + def test_storage_workspace_allowed(self): + """Workspace storage permission should be checked.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is True + + def test_storage_both_denied(self): + """Both storage permissions denied.""" + registry = AgentRunSessionRegistry() + resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) + session = make_session(resources=resources) + + assert registry.is_resource_allowed(session, 'storage', 'plugin') is False + assert registry.is_resource_allowed(session, 'storage', 'workspace') is False + + def test_unknown_resource_type(self): + """Unknown resource type should return False.""" + registry = AgentRunSessionRegistry() + session = make_session(resources=make_resources()) + + assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False + + def test_missing_resources_field(self): + """Missing resources field should not raise.""" + registry = AgentRunSessionRegistry() + session = make_session(resources={'models': []}) # Missing other fields + + # Should not raise, should return False + assert registry.is_resource_allowed(session, 'tool', 'web_search') is False + assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False + + +class TestGlobalRegistry: + """Tests for global registry singleton.""" + + def test_get_session_registry_returns_instance(self): + """get_session_registry should return AgentRunSessionRegistry.""" + # Use a separate test that doesn't modify global state + # The singleton pattern works in production, but modifying globals + # in tests can cause UnboundLocalError due to Python scoping + # Instead, just verify the function signature + from langbot.pkg.agent.runner.session_registry import get_session_registry + + assert callable(get_session_registry) + + # Create a fresh instance directly to verify the class works + fresh_registry = AgentRunSessionRegistry() + assert isinstance(fresh_registry, AgentRunSessionRegistry) + + def test_global_registry_singleton_behavior(self): + """The global registry should maintain singleton behavior.""" + # Test singleton behavior without modifying global state + # In production, calling get_session_registry() multiple times + # returns the same instance. We verify this by checking the + # module-level variable directly. + from langbot.pkg.agent.runner.session_registry import _global_registry + + # Check that the global variable exists and is either None or an instance + global_reg = _global_registry + if global_reg is None: + # First call creates the instance + registry1 = get_session_registry() + assert isinstance(registry1, AgentRunSessionRegistry) + # Subsequent calls return the same instance + registry2 = get_session_registry() + assert registry1 is registry2 + else: + # Instance already exists, verify singleton + registry1 = get_session_registry() + registry2 = get_session_registry() + assert registry1 is registry2 + assert registry1 is global_reg + + +class TestThreadSafety: + """Tests for asyncio.Lock thread safety.""" + + @pytest.mark.asyncio + async def test_concurrent_register(self): + """Concurrent register should be safe.""" + registry = AgentRunSessionRegistry() + + # Register multiple sessions concurrently + tasks = [] + for i in range(10): + tasks.append( + registry.register( + f'run_{i}', + 'plugin:test/runner/default', + i, + 'test/runner', + make_resources(), + ) + ) + + await asyncio.gather(*tasks) + + # All sessions should be registered + active_runs = await registry.list_active_runs() + assert len(active_runs) == 10 + + @pytest.mark.asyncio + async def test_concurrent_register_and_unregister(self): + """Concurrent register and unregister should be safe.""" + registry = AgentRunSessionRegistry() + + # Register + await registry.register('run_1', 'plugin:test/runner/default', 1, 'test/runner', make_resources()) + + # Concurrent unregister and get + tasks = [ + registry.unregister('run_1'), + registry.get('run_1'), + ] + + await asyncio.gather(*tasks) + + # After both complete, session should be unregistered + result = await registry.get('run_1') + assert result is None diff --git a/tests/unit_tests/agent/test_state_api_auth.py b/tests/unit_tests/agent/test_state_api_auth.py new file mode 100644 index 000000000..315bdfb7b --- /dev/null +++ b/tests/unit_tests/agent/test_state_api_auth.py @@ -0,0 +1,544 @@ +"""Tests for State API handler authorization in RuntimeConnectionHandler. + +Tests focus on: +- STATE_GET authorization +- STATE_SET authorization +- STATE_DELETE authorization +- STATE_LIST authorization + +These tests instantiate real RuntimeConnectionHandler action handlers and verify: +- Authorization errors for missing/mismatched caller_plugin_identity +- Authorization errors for disabled state or scope +- Full flow: set -> get -> list -> delete with real SQLite + +Authorization rules: +- caller_plugin_identity is REQUIRED when session has plugin_identity +- caller_plugin_identity must match session's plugin_identity +- enable_state must be True +- scope must be in state_scopes +""" +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock, patch +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry +from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore, reset_persistent_state_store +from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction + +# Import shared test fixtures +from .conftest import make_resources + + +class FakeConnection: + """Fake connection for testing.""" + pass + + +class FakeApplication: + """Fake Application for testing.""" + def __init__(self, db_engine=None): + self.logger = MagicMock() + self.logger.debug = MagicMock() + self.logger.warning = MagicMock() + self.logger.error = MagicMock() + self.persistence_mgr = MagicMock() + self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + +@pytest.fixture +def session_registry(): + """Create a fresh session registry for each test.""" + return AgentRunSessionRegistry() + + +@pytest.fixture +async def db_engine(): + """Create an in-memory SQLite database for testing.""" + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + yield engine + await engine.dispose() + + +@pytest.fixture +async def persistent_store(db_engine): + """Create a persistent state store with real SQLite.""" + reset_persistent_state_store() + store = PersistentStateStore(db_engine) + + # Create the table + from langbot.pkg.entity.persistence.agent_runner_state import AgentRunnerState + + async with db_engine.begin() as conn: + await conn.run_sync(AgentRunnerState.__table__.create, checkfirst=True) + + yield store + reset_persistent_state_store() + + +class TestStateAPIHandlerAuthorization: + """Tests for State API handler authorization with real action calls.""" + + @pytest.mark.asyncio + async def test_state_get_missing_run_id_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing run_id returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + # Get the STATE_GET action handler (actions dict is keyed by action value string) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call without run_id + result = await state_get_handler({'scope': 'conversation', 'key': 'test_key'}) + + assert result.code != 0 + assert 'run_id is required' in result.message + + @pytest.mark.asyncio + async def test_state_get_run_not_found_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: run_id not in session registry returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call with non-existent run_id + result = await state_get_handler({ + 'run_id': 'nonexistent_run', + 'scope': 'conversation', + 'key': 'test_key', + }) + + assert result.code != 0 + assert 'not found' in result.message.lower() + + @pytest.mark.asyncio + async def test_state_get_missing_caller_plugin_identity_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing caller_plugin_identity when session has plugin_identity returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register session with plugin_identity + await session_registry.register( + run_id='run_test_missing_identity', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call without caller_plugin_identity + result = await state_get_handler({ + 'run_id': 'run_test_missing_identity', + 'scope': 'conversation', + 'key': 'test_key', + }) + + assert result.code != 0 + assert 'caller_plugin_identity is required' in result.message + + await session_registry.unregister('run_test_missing_identity') + + @pytest.mark.asyncio + async def test_state_get_caller_identity_mismatch_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: caller_plugin_identity mismatch returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_mismatch', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Call with wrong caller_plugin_identity + result = await state_get_handler({ + 'run_id': 'run_test_mismatch', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'other/plugin', + }) + + assert result.code != 0 + assert 'mismatch' in result.message.lower() + + await session_registry.unregister('run_test_mismatch') + + @pytest.mark.asyncio + async def test_state_get_enable_state_false_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: enable_state=False returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_disabled', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': False, 'state_scopes': []}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + result = await state_get_handler({ + 'run_id': 'run_test_disabled', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'disabled' in result.message.lower() + + await session_registry.unregister('run_test_disabled') + + @pytest.mark.asyncio + async def test_state_get_scope_not_enabled_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: scope not in state_scopes returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_scope_disabled', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'}, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Request 'actor' scope which is not in state_scopes + result = await state_get_handler({ + 'run_id': 'run_test_scope_disabled', + 'scope': 'actor', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not enabled' in result.message.lower() or 'scope' in result.message.lower() + + await session_registry.unregister('run_test_scope_disabled') + + @pytest.mark.asyncio + async def test_state_get_missing_scope_key_returns_error(self, session_registry, db_engine, persistent_store): + """STATE_GET: missing scope_key in state_context returns error.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + await session_registry.register( + run_id='run_test_no_scope_key', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, # No scope_keys + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + result = await state_get_handler({ + 'run_id': 'run_test_no_scope_key', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'not available' in result.message.lower() + + await session_registry.unregister('run_test_no_scope_key') + + +class TestStateAPIFullFlowWithRealDB: + """Tests for complete State API flow with real SQLite database.""" + + @pytest.mark.asyncio + async def test_state_set_get_list_delete_flow(self, session_registry, db_engine, persistent_store): + """Test complete state flow: set -> get -> list -> delete with real SQLite.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register session + await session_registry.register( + run_id='run_full_flow', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation', 'runner']}, + state_context={ + 'scope_keys': { + 'conversation': 'conv:test_runner:binding_1:conv_123', + 'runner': 'runner:test_runner:binding_1', + }, + 'binding_identity': 'binding_1', + 'conversation_id': 'conv_123', + }, + ) + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + + # Verify session has correct state_context + session = await session_registry.get('run_full_flow') + assert session is not None + state_ctx = session['authorization']['state_context'] + assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}" + assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}" + assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}" + + # Get handlers (actions dict is keyed by action value string) + state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value] + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + state_list_handler = handler.actions[PluginToRuntimeAction.STATE_LIST.value] + state_delete_handler = handler.actions[PluginToRuntimeAction.STATE_DELETE.value] + + # 1. STATE_SET + set_result = await state_set_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'value': {'data': 'test_value'}, + 'caller_plugin_identity': 'test/runner', + }) + + assert set_result.code == 0 + assert set_result.data.get('success') is True + + # 2. STATE_GET + get_result = await state_get_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert get_result.code == 0 + assert get_result.data.get('value') == {'data': 'test_value'} + + # 3. STATE_LIST + list_result = await state_list_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'prefix': 'external.', + 'caller_plugin_identity': 'test/runner', + }) + + assert list_result.code == 0 + keys = list_result.data.get('keys', []) + assert 'external.test_key' in keys + + # 4. STATE_DELETE + delete_result = await state_delete_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert delete_result.code == 0 + + # 5. Verify deleted + get_after_delete = await state_get_handler({ + 'run_id': 'run_full_flow', + 'scope': 'conversation', + 'key': 'external.test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert get_after_delete.code == 0 + assert get_after_delete.data.get('value') is None + + await session_registry.unregister('run_full_flow') + + +class TestStateHandlerReadsFromAuthorizationSnapshot: + """Tests verifying handlers read state_policy/state_context from authorization snapshot.""" + + @pytest.mark.asyncio + async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store): + """Handler reads state_policy from session['authorization'], not resources.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register with explicit state_policy in the authorization snapshot + await session_registry.register( + run_id='run_policy_top_level', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': False, 'state_scopes': []}, + state_context={'scope_keys': {}, 'binding_identity': 'binding_1'}, + ) + + # Verify resources does NOT contain state_policy + session = await session_registry.get('run_policy_top_level') + assert session is not None + resources = session['authorization']['resources'] + assert 'state_policy' not in resources, "resources should NOT contain state_policy" + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value] + + # Should fail because enable_state=False in authorization.state_policy + result = await state_get_handler({ + 'run_id': 'run_policy_top_level', + 'scope': 'conversation', + 'key': 'test_key', + 'caller_plugin_identity': 'test/runner', + }) + + assert result.code != 0 + assert 'disabled' in result.message.lower() + + await session_registry.unregister('run_policy_top_level') + + @pytest.mark.asyncio + async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store): + """Handler reads state_context from session['authorization'], not resources.""" + fake_app = FakeApplication(db_engine) + fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) + + # Register with explicit state_context in the authorization snapshot + await session_registry.register( + run_id='run_context_top_level', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=make_resources(), + available_apis={'state': True}, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key_xyz'}, 'binding_identity': 'binding_xyz'}, + ) + + # Verify resources does NOT contain state_context + session = await session_registry.get('run_context_top_level') + assert session is not None + resources = session['authorization']['resources'] + assert 'state_context' not in resources, "resources should NOT contain state_context" + + async def fake_disconnect(): + return True + + with patch('langbot.pkg.plugin.agent_run_support.get_session_registry', return_value=session_registry): + handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) + state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value] + + # Should use scope_key from authorization.state_context.scope_keys.conversation + result = await state_set_handler({ + 'run_id': 'run_context_top_level', + 'scope': 'conversation', + 'key': 'test_key', + 'value': 'test_value', + 'caller_plugin_identity': 'test/runner', + }) + + # Should succeed - scope_key was found in state_context + assert result.code == 0 + + await session_registry.unregister('run_context_top_level') + + +class TestResourcesDoesNotContainStateMetadata: + """Tests verifying resources is clean - no state metadata mixed in.""" + + @pytest.mark.asyncio + async def test_resources_clean_after_register(self, session_registry): + """After register(), only authorization contains resources and state metadata.""" + resources = make_resources() + + await session_registry.register( + run_id='run_resources_clean', + runner_id='plugin:test/runner/default', + query_id=1, + plugin_identity='test/runner', + resources=resources, + state_policy={'enable_state': True, 'state_scopes': ['conversation']}, + state_context={'scope_keys': {'conversation': 'conv_key'}, 'binding_identity': 'binding_1'}, + ) + + session = await session_registry.get('run_resources_clean') + assert session is not None + + # Verify resources is nested under authorization and is clean. + assert 'resources' not in session + session_resources = session['authorization']['resources'] + assert 'state_policy' not in session_resources, \ + "authorization['resources'] should NOT contain state_policy" + assert 'state_context' not in session_resources, \ + "authorization['resources'] should NOT contain state_context" + + assert 'state_policy' in session['authorization'] + assert 'state_context' in session['authorization'] + + await session_registry.unregister('run_resources_clean') diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py new file mode 100644 index 000000000..668f40212 --- /dev/null +++ b/tests/unit_tests/agent/test_state_store.py @@ -0,0 +1,383 @@ +"""Tests for persistent AgentRunner state store.""" +from __future__ import annotations + +import asyncio +import os +import tempfile + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.host_models import BindingScope, StatePolicy +from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore +from langbot.pkg.agent.runner.state_scope import ( + STATE_KEY_ALIASES, + VALID_STATE_SCOPES, + build_state_context, + build_state_scope_key, + get_binding_identity, + normalize_state_key, +) + + +def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor: + """Create a test descriptor.""" + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='my-runner', + runner_name='default', + capabilities={'streaming': True}, + ) + + +class FakeActorContext: + """Fake actor context for event testing.""" + def __init__(self, actor_type: str = 'user', actor_id: str = 'user_123', actor_name: str = 'Test User'): + self.actor_type = actor_type + self.actor_id = actor_id + self.actor_name = actor_name + + +class FakeSubjectContext: + """Fake subject context for event testing.""" + def __init__(self, subject_type: str = 'message', subject_id: str = 'msg_001', data: dict | None = None): + self.subject_type = subject_type + self.subject_id = subject_id + self.data = data or {} + + +class FakeEventEnvelope: + """Fake event envelope for testing event-first state.""" + def __init__( + self, + event_id: str = 'evt_001', + event_type: str = 'message.received', + conversation_id: str | None = 'conv_001', + actor: FakeActorContext | None = None, + subject: FakeSubjectContext | None = None, + bot_id: str = 'bot_001', + workspace_id: str = 'ws_001', + thread_id: str | None = None, + ): + self.event_id = event_id + self.event_type = event_type + self.event_time = 1700000000 + self.source = 'platform' + self.bot_id = bot_id + self.workspace_id = workspace_id + self.conversation_id = conversation_id + self.thread_id = thread_id + self.actor = actor or FakeActorContext() + self.subject = subject + self.raw_ref = None + + +class FakeBinding: + """Fake binding for testing state.""" + def __init__( + self, + binding_id: str = 'binding_001', + state_policy: StatePolicy | None = None, + scope_type: str = 'agent', + scope_id: str = 'agent_001', + ): + self.binding_id = binding_id + self.scope = BindingScope(scope_type=scope_type, scope_id=scope_id) + self.state_policy = state_policy or StatePolicy() + + +class TestStateScopeHelpers: + """Tests for shared state scope helpers.""" + + def test_valid_state_scopes(self): + assert VALID_STATE_SCOPES == ('conversation', 'actor', 'subject', 'runner') + + def test_state_key_aliases(self): + assert STATE_KEY_ALIASES == {'conversation_id': 'external.conversation_id'} + assert normalize_state_key('conversation_id') == 'external.conversation_id' + assert normalize_state_key('external.session_id') == 'external.session_id' + + def test_binding_identity_uses_binding_id_first(self): + binding = FakeBinding(binding_id='binding_a') + assert get_binding_identity(binding) == 'binding_a' + + def test_binding_identity_falls_back_to_scope(self): + binding = FakeBinding(binding_id='', scope_type='workspace', scope_id='ws_001') + assert get_binding_identity(binding) == 'workspace:ws_001' + + def test_scope_key_building(self): + descriptor = make_descriptor() + binding = FakeBinding(binding_id='binding_a') + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + subject=FakeSubjectContext(subject_id='msg_001'), + thread_id='thread_001', + ) + + keys = { + scope: build_state_scope_key(scope, event, binding, descriptor) + for scope in VALID_STATE_SCOPES + } + + assert keys['conversation'].startswith('conversation:v2:') + assert keys['actor'].startswith('actor:v2:') + assert keys['subject'].startswith('subject:v2:') + assert keys['runner'].startswith('runner:v2:') + assert len(set(keys.values())) == len(keys) + + def test_scope_key_missing_identity_returns_none(self): + descriptor = make_descriptor() + binding = FakeBinding() + event = FakeEventEnvelope(conversation_id=None, actor=None, subject=None) + + assert build_state_scope_key('conversation', event, binding, descriptor) is None + assert build_state_scope_key('subject', event, binding, descriptor) is None + assert build_state_scope_key('runner', event, binding, descriptor) is not None + + def test_build_state_context(self): + descriptor = make_descriptor() + binding = FakeBinding(binding_id='binding_a') + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + subject=FakeSubjectContext(subject_id='msg_001'), + ) + + context = build_state_context(event, binding, descriptor) + + assert context['binding_identity'] == 'binding_a' + assert context['conversation_id'] == 'conv_001' + assert context['actor_id'] == 'user_001' + assert set(context['scope_keys']) == {'conversation', 'actor', 'subject', 'runner'} + + +class TestPersistentStateStore: + """Tests for persistent database-backed state store.""" + + @pytest.fixture + async def db_engine(self): + """Create a temporary async SQLite database for testing.""" + with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + db_path = f.name + + engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False) + + from langbot.pkg.entity.persistence.base import Base + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + os.unlink(db_path) + + @pytest.fixture + async def persistent_store(self, db_engine): + """Create a persistent state store for testing.""" + store = PersistentStateStore(db_engine) + yield store + await store.clear_all() + + @pytest.mark.asyncio + async def test_build_snapshot_empty(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + + assert snapshot['conversation'] == {'external.conversation_id': 'conv_001'} + assert snapshot['actor'] == {} + assert snapshot['subject'] == {} + assert snapshot['runner'] == {} + + @pytest.mark.asyncio + async def test_state_set_and_get(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'test_key', {'nested': 'value'}, None + ) + assert success is True + assert error is None + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['test_key'] == {'nested': 'value'} + + @pytest.mark.asyncio + async def test_concurrent_first_state_set_uses_upsert(self, persistent_store): + scope_key = 'conversation:runner:binding:conv_concurrent' + + async def set_value(value: int): + return await persistent_store.state_set( + scope_key=scope_key, + state_key='external.concurrent', + value={'value': value}, + runner_id='plugin:test/my-runner/default', + binding_identity='binding_001', + scope='conversation', + ) + + results = await asyncio.gather(*(set_value(value) for value in range(8))) + + assert all(success is True and error is None for success, error in results) + stored = await persistent_store.state_get(scope_key, 'external.concurrent') + assert stored in [{'value': value} for value in range(8)] + + @pytest.mark.asyncio + async def test_state_api_methods_normalize_public_key_aliases(self, persistent_store): + scope_key = 'conversation:runner:binding:conv_001' + + success, error = await persistent_store.state_set( + scope_key=scope_key, + state_key='conversation_id', + value='conv_001', + runner_id='plugin:test/my-runner/default', + binding_identity='binding_001', + scope='conversation', + ) + + assert success is True + assert error is None + assert await persistent_store.state_get(scope_key, 'external.conversation_id') == 'conv_001' + assert await persistent_store.state_get(scope_key, 'conversation_id') == 'conv_001' + + keys, _ = await persistent_store.state_list(scope_key, prefix='conversation_id') + assert keys == ['external.conversation_id'] + + assert await persistent_store.state_delete(scope_key, 'conversation_id') is True + assert await persistent_store.state_get(scope_key, 'external.conversation_id') is None + + @pytest.mark.asyncio + async def test_binding_isolation(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding_a = FakeBinding(binding_id='binding_a') + binding_b = FakeBinding(binding_id='binding_b') + + await persistent_store.apply_update_from_event( + event, binding_a, descriptor, 'conversation', 'key', 'value_a', None + ) + + snapshot_b = await persistent_store.build_snapshot_from_event(event, binding_b, descriptor) + assert snapshot_b['conversation'] == {'external.conversation_id': 'conv_001'} + + snapshot_a = await persistent_store.build_snapshot_from_event(event, binding_a, descriptor) + assert snapshot_a['conversation']['key'] == 'value_a' + + @pytest.mark.asyncio + async def test_policy_disable_state(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding(state_policy=StatePolicy(enable_state=False)) + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot == {'conversation': {}, 'actor': {}, 'subject': {}, 'runner': {}} + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value', None + ) + assert success is False + assert 'disabled' in error.lower() + + @pytest.mark.asyncio + async def test_policy_scope_restriction(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope( + conversation_id='conv_001', + actor=FakeActorContext(actor_id='user_001'), + ) + binding = FakeBinding(state_policy=StatePolicy(state_scopes=['conversation'])) + + success_conv, _ = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value_conv', None + ) + assert success_conv is True + + success_actor, error_actor = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'actor', 'key', 'value_actor', None + ) + assert success_actor is False + assert 'not enabled' in error_actor.lower() + + @pytest.mark.asyncio + async def test_value_json_size_limit(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + large_value = 'x' * (300 * 1024) + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', large_value, None + ) + assert success is False + assert 'exceeds limit' in error.lower() + + @pytest.mark.asyncio + async def test_value_not_json_serializable(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + success, error = await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', {'key': {1, 2, 3}}, None + ) + assert success is False + assert 'json' in error.lower() + + @pytest.mark.asyncio + async def test_state_list(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'external.id', '123', None + ) + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'external.name', 'test', None + ) + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'memory.key', 'value', None + ) + + scope_key = build_state_scope_key('conversation', event, binding, descriptor) + + keys, has_more = await persistent_store.state_list(scope_key) + assert len(keys) == 3 + assert has_more is False + + keys_ext, _ = await persistent_store.state_list(scope_key, prefix='external.') + assert len(keys_ext) == 2 + assert 'external.id' in keys_ext + assert 'external.name' in keys_ext + + @pytest.mark.asyncio + async def test_state_delete(self, persistent_store): + descriptor = make_descriptor() + event = FakeEventEnvelope(conversation_id='conv_001') + binding = FakeBinding() + + await persistent_store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'key', 'value', None + ) + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation']['key'] == 'value' + + scope_key = build_state_scope_key('conversation', event, binding, descriptor) + deleted = await persistent_store.state_delete(scope_key, 'key') + assert deleted is True + + snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor) + assert 'key' not in snapshot['conversation'] + + deleted_again = await persistent_store.state_delete(scope_key, 'key') + assert deleted_again is False diff --git a/tests/unit_tests/api/http/service/test_bot_service.py b/tests/unit_tests/api/http/service/test_bot_service.py index 6fdc2342f..6ca8075df 100644 --- a/tests/unit_tests/api/http/service/test_bot_service.py +++ b/tests/unit_tests/api/http/service/test_bot_service.py @@ -28,7 +28,7 @@ async def execute_async(self, statement): return _FakeResult(SimpleNamespace(name='Updated Pipeline')) -async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_name(): +async def test_update_bot_copies_input_before_filtering_legacy_routing_fields(): persistence_mgr = _PersistenceManager() runtime_bot = SimpleNamespace(enable=False) platform_mgr = SimpleNamespace( @@ -46,17 +46,17 @@ async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_nam 'uuid': 'caller-owned-uuid', 'name': 'Test Bot', 'use_pipeline_uuid': 'pipeline-1', + 'pipeline_routing_rules': [{'type': 'launcher_type'}], } await service.update_bot('bot-1', payload) + # caller's dict must not be mutated assert payload == { 'uuid': 'caller-owned-uuid', 'name': 'Test Bot', 'use_pipeline_uuid': 'pipeline-1', + 'pipeline_routing_rules': [{'type': 'launcher_type'}], } - assert persistence_mgr.update_values == { - 'name': 'Test Bot', - 'use_pipeline_uuid': 'pipeline-1', - 'use_pipeline_name': 'Updated Pipeline', - } + # legacy routing fields are stripped; only name is persisted + assert persistence_mgr.update_values == {'name': 'Test Bot'} diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py new file mode 100644 index 000000000..0b66df6ea --- /dev/null +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.http.service.agent import ( + AGENT_DEFAULT_EVENT_PATTERNS, + AGENT_KIND_AGENT, + AGENT_KIND_PIPELINE, + PIPELINE_EVENT_PATTERNS, + AgentService, +) + + +pytestmark = pytest.mark.asyncio + + +def _result(items: list | None = None, first_item=None): + result = Mock() + result.all = Mock(return_value=items or []) + result.first = Mock(return_value=first_item) + return result + + +def _agent_row( + agent_uuid: str = 'agent-1', + name: str = 'Test Agent', + updated_at: dt.datetime | str | None = None, + config: dict | None = None, + supported_event_patterns: list[str] | None = None, +): + return SimpleNamespace( + uuid=agent_uuid, + name=name, + description='Agent description', + emoji='A', + kind=AGENT_KIND_AGENT, + component_ref='plugin:test/runner/default', + config=config + or { + 'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0}, + 'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}}, + }, + enabled=True, + supported_event_patterns=supported_event_patterns or ['*'], + created_at=dt.datetime(2026, 1, 1, 9, 0, 0), + updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0), + ) + + +def _serialize_agent(model_cls, entity, masked_columns=None): + return { + 'uuid': entity.uuid, + 'name': entity.name, + 'description': entity.description, + 'emoji': entity.emoji, + 'kind': entity.kind, + 'component_ref': entity.component_ref, + 'config': entity.config, + 'enabled': entity.enabled, + 'supported_event_patterns': entity.supported_event_patterns, + 'created_at': entity.created_at, + 'updated_at': entity.updated_at, + } + + +def _compiled_params(statement): + return statement.compile().params + + +def _compiled_update_values(statement): + return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')} + + +def _make_app(): + app = SimpleNamespace() + app.persistence_mgr = SimpleNamespace( + execute_async=AsyncMock(), + serialize_model=Mock(side_effect=_serialize_agent), + ) + app.pipeline_service = SimpleNamespace( + get_pipeline_metadata=AsyncMock(return_value=[]), + get_pipelines=AsyncMock(return_value=[]), + get_pipeline=AsyncMock(return_value=None), + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + delete_pipeline=AsyncMock(), + _get_default_values_from_schema=Mock(return_value={}), + ) + app.agent_runner_registry = None + app.logger = Mock() + return app + + +class TestAgentServiceMetadata: + async def test_get_agent_metadata_exposes_runner_config_and_kind_capabilities(self): + app = _make_app() + ai_metadata = {'name': 'ai', 'stages': [{'name': 'runner'}]} + app.pipeline_service.get_pipeline_metadata = AsyncMock( + return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}] + ) + + metadata = await AgentService(app).get_agent_metadata() + + assert metadata['runner_config'] == ai_metadata + assert metadata['kinds'] == [ + { + 'name': AGENT_KIND_AGENT, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'message_only': False, + }, + { + 'name': AGENT_KIND_PIPELINE, + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + }, + ] + + +class TestAgentServiceListAndLookup: + async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + return_value=_result( + items=[ + _agent_row( + agent_uuid='agent-1', + updated_at=dt.datetime(2026, 1, 1, 10, 0, 0), + supported_event_patterns=['platform.member.*'], + ) + ] + ) + ) + app.pipeline_service.get_pipelines = AsyncMock( + return_value=[ + { + 'uuid': 'pipeline-1', + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {'ai': {'runner': {'id': 'pipeline-runner'}}}, + 'created_at': '2026-01-01T08:00:00', + 'updated_at': '2026-01-01T11:00:00', + } + ] + ) + + agents = await AgentService(app).get_agents(sort_by='updated_at', sort_order='DESC') + + assert [agent['uuid'] for agent in agents] == ['pipeline-1', 'agent-1'] + assert agents[0]['kind'] == AGENT_KIND_PIPELINE + assert agents[0]['component_ref'] == 'pipeline' + assert agents[0]['capability'] == { + 'supported_event_patterns': PIPELINE_EVENT_PATTERNS, + 'message_only': True, + } + assert agents[1]['kind'] == AGENT_KIND_AGENT + assert agents[1]['capability'] == { + 'supported_event_patterns': ['platform.member.*'], + 'message_only': False, + } + assert all('config' not in agent for agent in agents) + + async def test_get_agent_returns_agent_with_config_before_pipeline_fallback(self): + app = _make_app() + agent = _agent_row(agent_uuid='agent-1') + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=agent)) + + result = await AgentService(app).get_agent('agent-1') + + assert result['uuid'] == 'agent-1' + assert result['kind'] == AGENT_KIND_AGENT + assert result['config'] == agent.config + app.pipeline_service.get_pipeline.assert_not_awaited() + + async def test_get_agent_falls_back_to_pipeline_product_item_with_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None)) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ + 'uuid': 'pipeline-1', + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {'ai': {'runner': {'id': 'pipeline-runner'}}}, + 'created_at': '2026-01-01T08:00:00', + 'updated_at': '2026-01-01T11:00:00', + } + ) + + result = await AgentService(app).get_agent('pipeline-1') + + assert result['kind'] == AGENT_KIND_PIPELINE + assert result['enabled'] is True + assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}} + assert result['capability']['message_only'] is True + + +class TestAgentServiceCreateUpdateDelete: + async def test_create_agent_uses_default_runner_config_from_registry(self): + app = _make_app() + runner = SimpleNamespace( + id='plugin:langbot-team/LocalAgent/default', + config_schema=[ + {'name': 'model', 'default': 'gpt-4.1'}, + {'name': 'temperature', 'default': 0.2}, + {'name': 'no-default'}, + ], + ) + app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner])) + app.pipeline_service._get_default_values_from_schema = Mock( + return_value={'model': 'gpt-4.1', 'temperature': 0.2} + ) + app.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) + + result = await AgentService(app).create_agent( + { + 'name': 'Support Agent', + 'description': 'Handles support events', + 'emoji': 'S', + 'component_ref': 'plugin:caller/must-not-win/default', + } + ) + + insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + assert result['kind'] == AGENT_KIND_AGENT + assert result['uuid'] == insert_values['uuid'] + assert insert_values['name'] == 'Support Agent' + assert insert_values['component_ref'] == runner.id + assert insert_values['config'] == { + 'runner': {'id': runner.id, 'expire-time': 0}, + 'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}}, + } + assert insert_values['enabled'] is True + assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS + app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema) + + @pytest.mark.parametrize( + 'config', + [ + None, + [], + {'runner': {'id': 'plugin:test/runner/default'}}, + {'runner': {'id': 123}, 'runner_config': {}}, + { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': ['invalid']}, + }, + { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {}, + }, + ], + ) + async def test_create_agent_rejects_malformed_4x_runner_config(self, config): + app = _make_app() + + with pytest.raises(ValueError, match='Agent config|runner_config'): + await AgentService(app).create_agent({'name': 'Invalid Agent', 'config': config}) + + app.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + async def test_create_agent_rejects_non_boolean_security_fields_before_write( + self, + field_name, + invalid_value, + ): + app = _make_app() + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + await AgentService(app).create_agent( + { + 'name': 'Invalid Agent', + 'config': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {field_name: invalid_value}}, + }, + } + ) + + app.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_agent_rejects_non_boolean_mcp_resource_enabled_before_write(self, invalid_value): + app = _make_app() + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + await AgentService(app).create_agent( + { + 'name': 'Invalid Agent', + 'config': { + 'runner': {'id': runner_id}, + 'runner_config': { + runner_id: { + 'mcp-resources': [ + {'uri': 'file:///README.md', 'enabled': invalid_value}, + ] + } + }, + }, + } + ) + + app.persistence_mgr.execute_async.assert_not_awaited() + + async def test_create_agent_derives_empty_component_ref_from_empty_runner(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) + + await AgentService(app).create_agent( + { + 'name': 'Unconfigured Agent', + 'component_ref': 'plugin:caller/must-not-win/default', + 'config': { + 'runner': {'id': ''}, + 'runner_config': {}, + }, + } + ) + + insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + assert insert_values['component_ref'] is None + + async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1'))) + + with pytest.raises(ValueError, match='runner_config'): + await AgentService(app).update_agent( + 'agent-1', + { + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': 'invalid'}, + } + }, + ) + + assert app.persistence_mgr.execute_async.await_count == 1 + + async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + new_config = { + 'runner': {'id': 'plugin:test/new-runner/default', 'expire-time': 0}, + 'runner_config': {'plugin:test/new-runner/default': {'timeout': 30}}, + } + + await AgentService(app).update_agent( + 'agent-1', + { + 'uuid': 'caller-owned-uuid', + 'kind': AGENT_KIND_PIPELINE, + 'created_at': '2020-01-01T00:00:00', + 'updated_at': '2020-01-01T00:00:00', + 'capability': {'message_only': True}, + 'component_ref': 'plugin:caller/must-not-win/default', + 'name': 'Updated Agent', + 'config': new_config, + 'supported_event_patterns': [], + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == { + 'name': 'Updated Agent', + 'config': new_config, + 'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS, + 'component_ref': 'plugin:test/new-runner/default', + } + + async def test_update_agent_ignores_component_ref_without_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + + await AgentService(app).update_agent( + 'agent-1', + { + 'name': 'Updated Agent', + 'component_ref': 'plugin:caller/must-not-win/default', + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == { + 'name': 'Updated Agent', + 'component_ref': 'plugin:test/runner/default', + } + + async def test_update_agent_component_ref_only_repairs_from_existing_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + + await AgentService(app).update_agent( + 'agent-1', + {'component_ref': 'plugin:caller/must-not-win/default'}, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == {'component_ref': 'plugin:test/runner/default'} + + async def test_update_agent_clears_component_ref_for_empty_runner(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + config = {'runner': {'id': ''}, 'runner_config': {}} + + await AgentService(app).update_agent( + 'agent-1', + { + 'component_ref': 'plugin:caller/must-not-win/default', + 'config': config, + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == {'config': config, 'component_ref': None} + + async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None)) + app.pipeline_service.create_pipeline = AsyncMock(return_value='pipeline-created') + app.pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipeline-1'}) + service = AgentService(app) + + created = await service.create_agent( + { + 'kind': AGENT_KIND_PIPELINE, + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + } + ) + await service.update_agent('pipeline-1', {'name': 'Updated Pipeline'}) + await service.delete_agent('pipeline-1') + + assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE} + app.pipeline_service.create_pipeline.assert_awaited_once_with( + { + 'name': 'Pipeline Agent', + 'description': 'Legacy pipeline', + 'emoji': 'P', + 'config': {}, + } + ) + app.pipeline_service.update_pipeline.assert_awaited_once_with( + 'pipeline-1', + {'name': 'Updated Pipeline'}, + ) + app.pipeline_service.delete_pipeline.assert_awaited_once_with('pipeline-1') diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 8a6d0ad2a..8e629f532 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -52,6 +52,28 @@ def _create_mock_result(items: list = None, first_item=None): return result +def _compiled_update_values(statement): + """Return update values without SQLAlchemy WHERE bind params.""" + return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')} + + +def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None): + """Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests. + + adapter_webhook_flags maps adapter name -> whether its manifest declares a + webhook-url config item (mirrors _adapter_declares_webhook_url's lookup). + """ + components = [] + for name, has_webhook in (adapter_webhook_flags or {}).items(): + component = SimpleNamespace() + component.metadata = SimpleNamespace(name=name) + component.spec = {'config': ([{'name': 'webhook_url', 'type': 'webhook-url'}] if has_webhook else [])} + components.append(component) + discover = SimpleNamespace() + discover.get_components_by_kind = Mock(return_value=components) + return discover + + class TestBotServiceGetBots: """Tests for get_bots method.""" @@ -219,6 +241,7 @@ async def test_get_runtime_bot_info_returns_webhook_for_wecom(self): } ap.platform_mgr = SimpleNamespace() ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None) + ap.discover = _create_mock_discover({'wecom': True}) bot_data = { 'uuid': 'wecom-uuid', @@ -245,6 +268,7 @@ async def test_get_runtime_bot_info_no_webhook_for_telegram(self): ap.instance_config.data = {'api': {}} ap.platform_mgr = SimpleNamespace() ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None) + ap.discover = _create_mock_discover({'telegram': False}) bot_data = { 'uuid': 'telegram-uuid', @@ -276,6 +300,7 @@ async def test_get_runtime_bot_info_with_runtime_bot(self): runtime_bot.adapter = SimpleNamespace() runtime_bot.adapter.bot_account_id = 'runtime-account-123' ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot) + ap.discover = _create_mock_discover({'telegram': False}) bot_data = { 'uuid': 'runtime-uuid', @@ -334,7 +359,8 @@ async def test_create_bot_no_limit(self): } } ap.platform_mgr = SimpleNamespace() - ap.platform_mgr.load_bot = AsyncMock() + runtime_bot = SimpleNamespace(enable=True, run=AsyncMock()) + ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot) # Mock pipeline query pipeline_result = Mock() @@ -365,59 +391,7 @@ async def mock_execute(query): # Verify assert bot_uuid is not None assert len(bot_uuid) == 36 # UUID format - - async def test_create_bot_sets_default_pipeline(self): - """Sets default pipeline when one exists.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.instance_config = SimpleNamespace() - ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}} - ap.platform_mgr = SimpleNamespace() - ap.platform_mgr.load_bot = AsyncMock() - - # Mock default pipeline - mock_pipeline = SimpleNamespace() - mock_pipeline.uuid = 'default-pipeline-uuid' - mock_pipeline.name = 'Default Pipeline' - pipeline_result = Mock() - pipeline_result.first = Mock(return_value=mock_pipeline) - - # Mock bot after insert - bot_result = Mock() - bot_result.first = Mock(return_value=_create_mock_bot()) - - call_count = 0 - - async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return pipeline_result # Check default pipeline - elif call_count == 2: - return Mock() # Insert - return bot_result # Get bot - - ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'new-uuid', - 'name': 'New Bot', - 'use_pipeline_uuid': 'default-pipeline-uuid', - 'use_pipeline_name': 'Default Pipeline', - } - ) - - service = BotService(ap) - - # Execute - bot_data = {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}} - bot_uuid = await service.create_bot(bot_data) - - # Verify - pipeline uuid and name were set - assert 'use_pipeline_uuid' in bot_data - assert 'use_pipeline_name' in bot_data - assert bot_uuid is not None # Verify UUID was returned + runtime_bot.run.assert_awaited_once() class TestBotServiceUpdateBot: @@ -452,63 +426,493 @@ async def test_update_bot_removes_uuid_from_data(self): assert update_params['name'] == 'Updated Name' assert 'should-be-removed' not in update_params.values() - async def test_update_bot_pipeline_not_found_raises(self): - """Raises Exception when updating with nonexistent pipeline UUID.""" - # Setup + +class TestBotServiceEventBindings: + """Tests for EBA event binding validation and persistence.""" + + async def test_normalize_event_bindings_validates_targets_and_preserves_order(self): + """Valid bindings are normalized with stable order and target data.""" ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')), + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + supported_event_patterns=['platform.member.*'], + ) + ), + ] + ) + service = BotService(ap) - # Mock pipeline query returns None - pipeline_result = Mock() - pipeline_result.first = Mock(return_value=None) - ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result) + normalized = await service._normalize_event_bindings( + [ + { + 'event_pattern': ' message.received ', + 'target_type': 'pipeline', + 'target_uuid': ' pipeline-1 ', + 'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}], + 'priority': '5', + 'enabled': False, + 'description': 'Route message events', + }, + { + 'id': 'agent-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 7, + }, + { + 'event_pattern': 'platform.member.left', + 'target_type': 'discard', + 'target_uuid': 'ignored-target', + }, + ] + ) + uuid.UUID(normalized[0]['id']) + assert normalized == [ + { + 'id': normalized[0]['id'], + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}], + 'priority': 5, + 'enabled': False, + 'description': 'Route message events', + 'order': 0, + }, + { + 'id': 'agent-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [], + 'priority': 7, + 'enabled': True, + 'description': '', + 'order': 1, + }, + { + 'id': normalized[2]['id'], + 'event_pattern': 'platform.member.left', + 'target_type': 'discard', + 'target_uuid': '', + 'filters': [], + 'priority': 0, + 'enabled': True, + 'description': '', + 'order': 2, + }, + ] + + async def test_normalize_event_bindings_rejects_pipeline_for_non_message_event(self): + """Pipeline targets are limited to message events.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) service = BotService(ap) - # Execute & Verify - with pytest.raises(Exception, match='Pipeline not found'): - await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'}) + with pytest.raises(ValueError, match='Pipeline can only be bound to message events'): + await service._normalize_event_bindings( + [ + { + 'event_pattern': 'platform.member.joined', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + } + ] + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + ('agent', 'error'), + [ + (None, 'Agent not found'), + ( + SimpleNamespace(uuid='agent-1', supported_event_patterns=['message.*']), + 'Agent does not support this event pattern', + ), + ], + ) + async def test_normalize_event_bindings_rejects_invalid_agent_target(self, agent, error): + """Agent targets must exist and support the requested event pattern.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace( + execute_async=AsyncMock(return_value=_create_mock_result(first_item=agent)) + ) + service = BotService(ap) - async def test_update_bot_sets_pipeline_name(self): - """Sets use_pipeline_name when updating use_pipeline_uuid.""" - # Setup + with pytest.raises(ValueError, match=error): + await service._normalize_event_bindings( + [ + { + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + } + ] + ) + + async def test_update_bot_persists_normalized_event_bindings_and_reloads_runtime_bot(self): + """update_bot stores normalized bindings before reloading the runtime bot.""" ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - ap.platform_mgr = SimpleNamespace() - ap.platform_mgr.remove_bot = AsyncMock() + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + supported_event_patterns=['platform.member.*'], + ) + ), + Mock(), + ] + ) + runtime_bot = SimpleNamespace(enable=True, run=AsyncMock()) + loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'} + ap.platform_mgr = SimpleNamespace( + remove_bot=AsyncMock(), + load_bot=AsyncMock(return_value=runtime_bot), + ) + bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1')) + other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot')) + ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session]) + service = BotService(ap) + service.get_bot = AsyncMock(return_value=loaded_bot) + + await service.update_bot( + 'bot-1', + { + 'event_bindings': [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': '9', + } + ] + }, + ) - # Mock pipeline query - mock_pipeline = SimpleNamespace() - mock_pipeline.name = 'Updated Pipeline' - pipeline_result = Mock() - pipeline_result.first = Mock(return_value=mock_pipeline) + update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values['event_bindings'] == [ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [], + 'priority': 9, + 'enabled': True, + 'description': '', + 'order': 0, + } + ] + ap.platform_mgr.remove_bot.assert_awaited_once_with('bot-1') + service.get_bot.assert_awaited_once_with('bot-1') + ap.platform_mgr.load_bot.assert_awaited_once_with(loaded_bot) + runtime_bot.run.assert_awaited_once() + assert bot_session.using_conversation is None + assert other_session.using_conversation.bot_uuid == 'other-bot' + + +class TestBotServiceEventRouteDryRun: + """Tests for dry-run Bot event route diagnostics.""" + + @staticmethod + def _make_service( + event_bindings: list[dict], + *, + bot_uuid: str = 'bot-1', + persistence_results: list[Mock] | None = None, + ) -> BotService: + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.persistence_mgr.execute_async = AsyncMock(side_effect=persistence_results or []) + service = BotService(ap) + service.get_bot = AsyncMock( + return_value={ + 'uuid': bot_uuid, + 'name': 'Dry Run Bot', + 'event_bindings': event_bindings, + } + ) + return service + + async def test_dry_run_event_route_matches_agent(self): + """Matching Agent routes return the selected binding without running the Agent.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 5, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=True, + supported_event_patterns=['platform.member.*'], + ) + ) + ], + ) - call_count = 0 + result = await service.dry_run_event_route( + 'bot-1', + 'platform.member.joined', + event_data={'room': {'id': 'room-1'}}, + ) - async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return pipeline_result - return Mock() + assert result['matched'] is True + assert result['binding_id'] == 'agent-binding' + assert result['event_pattern'] == 'platform.member.*' + assert result['target_type'] == 'agent' + assert result['target_uuid'] == 'agent-1' + assert result['target']['target_uuid'] == 'agent-1' + assert result['target']['target_name'] is None + assert result['failure_code'] is None + assert result['matched_binding_id'] == 'agent-binding' + assert result['matched_binding_index'] == 0 + assert result['diagnostic_details'][0]['selected'] is True + assert result['diagnostic_steps'][0].startswith('Route 1') + + async def test_dry_run_event_route_matches_discard(self): + """Discard routes are terminal dry-run matches and do not query processors.""" + service = self._make_service( + [ + { + 'id': 'discard-binding', + 'enabled': True, + 'event_pattern': '*', + 'target_type': 'discard', + 'target_uuid': '', + 'priority': 0, + 'order': 0, + } + ] + ) - ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - ap.sess_mgr = SimpleNamespace() - ap.sess_mgr.session_list = [] + result = await service.dry_run_event_route('bot-1', 'platform.member.left') + + assert result['matched'] is True + assert result['binding_id'] == 'discard-binding' + assert result['target_type'] == 'discard' + assert result['target']['kind'] == 'discard' + assert result['failure_code'] is None + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_returns_route_not_found_when_no_binding_matches(self): + """Pattern/filter misses return route_not_found.""" + service = self._make_service( + [ + { + 'id': 'wrong-event', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ] + ) - service = BotService(ap) - service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'}) + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] is None + assert result['failure_code'] == 'route_not_found' + assert result['target'] is None + assert result['diagnostic_details'][0]['failure_code'] == 'event_pattern_mismatch' + assert 'skipped' in result['diagnostic_steps'][0] + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_reports_disabled_binding_without_matching(self): + """Disabled bindings are diagnosed but never selected.""" + service = self._make_service( + [ + { + 'id': 'disabled-binding', + 'enabled': False, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 100, + 'order': 0, + } + ] + ) - runtime_bot = SimpleNamespace() - runtime_bot.enable = False - ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot) + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['failure_code'] == 'route_not_found' + assert result['diagnostic_details'][0]['binding_id'] == 'disabled-binding' + assert result['diagnostic_details'][0]['failure_code'] == 'binding_disabled' + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_rejects_pipeline_for_non_message_event(self): + """Pipeline targets cannot dry-run match non-message events.""" + service = self._make_service( + [ + { + 'id': 'pipeline-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'priority': 0, + 'order': 0, + } + ] + ) - # Execute - await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'}) + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] == 'pipeline-binding' + assert result['target_type'] == 'pipeline' + assert result['failure_code'] == 'processor_incompatible' + assert result['diagnostic_details'][-1]['step'] == 'validate_processor' + service.ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_dry_run_event_route_rejects_incompatible_agent(self): + """Agent targets must support the incoming event type.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=True, + supported_event_patterns=['message.*'], + ) + ) + ], + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['binding_id'] == 'agent-binding' + assert result['target_type'] == 'agent' + assert result['failure_code'] == 'processor_incompatible' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_incompatible' + + async def test_dry_run_event_route_reports_disabled_agent(self): + """Disabled Agent targets return processor_disabled.""" + service = self._make_service( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[ + _create_mock_result( + first_item=SimpleNamespace( + uuid='agent-1', + kind='agent', + enabled=False, + supported_event_patterns=['*'], + ) + ) + ], + ) + + result = await service.dry_run_event_route('bot-1', 'platform.member.joined') + + assert result['matched'] is False + assert result['failure_code'] == 'processor_disabled' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_disabled' + + async def test_dry_run_event_route_reports_missing_pipeline(self): + """Missing Pipeline targets return processor_not_found.""" + service = self._make_service( + [ + { + 'id': 'pipeline-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'priority': 0, + 'order': 0, + } + ], + persistence_results=[_create_mock_result(first_item=None)], + ) + + result = await service.dry_run_event_route('bot-1', 'message.received') + + assert result['matched'] is False + assert result['failure_code'] == 'processor_not_found' + assert result['diagnostic_details'][-1]['failure_code'] == 'processor_not_found' + + async def test_dry_run_event_route_uses_draft_event_bindings(self): + """Draft bindings from the UI can be tested before saving the bot.""" + service = self._make_service( + [ + { + 'id': 'saved-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'discard', + 'priority': 0, + 'order': 0, + } + ] + ) + + result = await service.dry_run_event_route( + 'bot-1', + 'platform.member.joined', + event_bindings=[ + { + 'id': 'draft-binding', + 'event_pattern': 'platform.member.joined', + 'target_type': 'discard', + 'enabled': True, + 'priority': 0, + } + ], + ) - update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params - assert update_params['use_pipeline_uuid'] == 'pipeline-uuid' - assert update_params['use_pipeline_name'] == 'Updated Pipeline' + assert result['matched'] is True + assert result['binding_id'] == 'draft-binding' + assert result['matched_binding_index'] == 0 + assert result['target']['kind'] == 'discard' class TestBotServiceDeleteBot: @@ -591,6 +995,192 @@ async def test_list_event_logs_returns_logs(self): assert total == 5 +class TestBotServiceEventRouteStatuses: + """Tests for event route runtime status aggregation.""" + + async def test_list_event_route_statuses_bot_not_found_raises(self): + """Raises Exception when runtime bot not found.""" + ap = SimpleNamespace() + ap.platform_mgr = SimpleNamespace() + ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None) + + service = BotService(ap) + + with pytest.raises(Exception, match='Bot not found'): + await service.list_event_route_statuses('missing-bot') + + async def test_list_event_route_statuses_merges_latest_trace_by_binding(self): + """Current route definitions are enriched with the latest trace log.""" + ap = SimpleNamespace() + ap.platform_mgr = SimpleNamespace() + + runtime_bot = SimpleNamespace() + runtime_bot.bot_entity = SimpleNamespace( + event_bindings=[ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'enabled': True, + 'order': 0, + }, + { + 'id': 'binding-2', + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'enabled': False, + 'order': 1, + }, + ] + ) + runtime_bot.logger = SimpleNamespace( + logs=[ + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 1, + 'timestamp': 100, + 'level': 'info', + 'text': 'old matched', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'status': 'matched', + 'failure_code': None, + 'reason': 'matched', + 'run_id': None, + }, + } + ) + ), + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 2, + 'timestamp': 120, + 'level': 'error', + 'text': 'runner failed', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'status': 'failed', + 'failure_code': 'runner_failed', + 'reason': 'Agent runner failed', + 'run_id': None, + }, + } + ) + ), + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 3, + 'timestamp': 130, + 'level': 'info', + 'text': 'no route', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': None, + 'event_pattern': None, + 'event_type': 'platform.member.left', + 'target_type': None, + 'target_uuid': '', + 'status': 'not_matched', + 'failure_code': 'route_not_found', + 'reason': 'No event route matched', + 'run_id': None, + }, + } + ) + ), + ] + ) + ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot) + + service = BotService(ap) + result = await service.list_event_route_statuses('bot-1') + + assert len(result['routes']) == 2 + assert result['routes'][0]['binding_id'] == 'binding-1' + assert result['routes'][0]['last_status'] == 'failed' + assert result['routes'][0]['failure_code'] == 'runner_failed' + assert result['routes'][0]['timestamp'] == 120 + assert result['routes'][0]['current'] is True + assert result['routes'][1]['binding_id'] == 'binding-2' + assert result['routes'][1]['last_status'] is None + assert result['routes'][1]['enabled'] is False + assert result['unmatched_events'][0]['event_type'] == 'platform.member.left' + assert result['unmatched_events'][0]['failure_code'] == 'route_not_found' + + +class TestBotServiceDispatchTestEventRoute: + """Tests for synthetic Bot route test dispatch.""" + + async def test_dispatch_test_event_route_rejects_invalid_event_type(self): + """Missing event_type returns a non-dispatched test result.""" + service = BotService(SimpleNamespace()) + + result = await service.dispatch_test_event_route('bot-1', '', {}) + + assert result['dispatched'] is False + assert result['failure_code'] == 'invalid_event' + assert result['reason'] == 'event_type is required' + assert result['route_status']['routes'] == [] + + async def test_dispatch_test_event_route_rejects_non_object_payload(self): + """Payload must be a JSON object.""" + service = BotService(SimpleNamespace()) + + result = await service.dispatch_test_event_route('bot-1', 'message.received', []) # type: ignore[arg-type] + + assert result['dispatched'] is False + assert result['failure_code'] == 'invalid_event' + assert result['reason'] == 'payload must be an object' + assert result['route_status']['routes'] == [] + + async def test_dispatch_test_event_route_calls_runtime_and_returns_status(self): + """Synthetic route tests run against the runtime bot and return route status.""" + ap = SimpleNamespace() + ap.platform_mgr = SimpleNamespace() + runtime_bot = SimpleNamespace() + runtime_bot.dispatch_test_event = AsyncMock( + return_value={ + 'event_type': 'message.received', + 'dispatched': True, + 'status': 'delivered', + 'binding_id': 'binding-1', + 'failure_code': None, + 'reason': 'Delivered to processor', + 'suppressed_outputs': [{'method': 'send_message'}], + } + ) + runtime_bot.bot_entity = SimpleNamespace(event_bindings=[]) + runtime_bot.logger = SimpleNamespace(logs=[]) + ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot) + + service = BotService(ap) + result = await service.dispatch_test_event_route('bot-1', 'message.received', {'message_text': 'hello'}) + + runtime_bot.dispatch_test_event.assert_awaited_once_with('message.received', {'message_text': 'hello'}) + assert result['dispatched'] is True + assert result['status'] == 'delivered' + assert result['binding_id'] == 'binding-1' + assert result['reason'] == 'Delivered to processor' + assert result['event_type'] == 'message.received' + assert result['suppressed_outputs'] == [{'method': 'send_message'}] + assert result['route_status']['routes'] == [] + + class TestBotServiceSendMessage: """Tests for send_message method.""" diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 42129ed3b..e22248a52 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -13,10 +13,13 @@ from __future__ import annotations -import pytest -from unittest.mock import AsyncMock, Mock from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +import pytest + +from langbot.pkg.agent.runner.default_config import AgentRunnerDefaultConfigService +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.api.http.service.model import ( LLMModelsService, EmbeddingModelsService, @@ -29,6 +32,7 @@ pytestmark = pytest.mark.asyncio +RUNNER_ID = 'plugin:test/runner/default' def _create_mock_llm_model( @@ -101,6 +105,22 @@ def _create_mock_result(items: list = None, first_item=None): return result +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='runner', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}}, + ], + permissions={'models': ['invoke']}, + ) + + class TestParseProviderApiKeys: """Tests for _parse_provider_api_keys helper function.""" @@ -451,6 +471,52 @@ async def test_create_llm_model_persists_context_length_as_column(self): assert runtime_entity.extra_args == {'temperature': 0.2} assert 'context_length' not in runtime_entity.extra_args + async def test_create_llm_model_auto_sets_schema_defined_default_pipeline_model(self): + """Auto-default model selection should use runner schema, not legacy field names.""" + ap = SimpleNamespace() + ap.logger = Mock() + ap.persistence_mgr = SimpleNamespace() + ap.model_mgr = SimpleNamespace() + ap.model_mgr.provider_dict = {'provider-uuid': Mock()} + ap.model_mgr.llm_models = [] + ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock()) + ap.pipeline_service = SimpleNamespace(update_pipeline=AsyncMock()) + ap.agent_runner_registry = FakeAgentRunnerRegistry() + ap.agent_runner_default_config_service = AgentRunnerDefaultConfigService(ap) + + pipeline = SimpleNamespace( + uuid='pipeline-uuid', + config={ + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': { + RUNNER_ID: { + 'model': {'primary': '', 'fallbacks': []}, + }, + }, + }, + }, + ) + ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=pipeline)) + + service = LLMModelsService(ap) + + model_uuid = await service.create_llm_model({ + 'uuid': 'new-model-uuid', + 'name': 'New LLM', + 'provider_uuid': 'provider-uuid', + 'abilities': [], + 'extra_args': {}, + }, preserve_uuid=True) + + assert model_uuid == 'new-model-uuid' + ap.pipeline_service.update_pipeline.assert_awaited_once() + updated_config = ap.pipeline_service.update_pipeline.await_args.args[1]['config'] + assert updated_config['ai']['runner_config'][RUNNER_ID]['model'] == { + 'primary': 'new-model-uuid', + 'fallbacks': [], + } + async def test_create_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found in runtime.""" # Setup diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index fade30372..6ce935736 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -231,6 +231,63 @@ async def test_create_pipeline_max_limit_reached_raises(self): with pytest.raises(ValueError, match='Maximum number of pipelines'): await service.create_pipeline({'name': 'New Pipeline'}) + @pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) + async def test_create_pipeline_rejects_non_object_extension_preferences( + self, + invalid_preferences, + ): + service = PipelineService(SimpleNamespace()) + + with pytest.raises(ValueError, match='extensions_preferences must be an object'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'extensions_preferences': invalid_preferences, + } + ) + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_pipeline_rejects_non_boolean_runner_security_field(self, invalid_value): + service = PipelineService(SimpleNamespace()) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match='enable-all-tools.*boolean'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {'enable-all-tools': invalid_value}}, + } + }, + } + ) + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_pipeline_rejects_non_boolean_mcp_resource_enabled(self, invalid_value): + service = PipelineService(SimpleNamespace()) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': { + runner_id: { + 'mcp-resources': [ + {'uri': 'file:///README.md', 'enabled': invalid_value}, + ] + } + }, + } + }, + } + ) + async def test_create_pipeline_no_limit(self): """Creates pipeline without limit when max_pipelines=-1.""" # Setup @@ -353,19 +410,6 @@ async def mock_execute(query): } -class _MockResultWithBots: - """Helper class to mock SQLAlchemy result with iterable .all() method.""" - - def __init__(self, bots_list): - self._bots_list = bots_list - - def all(self): - return self._bots_list - - def first(self): - return self._bots_list[0] if self._bots_list else None - - class TestPipelineServiceUpdatePipeline: """Tests for update_pipeline method.""" @@ -379,20 +423,18 @@ async def test_update_pipeline_removes_protected_fields(self): ap.pipeline_mgr.load_pipeline = AsyncMock() ap.sess_mgr = SimpleNamespace() ap.sess_mgr.session_list = [] - ap.bot_service = None # No bot_service when not updating name - ap.persistence_mgr.execute_async = AsyncMock() service = PipelineService(ap) service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'}) - # Execute with protected fields - no name change, so no bot sync + # Execute with protected fields. pipeline_data = { 'uuid': 'should-be-removed', 'for_version': 'should-be-removed', 'stages': ['should-be-removed'], 'is_default': True, - 'description': 'New description', # Not name change, so no bot_service needed + 'description': 'New description', } await service.update_pipeline('test-uuid', pipeline_data) @@ -402,8 +444,45 @@ async def test_update_pipeline_removes_protected_fields(self): assert ['should-be-removed'] not in update_params.values() assert not any(value is True for value in update_params.values()) - async def test_update_pipeline_syncs_bot_names(self): - """Updates bot use_pipeline_name when pipeline name changes.""" + @pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) + async def test_update_pipeline_rejects_non_object_extension_preferences_before_write( + self, + invalid_preferences, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match='extensions_preferences must be an object'): + await service.update_pipeline( + 'test-uuid', + {'extensions_preferences': invalid_preferences}, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_pipeline_rejects_non_boolean_runner_security_field_before_write(self, invalid_value): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match='mcp-resource-agent-read-enabled.*boolean'): + await service.update_pipeline( + 'test-uuid', + { + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {'mcp-resource-agent-read-enabled': invalid_value}}, + } + } + }, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + async def test_update_pipeline_name_does_not_rewrite_bot_routes(self): + """Bot event bindings remain independent from pipeline display names.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() @@ -415,45 +494,14 @@ async def test_update_pipeline_syncs_bot_names(self): ap.bot_service = SimpleNamespace() ap.bot_service.update_bot = AsyncMock() - # Create proper mock Bot entities with uuid attribute - mock_bot1 = Mock() - mock_bot1.uuid = 'bot-uuid-1' - mock_bot2 = Mock() - mock_bot2.uuid = 'bot-uuid-2' - - # Create bot list - bot_list = [mock_bot1, mock_bot2] - - # Create mock result using helper class - bot_result = _MockResultWithBots(bot_list) - - # The order of calls in update_pipeline: - # 1. UPDATE (line 125) - returns Mock (no result needed) - # 2. SELECT bots (line 136) - returns bot_result with .all() - call_count = 0 - - async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - # First call is the UPDATE - just return a Mock - return Mock() - elif call_count == 2: - # Second call is the SELECT bots - return proper result - return bot_result - return Mock() # Any additional calls - - ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - ap.persistence_mgr.serialize_model = Mock(return_value={}) + ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) service = PipelineService(ap) service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'}) - # Execute with name change await service.update_pipeline('test-uuid', {'name': 'New Name'}) - # Verify - bot_service.update_bot was called for each bot - assert ap.bot_service.update_bot.call_count == 2 + ap.bot_service.update_bot.assert_not_awaited() async def test_update_pipeline_clears_conversations(self): """Clears session conversations using this pipeline.""" @@ -670,6 +718,98 @@ async def test_update_extensions_pipeline_not_found_raises(self): with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'): await service.update_pipeline_extensions('nonexistent-uuid', []) + @pytest.mark.parametrize( + ('field', 'invalid_value'), + [ + ('bound_plugins', 'author/plugin'), + ('bound_plugins', [{'author': 'author'}]), + ('bound_mcp_servers', 'server-1'), + ('bound_mcp_servers', ['server-1', 2]), + ('bound_skills', 'skill-1'), + ('bound_skills', ['skill-1', None]), + ('bound_mcp_resources', {'uri': 'file:///README.md'}), + ('bound_mcp_resources', [{'uri': 'file:///README.md'}, 'bad']), + ], + ) + async def test_update_extensions_rejects_malformed_binding_lists_before_query( + self, + field, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + kwargs = {field: invalid_value} + if field != 'bound_plugins': + kwargs['bound_plugins'] = [] + + with pytest.raises(ValueError, match=field): + await service.update_pipeline_extensions('test-uuid', **kwargs) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, 'false']) + async def test_update_extensions_rejects_non_boolean_resource_read_before_query(self, invalid_value): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match='mcp_resource_agent_read_enabled.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + mcp_resource_agent_read_enabled=invalid_value, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + 'field', + [ + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + ], + ) + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_extensions_rejects_non_boolean_enable_all_flags_before_query( + self, + field, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match=rf'{field}.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + **{field: invalid_value}, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_extensions_rejects_non_boolean_attachment_enabled_before_query( + self, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match=r'bound_mcp_resources.*enabled.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + bound_mcp_resources=[ + { + 'server_uuid': 'server-1', + 'uri': 'file:///README.md', + 'enabled': invalid_value, + } + ], + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + async def test_update_extensions_sets_plugins(self): """Updates plugins in extensions_preferences.""" # Setup @@ -713,7 +853,7 @@ async def mock_execute(query): ) # Execute - bound_plugins = [{'plugin_uuid': 'plugin-1'}] + bound_plugins = [{'author': 'test', 'name': 'plugin-1'}] await service.update_pipeline_extensions( 'test-uuid', bound_plugins=bound_plugins, diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py new file mode 100644 index 000000000..1a1fe5a05 --- /dev/null +++ b/tests/unit_tests/api/test_agents_controller.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +core_app_module = types.ModuleType('langbot.pkg.core.app') +core_app_module.Application = object +sys.modules.setdefault('langbot.pkg.core.app', core_app_module) + + +pytestmark = pytest.mark.asyncio + + +async def _create_test_client(agent_service: SimpleNamespace): + app = quart.Quart(__name__) + user_service = SimpleNamespace( + verify_jwt_token=AsyncMock(return_value='test@example.com'), + get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')), + ) + ap = SimpleNamespace(agent_service=agent_service, user_service=user_service) + AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup + group = AgentsRouterGroup(ap, app) + await group.initialize() + return app.test_client() + + +async def test_create_agent_returns_bad_request_for_invalid_runner_config(): + message = 'agent config runner_config must be an object' + agent_service = SimpleNamespace(create_agent=AsyncMock(side_effect=ValueError(message))) + client = await _create_test_client(agent_service) + + response = await client.post( + '/api/v1/agents', + json={'name': 'Invalid Agent', 'config': {'runner_config': []}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + agent_service.create_agent.assert_awaited_once_with( + {'name': 'Invalid Agent', 'config': {'runner_config': []}}, + ) + + +async def test_update_agent_returns_bad_request_for_invalid_runner_config(): + message = 'agent config runner.id must be a string' + agent_service = SimpleNamespace(update_agent=AsyncMock(side_effect=ValueError(message))) + client = await _create_test_client(agent_service) + + response = await client.put( + '/api/v1/agents/agent-1', + json={'config': {'runner': {'id': 7}}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + agent_service.update_agent.assert_awaited_once_with( + 'agent-1', + {'config': {'runner': {'id': 7}}}, + ) diff --git a/tests/unit_tests/api/test_mcp_server.py b/tests/unit_tests/api/test_mcp_server.py new file mode 100644 index 000000000..f232113d9 --- /dev/null +++ b/tests/unit_tests/api/test_mcp_server.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.mcp.server import LangBotMCPServer + + +def _make_app() -> SimpleNamespace: + app = SimpleNamespace() + app.instance_config = SimpleNamespace(data={'system': {'edition': 'community', 'instance_id': 'inst-1'}}) + app.ver_mgr = SimpleNamespace(get_current_version=lambda: 'test-version') + app.bot_service = SimpleNamespace( + get_bots=AsyncMock(return_value=[]), + get_bot=AsyncMock(return_value={'uuid': 'bot-1'}), + create_bot=AsyncMock(return_value='bot-1'), + update_bot=AsyncMock(), + delete_bot=AsyncMock(), + list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}), + dispatch_test_event_route=AsyncMock( + return_value={ + 'dispatched': True, + 'event_type': 'message.received', + 'suppressed_outputs': [], + 'route_status': { + 'routes': [], + 'unmatched_events': [], + 'stale_routes': [], + }, + } + ), + ) + app.pipeline_service = SimpleNamespace( + get_pipelines=AsyncMock(return_value=[]), + get_pipeline=AsyncMock(return_value={}), + create_pipeline=AsyncMock(return_value='pipeline-1'), + update_pipeline=AsyncMock(), + delete_pipeline=AsyncMock(), + ) + app.agent_service = SimpleNamespace( + get_agents=AsyncMock(return_value=[]), + get_agent=AsyncMock(return_value={}), + create_agent=AsyncMock(return_value={'uuid': 'agent-1', 'kind': 'agent'}), + update_agent=AsyncMock(), + delete_agent=AsyncMock(), + ) + app.llm_model_service = SimpleNamespace( + get_llm_models=AsyncMock(return_value=[]), + get_llm_model=AsyncMock(return_value={}), + ) + app.embedding_models_service = SimpleNamespace(get_embedding_models=AsyncMock(return_value=[])) + app.provider_service = SimpleNamespace(get_providers=AsyncMock(return_value=[])) + app.knowledge_service = SimpleNamespace( + get_knowledge_bases=AsyncMock(return_value=[]), + get_knowledge_base=AsyncMock(return_value={}), + retrieve_knowledge_base=AsyncMock(return_value=[]), + ) + app.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])) + app.skill_service = SimpleNamespace( + list_skills=AsyncMock(return_value=[]), + get_skill=AsyncMock(return_value={}), + ) + return app + + +@pytest.mark.asyncio +async def test_mcp_server_exposes_bot_event_route_tools(): + app = _make_app() + server = LangBotMCPServer(app) + + tools = await server.mcp.list_tools() + tool_names = {tool.name for tool in tools} + + assert 'list_bot_event_route_statuses' in tool_names + assert 'test_bot_event_route' in tool_names + assert 'list_processors' in tool_names + assert 'list_agents' not in tool_names + + +@pytest.mark.asyncio +async def test_mcp_test_bot_event_route_calls_service_layer(): + app = _make_app() + server = LangBotMCPServer(app) + + result_blocks, _ = await server.mcp.call_tool( + 'test_bot_event_route', + { + 'bot_uuid': 'bot-1', + 'event_type': 'message.received', + 'payload': {'message_text': 'hello'}, + }, + ) + + app.bot_service.dispatch_test_event_route.assert_awaited_once_with( + bot_uuid='bot-1', + event_type='message.received', + payload={'message_text': 'hello'}, + ) + data = json.loads(result_blocks[0].text) + assert data['dispatched'] is True + assert data['event_type'] == 'message.received' diff --git a/tests/unit_tests/api/test_pipeline_service_defaults.py b/tests/unit_tests/api/test_pipeline_service_defaults.py new file mode 100644 index 000000000..136ac1e95 --- /dev/null +++ b/tests/unit_tests/api/test_pipeline_service_defaults.py @@ -0,0 +1,77 @@ +"""Tests for dynamic default pipeline config rendering.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.api.http.service.pipeline import PipelineService + + +class FakeLogger: + def warning(self, msg): + pass + + +class FakeRegistry: + def __init__(self, runners): + self.runners = runners + + async def list_runners(self, bound_plugins=None): + return self.runners + + +def make_runner(runner_id: str, config_schema: list[dict]): + parts = runner_id.removeprefix('plugin:').split('/') + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': runner_id}, + plugin_author=parts[0], + plugin_name=parts[1], + runner_name=parts[2], + config_schema=config_schema, + ) + + +@pytest.mark.asyncio +async def test_default_pipeline_config_uses_first_installed_runner_schema(): + local_agent = make_runner( + 'plugin:langbot-team/LocalAgent/default', + [ + {'name': 'model', 'type': 'model-fallback-selector', 'default': {'primary': '', 'fallbacks': []}}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': [{'role': 'system', 'content': 'Hello'}]}, + ], + ) + custom_agent = make_runner( + 'plugin:alice/custom-agent/default', + [{'name': 'api-key', 'type': 'string', 'default': ''}], + ) + ap = SimpleNamespace( + logger=FakeLogger(), + agent_runner_registry=FakeRegistry([custom_agent, local_agent]), + ) + + config = await PipelineService(ap).get_default_pipeline_config() + + assert config['ai']['runner']['id'] == 'plugin:alice/custom-agent/default' + assert config['ai']['runner_config'] == { + 'plugin:alice/custom-agent/default': { + 'api-key': '', + }, + } + + +@pytest.mark.asyncio +async def test_default_pipeline_config_stays_neutral_without_installed_runners(): + ap = SimpleNamespace( + logger=FakeLogger(), + agent_runner_registry=FakeRegistry([]), + ) + + config = await PipelineService(ap).get_default_pipeline_config() + + assert config['ai']['runner']['id'] == '' + assert config['ai']['runner_config'] == {} diff --git a/tests/unit_tests/api/test_pipelines_controller.py b/tests/unit_tests/api/test_pipelines_controller.py new file mode 100644 index 000000000..66300b7fa --- /dev/null +++ b/tests/unit_tests/api/test_pipelines_controller.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +core_app_module = types.ModuleType('langbot.pkg.core.app') +core_app_module.Application = object +sys.modules.setdefault('langbot.pkg.core.app', core_app_module) + + +pytestmark = pytest.mark.asyncio + + +async def _create_test_client(pipeline_service: SimpleNamespace, **extra_ap): + app = quart.Quart(__name__) + user_service = SimpleNamespace( + verify_jwt_token=AsyncMock(return_value='test@example.com'), + get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')), + ) + ap = SimpleNamespace(pipeline_service=pipeline_service, user_service=user_service, **extra_ap) + router_class = import_module('langbot.pkg.api.http.controller.groups.pipelines.pipelines').PipelinesRouterGroup + group = router_class(ap, app) + await group.initialize() + return app.test_client() + + +@pytest.mark.parametrize( + ('method', 'path', 'service_method'), + [ + ('post', '/api/v1/pipelines', 'create_pipeline'), + ('put', '/api/v1/pipelines/pipeline-1', 'update_pipeline'), + ], +) +async def test_pipeline_writes_return_bad_request_for_invalid_runner_security_field( + method, + path, + service_method, +): + message = "Pipeline runner_config['runner-1'] field 'enable-all-tools' must be a boolean" + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + getattr(pipeline_service, service_method).side_effect = ValueError(message) + client = await _create_test_client(pipeline_service) + + response = await getattr(client, method)( + path, + json={'config': {'ai': {'runner': {'id': 'runner-1'}}}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_reject_non_boolean_resource_read(invalid_value): + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={'mcp_resource_agent_read_enabled': invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == { + 'code': -1, + 'msg': "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean", + } + pipeline_service.update_pipeline_extensions.assert_not_awaited() + + +@pytest.mark.parametrize( + 'field', + [ + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + ], +) +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_reject_non_boolean_enable_all_flags(field, invalid_value): + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={field: invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == { + 'code': -1, + 'msg': f"Pipeline extension field '{field}' must be a boolean", + } + pipeline_service.update_pipeline_extensions.assert_not_awaited() + + +@pytest.mark.parametrize( + ('field', 'invalid_value'), + [ + ('bound_plugins', 'author/plugin'), + ('bound_mcp_servers', 'server-1'), + ('bound_skills', 'skill-1'), + ('bound_mcp_resources', {'uri': 'file:///README.md'}), + ], +) +async def test_pipeline_extensions_return_bad_request_for_malformed_binding_lists( + field, + invalid_value, +): + message = f"Pipeline extension field '{field}' must be a list" + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(side_effect=ValueError(message)), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={field: invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(invalid_value): + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': invalid_value, + 'enable_all_mcp_servers': invalid_value, + 'enable_all_skills': invalid_value, + 'mcp_resource_agent_read_enabled': invalid_value, + } + } + ), + ) + client = await _create_test_client( + pipeline_service, + plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), + mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), + skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), + logger=SimpleNamespace(warning=AsyncMock()), + ) + + response = await client.get( + '/api/v1/pipelines/pipeline-1/extensions', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['enable_all_plugins'] is False + assert payload['data']['enable_all_mcp_servers'] is False + assert payload['data']['enable_all_skills'] is False + assert payload['data']['mcp_resource_agent_read_enabled'] is False + + +@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) +async def test_pipeline_extensions_get_malformed_root_is_fail_closed(invalid_preferences): + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={'extensions_preferences': invalid_preferences} + ), + ) + client = await _create_test_client( + pipeline_service, + plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), + mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), + skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), + logger=SimpleNamespace(warning=AsyncMock()), + ) + + response = await client.get( + '/api/v1/pipelines/pipeline-1/extensions', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['enable_all_plugins'] is False + assert payload['data']['enable_all_mcp_servers'] is False + assert payload['data']['enable_all_skills'] is False + assert payload['data']['mcp_resource_agent_read_enabled'] is False + assert payload['data']['bound_plugins'] == [] + assert payload['data']['bound_mcp_servers'] == [] + assert payload['data']['bound_skills'] == [] + assert payload['data']['bound_mcp_resources'] == [] diff --git a/tests/unit_tests/api/test_tools_controller.py b/tests/unit_tests/api/test_tools_controller.py new file mode 100644 index 000000000..bf40239bc --- /dev/null +++ b/tests/unit_tests/api/test_tools_controller.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +core_app_module = types.ModuleType('langbot.pkg.core.app') +core_app_module.Application = object +sys.modules.setdefault('langbot.pkg.core.app', core_app_module) + + +pytestmark = pytest.mark.asyncio + + +async def _create_test_client(tool_mgr: SimpleNamespace, pipeline_service: SimpleNamespace): + app = quart.Quart(__name__) + user_service = SimpleNamespace( + verify_jwt_token=AsyncMock(return_value='test@example.com'), + get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')), + ) + ap = SimpleNamespace( + tool_mgr=tool_mgr, + pipeline_service=pipeline_service, + user_service=user_service, + ) + router_class = import_module('langbot.pkg.api.http.controller.groups.resources.tools').ToolsRouterGroup + group = router_class(ap, app) + await group.initialize() + return app.test_client() + + +async def test_global_tool_selector_uses_unambiguous_host_catalog(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'unique_tool', 'source': 'builtin'}]) + ) + pipeline_service = SimpleNamespace(get_pipeline=AsyncMock()) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tools'] == [{'name': 'unique_tool', 'source': 'builtin'}] + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + ) + pipeline_service.get_pipeline.assert_not_awaited() + + +async def test_pipeline_tool_selector_resolves_only_bound_sources(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'shared_tool', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ) + ) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': False, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': False, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tools'][0]['source_id'] == 'bound-mcp' + pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1') + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_tool_selector_malformed_enable_all_flags_fail_closed(invalid_value): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': invalid_value, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': invalid_value, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) +async def test_pipeline_tool_selector_malformed_extension_root_uses_empty_allowlists( + invalid_preferences, +): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={'extensions_preferences': invalid_preferences} + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + [], + [], + include_skill_authoring=True, + ) + + +async def test_pipeline_tool_selector_malformed_binding_lists_use_empty_allowlists(): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': True, + 'plugins': 'allowed/plugin', + 'enable_all_mcp_servers': True, + 'mcp_servers': 'bound-mcp', + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + [], + [], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('pipeline_query_key', ['pipeline_uuid', 'pipeline_id']) +async def test_tool_detail_uses_pipeline_scoped_catalog_and_path_tool_name(pipeline_query_key): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'namespace/unique_tool', + 'description': 'Unique tool', + 'human_desc': 'A unique tool', + 'parameters': {'type': 'object'}, + 'source': 'plugin', + 'source_name': 'allowed/plugin', + 'source_id': 'allowed/plugin', + } + ] + ) + ) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': False, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': False, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + f'/api/v1/tools/namespace%2Funique_tool?{pipeline_query_key}=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tool'] == { + 'name': 'namespace/unique_tool', + 'description': 'Unique tool', + 'human_desc': 'A unique tool', + 'parameters': {'type': 'object'}, + 'source': 'plugin', + 'source_name': 'allowed/plugin', + 'source_id': 'allowed/plugin', + } + pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1') + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +async def test_global_builtin_tool_detail_includes_nullable_source_id(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'exec', + 'description': 'Execute a command', + 'human_desc': 'Execute', + 'parameters': {'type': 'object'}, + 'source': 'builtin', + 'source_name': 'LangBot', + } + ] + ) + ) + client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock())) + + response = await client.get( + '/api/v1/tools/exec', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tool']['source'] == 'builtin' + assert payload['data']['tool']['source_id'] is None + + +async def test_tool_detail_hides_ambiguous_or_missing_name(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'other_tool', 'source': 'builtin'}]) + ) + client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock())) + + response = await client.get( + '/api/v1/tools/shared_tool', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 404 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + ) + + +async def test_tool_detail_returns_pipeline_not_found_before_catalog_lookup(): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock()) + pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=None)) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools/unique_tool?pipeline_uuid=missing-pipeline', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 404 + assert await response.get_json() == {'code': -1, 'msg': 'pipeline not found'} + pipeline_service.get_pipeline.assert_awaited_once_with('missing-pipeline') + tool_mgr.get_resolved_tool_catalog.assert_not_awaited() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 4d66ec8f8..254db172a 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -153,7 +153,6 @@ def make_app( host_root: str = '', workspace_quota_mb: int | None = None, enabled: bool = True, - force_box_session_id_template: str = '', ): box_config = { 'enabled': enabled, @@ -172,13 +171,107 @@ def make_app( return SimpleNamespace( logger=logger, - instance_config=SimpleNamespace( - data={ - 'box': box_config, - 'system': {'limitation': {'force_box_session_id_template': force_box_session_id_template}}, - } - ), + instance_config=SimpleNamespace(data={'box': box_config}), + ) + + +def test_resolve_box_session_id_is_host_owned(): + query = make_query(101) + query.pipeline_config = { + 'ai': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': {}}, + }, + } + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + session_id = service.resolve_box_session_id(query) + assert session_id.startswith('lb-box-') + assert len(session_id) == 71 + assert set(session_id.removeprefix('lb-box-')) <= set('0123456789abcdef') + assert 'test_user' not in session_id + + +def test_resolve_box_session_id_is_stable_and_conversation_scoped(): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + first = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='group', + launcher_id='room-1', + bot_uuid='bot-1', + ) + same_conversation = pipeline_query.Query.model_construct( + query_id=2, + launcher_type='group', + launcher_id='room-1', + bot_uuid='bot-1', + ) + other_conversation = pipeline_query.Query.model_construct( + query_id=3, + launcher_type='group', + launcher_id='room-2', + bot_uuid='bot-1', + ) + + assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_conversation) + assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_conversation) + + +def test_resolve_box_session_id_prefers_private_host_scope(): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + first = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='raw-launcher-a', + variables={'_host_box_scope': 'trusted-conversation'}, + ) + same_scope = pipeline_query.Query.model_construct( + query_id=2, + launcher_type='group', + launcher_id='raw-launcher-b', + variables={'_host_box_scope': 'trusted-conversation'}, + ) + other_scope = pipeline_query.Query.model_construct( + query_id=3, + launcher_type='person', + launcher_id='raw-launcher-a', + variables={'_host_box_scope': 'other-conversation'}, + ) + + assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_scope) + assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_scope) + + +def test_resolve_box_session_id_hashes_unsafe_unicode_and_long_identity(): + raw_identity = '用户/../../workspace/' + ('x' * 1000) + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='group/unsafe', + launcher_id=raw_identity, + variables={'_host_box_scope': raw_identity}, + ) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + session_id = service.resolve_box_session_id(query) + + assert session_id.startswith('lb-box-') + assert len(session_id) == 71 + assert session_id.isascii() + assert '/' not in session_id + assert '用户' not in session_id + + +def test_resolve_box_session_id_rejects_missing_private_host_scope(): + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='fallback-must-not-be-used', + variables={'_host_box_scope': None}, ) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + with pytest.raises(BoxValidationError, match='Host conversation scope'): + service.resolve_box_session_id(query) @pytest.mark.asyncio @@ -354,11 +447,13 @@ async def test_box_service_defaults_session_id_from_query(): service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - result = await service.execute_tool({'command': 'pwd'}, make_query(7)) + query = make_query(7) + expected_session_id = service.resolve_box_session_id(query) + result = await service.execute_tool({'command': 'pwd'}, query) - assert result['session_id'] == 'person_test_user' + assert result['session_id'] == expected_session_id assert result['ok'] is True - assert backend.start_calls == ['person_test_user'] + assert backend.start_calls == [expected_session_id] @pytest.mark.asyncio @@ -370,15 +465,16 @@ async def test_box_service_session_id_uses_query_attributes_without_variables(): await service.initialize() query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1') + expected_session_id = service.resolve_box_session_id(query) result = await service.execute_tool({'command': 'pwd'}, query) - assert result['session_id'] == 'group_room-1' + assert result['session_id'] == expected_session_id assert result['ok'] is True - assert backend.start_calls == ['group_room-1'] + assert backend.start_calls == [expected_session_id] @pytest.mark.asyncio -async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries(): +async def test_box_service_session_id_fails_closed_without_session_context(): logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) @@ -386,76 +482,11 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri await service.initialize() query = pipeline_query.Query.model_construct(query_id=7) - result = await service.execute_tool({'command': 'pwd'}, query) - - assert result['session_id'] == 'query_7' - assert result['ok'] is True - assert backend.start_calls == ['query_7'] + with pytest.raises(BoxValidationError, match='Host session context'): + await service.execute_tool({'command': 'pwd'}, query) -@pytest.mark.asyncio -async def test_box_service_forced_global_scope_overrides_pipeline_template(): - """SaaS guard: a non-empty ``force_box_session_id_template`` pins every - query to one shared sandbox regardless of the pipeline's own scope.""" - logger = Mock() - backend = FakeBackend(logger) - runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=_InProcessBoxRuntimeClient(logger, runtime), - ) - await service.initialize() - - # Two distinct callers that would otherwise get separate sandboxes. - q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1') - q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice') - - r1 = await service.execute_tool({'command': 'pwd'}, q1) - r2 = await service.execute_tool({'command': 'pwd'}, q2) - - assert r1['session_id'] == 'global' - assert r2['session_id'] == 'global' - # Only one sandbox was ever started — the shared global one. - assert backend.start_calls == ['global'] - - -def test_box_service_forced_template_ignores_pipeline_config(): - """The forced template wins even when the pipeline explicitly sets a - per-user scope — proving the override is not bypassable via pipeline config.""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='person', - launcher_id='test_user', - sender_id='test_user', - pipeline_config={ - 'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}} - }, - ) - - assert service.resolve_box_session_id(query) == 'global' - - -def test_box_service_empty_forced_template_respects_pipeline_config(): - """An empty/whitespace forced template is a no-op: the pipeline's own - scope template is honoured (default non-SaaS behaviour).""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template=' '), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='group', - launcher_id='room-1', - pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}}, - ) - - assert service.resolve_box_session_id(query) == 'group_room-1' + assert backend.start_calls == [] @pytest.mark.asyncio @@ -506,11 +537,13 @@ async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_pat service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - result = await service.execute_tool({'command': 'pwd'}, make_query(15)) + query = make_query(15) + expected_session_id = service.resolve_box_session_id(query) + result = await service.execute_tool({'command': 'pwd'}, query) assert result['ok'] is True - assert backend.start_calls == ['person_test_user'] - assert backend.exec_calls == [('person_test_user', 'pwd')] + assert backend.start_calls == [expected_session_id] + assert backend.exec_calls == [(expected_session_id, 'pwd')] assert backend.start_specs[0].host_path == os.path.realpath(host_dir) @@ -573,41 +606,6 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): ) -class TestGetSystemGuidance: - """``get_system_guidance`` must ALWAYS advertise the per-query outbox path - when given a ``query_id`` — even with no inbound attachment — so files the - agent generates (QR codes, charts, rendered docs) are actually delivered. - - The wrapper collects the outbox on every turn regardless of inbound files; - before this, the agent was only told the outbox path inside the - inbound-attachment note, so pure-generation turns produced files that were - silently dropped. - """ - - def _service(self, logger=None): - logger = logger or Mock() - runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) - return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) - - def test_guidance_includes_outbox_when_query_id_given(self): - service = self._service() - guidance = service.get_system_guidance(42) - assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance - assert 'delivered to the user automatically' in guidance - - def test_guidance_omits_outbox_without_query_id(self): - service = self._service() - guidance = service.get_system_guidance() - assert service.OUTBOX_MOUNT_DIR not in guidance - # core exec guidance is still present - assert 'exec tool' in guidance - - def test_guidance_outbox_independent_of_inbound_attachments(self): - # A bare query_id (the pure-generation case) still gets the outbox note. - service = self._service() - assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0) - - @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): logger = Mock() @@ -980,11 +978,13 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac await service.initialize() + query = make_query(45) + expected_session_id = service.resolve_box_session_id(query) with pytest.raises(BoxValidationError, match='workspace quota exceeded after execution'): - await service.execute_tool({'command': 'generate-output'}, make_query(45)) + await service.execute_tool({'command': 'generate-output'}, query) - assert backend.start_calls == ['person_test_user'] - assert backend.stop_calls == ['person_test_user'] + assert backend.start_calls == [expected_session_id] + assert backend.stop_calls == [expected_session_id] @pytest.mark.asyncio diff --git a/tests/unit_tests/command/test_operator.py b/tests/unit_tests/command/test_operator.py index a1d345292..0ac137445 100644 --- a/tests/unit_tests/command/test_operator.py +++ b/tests/unit_tests/command/test_operator.py @@ -198,7 +198,7 @@ async def execute(self, context): # Should not raise import asyncio - asyncio.get_event_loop().run_until_complete(op.initialize()) + asyncio.run(op.initialize()) def test_execute_is_abstract(self): """execute() must be implemented by subclass.""" diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index c57baaf4b..13fa156d7 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -28,7 +28,7 @@ def test_check_deps_all_present(self): import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) assert result == [] @@ -48,7 +48,7 @@ def mock_find_spec(name): import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) assert 'requests' in result assert 'openai' in result @@ -64,7 +64,7 @@ def test_check_deps_all_missing(self): import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) # Should include all required_deps keys assert len(result) == len(required_deps) @@ -111,7 +111,7 @@ def test_precheck_plugin_deps_no_plugins_dir(self): with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio - asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) + asyncio.run(precheck_plugin_deps()) mock_install.assert_not_called() @@ -134,6 +134,6 @@ def mock_listdir(path): with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio - asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) + asyncio.run(precheck_plugin_deps()) mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[]) diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index ce8ee7eb0..047530f87 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -27,6 +27,9 @@ from langbot.pkg.pipeline import entities as pipeline_entities +DEFAULT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' + + class MockApplication: """Mock Application object providing all basic dependencies needed by stages""" @@ -202,8 +205,13 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter): bot_uuid='test-bot-uuid', pipeline_config={ 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': [{'role': 'system', 'content': 'test-prompt'}], + }, + }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'trigger': {'misc': {'combine-quote-message': False}}, @@ -227,8 +235,13 @@ def sample_pipeline_config(): """Provides sample pipeline configuration""" return { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': 'test-model-uuid', 'fallbacks': []}, + 'prompt': [{'role': 'system', 'content': 'test-prompt'}], + }, + }, }, 'output': {'misc': {'at-sender': False, 'quote-origin': False}}, 'trigger': {'misc': {'combine-quote-message': False}}, diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py index c8a923d78..65ee0d11c 100644 --- a/tests/unit_tests/pipeline/test_chat_handler.py +++ b/tests/unit_tests/pipeline/test_chat_handler.py @@ -30,23 +30,9 @@ def mock_circular_import_chain(): make_pipeline_handler_import_mocks, get_handler_modules_to_clear, ) - from langbot_plugin.api.entities.builtin.provider.message import Message mocks = make_pipeline_handler_import_mocks() - # Create a default runner that yields a simple response - class DefaultRunner: - name = 'local-agent' - - def __init__(self, app, config): - self.app = app - self.config = config - - async def run(self, query): - yield Message(role='assistant', content='fake response') - - mocks['langbot.pkg.provider.runner'].preregistered_runners = [DefaultRunner] - clear = get_handler_modules_to_clear('chat') with isolated_sys_modules(mocks=mocks, clear=clear): @@ -56,7 +42,30 @@ async def run(self, query): @pytest.fixture def fake_app(): """Create FakeApp instance.""" - return FakeApp() + from langbot_plugin.api.entities.builtin.provider.message import Message + + app = FakeApp() + + class FakeAgentRunOrchestrator: + runner_class = None + + async def try_claim_steering_from_query(self, query): + return False + + async def run_from_query(self, query): + if self.runner_class is None: + yield Message(role='assistant', content='fake response') + return + + runner = self.runner_class(app, {}) + async for result in runner.run(query): + yield result + + def resolve_runner_id_for_telemetry(self, query): + return 'plugin:langbot-team/LocalAgent/default' + + app.agent_run_orchestrator = FakeAgentRunOrchestrator() + return app @pytest.fixture @@ -71,13 +80,11 @@ def mock_event_ctx(): @pytest.fixture -def set_runner(): - """Factory fixture to set a custom runner for tests.""" +def set_runner(fake_app): + """Configure the orchestrator test double for one test.""" def _set_runner(runner_class): - import sys - - sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class] + fake_app.agent_run_orchestrator.runner_class = runner_class return _set_runner @@ -319,8 +326,13 @@ async def test_runner_exception_yields_interrupt(self, fake_app, mock_event_ctx, query.pipeline_config = { 'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}}, 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'prompt': 'default', + 'model': {'primary': 'test'}, + }, + }, }, } @@ -367,8 +379,13 @@ async def test_exception_show_error_mode(self, fake_app, mock_event_ctx, set_run query.pipeline_config = { 'output': {'misc': {'exception-handling': 'show-error'}}, 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'prompt': 'default', + 'model': {'primary': 'test'}, + }, + }, }, } @@ -412,8 +429,13 @@ async def test_exception_hide_mode(self, fake_app, mock_event_ctx, set_runner): query.pipeline_config = { 'output': {'misc': {'exception-handling': 'hide'}}, 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}, + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'prompt': 'default', + 'model': {'primary': 'test'}, + }, + }, }, } diff --git a/tests/unit_tests/pipeline/test_chat_session_limit.py b/tests/unit_tests/pipeline/test_chat_session_limit.py index ef351b29f..8705a9787 100644 --- a/tests/unit_tests/pipeline/test_chat_session_limit.py +++ b/tests/unit_tests/pipeline/test_chat_session_limit.py @@ -50,8 +50,13 @@ async def _run_preprocessor(mock_app, sample_query, conversation): sample_query.pipeline_config = { 'ai': { - 'runner': {'runner': 'local-agent', 'expire-time': 60}, - 'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []}, + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default', 'expire-time': 60}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': '', 'fallbacks': []}, + 'prompt': [], + }, + }, }, 'trigger': {'misc': {'combine-quote-message': False}}, 'output': {'misc': {'exception-handling': 'show-hint'}}, diff --git a/tests/unit_tests/pipeline/test_controller.py b/tests/unit_tests/pipeline/test_controller.py new file mode 100644 index 000000000..8c5fc6d85 --- /dev/null +++ b/tests/unit_tests/pipeline/test_controller.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from langbot.pkg.agent.runner.errors import RunnerNotFoundError +from langbot.pkg.pipeline.controller import Controller + + +def make_app(): + app = SimpleNamespace() + app.instance_config = SimpleNamespace(data={'concurrency': {'pipeline': 10}}) + app.logger = MagicMock() + app.pipeline_mgr = SimpleNamespace() + app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock() + app.sess_mgr = SimpleNamespace() + app.sess_mgr.get_session = AsyncMock(return_value=SimpleNamespace()) + app.agent_run_orchestrator = SimpleNamespace() + app.agent_run_orchestrator.try_claim_steering_from_query = AsyncMock() + return app + + +def make_pipeline(): + return SimpleNamespace( + pipeline_entity=SimpleNamespace(config={'ai': {'runner': {'id': 'plugin:test/runner/default'}}}), + bound_plugins=['test/runner'], + bound_mcp_servers=[], + ) + + +@pytest.mark.asyncio +async def test_try_claim_steering_returns_false_when_runner_lookup_fails(): + app = make_app() + app.pipeline_mgr.get_pipeline_by_uuid.return_value = make_pipeline() + app.agent_run_orchestrator.try_claim_steering_from_query.side_effect = RunnerNotFoundError( + 'plugin:missing/runner/default' + ) + controller = Controller(app) + query = SimpleNamespace(query_id=1, pipeline_uuid='pipeline-001', variables={}) + + claimed = await controller._try_claim_steering_before_session_slot(query) + + assert claimed is False + app.logger.warning.assert_called_once() + + +@pytest.mark.asyncio +async def test_try_claim_steering_sets_pipeline_context_before_claiming(): + app = make_app() + pipeline = make_pipeline() + app.pipeline_mgr.get_pipeline_by_uuid.return_value = pipeline + app.agent_run_orchestrator.try_claim_steering_from_query.return_value = True + controller = Controller(app) + query = SimpleNamespace(query_id=2, pipeline_uuid='pipeline-002', variables={}) + + claimed = await controller._try_claim_steering_before_session_slot(query) + + assert claimed is True + assert query.pipeline_config is pipeline.pipeline_entity.config + assert query.variables['_pipeline_bound_plugins'] == ['test/runner'] + app.agent_run_orchestrator.try_claim_steering_from_query.assert_awaited_once_with(query) diff --git a/tests/unit_tests/pipeline/test_msgtrun.py b/tests/unit_tests/pipeline/test_msgtrun.py deleted file mode 100644 index 4470c6945..000000000 --- a/tests/unit_tests/pipeline/test_msgtrun.py +++ /dev/null @@ -1,321 +0,0 @@ -""" -Unit tests for ConversationMessageTruncator (msgtrun) pipeline stage. - -Tests cover: -- Normal truncation behavior based on max-round -- Boundary length handling -- Empty message handling -- Multi-message chain truncation -""" - -from __future__ import annotations - -import pytest -from importlib import import_module - -from tests.factories import ( - FakeApp, - text_query, -) - -import langbot_plugin.api.entities.builtin.provider.message as provider_message - - -def get_msgtrun_module(): - """Lazy import to avoid circular import issues.""" - # Import pipelinemgr first to trigger stage registration - import_module('langbot.pkg.pipeline.pipelinemgr') - return import_module('langbot.pkg.pipeline.msgtrun.msgtrun') - - -def get_truncator_module(): - """Lazy import for truncator base.""" - return import_module('langbot.pkg.pipeline.msgtrun.truncator') - - -def get_entities_module(): - """Lazy import for pipeline entities.""" - return import_module('langbot.pkg.pipeline.entities') - - -def get_round_truncator_module(): - """Lazy import for round truncator.""" - return import_module('langbot.pkg.pipeline.msgtrun.truncators.round') - - -def make_truncate_config(max_round: int = 5): - """Create a pipeline config with max-round setting.""" - return { - 'ai': { - 'local-agent': { - 'max-round': max_round, - } - } - } - - -class TestConversationMessageTruncatorInit: - """Tests for ConversationMessageTruncator initialization.""" - - @pytest.mark.asyncio - async def test_initialize_round_truncator(self): - """Initialize should select 'round' truncator by default.""" - msgtrun = get_msgtrun_module() - truncator = get_truncator_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - assert stage.trun is not None - assert isinstance(stage.trun, truncator.Truncator) - - @pytest.mark.asyncio - async def test_initialize_unknown_truncator_raises(self): - """Initialize with unknown truncator method should raise ValueError.""" - msgtrun = get_msgtrun_module() - truncator = get_truncator_module() - - # Save original preregistered_truncators - original_truncators = truncator.preregistered_truncators.copy() - - try: - # Clear registered truncators to simulate unknown method - truncator.preregistered_truncators = [] - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - with pytest.raises(ValueError, match='Unknown truncator'): - await stage.initialize(pipeline_config) - finally: - # Restore original truncators - truncator.preregistered_truncators = original_truncators - - -class TestRoundTruncatorProcess: - """Tests for RoundTruncator truncation behavior.""" - - @pytest.mark.asyncio - async def test_truncate_within_limit(self): - """Messages within max-round limit should not be truncated.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=5) - - await stage.initialize(pipeline_config) - - # Create query with 3 messages (within limit) - query = text_query('current message') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='message 1'), - provider_message.Message(role='assistant', content='response 1'), - provider_message.Message(role='user', content='message 2'), - provider_message.Message(role='assistant', content='response 2'), - provider_message.Message(role='user', content='current message'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - # All messages should be preserved - assert len(result.new_query.messages) == 5 - - @pytest.mark.asyncio - async def test_truncate_exceeds_limit(self): - """Messages exceeding max-round should be truncated precisely. - - Algorithm: traverse backwards, collect while current_round < max_round, count user messages as rounds. - For max_round=2 with 7 messages (u1, a1, u2, a2, u3, a3, u_current): - - Iterate: u_current(r=0<2, collect, r=1), a3(r=1<2, collect), u3(r=1<2, collect, r=2) - - a2: r=2 not < 2 → break - - Collected reverse: [u_current, a3, u3] - - Reversed: [u3, a3, u_current] = 3 messages - """ - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=2) # Only keep 2 rounds - - await stage.initialize(pipeline_config) - - # Create query with many messages exceeding limit - # 7 messages = 3 full rounds + 1 current user - query = text_query('current message') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='message 1'), - provider_message.Message(role='assistant', content='response 1'), - provider_message.Message(role='user', content='message 2'), - provider_message.Message(role='assistant', content='response 2'), - provider_message.Message(role='user', content='message 3'), - provider_message.Message(role='assistant', content='response 3'), - provider_message.Message(role='user', content='current message'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - # Should keep exactly 3 messages: message3, response3, current message - messages = result.new_query.messages - assert len(messages) == 3 - - # Verify exact message content - assert messages[0].role == 'user' - assert messages[0].content == 'message 3' - assert messages[1].role == 'assistant' - assert messages[1].content == 'response 3' - assert messages[2].role == 'user' - assert messages[2].content == 'current message' - - @pytest.mark.asyncio - async def test_truncate_empty_messages(self): - """Empty messages list should return empty list.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - query = text_query('hello') - query.pipeline_config = pipeline_config - query.messages = [] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - assert len(result.new_query.messages) == 0 - - @pytest.mark.asyncio - async def test_truncate_single_message(self): - """Single message should be preserved.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config() - - await stage.initialize(pipeline_config) - - query = text_query('hello') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='hello'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - assert len(result.new_query.messages) == 1 - - @pytest.mark.asyncio - async def test_truncate_preserves_order(self): - """Truncation should preserve message order.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=2) - - await stage.initialize(pipeline_config) - - query = text_query('current') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='user1'), - provider_message.Message(role='assistant', content='asst1'), - provider_message.Message(role='user', content='user2'), - provider_message.Message(role='assistant', content='asst2'), - provider_message.Message(role='user', content='user3'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - - messages = result.new_query.messages - assert [(msg.role, msg.content) for msg in messages] == [ - ('user', 'user2'), - ('assistant', 'asst2'), - ('user', 'user3'), - ] - - @pytest.mark.asyncio - async def test_truncate_max_round_one(self): - """max-round=1 should only keep last user message.""" - msgtrun = get_msgtrun_module() - entities = get_entities_module() - - app = FakeApp() - stage = msgtrun.ConversationMessageTruncator(app) - - pipeline_config = make_truncate_config(max_round=1) - - await stage.initialize(pipeline_config) - - query = text_query('current') - query.pipeline_config = pipeline_config - query.messages = [ - provider_message.Message(role='user', content='old1'), - provider_message.Message(role='assistant', content='old1_resp'), - provider_message.Message(role='user', content='current'), - ] - - result = await stage.process(query, 'ConversationMessageTruncator') - - assert result.result_type == entities.ResultType.CONTINUE - messages = result.new_query.messages - assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')] - - -class TestRoundTruncatorDirect: - """Direct tests for RoundTruncator class.""" - - @pytest.mark.asyncio - async def test_round_truncator_direct_process(self): - """Test RoundTruncator truncate method directly.""" - truncator_mod = get_truncator_module() - - app = FakeApp() - - # Get the RoundTruncator class from preregistered - for trun_cls in truncator_mod.preregistered_truncators: - if trun_cls.name == 'round': - trun = trun_cls(app) - break - - query = text_query('hello') - query.pipeline_config = make_truncate_config(max_round=3) - query.messages = [ - provider_message.Message(role='user', content='m1'), - provider_message.Message(role='assistant', content='r1'), - provider_message.Message(role='user', content='m2'), - provider_message.Message(role='assistant', content='r2'), - provider_message.Message(role='user', content='hello'), - ] - - result = await trun.truncate(query) - - assert result is not None - assert hasattr(result, 'messages') diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py deleted file mode 100644 index 787472375..000000000 --- a/tests/unit_tests/pipeline/test_n8nsvapi.py +++ /dev/null @@ -1,353 +0,0 @@ -""" -Unit tests for N8nServiceAPIRunner._process_response - -Tests cover four scenarios: -- Stream adapter + n8n stream format (type:item/end) -- Stream adapter + n8n plain JSON -- Non-stream adapter + n8n stream format -- Non-stream adapter + n8n plain JSON -""" - -from __future__ import annotations - -import json -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import langbot_plugin.api.entities.builtin.provider.message as provider_message - -# Break the circular import chain while importing n8nsvapi: -# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init) -# The stubs are restored in a ``finally`` block so this module does NOT pollute -# sys.modules for other test modules (e.g. ones importing the real -# LocalAgentRunner, which would otherwise inherit ``object`` and break). -# Mirrors master's intent but uses try/finally so a raised import doesn't -# leave the global namespace in a stubbed state, and includes -# ``langbot.pkg.utils.httpclient`` which master didn't stub. -_runner_stub = MagicMock() -_runner_stub.runner_class = lambda name: (lambda cls: cls) # no-op decorator -_runner_stub.RequestRunner = object -_import_stubs = { - 'langbot.pkg.provider.runner': _runner_stub, - 'langbot.pkg.core.app': MagicMock(), - 'langbot.pkg.utils.httpclient': MagicMock(), -} -_saved_modules = {name: sys.modules.get(name) for name in _import_stubs} -for _name, _stub in _import_stubs.items(): - sys.modules[_name] = _stub -try: - from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner -finally: - for _name, _original in _saved_modules.items(): - if _original is None: - sys.modules.pop(_name, None) - else: - sys.modules[_name] = _original - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: - ap = Mock() - ap.logger = Mock() - pipeline_config = { - 'ai': { - 'n8n-service-api': { - 'webhook-url': 'http://test-n8n/webhook', - 'output-key': output_key, - 'auth-type': 'none', - } - } - } - return N8nServiceAPIRunner(ap, pipeline_config) - - -def make_mock_response(chunks: list[bytes | str], status: int = 200): - """Build a minimal aiohttp.ClientResponse mock with iter_chunked support.""" - response = Mock() - response.status = status - - async def iter_chunked(size): - for chunk in chunks: - yield chunk - - response.content = Mock() - response.content.iter_chunked = iter_chunked - return response - - -async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]): - """Run _process_response and collect all yielded MessageChunks.""" - response = make_mock_response(chunks) - result = [] - async for chunk in runner._process_response(response): - result.append(chunk) - return result - - -# --------------------------------------------------------------------------- -# _process_response: stream format (type:item/end) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_stream_format_single_item(): - """Single item + end in one chunk yields final chunk with full content.""" - runner = make_runner() - data = b'{"type":"item","content":"hello"}{"type":"end"}' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'hello' - assert chunks[0].msg_sequence == 1 - - -@pytest.mark.asyncio -async def test_stream_format_multi_item_accumulates(): - """Multiple items accumulate into full_content.""" - runner = make_runner() - chunks_data = [ - b'{"type":"item","content":"foo"}', - b'{"type":"item","content":"bar"}', - b'{"type":"end"}', - ] - - chunks = await collect_chunks(runner, chunks_data) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'foobar' - assert chunks[0].msg_sequence == 1 - - -@pytest.mark.asyncio -async def test_stream_format_batches_every_8_items(): - """Every 8th item triggers an intermediate yield before the final.""" - runner = make_runner() - items = [f'{{"type":"item","content":"{i}"}}' for i in range(8)] - items.append('{"type":"end"}') - data = ''.join(items).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 2 - assert chunks[0].is_final is False - assert chunks[0].content == '01234567' - assert chunks[0].msg_sequence == 1 - assert chunks[1].is_final is True - assert chunks[1].content == '01234567' - assert chunks[1].msg_sequence == 2 - - -@pytest.mark.asyncio -async def test_stream_format_split_across_network_chunks(): - """JSON split across multiple network chunks is reassembled correctly.""" - runner = make_runner() - part1 = b'{"type":"item","con' - part2 = b'tent":"world"}{"type":"end"}' - - chunks = await collect_chunks(runner, [part1, part2]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'world' - - -@pytest.mark.asyncio -async def test_stream_format_no_spurious_empty_yield(): - """chunk_idx==0 guard prevents spurious empty yield before any item is received.""" - runner = make_runner() - # Send some non-stream JSON first, then stream - data = b'{"type":"item","content":"x"}{"type":"end"}' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].content == 'x' - - -# --------------------------------------------------------------------------- -# _process_response: plain JSON fallback -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_plain_json_with_output_key(): - """Plain JSON with matching output_key extracts value via output_key.""" - runner = make_runner(output_key='response') - data = json.dumps({'response': 'hello world'}).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'hello world' - - -@pytest.mark.asyncio -async def test_plain_json_output_key_not_found(): - """Plain JSON without output_key falls back to entire JSON string.""" - runner = make_runner(output_key='response') - payload = {'other_key': 'hello'} - data = json.dumps(payload).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert json.loads(chunks[0].content) == payload - - -@pytest.mark.asyncio -async def test_plain_json_output_key_empty_string(): - """output_key present but value is empty string — returns empty string, not whole JSON.""" - runner = make_runner(output_key='response') - data = json.dumps({'response': ''}).encode() - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == '' - - -@pytest.mark.asyncio -async def test_plain_json_non_dict_response(): - """Plain JSON array falls back to raw text.""" - runner = make_runner() - data = b'["a", "b"]' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == '["a", "b"]' - - -@pytest.mark.asyncio -async def test_invalid_json_returns_raw_text(): - """Non-JSON response returns raw text as-is.""" - runner = make_runner() - data = b'plain text response' - - chunks = await collect_chunks(runner, [data]) - - assert len(chunks) == 1 - assert chunks[0].is_final is True - assert chunks[0].content == 'plain text response' - - -# --------------------------------------------------------------------------- -# _call_webhook: output type depends on is_stream -# --------------------------------------------------------------------------- - - -def make_query(is_stream: bool): - """Build a minimal Query mock.""" - query = Mock() - query.adapter = AsyncMock() - query.adapter.is_stream_output_supported = AsyncMock(return_value=is_stream) - - session = Mock() - session.using_conversation = Mock() - session.using_conversation.uuid = 'test-uuid' - session.launcher_type = Mock() - session.launcher_type.value = 'person' - session.launcher_id = '12345' - query.session = session - - query.user_message = Mock() - query.user_message.content = 'hi' - query.variables = {} - return query - - -def make_http_session_mock(response_bytes: bytes, status: int = 200): - """Mock httpclient.get_session() returning a session whose post() yields response_bytes.""" - mock_response = make_mock_response([response_bytes], status=status) - mock_response.status = status - - mock_cm = AsyncMock() - mock_cm.__aenter__ = AsyncMock(return_value=mock_response) - mock_cm.__aexit__ = AsyncMock(return_value=False) - - mock_session = Mock() - mock_session.post = Mock(return_value=mock_cm) - return mock_session - - -@pytest.mark.asyncio -async def test_call_webhook_nonstream_adapter_plain_json(): - """Non-stream adapter + plain JSON → single Message with output_key value.""" - runner = make_runner(output_key='response') - query = make_query(is_stream=False) - http_session = make_http_session_mock(json.dumps({'response': 'result text'}).encode()) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert len(results) == 1 - assert isinstance(results[0], provider_message.Message) - assert results[0].content == 'result text' - - -@pytest.mark.asyncio -async def test_call_webhook_stream_adapter_stream_format(): - """Stream adapter + stream format → MessageChunks, last is_final.""" - runner = make_runner() - query = make_query(is_stream=True) - data = b'{"type":"item","content":"hi"}{"type":"end"}' - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert all(isinstance(r, provider_message.MessageChunk) for r in results) - assert results[-1].is_final is True - assert results[-1].content == 'hi' - - -@pytest.mark.asyncio -async def test_call_webhook_stream_adapter_plain_json(): - """Stream adapter + plain JSON → single MessageChunk with is_final=True.""" - runner = make_runner(output_key='response') - query = make_query(is_stream=True) - data = json.dumps({'response': 'fallback'}).encode() - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert all(isinstance(r, provider_message.MessageChunk) for r in results) - assert results[-1].is_final is True - assert results[-1].content == 'fallback' - - -@pytest.mark.asyncio -async def test_call_webhook_nonstream_adapter_stream_format(): - """Non-stream adapter + stream format → single Message with accumulated content.""" - runner = make_runner() - query = make_query(is_stream=False) - data = b'{"type":"item","content":"foo"}{"type":"item","content":"bar"}{"type":"end"}' - http_session = make_http_session_mock(data) - - with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session): - results = [] - async for msg in runner._call_webhook(query): - results.append(msg) - - assert len(results) == 1 - assert isinstance(results[0], provider_message.Message) - assert results[0].content == 'foobar' diff --git a/tests/unit_tests/pipeline/test_pipeline_service.py b/tests/unit_tests/pipeline/test_pipeline_service.py index b862c3ff4..b73cdb2ac 100644 --- a/tests/unit_tests/pipeline/test_pipeline_service.py +++ b/tests/unit_tests/pipeline/test_pipeline_service.py @@ -11,10 +11,7 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m loaded_pipeline = Mock() service.get_pipeline = AsyncMock(return_value=loaded_pipeline) - bot = Mock(uuid='bot-uuid') - bot_result = Mock(all=Mock(return_value=[bot])) - mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=[None, bot_result]) - mock_app.bot_service = Mock(update_bot=AsyncMock()) + mock_app.persistence_mgr.execute_async = AsyncMock(return_value=None) mock_app.pipeline_mgr = Mock(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()) mock_app.sess_mgr.session_list = [] @@ -35,9 +32,5 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m updated_fields = {getattr(field, 'key', str(field)) for field in update_stmt._values} assert updated_fields == {'name'} - mock_app.bot_service.update_bot.assert_awaited_once_with( - 'bot-uuid', - {'use_pipeline_name': 'Updated pipeline'}, - ) mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('pipeline-uuid') mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with(loaded_pipeline) diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index 49984542c..3ee9a2477 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -164,18 +164,21 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): mock_stage.process.assert_called_once() -def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app): - """Local Agent resource selection should override legacy extension prefs.""" +def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app): + """Runner resource selection should override extension preferences.""" pipelinemgr = get_pipelinemgr_module() persistence_pipeline = get_persistence_pipeline_module() pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) pipeline_entity.config = { 'ai': { - 'local-agent': { - 'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}], - 'mcp-resource-agent-read-enabled': False, - } + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}], + 'mcp-resource-agent-read-enabled': False, + }, + }, } } pipeline_entity.extensions_preferences = { @@ -190,12 +193,17 @@ def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app): def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app): - """Existing extension prefs remain compatible until a Local Agent value exists.""" + """Extension preferences apply when the current runner has no override.""" pipelinemgr = get_pipelinemgr_module() persistence_pipeline = get_persistence_pipeline_module() pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) - pipeline_entity.config = {'ai': {'local-agent': {}}} + pipeline_entity.config = { + 'ai': { + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': {'plugin:langbot-team/LocalAgent/default': {}}, + } + } pipeline_entity.extensions_preferences = { 'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}], 'mcp_resource_agent_read_enabled': False, @@ -205,3 +213,95 @@ def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app): assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}] assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_runtime_pipeline_mcp_resource_read_flag_fails_closed(mock_app, invalid_value): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = { + 'ai': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'mcp-resource-agent-read-enabled': invalid_value, + } + }, + } + } + pipeline_entity.extensions_preferences = {'mcp_resource_agent_read_enabled': True} + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_runtime_pipeline_extension_enable_all_flags_fail_closed(mock_app, invalid_value): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = { + 'enable_all_plugins': invalid_value, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': invalid_value, + 'mcp_servers': ['bound-mcp'], + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == ['allowed/plugin'] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == ['bound-mcp'] + + +@pytest.mark.parametrize('invalid_preferences', [None, [], '', 0, False]) +def test_runtime_pipeline_malformed_extension_root_disables_all_extensions( + mock_app, + invalid_preferences, +): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = invalid_preferences + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == [] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == [] + assert runtime_pipeline.mcp_resource_attachments == [] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +def test_runtime_pipeline_malformed_extension_lists_are_empty_allowlists(mock_app): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = { + 'enable_all_plugins': True, + 'plugins': 'allowed/plugin', + 'enable_all_mcp_servers': True, + 'mcp_servers': 'bound-mcp', + 'mcp_resources': 'file:///README.md', + 'mcp_resource_agent_read_enabled': True, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == [] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == [] + assert runtime_pipeline.mcp_resource_attachments == [] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 15bf60801..accb5f44e 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -14,7 +14,6 @@ import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module -from types import SimpleNamespace from tests.factories import ( FakeApp, @@ -35,6 +34,50 @@ def get_entities_module(): return import_module('langbot.pkg.pipeline.entities') +RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' + + +def attach_agent_runner_descriptor(app, *, multimodal_input=True, tool_calling=True): + """Attach a schema-backed AgentRunner descriptor to a FakeApp.""" + from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor + + descriptor = AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + ], + capabilities={ + 'tool_calling': tool_calling, + 'multimodal_input': multimodal_input, + }, + ) + app.agent_runner_registry = Mock() + app.agent_runner_registry.get = AsyncMock(return_value=descriptor) + return descriptor + + +def agent_runner_pipeline_config(model_config, *, prompt='default'): + return { + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': { + RUNNER_ID: { + 'model': model_config, + 'prompt': prompt, + }, + }, + }, + 'output': {'misc': {'at-sender': False}}, + 'trigger': {'misc': {}}, + } + + class TestPreProcessorNormalText: """Tests for normal text message preprocessing.""" @@ -69,7 +112,7 @@ async def test_normal_text_continues(self): app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) # Mock tool manager - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) # Mock plugin connector mock_event_ctx = Mock() @@ -107,7 +150,7 @@ async def test_normal_text_sets_user_message(self): mock_model = Mock() mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -145,7 +188,7 @@ async def test_empty_message_continues(self): app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -187,7 +230,7 @@ async def test_image_with_vision_model(self): mock_model = Mock() mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -235,7 +278,7 @@ async def test_image_without_vision_model(self): mock_model = Mock() mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -273,7 +316,8 @@ async def test_primary_model_selected(self): mock_model = Mock() mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + attach_agent_runner_descriptor(app) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -283,17 +327,9 @@ async def test_primary_model_selected(self): query = text_query('hello') # Set pipeline config with primary model - query.pipeline_config = { - 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'primary-model-uuid', 'fallbacks': []}, - 'prompt': 'default', - }, - }, - 'output': {'misc': {'at-sender': False}}, - 'trigger': {'misc': {}}, - } + query.pipeline_config = agent_runner_pipeline_config( + {'primary': 'primary-model-uuid', 'fallbacks': []}, + ) result = await stage.process(query, 'PreProcessor') @@ -332,7 +368,8 @@ async def mock_get_model(uuid): raise ValueError(f'Model {uuid} not found') app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + attach_agent_runner_descriptor(app) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -341,17 +378,9 @@ async def mock_get_model(uuid): stage = preproc.PreProcessor(app) query = text_query('hello') - query.pipeline_config = { - 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']}, - 'prompt': 'default', - }, - }, - 'output': {'misc': {'at-sender': False}}, - 'trigger': {'misc': {}}, - } + query.pipeline_config = agent_runner_pipeline_config( + {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']}, + ) result = await stage.process(query, 'PreProcessor') @@ -381,7 +410,7 @@ async def test_variables_set_from_query(self): app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -418,7 +447,7 @@ async def test_group_variables_include_group_name(self): app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -433,12 +462,62 @@ async def test_group_variables_include_group_name(self): assert 'group_name' in variables assert 'sender_name' in variables + @pytest.mark.asyncio + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + @pytest.mark.parametrize( + ('configured_skills', 'expected_skills'), + [ + (['bound-skill'], ['bound-skill']), + (None, []), + ('bound-skill', []), + ], + ) + async def test_malformed_enable_all_skills_flag_uses_bound_skills( + self, + invalid_value, + configured_skills, + expected_skills, + ): + preproc = get_preproc_module() + + app = FakeApp() + mock_session = Mock() + mock_session.launcher_type = Mock(value='person') + mock_session.launcher_id = 12345 + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_skills': invalid_value, + 'skills': configured_skills, + } + } + ) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + result = await preproc.PreProcessor(app).process(text_query('hello'), 'PreProcessor') + + assert result.new_query.variables['_pipeline_bound_skills'] == expected_skills + class TestPreProcessorToolSelection: - """Tests for Local Agent tool selection.""" + """Tests for generic AgentRunner tool selection.""" @pytest.mark.asyncio - async def test_local_agent_filters_selected_tools(self): + async def test_agent_runner_filters_selected_tools(self): """Only selected tools should be exposed when all-tools mode is off.""" preproc = get_preproc_module() @@ -458,34 +537,98 @@ async def test_local_agent_filters_selected_tools(self): mock_model = Mock() mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock( + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( return_value=[ - SimpleNamespace(name='exec'), - SimpleNamespace(name='plugin_tool'), - SimpleNamespace(name='mcp_tool'), + { + 'name': 'exec', + 'source': 'builtin', + 'description': 'Execute', + 'parameters': {}, + }, + { + 'name': 'plugin_tool', + 'source': 'plugin', + 'source_id': 'test/plugin', + 'description': 'Plugin tool', + 'parameters': {}, + }, + { + 'name': 'mcp_tool', + 'source': 'mcp', + 'source_id': 'mcp-server', + 'description': 'MCP tool', + 'parameters': {}, + }, ] ) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + attach_agent_runner_descriptor(app) stage = preproc.PreProcessor(app) query = text_query('hello') - query.pipeline_config = { - 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'primary-model-uuid', 'fallbacks': []}, - 'prompt': 'default', - 'enable-all-tools': False, - 'tools': ['plugin_tool'], - }, - }, - 'output': {'misc': {'at-sender': False}}, - 'trigger': {'misc': {}}, - } + query.pipeline_config = agent_runner_pipeline_config( + {'primary': 'primary-model-uuid', 'fallbacks': []}, + ) + query.pipeline_config['ai']['runner_config'][RUNNER_ID].update( + { + 'enable-all-tools': False, + 'tools': ['plugin_tool'], + } + ) result = await stage.process(query, 'PreProcessor') assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] + assert result.new_query.variables['_host_tool_source_refs'] == { + 'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'}, + } + + +class TestPreProcessorMCPResourceContext: + """Tests for deferring MCP context until the run-scoped execution input.""" + + @pytest.mark.asyncio + async def test_pinned_context_does_not_mutate_preprocessed_input(self): + preproc = get_preproc_module() + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + app = FakeApp() + mock_session = Mock() + mock_session.launcher_type = Mock(value='person') + mock_session.launcher_id = 12345 + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = 'conversation-1' + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + mock_model = Mock() + mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=[]) + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + mcp_loader = Mock() + mcp_loader.build_resource_context_for_query = AsyncMock(return_value='Pinned documentation') + app.tool_mgr.mcp_tool_loader = mcp_loader + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + attach_agent_runner_descriptor(app, tool_calling=False) + + query = text_query('hello') + query.launcher_id = '12345' + query.pipeline_config = agent_runner_pipeline_config( + {'primary': 'primary-model-uuid', 'fallbacks': []}, + ) + + result = await preproc.PreProcessor(app).process(query, 'PreProcessor') + event = QueryEntryAdapter.query_to_event(result.new_query) + + assert event.input.text == 'hello' + assert 'Pinned documentation' not in str(event.input.contents) + mcp_loader.build_resource_context_for_query.assert_not_awaited() diff --git a/tests/unit_tests/pipeline/test_preproc_media_fallback.py b/tests/unit_tests/pipeline/test_preproc_media_fallback.py new file mode 100644 index 000000000..355052329 --- /dev/null +++ b/tests/unit_tests/pipeline/test_preproc_media_fallback.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' + + +def _attach_agent_runner_descriptor(app): + from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor + + descriptor = AgentRunnerDescriptor( + id=RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + ], + capabilities={'tool_calling': True, 'multimodal_input': True}, + ) + app.agent_runner_registry = Mock() + app.agent_runner_registry.get = AsyncMock(return_value=descriptor) + + +def _pipeline_config(model_config): + return { + 'ai': { + 'runner': {'id': RUNNER_ID}, + 'runner_config': {RUNNER_ID: {'model': model_config, 'prompt': []}}, + }, + 'trigger': {'misc': {'combine-quote-message': False}}, + 'output': {'misc': {'exception-handling': 'show-hint'}}, + } + + +def _conversation(): + prompt = Mock() + prompt.messages = [] + prompt.copy = Mock(return_value=Mock(messages=[])) + + return SimpleNamespace( + uuid='conversation-uuid', + create_time=datetime.now(), + update_time=datetime.now(), + prompt=prompt, + messages=[], + ) + + +def _prompt_preprocessing_context(): + ctx = Mock() + ctx.event.default_prompt = [] + ctx.event.prompt = [] + return ctx + + +@pytest.mark.asyncio +async def test_preprocessor_keeps_image_placeholder_for_text_only_local_agent(mock_app, sample_query): + model = Mock() + model.model_entity.uuid = 'text-only-model' + model.model_entity.abilities = [] + + mock_app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model) + _attach_agent_runner_descriptor(mock_app) + mock_app.sess_mgr.get_session = AsyncMock( + return_value=SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id) + ) + mock_app.sess_mgr.get_conversation = AsyncMock(return_value=_conversation()) + mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context()) + + sample_query.pipeline_config = _pipeline_config({'primary': 'text-only-model', 'fallbacks': []}) + sample_query.message_chain = platform_message.MessageChain( + [platform_message.Image(base64='data:image/png;base64,AAAA')] + ) + sample_query.messages = [] + sample_query.variables = {} + + from importlib import import_module + + import_module('langbot.pkg.pipeline.pipelinemgr') + preproc_module = import_module('langbot.pkg.pipeline.preproc.preproc') + result = await preproc_module.PreProcessor(mock_app).process(sample_query, 'PreProcessor') + content = result.new_query.user_message.content + + assert len(content) == 1 + assert content[0].type == 'text' + assert content[0].text == '[Image]' + assert result.new_query.variables['user_message_text'] == '[Image]' diff --git a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py new file mode 100644 index 000000000..5b42aef1d --- /dev/null +++ b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import pathlib +import datetime +from unittest.mock import AsyncMock + +import aiocqhttp +import pytest +import yaml + +from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter +from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter +from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter +from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +def make_adapter() -> AiocqhttpAdapter: + return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': ''}, DummyLogger()) + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'aiocqhttp' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def onebot_event(payload: dict) -> aiocqhttp.Event: + return aiocqhttp.Event.from_payload(payload) + + +def test_aiocqhttp_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_aiocqhttp_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_aiocqhttp_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_aiocqhttp_adapter_dispatches_most_specific_eba_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + async def message_listener(event, adapter): + calls.append('message') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + adapter.register_listener(platform_events.MessageReceivedEvent, message_listener) + + event = platform_events.MessageReceivedEvent( + message_id=1, + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + sender={'id': 1}, + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + + assert calls == ['message'] + + +@pytest.mark.asyncio +async def test_aiocqhttp_message_converter_maps_chain_to_onebot_segments(): + target, source_id, _ = await AiocqhttpMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Source(id=42, time=datetime.datetime.now()), + platform_message.Plain(text='hi '), + platform_message.At(target='10001'), + platform_message.AtAll(), + platform_message.Image(base64='data:image/png;base64,AAAA'), + platform_message.Face(face_id=14, face_name='微笑'), + ] + ) + ) + + assert source_id == 42 + assert [segment.type for segment in target] == ['text', 'at', 'at', 'image', 'face'] + assert target[3].data['file'] == 'base64://AAAA' + + +@pytest.mark.asyncio +async def test_aiocqhttp_message_converter_maps_media_file_quote_and_face_to_onebot_segments(): + target, _, _ = await AiocqhttpMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Quote(id=123, origin=platform_message.MessageChain([])), + platform_message.Image(url='https://example.test/a.png'), + platform_message.Voice(base64='data:audio/silk;base64,BBBB'), + platform_message.File(name='doc.txt', url='https://example.test/doc.txt'), + platform_message.Face(face_type='rps', face_id=1, face_name='猜拳'), + platform_message.Face(face_type='dice', face_id=6, face_name='骰子'), + ] + ) + ) + + assert [segment.type for segment in target] == ['reply', 'image', 'record', 'file', 'rps', 'dice'] + assert target[0].data['id'] == '123' + assert target[1].data['file'] == 'https://example.test/a.png' + assert target[2].data['file'] == 'base64://BBBB' + assert target[3].data['name'] == 'doc.txt' + + +@pytest.mark.asyncio +async def test_aiocqhttp_message_converter_flattens_forward_nodes(): + target, _, _ = await AiocqhttpMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id='10001', + sender_name='Alice', + message_chain=platform_message.MessageChain([platform_message.Plain(text='node 1')]), + ), + platform_message.ForwardMessageNode( + sender_id='10002', + sender_name='Bob', + message_chain=platform_message.MessageChain([platform_message.At(target='999')]), + ), + ] + ) + ] + ) + ) + + assert [segment.type for segment in target] == ['text', 'at'] + assert target[0].data['text'] == 'node 1' + + +@pytest.mark.asyncio +async def test_aiocqhttp_message_converter_maps_onebot_segments_to_chain(): + chain = await AiocqhttpMessageConverter.target2yiri( + [ + {'type': 'text', 'data': {'text': 'hello '}}, + {'type': 'at', 'data': {'qq': 'all'}}, + {'type': 'at', 'data': {'qq': '10001'}}, + {'type': 'image', 'data': {'file': 'abc.image', 'url': 'https://example.test/a.png'}}, + {'type': 'image', 'data': {'emoji_package_id': '14', 'summary': '微笑'}}, + {'type': 'record', 'data': {'file': 'voice.silk', 'url': 'https://example.test/a.silk'}}, + {'type': 'file', 'data': {'file_id': 'file-1', 'name': 'doc.txt', 'size': '5'}}, + {'type': 'reply', 'data': {'id': '99'}}, + {'type': 'face', 'data': {'id': '14', 'raw': {'faceText': '/微笑'}}}, + {'type': 'rps', 'data': {'result': '2'}}, + {'type': 'dice', 'data': {'result': '6'}}, + {'type': 'json', 'data': {'data': '{}'}}, + ], + message_id=123, + timestamp=1710000000, + ) + + assert isinstance(chain[0], platform_message.Source) + assert isinstance(chain[1], platform_message.Plain) + assert isinstance(chain[2], platform_message.AtAll) + assert isinstance(chain[3], platform_message.At) + assert isinstance(chain[4], platform_message.Image) + assert chain[4].url == 'https://example.test/a.png' + assert isinstance(chain[5], platform_message.Face) + assert isinstance(chain[6], platform_message.Voice) + assert isinstance(chain[7], platform_message.File) + assert isinstance(chain[8], platform_message.Quote) + assert isinstance(chain[9], platform_message.Face) + assert isinstance(chain[10], platform_message.Face) + assert chain[10].face_type == 'rps' + assert isinstance(chain[11], platform_message.Face) + assert chain[11].face_type == 'dice' + assert isinstance(chain[12], platform_message.Unknown) + + +@pytest.mark.asyncio +async def test_aiocqhttp_message_converter_fetches_reply_origin_when_bot_available(): + bot = AsyncMock() + bot.get_msg.return_value = { + 'message_id': 99, + 'user_id': 10001, + 'group_id': 20001, + 'time': 1710000000, + 'message': [{'type': 'text', 'data': {'text': 'origin'}}], + } + + chain = await AiocqhttpMessageConverter.target2yiri( + [{'type': 'reply', 'data': {'id': '99'}}], + message_id=123, + bot=bot, + ) + + quote = chain[1] + assert isinstance(quote, platform_message.Quote) + assert quote.sender_id == 10001 + assert quote.group_id == 20001 + assert str(quote.origin) == 'origin' + + +@pytest.mark.asyncio +async def test_aiocqhttp_event_converter_maps_private_and_group_messages(): + private = onebot_event( + { + 'post_type': 'message', + 'message_type': 'private', + 'sub_type': 'friend', + 'time': 1710000000, + 'self_id': 999, + 'message_id': 11, + 'user_id': 10001, + 'message': [{'type': 'text', 'data': {'text': 'hello'}}], + 'raw_message': 'hello', + 'sender': {'user_id': 10001, 'nickname': 'Alice'}, + } + ) + private_event = await AiocqhttpEventConverter.target2yiri(private) + + assert isinstance(private_event, platform_events.MessageReceivedEvent) + assert private_event.type == 'message.received' + assert private_event.adapter_name == 'aiocqhttp' + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 10001 + assert private_event.sender.nickname == 'Alice' + + group = onebot_event( + { + 'post_type': 'message', + 'message_type': 'group', + 'sub_type': 'normal', + 'time': 1710000000, + 'self_id': 999, + 'message_id': 12, + 'group_id': 20001, + 'user_id': 10001, + 'message': [{'type': 'at', 'data': {'qq': '999'}}, {'type': 'text', 'data': {'text': ' ping'}}], + 'raw_message': '[CQ:at,qq=999] ping', + 'sender': {'user_id': 10001, 'nickname': 'Alice', 'card': 'Alice Card', 'role': 'member'}, + } + ) + group_event = await AiocqhttpEventConverter.target2yiri(group) + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 20001 + assert group_event.group.id == 20001 + assert isinstance(group_event.message_chain[1], platform_message.At) + + +def test_aiocqhttp_event_converter_maps_notice_and_request_events(): + deleted = AiocqhttpEventConverter.notice_to_eba( + onebot_event( + { + 'post_type': 'notice', + 'notice_type': 'group_recall', + 'time': 1710000000, + 'self_id': 999, + 'group_id': 20001, + 'user_id': 10001, + 'operator_id': 10002, + 'message_id': 33, + } + ) + ) + assert isinstance(deleted, platform_events.MessageDeletedEvent) + assert deleted.message_id == 33 + + joined = AiocqhttpEventConverter.notice_to_eba( + onebot_event( + { + 'post_type': 'notice', + 'notice_type': 'group_increase', + 'sub_type': 'invite', + 'time': 1710000000, + 'self_id': 999, + 'group_id': 20001, + 'operator_id': 10002, + 'user_id': 10003, + } + ), + bot_user_id=999, + ) + assert isinstance(joined, platform_events.MemberJoinedEvent) + assert joined.join_type == 'invite' + + bot_muted = AiocqhttpEventConverter.notice_to_eba( + onebot_event( + { + 'post_type': 'notice', + 'notice_type': 'group_ban', + 'sub_type': 'ban', + 'time': 1710000000, + 'self_id': 999, + 'group_id': 20001, + 'operator_id': 10002, + 'user_id': 999, + 'duration': 60, + } + ), + bot_user_id=999, + ) + assert isinstance(bot_muted, platform_events.BotMutedEvent) + assert bot_muted.duration == 60 + + friend_request = AiocqhttpEventConverter.request_to_eba( + onebot_event( + { + 'post_type': 'request', + 'request_type': 'friend', + 'time': 1710000000, + 'self_id': 999, + 'user_id': 10004, + 'comment': 'please', + 'flag': 'flag-1', + } + ) + ) + assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent) + assert friend_request.request_id == 'flag-1' + + group_invite = AiocqhttpEventConverter.request_to_eba( + onebot_event( + { + 'post_type': 'request', + 'request_type': 'group', + 'sub_type': 'invite', + 'time': 1710000000, + 'self_id': 999, + 'group_id': 20001, + 'user_id': 10004, + 'flag': 'group-flag', + } + ) + ) + assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent) + assert group_invite.request_id == 'group-flag' + + member_left = AiocqhttpEventConverter.notice_to_eba( + onebot_event( + { + 'post_type': 'notice', + 'notice_type': 'group_decrease', + 'sub_type': 'kick', + 'time': 1710000000, + 'self_id': 999, + 'group_id': 20001, + 'operator_id': 10002, + 'user_id': 10003, + } + ), + bot_user_id=999, + ) + assert isinstance(member_left, platform_events.MemberLeftEvent) + assert member_left.is_kicked is True + + friend_added = AiocqhttpEventConverter.notice_to_eba( + onebot_event( + { + 'post_type': 'notice', + 'notice_type': 'friend_add', + 'time': 1710000000, + 'self_id': 999, + 'user_id': 10003, + } + ) + ) + assert isinstance(friend_added, platform_events.FriendAddedEvent) + + +@pytest.mark.asyncio +async def test_aiocqhttp_send_reply_and_common_api_call_shapes(): + adapter = make_adapter() + bot = AsyncMock() + bot.send_group_msg.return_value = {'message_id': 1} + bot.send_private_msg.return_value = {'message_id': 3} + bot.send.return_value = {'message_id': 2} + bot.delete_msg.return_value = {} + bot.get_msg.return_value = { + 'message_id': 77, + 'message_type': 'group', + 'time': 1710000000, + 'group_id': 20001, + 'sender': {'user_id': 10001, 'nickname': 'Alice'}, + 'message': [{'type': 'text', 'data': {'text': 'fetched'}}], + } + bot.get_group_info.return_value = {'group_id': 20001, 'group_name': 'Group', 'member_count': 3} + bot.get_group_list.return_value = [{'group_id': 20001, 'group_name': 'Group', 'member_count': 3}] + bot.get_group_member_list.return_value = [ + { + 'group_id': 20001, + 'user_id': 10001, + 'nickname': 'Alice', + 'card': 'Alice Card', + 'role': 'admin', + 'join_time': 1710000000, + } + ] + bot.get_group_member_info.return_value = { + 'group_id': 20001, + 'user_id': 10001, + 'nickname': 'Alice', + 'card': 'Alice Card', + 'role': 'admin', + 'join_time': 1710000000, + } + bot.get_stranger_info.return_value = {'user_id': 10001, 'nickname': 'Alice'} + bot.get_friend_list.return_value = [{'user_id': 10001, 'nickname': 'Alice', 'remark': 'A'}] + object.__setattr__(adapter, 'bot', bot) + + result = await adapter.send_message( + 'group', + '20001', + platform_message.MessageChain([platform_message.Plain(text='hello')]), + ) + assert result.message_id == 1 + bot.send_group_msg.assert_awaited_once() + assert bot.send_group_msg.await_args.kwargs['group_id'] == 20001 + + source_event = onebot_event( + { + 'post_type': 'message', + 'message_type': 'group', + 'sub_type': 'normal', + 'time': 1710000000, + 'self_id': 999, + 'message_id': 12, + 'group_id': 20001, + 'user_id': 10001, + 'message': [], + 'raw_message': '', + 'sender': {'user_id': 10001, 'nickname': 'Alice'}, + } + ) + eba_source = await AiocqhttpEventConverter.message_to_eba(source_event) + reply = await adapter.reply_message( + eba_source, + platform_message.MessageChain([platform_message.Plain(text='pong')]), + quote_origin=True, + ) + assert reply.message_id == 2 + assert bot.send.await_args.args[0] is source_event + + await adapter.delete_message('group', 20001, 12) + bot.delete_msg.assert_awaited_once_with(message_id=12) + + group = await adapter.get_group_info(20001) + assert group.name == 'Group' + + groups = await adapter.get_group_list() + assert groups[0].id == 20001 + + members = await adapter.get_group_member_list(20001) + assert members[0].display_name == 'Alice Card' + + member = await adapter.get_group_member_info(20001, 10001) + assert member.role == platform_entities.MemberRole.ADMIN + + user = await adapter.get_user_info(10001) + assert user.nickname == 'Alice' + + friends = await adapter.get_friend_list() + assert friends[0].remark == 'A' + + fetched = await adapter.get_message('group', 20001, 77) + assert fetched.message_id == 77 + assert str(fetched.message_chain) == 'fetched' + + forwarded = await adapter.forward_message('group', 20001, 77, 'private', 10001) + assert forwarded.message_id == 3 + bot.send_private_msg.assert_awaited_once() + + await adapter.set_group_name(20001, 'New Name') + bot.set_group_name.assert_awaited_once_with(group_id=20001, group_name='New Name') + + await adapter.mute_member(20001, 10001, 60) + bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=60) + + await adapter.unmute_member(20001, 10001) + bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=0) + + await adapter.kick_member(20001, 10001) + bot.set_group_kick.assert_awaited_once_with(group_id=20001, user_id=10001, reject_add_request=False) + + await adapter.leave_group(20001) + bot.set_group_leave.assert_awaited_once_with(group_id=20001, is_dismiss=False) + + await adapter.approve_friend_request('flag-1', True, 'Alice') + bot.set_friend_add_request.assert_awaited_once_with(flag='flag-1', approve=True, remark='Alice') + + await adapter.approve_group_invite('flag-2', False) + bot.set_group_add_request.assert_awaited_once_with(flag='flag-2', sub_type='invite', approve=False, reason='') + + with pytest.raises(NotSupportedError): + await adapter.upload_file(b'data', 'a.txt') + + with pytest.raises(NotSupportedError): + await adapter.get_file_url('file-1') + + +@pytest.mark.asyncio +async def test_aiocqhttp_platform_specific_api_calls_all_declared_actions(): + adapter = make_adapter() + bot = AsyncMock() + bot.call_action.return_value = {'ok': True} + object.__setattr__(adapter, 'bot', bot) + + for action in PLATFORM_API_MAP: + result = await adapter.call_platform_api(action, {'x': 1}) + assert result == {'ok': True} + + called_actions = [call.args[0] for call in bot.call_action.await_args_list] + assert called_actions == list(PLATFORM_API_MAP) + + with pytest.raises(NotSupportedError): + await adapter.call_platform_api('missing_action', {}) diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py new file mode 100644 index 000000000..2613a1897 --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import pathlib +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent +from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter +from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter +from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter +from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyDingTalkClient(DingTalkClient): + def __init__(self, *args, **kwargs): + self._message_handlers = {} + self.client = SimpleNamespace(register_callback_handler=AsyncMock()) + self.markdown_card = kwargs.get('markdown_card', True) + self.access_token = '' + self.send_message = AsyncMock() + self.send_proactive_message_to_one = AsyncMock(return_value={'ok': True}) + self.send_proactive_message_to_group = AsyncMock(return_value={'ok': True}) + self.get_file_url = AsyncMock(return_value='https://example.test/file') + self.check_access_token = AsyncMock(return_value=True) + self.get_access_token = AsyncMock() + self.get_audio_url = AsyncMock(return_value='data:audio/ogg;base64,AAAA') + self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB') + self.create_and_card = AsyncMock(return_value=('card', 'card-id')) + self.send_card_message = AsyncMock() + self.start = AsyncMock() + self.stop = AsyncMock() + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'dingtalk' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter() -> DingTalkAdapter: + config = { + 'client_id': 'client-id', + 'client_secret': 'client-secret', + 'robot_name': 'LangBot', + 'robot_code': 'robot-code', + 'markdown_card': True, + 'enable-stream-reply': False, + 'card_auto_layout': False, + 'card_template_id': 'template-id', + } + with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient): + return DingTalkAdapter(config, DummyLogger()) + + +def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent: + incoming = SimpleNamespace( + message_id=overrides.get('message_id', 'msg-1'), + create_at=1_714_000_000_000, + sender_staff_id=overrides.get('sender_staff_id', 'user-1'), + sender_nick=overrides.get('sender_nick', 'Alice'), + conversation_id=overrides.get('conversation_id', 'group-1'), + conversation_title=overrides.get('conversation_title', 'LangBot Team'), + chatbot_user_id='robot-dingtalk-id', + at_users=[SimpleNamespace(dingtalk_id='robot-dingtalk-id')], + ) + payload = { + 'IncomingMessage': incoming, + 'conversation_type': conversation, + 'Type': overrides.get('msg_type', 'text'), + 'Content': overrides.get('content', '@LangBot hello'), + 'Picture': overrides.get('picture', ''), + 'Audio': overrides.get('audio', ''), + 'File': overrides.get('file', ''), + 'Name': overrides.get('name', ''), + 'QuotedMessage': overrides.get('quoted_message'), + } + if 'rich_content' in overrides: + payload['Rich_Content'] = overrides['rich_content'] + return DingTalkEvent.from_payload(payload) + + +def dingtalk_card_callback_event(**overrides) -> DingTalkEvent: + return DingTalkEvent.from_payload( + { + 'conversation_type': 'CardCallback', + 'Type': 'card_callback', + 'CardCallback': { + 'extension': overrides.get('extension', {}), + 'corp_id': overrides.get('corp_id', 'corp-1'), + 'user_id': overrides.get('user_id', 'user-1'), + 'content': overrides.get( + 'content', + { + 'action': 'like', + 'feedback_id': 'feedback-1', + 'message_id': 'bot-msg-1', + }, + ), + 'space_id': overrides.get('space_id', 'space-1'), + 'card_instance_id': overrides.get('card_instance_id', 'card-1'), + }, + } + ) + + +def test_dingtalk_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_dingtalk_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_dingtalk_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_dingtalk_message_converter_maps_outbound_components(): + content, at = await DingTalkMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi '), + platform_message.At(target='user-1', display='Alice'), + platform_message.AtAll(), + platform_message.Image(url='https://example.test/a.png'), + platform_message.File(name='doc.txt', url='https://example.test/doc.txt'), + platform_message.Quote( + id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]) + ), + platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id='user-2', + sender_name='Bob', + message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]), + ) + ] + ), + ] + ) + ) + + assert at is True + assert '@Alice' in content + assert '@所有人' in content + assert '![image](https://example.test/a.png)' in content + assert '[doc.txt](https://example.test/doc.txt)' in content + assert '[引用消息 origin]' in content + assert '[Bob] node' in content + + +@pytest.mark.asyncio +async def test_dingtalk_message_converter_maps_inbound_components(): + event = dingtalk_event( + file='https://example.test/doc.txt', + name='doc.txt', + quoted_message={ + 'message_id': 'origin', + 'msg_type': 'text', + 'sender_id': 'user-2', + 'content': 'quoted text', + }, + ) + chain = await DingTalkMessageConverter.target2yiri(event, 'LangBot') + + assert isinstance(chain[0], platform_message.Source) + assert isinstance(chain[1], platform_message.At) + assert isinstance(chain[2], platform_message.Plain) + assert chain[2].text == ' hello' + assert isinstance(chain[3], platform_message.File) + assert isinstance(chain[4], platform_message.Quote) + assert str(chain[4].origin) == 'quoted text' + + +@pytest.mark.asyncio +async def test_dingtalk_event_converter_maps_group_and_private_message(): + group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot') + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.adapter_name == 'dingtalk-eba' + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'group-1' + assert group_event.group.name == 'LangBot Team' + assert group_event.sender.id == 'user-1' + + private_event = await DingTalkEventConverter.target2yiri( + dingtalk_event('FriendMessage', content='hello'), + 'LangBot', + ) + + assert isinstance(private_event, platform_events.MessageReceivedEvent) + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'user-1' + assert private_event.group is None + + +@pytest.mark.asyncio +async def test_dingtalk_event_converter_maps_card_feedback(): + feedback = await DingTalkEventConverter.target2yiri(dingtalk_card_callback_event(), 'LangBot') + callback = await DingTalkEventConverter.target2yiri( + dingtalk_card_callback_event(content={'action': 'open_details'}), + 'LangBot', + ) + + assert isinstance(feedback, platform_events.FeedbackReceivedEvent) + assert feedback.adapter_name == 'dingtalk-eba' + assert feedback.feedback_id == 'feedback-1' + assert feedback.feedback_type == 1 + assert feedback.user_id == 'user-1' + assert feedback.session_id == 'space-1' + assert feedback.message_id == 'bot-msg-1' + + assert isinstance(callback, platform_events.PlatformSpecificEvent) + assert callback.action == 'card.callback' + + +@pytest.mark.asyncio +async def test_dingtalk_adapter_dispatches_card_feedback_event(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.FeedbackReceivedEvent, listener) + await adapter._handle_native_event(dingtalk_card_callback_event()) + + assert len(calls) == 1 + assert isinstance(calls[0], platform_events.FeedbackReceivedEvent) + assert calls[0].feedback_type == 1 + + +@pytest.mark.asyncio +async def test_dingtalk_adapter_dispatches_and_caches_message_event(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, listener) + event = dingtalk_event() + + await adapter._handle_native_event(event) + + assert len(calls) == 1 + received = calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert await adapter.get_message('group', 'group-1', 'msg-1') == received + assert (await adapter.get_group_info('group-1')).name == 'LangBot Team' + assert (await adapter.get_user_info('user-1')).nickname == 'Alice' + + +@pytest.mark.asyncio +async def test_dingtalk_send_reply_and_platform_api_use_underlying_client(): + adapter = make_adapter() + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + sent = await adapter.send_message('group', 'group-1', message) + assert sent.raw == {'ok': True} + adapter.bot.send_proactive_message_to_group.assert_awaited_once() + + source_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot') + replied = await adapter.reply_message(source_event, message) + assert replied.message_id == 'msg-1' + adapter.bot.send_message.assert_awaited_once() + + token_status = await adapter.call_platform_api('check_access_token', {}) + file_url = await adapter.call_platform_api('get_file_url', {'download_code': 'download-code'}) + + assert token_status == {'valid': True} + assert file_url == {'url': 'https://example.test/file'} diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py new file mode 100644 index 000000000..67430461c --- /dev/null +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import pathlib +import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import yaml + +from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter +import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module +from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +def make_adapter() -> DiscordAdapter: + return DiscordAdapter({'client_id': '123', 'token': 'fake'}, DummyLogger()) + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'discord' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def test_discord_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_discord_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_discord_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_discord_adapter_dispatches_most_specific_eba_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + async def message_listener(event, adapter): + calls.append('message') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + adapter.register_listener( + platform_events.MessageReceivedEvent, + message_listener, + ) + + event = platform_events.MessageReceivedEvent( + message_id=1, + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + sender={'id': 1}, + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + + assert calls == ['message'] + + +@pytest.mark.asyncio +async def test_discord_message_converter_maps_mentions_and_text_to_target(): + content, files = await DiscordMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi '), + platform_message.At(target='123'), + platform_message.Plain(text=' all '), + platform_message.AtAll(), + ] + ) + ) + + assert content == 'hi <@123> all @everyone' + assert files == [] + + +def test_discord_message_converter_splits_discord_mentions(): + components = DiscordMessageConverter._text_components('hi <@123> and @everyone') + + assert isinstance(components[0], platform_message.Plain) + assert components[0].text == 'hi ' + assert isinstance(components[1], platform_message.At) + assert components[1].target == '123' + assert isinstance(components[3], platform_message.AtAll) + + +def fake_user(user_id=123, name='user', bot=False): + return SimpleNamespace( + id=user_id, + name=name, + display_name=name.title(), + bot=bot, + display_avatar=SimpleNamespace(url=f'https://cdn.example/{user_id}.png'), + ) + + +def fake_guild(guild_id=456): + return SimpleNamespace( + id=guild_id, + name='Guild', + member_count=3, + icon=None, + owner_id=1, + ) + + +def fake_channel(channel_id=789, guild=None): + return SimpleNamespace( + id=channel_id, + name='general', + guild=guild, + ) + + +def fake_message(content='hello <@123>', *, guild=None, channel=None, author=None, message_id=999): + guild = guild if guild is not None else fake_guild() + channel = channel if channel is not None else fake_channel(guild=guild) + author = author if author is not None else fake_user() + return SimpleNamespace( + id=message_id, + content=content, + author=author, + channel=channel, + guild=guild, + attachments=[], + created_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC), + edited_at=datetime.datetime(2026, 5, 7, 0, 1, tzinfo=datetime.UTC), + ) + + +@pytest.mark.asyncio +async def test_discord_converter_maps_message_edit_delete_and_reaction_events(): + message = fake_message() + received = await DiscordEventConverter.message_to_eba(message) + + assert isinstance(received, platform_events.MessageReceivedEvent) + assert received.type == 'message.received' + assert received.adapter_name == 'discord' + assert received.chat_type == platform_entities.ChatType.GROUP + assert received.chat_id == 789 + assert received.group.id == 456 + assert isinstance(received.message_chain[1], platform_message.Plain) + assert isinstance(received.message_chain[2], platform_message.At) + + edited = await DiscordEventConverter.message_edit_to_eba(message, fake_message(content='edited', message_id=999)) + assert isinstance(edited, platform_events.MessageEditedEvent) + assert edited.type == 'message.edited' + assert str(edited.new_content) == 'edited' + + deleted = await DiscordEventConverter.message_delete_to_eba(message) + assert isinstance(deleted, platform_events.MessageDeletedEvent) + assert deleted.type == 'message.deleted' + assert deleted.message_id == 999 + + reaction = SimpleNamespace(message=message, emoji='👍') + reaction_event = DiscordEventConverter.reaction_to_eba(reaction, fake_user(321, 'reactor'), True) + assert isinstance(reaction_event, platform_events.MessageReactionEvent) + assert reaction_event.type == 'message.reaction' + assert reaction_event.reaction == '👍' + assert reaction_event.user.id == 321 + + +@pytest.mark.asyncio +async def test_discord_converter_maps_uncached_raw_gateway_events(): + raw_delete = SimpleNamespace(message_id=10, channel_id=20, guild_id=30) + deleted = await DiscordEventConverter.target2yiri(('raw_message_delete', raw_delete), bot_user_id=1) + assert isinstance(deleted, platform_events.MessageDeletedEvent) + assert deleted.message_id == 10 + assert deleted.chat_id == 20 + assert deleted.group.id == 30 + + raw_reaction = SimpleNamespace( + message_id=11, + channel_id=21, + guild_id=31, + user_id=41, + emoji='🔥', + member=fake_user(41, 'member'), + ) + reaction = await DiscordEventConverter.target2yiri(('raw_reaction_add', raw_reaction), bot_user_id=1) + assert isinstance(reaction, platform_events.MessageReactionEvent) + assert reaction.reaction == '🔥' + assert reaction.is_add is True + assert reaction.user.id == 41 + + +def test_discord_converter_maps_member_and_bot_guild_events(): + guild = fake_guild() + member = SimpleNamespace( + **fake_user(123, 'member').__dict__, + guild=guild, + joined_at=datetime.datetime(2026, 5, 7, tzinfo=datetime.UTC), + ) + + joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=999) + assert isinstance(joined, platform_events.MemberJoinedEvent) + assert joined.join_type == 'direct' + + bot_joined = DiscordEventConverter.member_join_to_eba(member, bot_user_id=123) + assert isinstance(bot_joined, platform_events.BotInvitedToGroupEvent) + + bot_invited = DiscordEventConverter.guild_join_to_eba(guild) + bot_removed = DiscordEventConverter.guild_remove_to_eba(guild) + assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent) + assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent) + + +@pytest.mark.asyncio +async def test_discord_send_and_reply_omit_empty_files_and_return_message_result(monkeypatch): + adapter = make_adapter() + sent = SimpleNamespace(id=111) + channel = SimpleNamespace(send=AsyncMock(return_value=sent)) + object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel)) + + result = await adapter.send_message( + 'group', + '789', + platform_message.MessageChain([platform_message.Plain(text='hello')]), + ) + + assert result.message_id == 111 + assert channel.send.await_args.kwargs == {'content': 'hello'} + + monkeypatch.setattr(discord_adapter_module.discord, 'Message', SimpleNamespace) + source_message = SimpleNamespace(channel=channel) + source = platform_events.MessageReceivedEvent( + message_id=1, + source_platform_object=source_message, + ) + result = await adapter.reply_message( + source, + platform_message.MessageChain([platform_message.Plain(text='reply')]), + quote_origin=True, + ) + + assert result.message_id == 111 + assert channel.send.await_args.kwargs['content'] == 'reply' + assert channel.send.await_args.kwargs['reference'] is source_message + assert channel.send.await_args.kwargs['mention_author'] is False diff --git a/tests/unit_tests/platform/test_kook_eba_adapter.py b/tests/unit_tests/platform/test_kook_eba_adapter.py new file mode 100644 index 000000000..bc097acd8 --- /dev/null +++ b/tests/unit_tests/platform/test_kook_eba_adapter.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import pathlib +from unittest.mock import AsyncMock + +import pytest +import yaml + +from langbot.pkg.platform.adapters.kook.adapter import KookAdapter +from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter +from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter +from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP, get_gateway +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +def make_adapter() -> KookAdapter: + return KookAdapter({'token': 'fake', 'enable-stream-reply': False}, DummyLogger()) + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'kook' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def fake_kook_message(**overrides): + event = { + 'channel_type': 'GROUP', + 'type': 9, + 'author_id': 'u1', + 'target_id': 'c1', + 'msg_id': 'm1', + 'msg_timestamp': 1_775_000_000_000, + 'content': 'hi (met)u2(met) and (met)all(met)', + 'extra': { + 'channel_name': 'general', + 'guild_id': 'g1', + 'guild_name': 'Guild', + 'author': { + 'id': 'u1', + 'username': 'alice', + 'nickname': 'Alice', + 'avatar': 'https://example/avatar.png', + }, + 'mention': ['u2'], + 'mention_all': True, + }, + } + event.update(overrides) + return event + + +def test_kook_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_kook_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_kook_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_kook_adapter_dispatches_most_specific_eba_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def event_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + async def message_listener(event, adapter): + calls.append('message') + + adapter.register_listener(platform_events.Event, event_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + adapter.register_listener(platform_events.MessageReceivedEvent, message_listener) + + event = platform_events.MessageReceivedEvent( + message_id='m1', + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + sender=platform_entities.User(id='u1'), + chat_id='c1', + ) + + await adapter._dispatch_eba_event(event) + + assert calls == ['message'] + + +@pytest.mark.asyncio +async def test_kook_message_converter_maps_target_text_mentions_and_source(): + chain = await KookMessageConverter.target2yiri(fake_kook_message(), bot_account_id='bot') + + assert isinstance(chain[0], platform_message.Source) + assert chain[0].id == 'm1' + assert isinstance(chain[1], platform_message.Plain) + assert chain[1].text == 'hi ' + assert isinstance(chain[2], platform_message.At) + assert chain[2].target == 'u2' + assert isinstance(chain[4], platform_message.AtAll) + + +@pytest.mark.asyncio +async def test_kook_message_converter_maps_media_components(): + image = await KookMessageConverter.target2yiri(fake_kook_message(type=2, content='https://example/image.png')) + assert isinstance(image[1], platform_message.Image) + assert image[1].url == 'https://example/image.png' + + file_chain = await KookMessageConverter.target2yiri( + fake_kook_message(type=4, content='https://example/file.bin', extra={'attachments': {'name': 'file.bin'}}) + ) + assert isinstance(file_chain[1], platform_message.File) + assert file_chain[1].name == 'file.bin' + + voice = await KookMessageConverter.target2yiri(fake_kook_message(type=8, content='https://example/voice.mp3')) + assert isinstance(voice[1], platform_message.Voice) + + +@pytest.mark.asyncio +async def test_kook_message_converter_maps_common_components_to_target(): + content, msg_type = await KookMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi '), + platform_message.At(target='u1'), + platform_message.Plain(text=' all '), + platform_message.AtAll(), + ] + ) + ) + + assert content == 'hi (met)u1(met) all (met)all(met)' + assert msg_type == 1 + + +@pytest.mark.asyncio +async def test_kook_event_converter_maps_group_private_and_platform_specific_events(): + group_event = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot') + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.type == 'message.received' + assert group_event.adapter_name == 'kook' + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'c1' + assert group_event.group.id == 'c1' + assert group_event.sender.id == 'u1' + + private_event = await KookEventConverter.target2yiri( + fake_kook_message(channel_type='PERSON', target_id='u1', extra={'code': 'chat-code'}), + bot_account_id='bot', + ) + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'chat-code' + assert private_event.group is None + + specific = await KookEventConverter.target2yiri({'type': 255, 'target_id': 'raw'}, bot_account_id='bot') + assert isinstance(specific, platform_events.PlatformSpecificEvent) + assert specific.action == '255' + + +@pytest.mark.asyncio +async def test_kook_event_converter_maps_legacy_events(): + legacy_group = await KookEventConverter.target2legacy(fake_kook_message(), bot_account_id='bot') + assert isinstance(legacy_group, platform_events.GroupMessage) + assert legacy_group.sender.group.id == 'c1' + + legacy_private = await KookEventConverter.target2legacy( + fake_kook_message(channel_type='PERSON', target_id='u1'), + bot_account_id='bot', + ) + assert isinstance(legacy_private, platform_events.FriendMessage) + + +@pytest.mark.asyncio +async def test_kook_send_and_reply_pass_expected_payloads(): + adapter = make_adapter() + adapter._request = AsyncMock(return_value={'code': 0, 'data': {'msg_id': 'sent'}}) + + result = await adapter.send_message( + 'group', + 'c1', + platform_message.MessageChain([platform_message.Plain(text='hello')]), + ) + + assert result.message_id == 'sent' + adapter._request.assert_awaited_with( + 'POST', + '/message/create', + json={'target_id': 'c1', 'content': 'hello', 'type': 1}, + ) + + source = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot') + await adapter.reply_message(source, platform_message.MessageChain([platform_message.Plain(text='reply')]), True) + + assert adapter._request.await_args_list[-1].args == ('POST', '/message/create') + payload = adapter._request.await_args_list[-1].kwargs['json'] + assert payload['reply_msg_id'] == 'm1' + assert payload['quote'] == 'm1' + assert payload['content'] == 'reply' + + +@pytest.mark.asyncio +async def test_kook_get_gateway_redacts_token_in_platform_api_result(): + adapter = make_adapter() + adapter._request = AsyncMock( + return_value={ + 'code': 0, + 'data': { + 'url': 'wss://example.invalid/gateway?compress=1&token=secret-token', + }, + } + ) + + result = await get_gateway(adapter, {'compress': 1}) + + assert result['data']['url'] == 'wss://example.invalid/gateway?compress=1&token=%3Credacted%3E' + assert 'secret-token' not in result['data']['url'] + + +@pytest.mark.asyncio +async def test_kook_handle_event_dispatches_eba_and_legacy_then_caches(): + adapter = make_adapter() + adapter.bot_account_id = 'bot' + calls: list[str] = [] + + async def legacy_listener(event, adapter): + calls.append(type(event).__name__) + + async def eba_listener(event, adapter): + calls.append(event.type) + + adapter.register_listener(platform_events.GroupMessage, legacy_listener) + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + + await adapter._handle_event(fake_kook_message(), 7) + + assert calls == ['GroupMessage', 'message.received'] + assert adapter.current_sn == 7 + assert 'm1' in adapter._message_cache + assert 'u1' in adapter._user_cache + assert 'c1' in adapter._group_cache diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py new file mode 100644 index 000000000..10b5ed2d8 --- /dev/null +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import pathlib +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter +from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter +from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter +from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyResponse: + def __init__(self, data=None, ok=True): + self.data = data or SimpleNamespace(message_id='reply-msg') + self.code = 0 if ok else 1 + self.msg = 'ok' if ok else 'failed' + self.raw = SimpleNamespace(content='{}', headers={'content-type': 'text/plain'}) + self.file = SimpleNamespace(read=lambda: b'data') + self._ok = ok + + def success(self): + return self._ok + + def get_log_id(self): + return 'log-id' + + +def message_item(message_id='msg-remote'): + return SimpleNamespace( + message_id=message_id, + msg_type='text', + create_time=1_714_000_000_000, + body=SimpleNamespace(content='{"text":"remote"}'), + mentions=[], + chat_id='chat-1', + ) + + +class DummyAPIClient: + def __init__(self): + self.im = SimpleNamespace( + v1=SimpleNamespace( + message=SimpleNamespace( + acreate=AsyncMock(return_value=DummyResponse()), + areply=AsyncMock(return_value=DummyResponse()), + aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))), + ), + chat=SimpleNamespace( + aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(chat_id='chat-1', name='LangBot Team'))) + ), + image=SimpleNamespace( + acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(image_key='img-key'))) + ), + file=SimpleNamespace( + acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(file_key='file-key'))) + ), + message_resource=SimpleNamespace(aget=AsyncMock(return_value=DummyResponse())), + ) + ) + self.auth = SimpleNamespace( + v3=SimpleNamespace( + app_ticket=SimpleNamespace(resend=AsyncMock(return_value=DummyResponse())), + app_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())), + tenant_access_token=SimpleNamespace(create=AsyncMock(return_value=DummyResponse())), + ) + ) + self.cardkit = SimpleNamespace( + v1=SimpleNamespace( + card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))), + card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())), + ) + ) + + +class DummyWSClient: + def __init__(self): + self._auto_reconnect = True + self._connect = AsyncMock() + self._disconnect = AsyncMock() + self._reconnect = AsyncMock() + + +def manifest() -> dict: + path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'lark' + / 'manifest.yaml' + ) + return yaml.safe_load(path.read_text()) + + +def make_adapter(config: dict | None = None) -> LarkAdapter: + adapter = LarkAdapter( + { + 'app_id': 'cli_xxx', + 'app_secret': 'secret', + 'bot_name': 'LangBotDev', + 'enable-webhook': False, + 'enable-stream-reply': False, + 'app_type': 'self', + **(config or {}), + }, + DummyLogger(), + ) + adapter.api_client = DummyAPIClient() + adapter.bot = DummyWSClient() + return adapter + + +def lark_event(chat_type='group', message_type='text', content=None): + message = SimpleNamespace( + message_id='msg-1', + message_type=message_type, + content=content or '{"text":"hello @_user_1"}', + create_time=1_714_000_000_000, + mentions=[SimpleNamespace(key='@_user_1', id='user-mention', name='Alice')], + chat_type=chat_type, + chat_id='chat-1', + parent_id=None, + thread_id=None, + ) + sender = SimpleNamespace(sender_id=SimpleNamespace(open_id='user-1', union_id='Alice Union')) + header = SimpleNamespace(tenant_key='tenant-1') + return SimpleNamespace(event=SimpleNamespace(message=message, sender=sender), header=header, schema='2.0') + + +def test_lark_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_lark_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_lark_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_lark_message_converter_maps_outbound_components(): + with ( + patch.object(LarkMessageConverter, 'upload_image_to_lark', AsyncMock(return_value='img-key')), + patch.object(LarkMessageConverter, 'upload_file_to_lark', AsyncMock(return_value='file-key')), + patch.object(LarkMessageConverter, '_get_component_bytes', AsyncMock(return_value=b'data')), + ): + text_elements, media_items = await LarkMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hello\n\nsecond'), + platform_message.At(target='ou_user', display='Alice'), + platform_message.AtAll(), + platform_message.Image(base64='ZGF0YQ=='), + platform_message.Voice(base64='ZGF0YQ==', length=2), + platform_message.File(name='doc.txt', base64='ZGF0YQ=='), + platform_message.Quote( + id='origin', origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]) + ), + platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id='user-2', + sender_name='Bob', + message_chain=platform_message.MessageChain([platform_message.Plain(text='node')]), + ) + ] + ), + ] + ), + DummyAPIClient(), + ) + + assert any(ele.get('text') == 'hello' for paragraph in text_elements for ele in paragraph) + assert any(ele.get('user_id') == 'ou_user' for paragraph in text_elements for ele in paragraph) + assert any(ele.get('user_id') == 'all' for paragraph in text_elements for ele in paragraph) + assert {'msg_type': 'image', 'content': {'image_key': 'img-key'}} in media_items + assert {'msg_type': 'audio', 'content': {'file_key': 'file-key'}} in media_items + assert {'msg_type': 'file', 'content': {'file_key': 'file-key'}} in media_items + + +@pytest.mark.asyncio +async def test_lark_message_converter_maps_inbound_components(): + with patch.object( + LarkMessageConverter, + '_download_resource', + AsyncMock( + return_value={ + 'url': 'file:///tmp/file', + 'path': '/tmp/file', + 'base64': 'data:text/plain;base64,ZGF0YQ==', + 'size': 4, + } + ), + ): + text_chain = await LarkMessageConverter.target2yiri(lark_event().event.message, DummyAPIClient()) + image_chain = await LarkMessageConverter.target2yiri( + lark_event(message_type='image', content='{"image_key":"img-key"}').event.message, DummyAPIClient() + ) + file_chain = await LarkMessageConverter.target2yiri( + lark_event(message_type='file', content='{"file_key":"file-key","file_name":"doc.txt"}').event.message, + DummyAPIClient(), + ) + + assert isinstance(text_chain[0], platform_message.Source) + assert isinstance(text_chain[1], platform_message.Plain) + assert isinstance(text_chain[2], platform_message.At) + assert text_chain[2].target == 'user-mention' + assert isinstance(image_chain[1], platform_message.Image) + assert image_chain[1].image_id == 'img-key' + assert isinstance(file_chain[1], platform_message.File) + assert file_chain[1].name == 'doc.txt' + + +@pytest.mark.asyncio +async def test_lark_event_converter_maps_group_and_private_message(): + group_event = await LarkEventConverter.target2yiri(lark_event('group'), DummyAPIClient()) + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.adapter_name == 'lark-eba' + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'chat-1' + assert group_event.group.id == 'chat-1' + assert group_event.sender.id == 'user-1' + + private_event = await LarkEventConverter.target2yiri( + lark_event('p2p', content='{"text":"hello"}'), + DummyAPIClient(), + ) + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'user-1' + assert private_event.group is None + + +@pytest.mark.asyncio +async def test_lark_adapter_dispatches_and_caches_message_event(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, listener) + await adapter._handle_message_event(lark_event()) + + assert len(calls) == 1 + received = calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert await adapter.get_message('group', 'chat-1', 'msg-1') == received + assert (await adapter.get_group_info('chat-1')).name == '' + assert (await adapter.get_user_info('user-1')).nickname == 'Alice Union' + + +@pytest.mark.asyncio +async def test_lark_get_message_fetches_uncached_message(): + adapter = make_adapter() + adapter.api_client.im.v1.message.aget = AsyncMock( + return_value=DummyResponse(SimpleNamespace(items=[message_item('msg-remote')])) + ) + + event = await adapter.get_message('group', 'chat-1', 'msg-remote') + + assert event.adapter_name == 'lark-eba' + assert event.message_id == 'msg-remote' + assert event.chat_type == platform_entities.ChatType.GROUP + assert isinstance(event.message_chain[1], platform_message.Plain) + assert event.message_chain[1].text == 'remote' + + +@pytest.mark.asyncio +async def test_lark_send_reply_platform_api_and_modes(): + adapter = make_adapter() + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + sent = await adapter.send_message('group', 'chat-1', message) + assert sent.raw['message_ids'] == ['reply-msg'] + adapter.api_client.im.v1.message.acreate.assert_awaited_once() + + source_event = await LarkEventConverter.target2yiri(lark_event(), adapter.api_client) + replied = await adapter.reply_message(source_event, message) + assert replied.raw['message_ids'] == ['reply-msg'] + adapter.api_client.im.v1.message.areply.assert_awaited_once() + + assert await adapter.call_platform_api('check_tenant_access_token', {}) == {'ok': True} + + await adapter.run_async() + adapter.bot._connect.assert_awaited_once() + + webhook_adapter = make_adapter({'enable-webhook': True}) + task = asyncio.create_task(webhook_adapter.run_async()) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + webhook_adapter.bot._connect.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_officialaccount_eba_adapter.py b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py new file mode 100644 index 000000000..8b21103d7 --- /dev/null +++ b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.official_account_api.oaevent import OAEvent +from langbot.pkg.platform.adapters.officialaccount.adapter import OfficialAccountAdapter +from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError +from langbot.pkg.platform.adapters.officialaccount.event_converter import OfficialAccountEventConverter +from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter +from langbot.pkg.platform.adapters.officialaccount.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyOAClient: + def __init__(self, *args, **kwargs): + self.token = kwargs['token'] + self.aes = kwargs['EncodingAESKey'] + self.appid = kwargs['AppID'] + self.appsecret = kwargs['Appsecret'] + self.base_url = kwargs.get('api_base_url') + self._message_handlers = {} + self.generated_content = {} + self.handle_unified_webhook = AsyncMock(return_value='success') + self.set_message = AsyncMock(return_value=None) + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + +class DummyLongerOAClient(DummyOAClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.loading_message = kwargs['LoadingMessage'] + self.msg_queue = {} + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'officialaccount' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter(mode: str = 'drop') -> OfficialAccountAdapter: + config = { + 'token': 'token', + 'EncodingAESKey': 'encoding-key', + 'AppSecret': 'secret', + 'AppID': 'app-id', + 'Mode': mode, + 'LoadingMessage': 'loading', + } + with ( + patch('langbot.pkg.platform.adapters.officialaccount.adapter.OAClient', DummyOAClient), + patch('langbot.pkg.platform.adapters.officialaccount.adapter.OAClientForLongerResponse', DummyLongerOAClient), + ): + return OfficialAccountAdapter(config, DummyLogger()) + + +def oa_event(**overrides) -> OAEvent: + payload = { + 'ToUserName': overrides.get('to_user', 'gh_app'), + 'FromUserName': overrides.get('from_user', 'openid-1'), + 'CreateTime': overrides.get('timestamp', 1710000000), + 'MsgType': overrides.get('msgtype', 'text'), + 'Content': overrides.get('content', 'hello'), + 'MsgId': overrides.get('message_id', 123), + } + if payload['MsgType'] == 'image': + payload.update({'PicUrl': 'https://example.test/a.jpg', 'MediaId': 'media-1', 'Content': None}) + if payload['MsgType'] == 'voice': + payload.update({'MediaId': 'voice-1', 'Content': None}) + if payload['MsgType'] == 'event': + payload.update({'Event': overrides.get('event', 'subscribe'), 'EventKey': 'qrscene_1', 'Content': None}) + return OAEvent(payload) + + +def test_officialaccount_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_officialaccount_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_officialaccount_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_officialaccount_message_converter_maps_components_to_passive_text(): + content = await OfficialAccountMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi'), + platform_message.Image(url='https://example.test/a.png'), + platform_message.File(name='a.txt', url='https://example.test/a.txt'), + platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])), + ] + ) + ) + + assert 'hi' in content + assert '[Image]' in content + assert '[File: a.txt]' in content + assert 'quoted' in content + + +@pytest.mark.asyncio +async def test_officialaccount_event_converter_maps_text_image_voice_and_platform_event(): + text_event = await OfficialAccountEventConverter().target2yiri(oa_event(content='hello')) + image_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='image')) + voice_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='voice')) + subscribe_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='event', event='subscribe')) + + assert isinstance(text_event, platform_events.MessageReceivedEvent) + assert text_event.adapter_name == 'officialaccount-eba' + assert text_event.chat_type == platform_entities.ChatType.PRIVATE + assert text_event.chat_id == 'openid-1' + assert str(text_event.message_chain) == 'hello' + + assert isinstance(image_event.message_chain[1], platform_message.Image) + assert image_event.message_chain[1].image_id == 'media-1' + assert isinstance(voice_event.message_chain[1], platform_message.Voice) + assert voice_event.message_chain[1].voice_id == 'voice-1' + assert isinstance(subscribe_event, platform_events.PlatformSpecificEvent) + assert subscribe_event.action == 'officialaccount.subscribe' + + +@pytest.mark.asyncio +async def test_officialaccount_adapter_dispatches_eba_and_legacy_and_caches_message_event(): + adapter = make_adapter() + eba_calls: list[platform_events.Event] = [] + legacy_calls: list[platform_events.Event] = [] + + async def eba_listener(event, adapter): + eba_calls.append(event) + + async def legacy_listener(event, adapter): + legacy_calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + adapter.register_listener(platform_events.FriendMessage, legacy_listener) + await adapter._handle_native_event(oa_event()) + + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + received = eba_calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert await adapter.get_message('private', 'openid-1', 123) == received + assert (await adapter.get_user_info('openid-1')).nickname == 'openid-1' + + +@pytest.mark.asyncio +async def test_officialaccount_reply_platform_api_and_unsupported_send(): + adapter = make_adapter() + source_event = await OfficialAccountEventConverter().target2yiri(oa_event()) + message = platform_message.MessageChain([platform_message.Plain(text='reply')]) + + await adapter.reply_message(source_event, message) + adapter.bot.set_message.assert_awaited_once_with(123, 'reply') + + assert await adapter.call_platform_api('get_mode', {}) == {'mode': 'drop', 'longer_response': False} + + with pytest.raises(NotSupportedError): + await adapter.send_message('person', 'openid-1', message) + + +@pytest.mark.asyncio +async def test_officialaccount_passive_mode_reply_queues_by_user(): + adapter = make_adapter(mode='passive') + source_event = await OfficialAccountEventConverter().target2yiri(oa_event()) + + await adapter.reply_message(source_event, platform_message.MessageChain([platform_message.Plain(text='reply')])) + + adapter.bot.set_message.assert_awaited_once_with('openid-1', 123, 'reply') diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py new file mode 100644 index 000000000..d979a033e --- /dev/null +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import pathlib +import datetime +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter +from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError +from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter +from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter +from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyQQOfficialClient: + MEDIA_TYPE_IMAGE = 1 + MEDIA_TYPE_VOICE = 3 + MEDIA_TYPE_FILE = 4 + + def __init__(self, *args, **kwargs): + self.app_id = kwargs['app_id'] + self.secret = kwargs['secret'] + self.token = kwargs['token'] + self.unified_mode = kwargs['unified_mode'] + self._message_handlers = {} + self.sent = [] + self.access_token = '' + self.access_token_expiry_time = None + self.handle_unified_webhook = AsyncMock(return_value='success') + self.connect_gateway_loop = AsyncMock() + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + async def check_access_token(self): + return bool(self.access_token) + + async def get_access_token(self): + self.access_token = 'token' + self.access_token_expiry_time = 1710003600 + + async def get_gateway_url(self): + return 'wss://gateway.example.test' + + async def send_private_text_msg(self, user_openid, content, msg_id=None): + self.sent.append(('private_text', user_openid, content, msg_id)) + return {'id': 'sent-private'} + + async def send_group_text_msg(self, group_openid, content, msg_id=None): + self.sent.append(('group_text', group_openid, content, msg_id)) + return {'id': 'sent-group'} + + async def send_channle_group_text_msg(self, channel_id, content, msg_id=None): + self.sent.append(('channel_text', channel_id, content, msg_id)) + return {'id': 'sent-channel'} + + async def send_channle_private_text_msg(self, guild_id, content, msg_id=None): + self.sent.append(('dm_text', guild_id, content, msg_id)) + return {'id': 'sent-dm'} + + async def send_image_msg(self, target_type, target_id, file_url=None, file_data=None, msg_id=None, content=None): + self.sent.append(('image', target_type, target_id, file_url, file_data, msg_id)) + return {'id': 'sent-image'} + + async def send_voice_msg(self, target_type, target_id, file_url=None, file_data=None, msg_id=None): + self.sent.append(('voice', target_type, target_id, file_url, file_data, msg_id)) + return {'id': 'sent-voice'} + + async def send_file_msg(self, target_type, target_id, file_url=None, file_data=None, file_name=None, msg_id=None): + self.sent.append(('file', target_type, target_id, file_url, file_data, file_name, msg_id)) + return {'id': 'sent-file'} + + async def send_stream_msg(self, **kwargs): + self.sent.append(('stream', kwargs)) + return {'id': 'stream-1'} + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'qqofficial' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter: + config = { + 'appid': 'app-id', + 'secret': 'secret', + 'token': 'token', + 'enable-webhook': enable_webhook, + 'enable-stream-reply': True, + } + with patch('langbot.pkg.platform.adapters.qqofficial.adapter.QQOfficialClient', DummyQQOfficialClient): + return QQOfficialAdapter(config, DummyLogger()) + + +def qq_event(event_type: str = 'C2C_MESSAGE_CREATE', **overrides) -> QQOfficialEvent: + payload = { + 't': event_type, + 'user_openid': overrides.get('user_openid', 'user-openid'), + 'timestamp': overrides.get('timestamp', '2026-06-01T10:00:00+0800'), + 'd_author_id': overrides.get('author_id', 'author-id'), + 'content': overrides.get('content', 'hello'), + 'd_id': overrides.get('message_id', 'msg-1'), + 'id': overrides.get('event_id', 'event-1'), + 'channel_id': overrides.get('channel_id', 'channel-1'), + 'username': overrides.get('username', 'alice'), + 'guild_id': overrides.get('guild_id', 'guild-1'), + 'member_openid': overrides.get('member_openid', 'member-openid'), + 'group_openid': overrides.get('group_openid', 'group-openid'), + 'image_attachments': overrides.get('image_attachments'), + 'content_type': overrides.get('content_type', 'image/png'), + } + payload.update(overrides.get('extra', {})) + return QQOfficialEvent(payload) + + +def test_qqofficial_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_qqofficial_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_qqofficial_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_qqofficial_message_converter_maps_common_components_to_send_payloads(): + payload = await QQOfficialMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Source(id='msg-0', time=datetime.datetime.now()), + platform_message.Plain(text='hi'), + platform_message.At(target='user-1', display='Alice'), + platform_message.AtAll(), + platform_message.Image(url='https://example.test/a.png'), + platform_message.Voice(base64='data:audio/mpeg;base64,AAAA'), + platform_message.File(name='a.txt', url='https://example.test/a.txt'), + platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])), + ] + ) + ) + + assert {'type': 'text', 'content': 'hi'} in payload + assert {'type': 'text', 'content': '@Alice'} in payload + assert {'type': 'text', 'content': '@all'} in payload + assert any(item['type'] == 'image' and item['url'] == 'https://example.test/a.png' for item in payload) + assert any(item['type'] == 'voice' and item['base64'].startswith('data:audio') for item in payload) + assert any(item['type'] == 'file' and item['name'] == 'a.txt' for item in payload) + assert {'type': 'text', 'content': 'quoted'} in payload + + +@pytest.mark.asyncio +async def test_qqofficial_event_converter_maps_private_group_and_platform_specific(): + private_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE')) + group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE')) + channel_event = await QQOfficialEventConverter().target2yiri(qq_event('AT_MESSAGE_CREATE')) + platform_event = await QQOfficialEventConverter().target2yiri(qq_event('UNKNOWN_EVENT')) + + assert isinstance(private_event, platform_events.MessageReceivedEvent) + assert private_event.adapter_name == 'qqofficial-eba' + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'user-openid' + assert str(private_event.message_chain) == 'hello' + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'group-openid' + assert isinstance(group_event.message_chain[1], platform_message.At) + + assert channel_event.chat_id == 'channel-1' + assert isinstance(platform_event, platform_events.PlatformSpecificEvent) + assert platform_event.action == 'qqofficial.UNKNOWN_EVENT' + + +@pytest.mark.asyncio +async def test_qqofficial_event_converter_maps_declared_lifecycle_events(): + converter = QQOfficialEventConverter() + + reaction_add = await converter.target2yiri( + qq_event( + 'MESSAGE_REACTION_ADD', + extra={ + 'message_id': 'reacted-msg', + 'emoji': 'thumbs_up', + 'group_openid': 'group-openid', + 'openid': 'reactor-openid', + }, + ) + ) + reaction_remove = await converter.target2yiri( + qq_event('MESSAGE_REACTION_REMOVE', extra={'message_id': 'reacted-msg', 'emoji_id': '1'}) + ) + member_joined = await converter.target2yiri( + qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'}) + ) + member_left = await converter.target2yiri( + qq_event('GROUP_MEMBER_REMOVE', extra={'openid': 'old-member', 'operator_openid': 'operator-openid'}) + ) + bot_invited = await converter.target2yiri( + qq_event('GROUP_ADD_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'inviter-openid'}) + ) + bot_removed = await converter.target2yiri( + qq_event('GROUP_DEL_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'operator-openid'}) + ) + + assert isinstance(reaction_add, platform_events.MessageReactionEvent) + assert reaction_add.type == 'message.reaction' + assert reaction_add.is_add is True + assert reaction_add.message_id == 'reacted-msg' + assert reaction_add.reaction == 'thumbs_up' + assert reaction_add.chat_type == platform_entities.ChatType.GROUP + + assert isinstance(reaction_remove, platform_events.MessageReactionEvent) + assert reaction_remove.is_add is False + + assert isinstance(member_joined, platform_events.MemberJoinedEvent) + assert member_joined.type == 'group.member_joined' + assert member_joined.group.id == 'group-openid' + assert member_joined.member.id == 'new-member' + + assert isinstance(member_left, platform_events.MemberLeftEvent) + assert member_left.type == 'group.member_left' + assert member_left.member.id == 'old-member' + assert member_left.operator.id == 'operator-openid' + + assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent) + assert bot_invited.type == 'bot.invited_to_group' + assert bot_invited.inviter.id == 'inviter-openid' + + assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent) + assert bot_removed.type == 'bot.removed_from_group' + assert bot_removed.operator.id == 'operator-openid' + + +@pytest.mark.asyncio +async def test_qqofficial_adapter_dispatches_eba_and_legacy_and_caches_group_event(): + adapter = make_adapter() + eba_calls: list[platform_events.Event] = [] + legacy_calls: list[platform_events.Event] = [] + + async def eba_listener(event, adapter): + eba_calls.append(event) + + async def legacy_listener(event, adapter): + legacy_calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + adapter.register_listener(platform_events.GroupMessage, legacy_listener) + await adapter._handle_native_event(qq_event('GROUP_AT_MESSAGE_CREATE')) + + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + received = eba_calls[0] + assert await adapter.get_message('group', 'group-openid', 'msg-1') == received + assert (await adapter.get_group_info('group-openid')).id == 'group-openid' + assert (await adapter.get_group_member_info('group-openid', 'member-openid')).user.id == 'member-openid' + + +@pytest.mark.asyncio +async def test_qqofficial_adapter_dispatches_non_message_eba_events(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def member_listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.MemberJoinedEvent, member_listener) + await adapter._handle_native_event( + qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'}) + ) + + assert len(calls) == 1 + assert isinstance(calls[0], platform_events.MemberJoinedEvent) + assert calls[0].member.id == 'new-member' + + +@pytest.mark.asyncio +async def test_qqofficial_send_reply_stream_platform_api_and_unsupported(): + adapter = make_adapter() + message = platform_message.MessageChain( + [ + platform_message.Plain(text='reply'), + platform_message.Image(url='https://example.test/a.png'), + ] + ) + source_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE')) + + reply_result = await adapter.reply_message(source_event, message) + assert reply_result.message_id == 'msg-1' + assert ('private_text', 'user-openid', 'reply', 'msg-1') in adapter.bot.sent + assert any(call[0] == 'image' and call[1] == 'c2c' for call in adapter.bot.sent) + + await adapter.send_message( + 'group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')]) + ) + assert ('group_text', 'group-openid', 'hello group', None) in adapter.bot.sent + + assert await adapter.call_platform_api('get_mode', {}) == { + 'webhook': True, + 'stream_reply': True, + 'bot_account_id': 'app-id', + } + await adapter.call_platform_api('refresh_access_token', {}) + assert adapter.bot.access_token == 'token' + + with pytest.raises(NotSupportedError): + await adapter.call_platform_api('missing', {}) diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 3928f6f11..32846bfb8 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -1,280 +1,534 @@ -""" -RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests -""" +"""RuntimeBot event binding and route observability unit tests.""" -from unittest.mock import Mock +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +import pytest -class TestMatchOperator: - """Test the _match_operator static method.""" - @staticmethod - def _get_class(): - from langbot.pkg.platform.botmgr import RuntimeBot - - return RuntimeBot - - def test_eq(self): - cls = self._get_class() - assert cls._match_operator('hello', 'eq', 'hello') is True - assert cls._match_operator('hello', 'eq', 'world') is False - - def test_neq(self): - cls = self._get_class() - assert cls._match_operator('hello', 'neq', 'world') is True - assert cls._match_operator('hello', 'neq', 'hello') is False - - def test_contains(self): - cls = self._get_class() - assert cls._match_operator('hello world', 'contains', 'world') is True - assert cls._match_operator('hello world', 'contains', 'xyz') is False - - def test_not_contains(self): - cls = self._get_class() - assert cls._match_operator('hello world', 'not_contains', 'xyz') is True - assert cls._match_operator('hello world', 'not_contains', 'world') is False - - def test_starts_with(self): - cls = self._get_class() - assert cls._match_operator('hello world', 'starts_with', 'hello') is True - assert cls._match_operator('hello world', 'starts_with', 'world') is False - - def test_regex(self): - cls = self._get_class() - assert cls._match_operator('hello123', 'regex', r'\d+') is True - assert cls._match_operator('hello', 'regex', r'\d+') is False - - def test_regex_invalid_pattern(self): - cls = self._get_class() - assert cls._match_operator('hello', 'regex', r'[invalid') is False - - def test_unknown_operator(self): - cls = self._get_class() - assert cls._match_operator('hello', 'unknown_op', 'hello') is False - - -class TestResolvePipelineUuid: - """Test the resolve_pipeline_uuid method.""" +class TestEventRouteTrace: + """Test structured event route trace logging.""" @staticmethod - def _make_bot(default_pipeline: str, rules: list): + def _make_bot(event_bindings: list[dict]): from langbot.pkg.platform.botmgr import RuntimeBot - bot_entity = Mock() - bot_entity.use_pipeline_uuid = default_pipeline - bot_entity.pipeline_routing_rules = rules - bot = object.__new__(RuntimeBot) - bot.bot_entity = bot_entity + bot.bot_entity = SimpleNamespace(uuid='bot-1', event_bindings=event_bindings) + bot.logger = SimpleNamespace( + info=AsyncMock(), + warning=AsyncMock(), + error=AsyncMock(), + ) return bot - def test_no_rules_returns_default(self): - bot = self._make_bot('default-uuid', []) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'default-uuid' - assert routed is False + @pytest.mark.asyncio + async def test_dispatch_no_matching_route_records_trace(self): + """A route miss is visible as structured route trace metadata.""" + bot = self._make_bot([]) + + await bot._dispatch_eba_event_to_processor(SimpleNamespace(type='platform.member.joined'), Mock()) + + bot.logger.info.assert_awaited_once() + _, kwargs = bot.logger.info.await_args + metadata = kwargs['metadata'] + assert metadata['kind'] == 'event_route_trace' + assert metadata['event_type'] == 'platform.member.joined' + assert metadata['status'] == 'not_matched' + assert metadata['failure_code'] == 'route_not_found' + + @pytest.mark.asyncio + async def test_record_event_route_trace_includes_binding_and_target(self): + """Trace metadata preserves binding and processor identifiers.""" + bot = self._make_bot([]) + + await bot._record_event_route_trace( + event_type='platform.member.joined', + status='failed', + level='warning', + binding={ + 'id': 'binding-1', + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + }, + failure_code='processor_disabled', + reason='Agent target is disabled', + text='disabled', + ) + + bot.logger.warning.assert_awaited_once() + _, kwargs = bot.logger.warning.await_args + metadata = kwargs['metadata'] + assert metadata['binding_id'] == 'binding-1' + assert metadata['event_pattern'] == 'platform.member.*' + assert metadata['target_type'] == 'agent' + assert metadata['target_uuid'] == 'agent-1' + assert metadata['status'] == 'failed' + + @pytest.mark.asyncio + async def test_dispatch_test_event_suppresses_agent_output_delivery(self): + """Synthetic test dispatch runs the route but does not call the real adapter.""" + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + captured_envelopes = [] + + async def fake_run(envelope, binding): + captured_envelopes.append(envelope) + yield provider_message.Message(role='assistant', content='test response') + + bot = self._make_bot( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ] + ) + bot.ap = SimpleNamespace( + agent_service=SimpleNamespace( + get_agent=AsyncMock( + return_value={ + 'uuid': 'agent-1', + 'kind': 'agent', + 'enabled': True, + 'supported_event_patterns': ['message.received'], + 'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}}, + } + ) + ), + agent_run_orchestrator=SimpleNamespace(run=fake_run), + ) + bot.adapter = SimpleNamespace( + bot_account_id='bot-account', + config={}, + logger=bot.logger, + send_message=AsyncMock(), + get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']), + ) + + result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'}) + + bot.adapter.send_message.assert_not_awaited() + assert result['dispatched'] is True + assert result['status'] == 'delivered' + assert result['suppressed_outputs'][0]['method'] == 'send_message' + assert captured_envelopes[0].delivery.supports_edit is False + assert captured_envelopes[0].delivery.supports_reaction is False + assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info'] + + @pytest.mark.asyncio + async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self): + """Persisted malformed Agent config cannot escape the per-event route boundary.""" + bot = self._make_bot( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ] + ) + malformed_agent = { + 'uuid': 'agent-1', + 'kind': 'agent', + 'enabled': True, + 'supported_event_patterns': ['platform.member.joined'], + 'config': { + 'runner': {'id': 'runner-1'}, + 'runner_config': {'runner-1': ['invalid']}, + }, + } + valid_agent = { + **malformed_agent, + 'config': { + 'runner': {'id': 'runner-1'}, + 'runner_config': {'runner-1': {}}, + }, + } + runner_calls = [] + + async def fake_run(envelope, binding): + runner_calls.append((envelope, binding)) + if False: + yield None + + bot.ap = SimpleNamespace( + agent_service=SimpleNamespace(get_agent=AsyncMock(side_effect=[malformed_agent, valid_agent])), + agent_run_orchestrator=SimpleNamespace(run=fake_run), + ) + event = SimpleNamespace(type='platform.member.joined') + + failed = await bot._dispatch_eba_event_to_processor(event, Mock()) + delivered = await bot._dispatch_eba_event_to_processor(event, Mock()) + + assert failed['status'] == 'failed' + assert failed['failure_code'] == 'runner_failed' + assert failed['reason'] == 'Agent configuration is invalid' + assert delivered['status'] == 'delivered' + assert len(runner_calls) == 1 + + @pytest.mark.asyncio + async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self): + """Pipeline route tests enqueue queries with the no-op adapter.""" + bot = self._make_bot( + [ + { + 'id': 'pipeline-binding', + 'enabled': True, + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'priority': 0, + 'order': 0, + } + ] + ) + bot.ap = SimpleNamespace( + msg_aggregator=SimpleNamespace(add_message=AsyncMock()), + ) + bot.adapter = SimpleNamespace( + bot_account_id='bot-account', + config={}, + logger=bot.logger, + send_message=AsyncMock(), + ) + + result = await bot.dispatch_test_event( + 'message.received', + {'chat_id': 'user-1', 'message_text': 'hello'}, + ) + + bot.adapter.send_message.assert_not_awaited() + bot.ap.msg_aggregator.add_message.assert_awaited_once() + _, kwargs = bot.ap.msg_aggregator.add_message.await_args + query_adapter = kwargs['adapter'] + assert query_adapter is not bot.adapter + assert getattr(query_adapter, 'source') is bot.adapter + assert result['dispatched'] is True + assert result['status'] == 'delivered' + assert result['suppressed_outputs'] == [] + + @pytest.mark.asyncio + async def test_dispatch_test_event_reports_unmatched_route_as_failure(self): + """Synthetic dispatch does not report success when no saved route matches.""" + bot = self._make_bot([]) + bot.adapter = SimpleNamespace( + bot_account_id='bot-account', + config={}, + logger=bot.logger, + ) + + result = await bot.dispatch_test_event( + 'message.received', + {'chat_id': 'user-1', 'message_text': 'hello'}, + ) + + assert result['dispatched'] is False + assert result['status'] == 'not_matched' + assert result['failure_code'] == 'route_not_found' + assert result['reason'] == 'No event route matched' + + @pytest.mark.asyncio + async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self): + """Synthetic adapter blocks optional platform APIs that mutate external state.""" + from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + source = SimpleNamespace( + bot_account_id='bot-account', + config={}, + logger=Mock(), + get_supported_apis=Mock( + return_value=[ + 'send_message', + 'delete_message', + 'get_group_info', + 'call_platform_api', + ] + ), + delete_message=AsyncMock(), + call_platform_api=AsyncMock(), + ) + adapter = SyntheticRouteTestAdapter(source) + + await adapter.delete_message('group', 'group-1', 'message-1') + await adapter.call_platform_api('set_title', {'name': 'New Title'}) + upload_result = await adapter.upload_file(b'data', 'test.txt') + + source.delete_message.assert_not_awaited() + source.call_platform_api.assert_not_awaited() + assert upload_result == 'suppressed:test.txt' + assert [item['method'] for item in adapter.suppressed_outputs] == [ + 'delete_message', + 'call_platform_api', + 'upload_file', + ] + assert adapter.get_supported_apis() == ['get_group_info'] + assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')])) - def test_none_rules_returns_default(self): - bot = self._make_bot('default-uuid', None) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'default-uuid' - assert routed is False + def test_build_test_platform_event_message_received_uses_payload(self): + """Synthetic message events preserve common route filter fields.""" + from langbot.pkg.platform.botmgr import RuntimeBot - def test_launcher_type_match(self): - rules = [ + event = RuntimeBot._build_test_platform_event( + 'message.received', { - 'type': 'launcher_type', - 'operator': 'eq', - 'value': 'group', - 'pipeline_uuid': 'group-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) + 'chat_type': 'group', + 'chat_id': 'group-1', + 'group_name': 'QA Group', + 'user_id': 'user-1', + 'user_name': 'QA User', + 'message_text': 'hello', + }, + ) + + assert event.type == 'message.received' + assert str(event.chat_id) == 'group-1' + assert event.group.name == 'QA Group' + assert event.sender.nickname == 'QA User' + assert str(event.message_chain) == 'hello' + + def test_agent_envelope_projects_adapter_delivery_capabilities(self): + """Runner delivery context reflects the active adapter's declared APIs.""" + from langbot_plugin.api.entities.builtin.platform import entities, events, message + + bot = self._make_bot([]) + bot.bot_entity.uuid = 'bot-1' + adapter = SimpleNamespace( + get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'edit_message', None]) + ) + event = events.MessageReceivedEvent( + message_id='message-1', + message_chain=message.MessageChain([message.Plain(text='hello')]), + sender=entities.User(id='user-1', nickname='QA User'), + chat_type=entities.ChatType.PRIVATE, + chat_id='user-1', + ) + + envelope = bot._eba_event_to_agent_envelope(event, adapter) + + assert envelope.delivery.supports_edit is True + assert envelope.delivery.supports_reaction is True + assert envelope.delivery.platform_capabilities == { + 'adapter': 'SimpleNamespace', + 'event_type': 'message.received', + 'supported_apis': ['send_message', 'edit_message', 'add_reaction'], + } + + def test_adapter_delivery_capabilities_degrade_on_invalid_declaration(self): + """Broken third-party capability declarations do not block event dispatch.""" + from langbot.pkg.platform.botmgr import RuntimeBot - uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi') - assert uuid == 'group-pipeline' - assert routed is True + adapter = SimpleNamespace(get_supported_apis=Mock(side_effect=RuntimeError('broken manifest'))) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'default-uuid' - assert routed is False + assert RuntimeBot._get_adapter_supported_apis(adapter) == [] - def test_launcher_id_match(self): - rules = [ - { - 'type': 'launcher_id', - 'operator': 'eq', - 'value': '12345', - 'pipeline_uuid': 'vip-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) - uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi') - assert uuid == 'vip-pipeline' - assert routed is True +class TestEventLoggerMetadata: + """Test platform EventLogger metadata compatibility.""" - uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi') - assert uuid == 'default-uuid' - assert routed is False + @pytest.mark.asyncio + async def test_metadata_is_serialized_without_breaking_no_throw_position(self): + """Metadata is optional and no_throw remains the fourth positional argument.""" + from langbot.pkg.platform.logger import EventLogger - def test_message_content_contains(self): - rules = [ - { - 'type': 'message_content', - 'operator': 'contains', - 'value': '紧急', - 'pipeline_uuid': 'urgent-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) + logger = EventLogger(name='test', ap=SimpleNamespace()) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息') - assert uuid == 'urgent-pipeline' - assert routed is True + await logger.info('plain log', None, None, False) + await logger.info( + 'route trace', + metadata={'kind': 'event_route_trace', 'status': 'matched'}, + ) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息') - assert uuid == 'default-uuid' - assert routed is False + assert logger.logs[0].to_json()['metadata'] is None + assert logger.logs[1].to_json()['metadata'] == { + 'kind': 'event_route_trace', + 'status': 'matched', + } - def test_message_content_regex(self): - rules = [ - { - 'type': 'message_content', - 'operator': 'regex', - 'value': r'^/admin\b', - 'pipeline_uuid': 'admin-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help') - assert uuid == 'admin-pipeline' - assert routed is True +class TestRuntimeBotLifecycle: + """Test RuntimeBot startup and shutdown lifecycle edges.""" - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin') - assert uuid == 'default-uuid' - assert routed is False + @pytest.mark.asyncio + async def test_shutdown_before_run_is_idempotent(self): + """A newly loaded Bot can be removed even before its task starts.""" + from langbot.pkg.platform.botmgr import RuntimeBot - def test_message_has_element_eq(self): - rules = [ - { - 'type': 'message_has_element', - 'operator': 'eq', - 'value': 'Image', - 'pipeline_uuid': 'image-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) + task_mgr = SimpleNamespace(cancel_task=Mock()) + bot = RuntimeBot( + ap=SimpleNamespace(task_mgr=task_mgr), + bot_entity=SimpleNamespace(enable=True), + adapter=SimpleNamespace(kill=AsyncMock()), + logger=Mock(), + ) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image']) - assert uuid == 'image-pipeline' - assert routed is True + await bot.shutdown() - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain']) - assert uuid == 'default-uuid' - assert routed is False + bot.adapter.kill.assert_awaited_once() + task_mgr.cancel_task.assert_not_called() - def test_message_has_element_neq(self): - rules = [ - { - 'type': 'message_has_element', - 'operator': 'neq', - 'value': 'Image', - 'pipeline_uuid': 'text-only-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain']) - assert uuid == 'text-only-pipeline' - assert routed is True +class TestEBAEventBindings: + """Test RuntimeBot EBA event binding helpers.""" - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image']) - assert uuid == 'default-uuid' - assert routed is False + @staticmethod + def _make_bot(bindings): + from langbot.pkg.platform.botmgr import RuntimeBot - def test_message_has_element_no_types_provided(self): - """When element types are not provided, should not match.""" - rules = [ - { - 'type': 'message_has_element', - 'operator': 'eq', - 'value': 'Image', - 'pipeline_uuid': 'image-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(event_bindings=bindings) + return bot - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'default-uuid' - assert routed is False + def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self): + """The selected binding is the first matching highest-priority binding.""" + bot = self._make_bot( + [ + { + 'id': 'disabled', + 'enabled': False, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-disabled', + 'priority': 100, + 'order': 0, + }, + { + 'id': 'wrong-room', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-wrong-room', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-2'}], + 'priority': 50, + 'order': 1, + }, + { + 'id': 'first-high', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-first', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 10, + 'order': 2, + }, + { + 'id': 'second-high', + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-second', + 'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}], + 'priority': 10, + 'order': 3, + }, + { + 'id': 'fallback', + 'event_pattern': '*', + 'target_type': 'discard', + 'priority': 1, + 'order': 4, + }, + ] + ) + + selected = bot._resolve_eba_event_binding( + {'room': {'id': 'room-1'}}, + 'platform.member.joined', + ) + + assert selected['id'] == 'first-high' + assert selected['target_uuid'] == 'agent-first' + + def test_agent_product_to_binding_projects_runner_config_and_policies(self): + """Agent products become bot-scoped runner bindings for EBA dispatch.""" + from langbot.pkg.platform.botmgr import RuntimeBot - def test_first_match_wins(self): - rules = [ - { - 'type': 'launcher_type', - 'operator': 'eq', - 'value': 'group', - 'pipeline_uuid': 'first-pipeline', - }, + binding = RuntimeBot._agent_product_to_binding( { - 'type': 'launcher_type', - 'operator': 'eq', - 'value': 'group', - 'pipeline_uuid': 'second-pipeline', + 'uuid': 'agent-1', + 'component_ref': 'plugin:test/fallback/default', + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'temperature': 0.2, + 'max_tokens': 1000, + } + }, + }, }, - ] - bot = self._make_bot('default-uuid', rules) - - uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi') - assert uuid == 'first-pipeline' - assert routed is True - - def test_skip_invalid_rules(self): - rules = [ - {'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'}, - {'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''}, - {'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'}, - ] - bot = self._make_bot('default-uuid', rules) - - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'valid' - assert routed is True + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) + + assert binding is not None + assert binding.binding_id == 'bot:bot-1:binding-1' + assert binding.scope.scope_type == 'bot' + assert binding.scope.scope_id == 'bot-1' + assert binding.event_types == ['platform.member.joined'] + assert binding.runner_id == 'plugin:test/runner/default' + assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000} + assert binding.resource_policy.allow_all_tools is True + assert binding.resource_policy.allowed_tool_names is None + assert binding.delivery_policy.enable_streaming is False + assert binding.delivery_policy.enable_reply is True + assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner'] + assert binding.agent_id == 'agent-1' + + def test_agent_product_to_binding_projects_selected_tool_policy(self): + """Independent Agents use the same standard runner resource fields as Pipelines.""" + from langbot.pkg.platform.botmgr import RuntimeBot - def test_default_operator_is_eq(self): - rules = [ + binding = RuntimeBot._agent_product_to_binding( { - 'type': 'launcher_type', - 'value': 'person', - 'pipeline_uuid': 'person-pipeline', - } - ] - bot = self._make_bot('default-uuid', rules) - - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi') - assert uuid == 'person-pipeline' - assert routed is True - - def test_discard_pipeline(self): - """When pipeline_uuid is __discard__, the message should be discarded.""" + 'uuid': 'agent-1', + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'enable-all-tools': False, + 'tools': ['exec', 'plugin_tool'], + 'knowledge-bases': ['kb-1'], + } + }, + }, + }, + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) + + assert binding is not None + assert binding.resource_policy.allow_all_tools is False + assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool'] + assert binding.resource_policy.allowed_kb_uuids == ['kb-1'] + + def test_agent_product_to_binding_does_not_fallback_to_component_ref(self): + """An empty config runner stays unconfigured even if component_ref is stale.""" from langbot.pkg.platform.botmgr import RuntimeBot - rules = [ + binding = RuntimeBot._agent_product_to_binding( { - 'type': 'message_content', - 'operator': 'contains', - 'value': 'spam', - 'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD, - } - ] - bot = self._make_bot('default-uuid', rules) - - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam') - assert uuid == RuntimeBot.PIPELINE_DISCARD - assert routed is True + 'uuid': 'agent-1', + 'component_ref': 'plugin:test/stale/default', + 'config': { + 'runner': {'id': ''}, + 'runner_config': {}, + }, + }, + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) - uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message') - assert uuid == 'default-uuid' - assert routed is False + assert binding is None diff --git a/tests/unit_tests/platform/test_slack_eba_adapter.py b/tests/unit_tests/platform/test_slack_eba_adapter.py new file mode 100644 index 000000000..e58eec9de --- /dev/null +++ b/tests/unit_tests/platform/test_slack_eba_adapter.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import datetime +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.adapter import SlackAdapter +from langbot.pkg.platform.adapters.slack.errors import NotSupportedError +from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter +from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter +from langbot.pkg.platform.adapters.slack.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummySlackWebClient: + async def auth_test(self): + return {'ok': True, 'user_id': 'B-1'} + + +class DummySlackClient: + def __init__(self, *args, **kwargs): + self.bot_token = kwargs['bot_token'] + self.signing_secret = kwargs['signing_secret'] + self.unified_mode = kwargs['unified_mode'] + self._message_handlers = {} + self.client = DummySlackWebClient() + self.sent = [] + self.handle_unified_webhook = AsyncMock(return_value='ok') + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + async def send_message_to_channel(self, text: str, channel_id: str): + self.sent.append(('channel', channel_id, text)) + return {'ok': True, 'channel': channel_id, 'message': {'ts': '1.1'}} + + async def send_message_to_one(self, text: str, user_id: str): + self.sent.append(('person', user_id, text)) + return {'ok': True, 'channel': user_id, 'message': {'ts': '1.2'}} + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'slack' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter() -> SlackAdapter: + config = { + 'bot_token': 'xoxb-token', + 'signing_secret': 'signing-secret', + 'bot_user_id': 'B-1', + } + with patch('langbot.pkg.platform.adapters.slack.adapter.SlackClient', DummySlackClient): + return SlackAdapter(config, DummyLogger()) + + +def slack_event(channel_type: str = 'im', **overrides) -> SlackEvent: + text = overrides.get('text', 'hello') + payload = { + 'event_id': overrides.get('event_id', 'evt-1'), + 'event': { + 'type': 'app_mention' if channel_type == 'channel' else 'message', + 'channel_type': channel_type, + 'user': overrides.get('user_id', 'U-1'), + 'channel': overrides.get('channel_id', 'C-1'), + 'ts': overrides.get('ts', '1710003600.000100'), + 'event_ts': overrides.get('ts', '1710003600.000100'), + 'blocks': [ + { + 'type': 'rich_text', + 'elements': [ + { + 'type': 'rich_text_section', + 'elements': [ + {'type': 'text', 'text': text}, + ], + } + ], + } + ], + }, + } + if channel_type == 'im': + payload['event']['blocks'] = [ + { + 'elements': [ + { + 'elements': [ + {'type': 'text', 'text': text}, + ] + } + ] + } + ] + if overrides.get('pic_url'): + payload['event']['files'] = [{'url_private': overrides['pic_url']}] + return SlackEvent(payload) + + +def test_slack_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_slack_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_slack_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_slack_message_converter_maps_common_components_to_text(): + text = await SlackMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Source(id='msg-0', time=datetime.datetime.now()), + platform_message.Plain(text='hi'), + platform_message.At(target='U-2'), + platform_message.AtAll(), + platform_message.Image(url='https://example.test/a.png'), + platform_message.File(name='a.txt'), + platform_message.Quote(origin=platform_message.MessageChain([platform_message.Plain(text='quoted')])), + ] + ) + ) + + assert 'hi' in text + assert '<@U-2>' in text + assert '' in text + assert 'https://example.test/a.png' in text + assert 'a.txt' in text + assert 'quoted' in text + + +@pytest.mark.asyncio +async def test_slack_event_converter_maps_private_group_and_platform_specific(): + private_event = await SlackEventConverter().target2yiri(slack_event('im')) + group_event = await SlackEventConverter().target2yiri(slack_event('channel')) + platform_event = await SlackEventConverter().target2yiri(slack_event('file_share')) + + assert isinstance(private_event, platform_events.MessageReceivedEvent) + assert private_event.adapter_name == 'slack-eba' + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'U-1' + assert str(private_event.message_chain) == 'hello' + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'C-1' + assert isinstance(group_event.message_chain[1], platform_message.At) + + assert isinstance(platform_event, platform_events.PlatformSpecificEvent) + assert platform_event.action == 'slack.file_share' + + +@pytest.mark.asyncio +async def test_slack_adapter_dispatches_eba_and_legacy_and_caches_group_event(): + adapter = make_adapter() + eba_calls: list[platform_events.Event] = [] + legacy_calls: list[platform_events.Event] = [] + + async def eba_listener(event, adapter): + eba_calls.append(event) + + async def legacy_listener(event, adapter): + legacy_calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + adapter.register_listener(platform_events.GroupMessage, legacy_listener) + await adapter._handle_native_event(slack_event('channel')) + + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + received = eba_calls[0] + assert await adapter.get_message('group', 'C-1', 'evt-1') == received + assert (await adapter.get_group_info('C-1')).id == 'C-1' + assert (await adapter.get_group_member_info('C-1', 'U-1')).user.id == 'U-1' + + +@pytest.mark.asyncio +async def test_slack_send_reply_platform_api_and_unsupported(): + adapter = make_adapter() + source_event = await SlackEventConverter().target2yiri(slack_event('im')) + + reply_result = await adapter.reply_message(source_event, platform_message.MessageChain([platform_message.Plain(text='reply')])) + assert reply_result.message_id == 'evt-1' + assert ('person', 'U-1', 'reply') in adapter.bot.sent + + await adapter.send_message('group', 'C-1', platform_message.MessageChain([platform_message.Plain(text='hello channel')])) + assert ('channel', 'C-1', 'hello channel') in adapter.bot.sent + + assert await adapter.call_platform_api('get_mode', {}) == { + 'webhook': True, + 'bot_account_id': 'B-1', + } + assert await adapter.call_platform_api('auth_test', {}) == {'ok': True, 'user_id': 'B-1'} + + with pytest.raises(NotSupportedError): + await adapter.call_platform_api('missing', {}) diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py new file mode 100644 index 000000000..c703e9a7d --- /dev/null +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import base64 +import datetime +import pathlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import yaml +import telegram +from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler, MessageReactionHandler + +from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter +from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP +from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter +from langbot.pkg.platform.botmgr import RuntimeBot +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities import events as plugin_events + + +@pytest.fixture(autouse=True) +def clear_proxy_env(monkeypatch): + for key in ('ALL_PROXY', 'HTTPS_PROXY', 'HTTP_PROXY', 'all_proxy', 'https_proxy', 'http_proxy'): + monkeypatch.delenv(key, raising=False) + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +def make_adapter() -> TelegramAdapter: + return TelegramAdapter( + { + 'token': '123456:ABCDEF_fake_token_for_object_parsing', + 'markdown_card': False, + 'enable-stream-reply': False, + }, + DummyLogger(), + ) + + +def make_update(data: dict) -> telegram.Update: + payload = {'update_id': 1000, **data} + return telegram.Update.de_json(payload, make_adapter().bot) + + +def base_message_payload(**overrides): + payload = { + 'message_id': 10, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'chat': {'id': 123, 'type': 'private', 'first_name': 'Chat User'}, + 'from': {'id': 456, 'is_bot': False, 'first_name': 'Sender', 'username': 'sender'}, + 'text': 'hello', + } + payload.update(overrides) + return payload + + +def test_telegram_adapter_registers_all_declared_update_handlers(): + adapter = make_adapter() + + handlers = adapter.application.handlers[0] + + assert sum(isinstance(handler, MessageHandler) for handler in handlers) == 2 + assert sum(isinstance(handler, ChatMemberHandler) for handler in handlers) == 2 + assert any(isinstance(handler, CallbackQueryHandler) for handler in handlers) + assert any(isinstance(handler, MessageReactionHandler) for handler in handlers) + + +@pytest.mark.asyncio +async def test_telegram_adapter_dispatches_only_most_specific_eba_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + async def message_listener(event, adapter): + calls.append('message.received') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + adapter.register_listener(platform_events.MessageReceivedEvent, message_listener) + + event = platform_events.MessageReceivedEvent( + message_id=1, + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + sender=platform_entities.User(id=1), + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + + assert calls == ['message.received'] + + +@pytest.mark.asyncio +async def test_telegram_adapter_dispatch_falls_back_to_eba_then_event_listener(): + adapter = make_adapter() + calls: list[str] = [] + + async def wildcard_listener(event, adapter): + calls.append('event') + + async def eba_listener(event, adapter): + calls.append('eba') + + adapter.register_listener(platform_events.Event, wildcard_listener) + adapter.register_listener(platform_events.EBAEvent, eba_listener) + + event = platform_events.MessageEditedEvent( + message_id=1, + new_content=platform_message.MessageChain([platform_message.Plain(text='edited')]), + editor=platform_entities.User(id=1), + chat_id=1, + ) + + await adapter._dispatch_eba_event(event) + assert calls == ['eba'] + + adapter.unregister_listener(platform_events.EBAEvent, eba_listener) + await adapter._dispatch_eba_event(event) + assert calls == ['eba', 'event'] + + +def test_telegram_supported_events_match_manifest(): + adapter_events = make_adapter().get_supported_events() + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'telegram' + / 'manifest.yaml' + ) + manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events'] + + assert adapter_events == manifest_events + assert 'message.deleted' not in adapter_events + assert 'group.info_updated' not in adapter_events + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_message_and_edited_message_events(): + update = make_update({'message': base_message_payload(text='hello @test_bot')}) + event = await TelegramEventConverter.target2yiri(update, make_adapter().bot, 'test_bot') + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.type == 'message.received' + assert event.chat_type == platform_entities.ChatType.PRIVATE + assert event.chat_id == 123 + assert event.sender.id == 456 + assert platform_message.At in event.message_chain + assert isinstance(event.message_chain[0], platform_message.At) + assert isinstance(event.message_chain[1], platform_message.Plain) + assert event.message_chain[1].text == 'hello ' + + group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'} + edited_payload = base_message_payload(chat=group_chat, text='edited') + edited_payload['edit_date'] = edited_payload['date'] + 1 + edited = make_update({'edited_message': edited_payload}) + edited_event = await TelegramEventConverter.target2yiri(edited, make_adapter().bot, 'test_bot') + + assert isinstance(edited_event, platform_events.MessageEditedEvent) + assert edited_event.type == 'message.edited' + assert edited_event.chat_type == platform_entities.ChatType.GROUP + assert edited_event.group.name == 'Test Group' + assert str(edited_event.new_content) == 'edited' + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_non_message_updates(): + chat_member = make_update( + { + 'chat_member': { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'}, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'old_chat_member': { + 'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'}, + 'status': 'left', + }, + 'new_chat_member': { + 'user': {'id': 2, 'is_bot': False, 'first_name': 'Member'}, + 'status': 'member', + }, + } + } + ) + joined = await TelegramEventConverter.target2yiri(chat_member, make_adapter().bot, 'test_bot') + assert isinstance(joined, platform_events.MemberJoinedEvent) + assert joined.type == 'group.member_joined' + + callback = make_update( + { + 'callback_query': { + 'id': 'cb-1', + 'from': {'id': 3, 'is_bot': False, 'first_name': 'Clicker'}, + 'chat_instance': 'ci', + 'data': 'button-data', + 'message': base_message_payload(message_id=77), + } + } + ) + callback_event = await TelegramEventConverter.target2yiri(callback, make_adapter().bot, 'test_bot') + assert isinstance(callback_event, platform_events.PlatformSpecificEvent) + assert callback_event.action == 'callback_query' + assert callback_event.data['callback_query_id'] == 'cb-1' + assert callback_event.data['data'] == 'button-data' + + reaction = make_update( + { + 'message_reaction': { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'message_id': 77, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + 'user': {'id': 3, 'is_bot': False, 'first_name': 'Reactor'}, + 'old_reaction': [], + 'new_reaction': [{'type': 'emoji', 'emoji': '👍'}], + } + } + ) + reaction_event = await TelegramEventConverter.target2yiri(reaction, make_adapter().bot, 'test_bot') + assert isinstance(reaction_event, platform_events.MessageReactionEvent) + assert reaction_event.reaction == '👍' + assert reaction_event.is_add is True + + +@pytest.mark.asyncio +async def test_telegram_converter_maps_bot_status_events(): + base_member = { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'from': {'id': 1, 'is_bot': False, 'first_name': 'Admin'}, + 'date': int(datetime.datetime.now(datetime.UTC).timestamp()), + } + restricted_member = { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'restricted', + 'is_member': True, + 'can_send_messages': False, + 'can_send_audios': False, + 'can_send_documents': False, + 'can_send_photos': False, + 'can_send_videos': False, + 'can_send_video_notes': False, + 'can_send_voice_notes': False, + 'can_send_polls': False, + 'can_send_other_messages': False, + 'can_add_web_page_previews': False, + 'can_change_info': False, + 'can_invite_users': False, + 'can_pin_messages': False, + 'can_manage_topics': False, + 'can_edit_tag': False, + 'can_react_to_messages': False, + 'until_date': 0, + } + invited = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'left', + }, + 'new_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + } + } + ) + invited_event = await TelegramEventConverter.target2yiri(invited, make_adapter().bot, 'test_bot') + assert isinstance(invited_event, platform_events.BotInvitedToGroupEvent) + + muted = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + 'new_chat_member': { + **restricted_member, + }, + } + } + ) + muted_event = await TelegramEventConverter.target2yiri(muted, make_adapter().bot, 'test_bot') + assert isinstance(muted_event, platform_events.BotMutedEvent) + + unmuted = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': { + **restricted_member, + }, + 'new_chat_member': { + 'user': {'id': 999, 'is_bot': True, 'first_name': 'Bot'}, + 'status': 'member', + }, + } + } + ) + unmuted_event = await TelegramEventConverter.target2yiri(unmuted, make_adapter().bot, 'test_bot') + assert isinstance(unmuted_event, platform_events.BotUnmutedEvent) + + +@pytest.mark.asyncio +async def test_telegram_reply_message_sends_text_image_and_file_components(): + adapter = make_adapter() + bot = SimpleNamespace( + send_message=AsyncMock(), + send_photo=AsyncMock(), + send_document=AsyncMock(), + ) + object.__setattr__(adapter, 'bot', bot) + update = make_update({'message': base_message_payload(message_id=88)}) + + message_source = platform_events.MessageReceivedEvent( + message_id=88, + source_platform_object=update, + ) + await adapter.reply_message( + message_source, + platform_message.MessageChain( + [ + platform_message.Plain(text='reply text'), + platform_message.Image( + base64='data:image/png;base64,' + base64.b64encode(b'image-bytes').decode('utf-8') + ), + platform_message.File( + name='test.txt', + size=4, + base64='data:text/plain;base64,' + base64.b64encode(b'test').decode('utf-8'), + ), + ] + ), + quote_origin=True, + ) + + bot.send_message.assert_awaited_once() + bot.send_photo.assert_awaited_once() + bot.send_document.assert_awaited_once() + assert bot.send_message.await_args.kwargs['reply_to_message_id'] == 88 + assert bot.send_photo.await_args.kwargs['reply_to_message_id'] == 88 + assert bot.send_photo.await_args.kwargs['photo'].input_file_content == b'image-bytes' + assert bot.send_document.await_args.kwargs['document'].filename == 'test.txt' + assert bot.send_document.await_args.kwargs['document'].input_file_content == b'test' + + +@pytest.mark.asyncio +async def test_telegram_platform_apis_call_underlying_bot_methods(): + bot = SimpleNamespace( + pin_chat_message=AsyncMock(), + unpin_chat_message=AsyncMock(), + unpin_all_chat_messages=AsyncMock(), + get_chat_administrators=AsyncMock( + return_value=[ + SimpleNamespace( + user=SimpleNamespace(id=1, username='admin', first_name='Admin'), + status='administrator', + custom_title='Boss', + ) + ] + ), + set_chat_title=AsyncMock(), + set_chat_description=AsyncMock(), + get_chat_member_count=AsyncMock(return_value=3), + send_chat_action=AsyncMock(), + create_chat_invite_link=AsyncMock( + return_value=SimpleNamespace( + invite_link='https://t.me/+abc', + name='invite', + is_primary=False, + is_revoked=False, + ) + ), + answer_callback_query=AsyncMock(), + ) + + assert await PLATFORM_API_MAP['pin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True} + assert await PLATFORM_API_MAP['unpin_message'](bot, {'chat_id': 1, 'message_id': 2}) == {'ok': True} + assert await PLATFORM_API_MAP['unpin_all_messages'](bot, {'chat_id': 1}) == {'ok': True} + admins = await PLATFORM_API_MAP['get_chat_administrators'](bot, {'chat_id': 1}) + assert admins['administrators'][0]['user_id'] == 1 + assert await PLATFORM_API_MAP['set_chat_title'](bot, {'chat_id': 1, 'title': 'New'}) == {'ok': True} + assert await PLATFORM_API_MAP['set_chat_description'](bot, {'chat_id': 1, 'description': 'Desc'}) == {'ok': True} + assert await PLATFORM_API_MAP['get_chat_member_count'](bot, {'chat_id': 1}) == {'count': 3} + assert await PLATFORM_API_MAP['send_chat_action'](bot, {'chat_id': 1, 'action': 'typing'}) == {'ok': True} + invite = await PLATFORM_API_MAP['create_chat_invite_link'](bot, {'chat_id': 1, 'name': 'invite'}) + assert invite['invite_link'] == 'https://t.me/+abc' + assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True} + + +@pytest.mark.asyncio +async def test_telegram_unmute_member_uses_current_chat_permissions_fields(): + adapter = make_adapter() + bot = SimpleNamespace(restrict_chat_member=AsyncMock()) + object.__setattr__(adapter, 'bot', bot) + + await adapter.unmute_member(group_id=-1001, user_id=123) + + permissions = bot.restrict_chat_member.await_args.kwargs['permissions'] + assert permissions.can_send_messages is True + assert permissions.can_send_photos is True + assert permissions.can_send_documents is True + + +def test_runtime_bot_maps_telegram_eba_events_to_plugin_events(): + group = platform_entities.UserGroup(id='group-1', name='Group') + user = platform_entities.User(id='user-1', nickname='User') + + cases = [ + ( + platform_events.MessageReceivedEvent( + message_id='msg', + message_chain=platform_message.MessageChain([platform_message.Plain(text='hi')]), + sender=user, + chat_id='user-1', + ), + plugin_events.MessageReceived, + ), + ( + platform_events.MessageReactionEvent(message_id='msg', user=user, reaction='👍'), + plugin_events.MessageReactionReceived, + ), + ( + platform_events.MemberJoinedEvent(group=group, member=user), + plugin_events.GroupMemberJoined, + ), + ( + platform_events.BotUnmutedEvent(group=group, operator=user), + plugin_events.BotUnmuted, + ), + ( + platform_events.PlatformSpecificEvent(adapter_name='telegram', action='callback_query', data={'data': 'x'}), + plugin_events.PlatformSpecificEventReceived, + ), + ] + + for platform_event, plugin_event_type in cases: + plugin_event = RuntimeBot._eba_event_to_plugin_event(platform_event) + assert isinstance(plugin_event, plugin_event_type) + assert plugin_event.model_dump()['event_name'] == plugin_event_type.__name__ diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py index 18138383d..71bce9024 100644 --- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -10,11 +10,17 @@ from __future__ import annotations import base64 +import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest -from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.botmgr import RuntimeBot +from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketSession def _make_adapter(load_return=b'hello', load_side_effect=None): @@ -90,3 +96,64 @@ async def test_load_failure_is_logged_not_raised(): await adapter._process_image_components(chain) assert 'base64' not in chain[0] adapter.logger.error.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_websocket_message_marks_event_with_pipeline_uuid(): + adapter, _ = _make_adapter() + adapter.websocket_person_session = WebSocketSession(id='websocketperson') + adapter.listeners = {} + adapter.listeners[platform_events.FriendMessage] = AsyncMock() + adapter.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = '' + + connection = SimpleNamespace( + pipeline_uuid='pipeline-123', + session_type='person', + connection_id='conn-1', + ) + + await adapter.handle_websocket_message( + connection, + {'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': True}, + ) + await asyncio.sleep(0) + + event = adapter.listeners[platform_events.FriendMessage].await_args.args[0] + assert getattr(event, '_langbot_pipeline_uuid') == 'pipeline-123' + + +@pytest.mark.asyncio +async def test_runtime_bot_websocket_listener_uses_event_pipeline_uuid(): + app = Mock() + app.msg_aggregator.add_message = AsyncMock() + app.webhook_pusher = None + logger = Mock() + logger.info = AsyncMock() + logger.warning = AsyncMock() + logger.error = AsyncMock() + bot_entity = Mock() + bot_entity.uuid = 'websocket-proxy-bot' + bot_entity.enable = True + bot_entity.use_pipeline_uuid = '' + adapter = WebSocketAdapter.model_construct( + ap=app, + logger=Mock(error=AsyncMock()), + listeners={}, + websocket_person_session=WebSocketSession(id='websocketperson'), + websocket_group_session=WebSocketSession(id='websocketgroup'), + ) + bot = RuntimeBot(ap=app, bot_entity=bot_entity, adapter=adapter, logger=logger) + + await bot.initialize() + + event = platform_events.FriendMessage( + sender=platform_entities.Friend(id='sender-1', nickname='User', remark='User'), + message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]), + time=1, + ) + object.__setattr__(event, '_langbot_pipeline_uuid', 'pipeline-123') + + await adapter.listeners[platform_events.FriendMessage](event, adapter) + + app.msg_aggregator.add_message.assert_awaited_once() + assert app.msg_aggregator.add_message.await_args.kwargs['pipeline_uuid'] == 'pipeline-123' diff --git a/tests/unit_tests/platform/test_wecom_eba_adapter.py b/tests/unit_tests/platform/test_wecom_eba_adapter.py new file mode 100644 index 000000000..9563c39a3 --- /dev/null +++ b/tests/unit_tests/platform/test_wecom_eba_adapter.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.wecom_api.api import WecomClient +from langbot.libs.wecom_api.wecomevent import WecomEvent +from langbot.pkg.platform.adapters.wecom.adapter import WecomAdapter +from langbot.pkg.platform.adapters.wecom.event_converter import WecomEventConverter +from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter, split_string_by_bytes +from langbot.pkg.platform.adapters.wecom.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyWecomClient(WecomClient): + def __init__(self, *args, **kwargs): + self.corpid = kwargs['corpid'] + self.secret = kwargs['secret'] + self.token = kwargs['token'] + self.aes = kwargs['EncodingAESKey'] + self.secret_for_contacts = kwargs.get('contacts_secret', '') + self.base_url = kwargs.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin') + self.logger = kwargs.get('logger') + self.access_token = '' + self._message_handlers = {} + self.get_media_id = AsyncMock(return_value='media-id') + self.send_private_msg = AsyncMock() + self.send_image = AsyncMock() + self.send_voice = AsyncMock() + self.send_file = AsyncMock() + self.get_user_info = AsyncMock(return_value={'userid': 'user-1', 'name': 'Alice', 'alias': 'alice'}) + self.check_access_token = AsyncMock(return_value=True) + self.get_access_token = AsyncMock(return_value='access-token') + self.send_to_all = AsyncMock() + self.handle_unified_webhook = AsyncMock(return_value='success') + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'wecom' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter() -> WecomAdapter: + config = { + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'encoding-key', + 'contacts_secret': 'contacts-secret', + 'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin', + } + with patch('langbot.pkg.platform.adapters.wecom.adapter.WecomClient', DummyWecomClient): + return WecomAdapter(config, DummyLogger()) + + +def wecom_event(**overrides) -> WecomEvent: + payload = { + 'ToUserName': overrides.get('to_user', 'corp-id'), + 'FromUserName': overrides.get('from_user', 'user-1'), + 'CreateTime': overrides.get('create_time', 1_714_000_000), + 'MsgType': overrides.get('msg_type', 'text'), + 'Content': overrides.get('content', 'hello'), + 'MsgId': overrides.get('message_id', 12345), + 'AgentID': overrides.get('agent_id', 1000002), + } + if payload['MsgType'] == 'image': + payload['MediaId'] = overrides.get('media_id', 'media-id') + payload['PicUrl'] = overrides.get('picurl', 'https://example.test/a.png') + return WecomEvent.from_payload(payload) + + +def test_wecom_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_wecom_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_wecom_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +def test_wecom_split_string_by_bytes_keeps_multibyte_boundaries(): + parts = split_string_by_bytes('你好hello', limit=7) + + assert ''.join(parts) == '你好hello' + assert all(len(part.encode('utf-8')) <= 7 for part in parts) + + +@pytest.mark.asyncio +async def test_wecom_message_converter_maps_outbound_components(): + adapter = make_adapter() + content = await WecomMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi'), + platform_message.Image(base64='data:image/png;base64,AAAA'), + platform_message.Voice(base64='data:audio/mp3;base64,BBBB'), + platform_message.File(name='doc.txt', base64='Q0NDQw=='), + platform_message.Quote( + id='origin', + origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]), + ), + ] + ), + adapter.bot, + ) + + assert content[0] == {'type': 'text', 'content': 'hi'} + assert {'type': 'image', 'media_id': 'media-id'} in content + assert {'type': 'voice', 'media_id': 'media-id'} in content + assert {'type': 'file', 'media_id': 'media-id'} in content + assert {'type': 'text', 'content': '[Quote origin] '} in content + assert {'type': 'text', 'content': 'quoted'} in content + + +@pytest.mark.asyncio +async def test_wecom_event_converter_maps_text_message_to_eba_and_legacy(): + adapter = make_adapter() + event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot) + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.adapter_name == 'wecom-eba' + assert event.chat_type == platform_entities.ChatType.PRIVATE + assert event.chat_id == 'user-1|1000002' + assert event.sender.nickname == 'Alice' + assert str(event.message_chain) == 'hello' + + legacy = await WecomEventConverter.target2legacy(wecom_event(), adapter.bot) + assert isinstance(legacy, platform_events.FriendMessage) + assert legacy.sender.id == 'user-1' + assert str(legacy.message_chain) == 'hello' + + +@pytest.mark.asyncio +async def test_wecom_event_converter_maps_image_message_to_eba(): + adapter = make_adapter() + + with patch( + 'langbot.pkg.platform.adapters.wecom.message_converter.image.get_wecom_image_base64', + AsyncMock(return_value=('AAAA', 'png')), + ): + event = await WecomEventConverter.target2yiri( + wecom_event(msg_type='image', content=None, picurl='https://example.test/a.png'), + adapter.bot, + ) + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.adapter_name == 'wecom-eba' + assert event.message_id == 12345 + assert isinstance(event.message_chain[1], platform_message.Image) + assert event.message_chain[1].base64 == 'data:image/png;base64,AAAA' + + +@pytest.mark.asyncio +async def test_wecom_adapter_dispatches_and_caches_message_event(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, listener) + await adapter._handle_native_event(wecom_event()) + + assert len(calls) == 1 + received = calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert adapter.bot_account_id == 'corp-id' + assert received.chat_id == 'user-1|1000002' + assert await adapter.get_message('private', 'user-1|1000002', 12345) == received + assert (await adapter.get_user_info('user-1')).nickname == 'Alice' + + +@pytest.mark.asyncio +async def test_wecom_send_reply_and_platform_api_use_underlying_client(): + adapter = make_adapter() + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'user-1|1000002', message) + adapter.bot.send_private_msg.assert_awaited_once_with('user-1', 1000002, 'hello') + + source_event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot) + await adapter.reply_message(source_event, message) + assert adapter.bot.send_private_msg.await_count == 2 + + token_status = await adapter.call_platform_api('check_access_token', {}) + user_info = await adapter.call_platform_api('get_user_info', {'user_id': 'user-1'}) + sent_all = await adapter.call_platform_api('send_to_all', {'content': 'notice', 'agent_id': 1000002}) + + assert token_status == {'valid': True} + assert user_info['name'] == 'Alice' + assert sent_all == {'ok': True} diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py new file mode 100644 index 000000000..e284abea9 --- /dev/null +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent +from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter +from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter +from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter +from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyWecomBotWsClient: + def __init__(self, *args, **kwargs): + self.bot_id = kwargs['bot_id'] + self.secret = kwargs['secret'] + self.encoding_aes_key = kwargs.get('encoding_aes_key', '') + self._message_handlers = {} + self.connect = AsyncMock() + self.disconnect = AsyncMock() + self.send_message = AsyncMock(return_value={'ok': True}) + self.reply_text = AsyncMock(return_value={'reply': True}) + self.push_stream_chunk = AsyncMock(return_value=True) + self.set_message = AsyncMock(return_value={'set': True}) + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + def on_feedback(self): + def decorator(func): + self._message_handlers.setdefault('feedback', []).append(func) + return func + + return decorator + + +class DummyWecomBotClient(DummyWecomBotWsClient): + def __init__(self, *args, **kwargs): + self.Token = kwargs['Token'] + self.EnCodingAESKey = kwargs['EnCodingAESKey'] + self.Corpid = kwargs['Corpid'] + self._message_handlers = {} + self.handle_unified_webhook = AsyncMock(return_value='success') + self.push_stream_chunk = AsyncMock(return_value=True) + self.set_message = AsyncMock(return_value={'set': True}) + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'wecombot' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter: + config = { + 'BotId': 'bot-id', + 'robot_name': 'EBA Bot', + 'enable-webhook': enable_webhook, + 'Secret': 'secret', + 'Token': 'token', + 'EncodingAESKey': 'encoding-key', + 'Corpid': 'corp-id', + 'enable-stream-reply': True, + } + with ( + patch('langbot.pkg.platform.adapters.wecombot.adapter.WecomBotWsClient', DummyWecomBotWsClient), + patch('langbot.pkg.platform.adapters.wecombot.adapter.WecomBotClient', DummyWecomBotClient), + ): + return WecomBotAdapter(config, DummyLogger()) + + +def wecombot_event(**overrides) -> WecomBotEvent: + event_type = overrides.get('type', 'single') + payload = { + 'type': event_type, + 'msgtype': overrides.get('msgtype', 'text'), + 'msgid': overrides.get('message_id', 'msg-1'), + 'userid': overrides.get('userid', 'user-1'), + 'username': overrides.get('username', 'Alice'), + 'content': overrides.get('content', 'hello'), + 'aibotid': overrides.get('aibotid', 'bot-id'), + 'req_id': overrides.get('req_id', 'req-1'), + 'stream_id': overrides.get('stream_id', 'stream-1'), + } + if event_type == 'group': + payload.update({'chatid': overrides.get('chatid', 'group-1'), 'chatname': overrides.get('chatname', 'Group')}) + if payload['msgtype'] == 'image': + payload['images'] = overrides.get('images', ['data:image/png;base64,AAAA']) + payload['content'] = overrides.get('content', '') + if payload['msgtype'] == 'file': + payload['file'] = overrides.get('file', {'download_url': 'https://example.test/a.txt', 'filename': 'a.txt'}) + payload['content'] = overrides.get('content', '') + if payload['msgtype'] == 'voice': + payload['voice'] = overrides.get('voice', {'base64': 'BBBB'}) + payload['content'] = overrides.get('content', '') + if 'quote' in overrides: + payload['quote'] = overrides['quote'] + return WecomBotEvent(payload) + + +def test_wecombot_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_wecombot_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_wecombot_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +@pytest.mark.asyncio +async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text(): + content = await WecomBotMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi'), + platform_message.At(target='user-1', display='Alice'), + platform_message.Image(base64='data:image/png;base64,AAAA'), + platform_message.File(name='a.txt', url='https://example.test/a.txt'), + platform_message.Quote( + id='origin', + origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]), + ), + ] + ) + ) + + assert 'hi' in content + assert '@Alice' in content + assert '[Image]' in content + assert '[File: a.txt]' in content + assert '[Quote origin]' in content + assert 'quoted' in content + + +@pytest.mark.asyncio +async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba(): + private_event = await WecomBotEventConverter(bot_name='EBA Bot').target2yiri( + wecombot_event(content='@EBA Bot hello') + ) + group_event = await WecomBotEventConverter(bot_name='EBA Bot').target2yiri( + wecombot_event(type='group', content='@EBA Bot group hello') + ) + + assert isinstance(private_event, platform_events.MessageReceivedEvent) + assert private_event.adapter_name == 'wecombot-eba' + assert private_event.chat_type == platform_entities.ChatType.PRIVATE + assert private_event.chat_id == 'user-1' + assert str(private_event.message_chain) == 'hello' + + assert isinstance(group_event, platform_events.MessageReceivedEvent) + assert group_event.chat_type == platform_entities.ChatType.GROUP + assert group_event.chat_id == 'group-1' + assert group_event.group.name == 'Group' + assert isinstance(group_event.message_chain[1], platform_message.At) + + +@pytest.mark.asyncio +async def test_wecombot_event_converter_maps_media_and_quote_components(): + event = await WecomBotEventConverter().target2yiri( + wecombot_event( + msgtype='image', + quote={ + 'content': 'quoted', + 'file': {'download_url': 'https://example.test/q.txt', 'filename': 'q.txt'}, + }, + ) + ) + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert any(isinstance(component, platform_message.Image) for component in event.message_chain) + quote = next(component for component in event.message_chain if isinstance(component, platform_message.Quote)) + assert any(isinstance(component, platform_message.File) for component in quote.origin) + + +@pytest.mark.asyncio +async def test_wecombot_adapter_dispatches_eba_and_legacy_and_caches_message_event(): + adapter = make_adapter() + eba_calls: list[platform_events.Event] = [] + legacy_calls: list[platform_events.Event] = [] + + async def eba_listener(event, adapter): + eba_calls.append(event) + + async def legacy_listener(event, adapter): + legacy_calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + adapter.register_listener(platform_events.FriendMessage, legacy_listener) + await adapter._handle_native_event(wecombot_event()) + + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + received = eba_calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert await adapter.get_message('private', 'user-1', 'msg-1') == received + assert (await adapter.get_user_info('user-1')).nickname == 'Alice' + + +@pytest.mark.asyncio +async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_client(): + adapter = make_adapter() + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'user-1', message) + adapter.bot.send_message.assert_awaited_once_with('user-1', 'hello') + + source_event = await WecomBotEventConverter().target2yiri(wecombot_event()) + await adapter.reply_message(source_event, message) + adapter.bot.reply_text.assert_awaited_once_with('req-1', 'hello') + + await adapter.reply_message_chunk(source_event, None, message, is_final=True) + adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True) + + platform_status = await adapter.call_platform_api('is_websocket_mode', {}) + assert platform_status == {'websocket': True} + + feedback_calls: list[platform_events.Event] = [] + + async def feedback_listener(event, adapter): + feedback_calls.append(event) + + adapter.register_listener(platform_events.FeedbackReceivedEvent, feedback_listener) + await adapter._handle_feedback(feedback_id='fb-1', feedback_type=1, inaccurate_reasons=[1, 2], session=None) + assert isinstance(feedback_calls[0], platform_events.FeedbackReceivedEvent) + assert feedback_calls[0].inaccurate_reasons == ['1', '2'] + + +@pytest.mark.asyncio +async def test_wecombot_webhook_mode_rejects_proactive_send(): + adapter = make_adapter(enable_webhook=True) + with pytest.raises(NotSupportedError): + await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])) diff --git a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py new file mode 100644 index 000000000..3d147b5d9 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from langbot.libs.wecom_customer_service_api.api import WecomCSClient +from langbot.libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent +from langbot.pkg.platform.adapters.wecomcs.adapter import WecomCSAdapter +from langbot.pkg.platform.adapters.wecomcs.event_converter import WecomCSEventConverter +from langbot.pkg.platform.adapters.wecomcs.message_converter import WecomCSMessageConverter, split_string_by_bytes +from langbot.pkg.platform.adapters.wecomcs.platform_api import PLATFORM_API_MAP +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError + + +class DummyLogger(AbstractEventLogger): + async def info(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def debug(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def warning(self, text, images=None, message_session_id=None, no_throw=True): + pass + + async def error(self, text, images=None, message_session_id=None, no_throw=True): + pass + + +class DummyWecomCSClient(WecomCSClient): + def __init__(self, *args, **kwargs): + self.corpid = kwargs['corpid'] + self.secret = kwargs['secret'] + self.token = kwargs['token'] + self.aes = kwargs['EncodingAESKey'] + self.base_url = kwargs.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin') + self.logger = kwargs.get('logger') + self.access_token = '' + self._message_handlers = {} + self.get_media_id = AsyncMock(return_value='media-id') + self.send_text_msg = AsyncMock(return_value={'msgid': 'sent-text'}) + self.send_image_msg = AsyncMock(return_value={'msgid': 'sent-image'}) + self.get_customer_info = AsyncMock( + return_value={'external_userid': 'external-1', 'nickname': 'Alice', 'avatar': 'https://example.test/a.png'} + ) + self.check_access_token = AsyncMock(return_value=True) + self.get_access_token = AsyncMock(return_value='access-token') + self.handle_unified_webhook = AsyncMock(return_value='success') + + def on_message(self, msg_type: str): + def decorator(func): + self._message_handlers.setdefault(msg_type, []).append(func) + return func + + return decorator + + +def manifest() -> dict: + manifest_path = ( + pathlib.Path(__file__).parents[3] + / 'src' + / 'langbot' + / 'pkg' + / 'platform' + / 'adapters' + / 'wecomcs' + / 'manifest.yaml' + ) + return yaml.safe_load(manifest_path.read_text()) + + +def make_adapter() -> WecomCSAdapter: + config = { + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'encoding-key', + 'api_base_url': 'https://qyapi.weixin.qq.com/cgi-bin', + } + with patch('langbot.pkg.platform.adapters.wecomcs.adapter.WecomCSClient', DummyWecomCSClient): + return WecomCSAdapter(config, DummyLogger()) + + +def wecomcs_event(**overrides) -> WecomCSEvent: + msgtype = overrides.get('msgtype', 'text') + payload = { + 'msgtype': msgtype, + 'msgid': overrides.get('message_id', 'msg-1'), + 'external_userid': overrides.get('external_userid', 'external-1'), + 'open_kfid': overrides.get('open_kfid', 'kf-1'), + 'send_time': overrides.get('send_time', 1_714_000_000), + } + if msgtype == 'text': + payload['text'] = {'content': overrides.get('content', 'hello')} + if msgtype == 'image': + payload['image'] = {'media_id': overrides.get('media_id', 'media-id')} + payload['picurl'] = overrides.get('picurl', 'data:image/png;base64,AAAA') + if msgtype == 'file': + payload['file'] = {'media_id': 'file-id', 'filename': 'a.txt', 'file_size': 12} + if msgtype == 'voice': + payload['voice'] = {'media_id': 'voice-id'} + return WecomCSEvent.from_payload(payload) + + +def test_wecomcs_supported_events_match_manifest(): + assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] + + +def test_wecomcs_supported_apis_match_manifest(): + supported_apis = make_adapter().get_supported_apis() + manifest_apis = manifest()['spec']['supported_apis'] + + assert supported_apis == manifest_apis['required'] + manifest_apis['optional'] + + +def test_wecomcs_platform_api_map_matches_manifest(): + manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']} + + assert set(PLATFORM_API_MAP) == manifest_actions + + +def test_wecomcs_split_string_by_bytes_keeps_multibyte_boundaries(): + parts = split_string_by_bytes('你好hello', limit=7) + + assert ''.join(parts) == '你好hello' + assert all(len(part.encode('utf-8')) <= 7 for part in parts) + + +@pytest.mark.asyncio +async def test_wecomcs_message_converter_maps_outbound_components(): + adapter = make_adapter() + content = await WecomCSMessageConverter.yiri2target( + platform_message.MessageChain( + [ + platform_message.Plain(text='hi'), + platform_message.Image(base64='data:image/png;base64,AAAA'), + platform_message.Quote( + id='origin', + origin=platform_message.MessageChain([platform_message.Plain(text='quoted')]), + ), + platform_message.At(target='external-2', display='Bob'), + platform_message.AtAll(), + ] + ), + adapter.bot, + ) + + assert content[0] == {'type': 'text', 'content': 'hi'} + assert {'type': 'image', 'media_id': 'media-id'} in content + assert {'type': 'text', 'content': '[Quote origin] '} in content + assert {'type': 'text', 'content': 'quoted'} in content + assert {'type': 'text', 'content': '@Bob'} in content + assert {'type': 'text', 'content': '@all'} in content + + +@pytest.mark.asyncio +async def test_wecomcs_message_converter_rejects_unsupported_outbound_media(): + adapter = make_adapter() + + with pytest.raises(NotSupportedError): + await WecomCSMessageConverter.yiri2target( + platform_message.MessageChain([platform_message.Voice(base64='BBBB')]), + adapter.bot, + ) + + +@pytest.mark.asyncio +async def test_wecomcs_event_converter_maps_text_message_to_eba_and_legacy(): + adapter = make_adapter() + event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot) + + assert isinstance(event, platform_events.MessageReceivedEvent) + assert event.adapter_name == 'wecomcs-eba' + assert event.chat_type == platform_entities.ChatType.PRIVATE + assert event.chat_id == 'external-1|kf-1' + assert event.sender.nickname == 'Alice' + assert str(event.message_chain) == 'hello' + + legacy = await WecomCSEventConverter.target2legacy(wecomcs_event(), adapter.bot) + assert isinstance(legacy, platform_events.FriendMessage) + assert legacy.sender.id == 'external-1' + assert str(legacy.message_chain) == 'hello' + + +@pytest.mark.asyncio +async def test_wecomcs_event_converter_maps_media_and_unknown_messages(): + image_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='image'), make_adapter().bot) + file_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='file'), make_adapter().bot) + voice_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='voice'), make_adapter().bot) + unknown_event = await WecomCSEventConverter.target2yiri(wecomcs_event(msgtype='event'), make_adapter().bot) + + assert isinstance(image_event.message_chain[1], platform_message.Image) + assert image_event.message_chain[1].base64 == 'data:image/png;base64,AAAA' + assert isinstance(file_event.message_chain[1], platform_message.File) + assert isinstance(voice_event.message_chain[1], platform_message.Voice) + assert isinstance(unknown_event, platform_events.PlatformSpecificEvent) + assert unknown_event.action == 'wecomcs.event' + + +@pytest.mark.asyncio +async def test_wecomcs_adapter_dispatches_eba_and_legacy_and_caches_message_event(): + adapter = make_adapter() + eba_calls: list[platform_events.Event] = [] + legacy_calls: list[platform_events.Event] = [] + + async def eba_listener(event, adapter): + eba_calls.append(event) + + async def legacy_listener(event, adapter): + legacy_calls.append(event) + + adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener) + adapter.register_listener(platform_events.FriendMessage, legacy_listener) + await adapter._handle_native_event(wecomcs_event()) + + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + received = eba_calls[0] + assert isinstance(received, platform_events.MessageReceivedEvent) + assert adapter.bot_account_id == 'kf-1' + assert await adapter.get_message('private', 'external-1|kf-1', 'msg-1') == received + assert (await adapter.get_user_info('external-1')).nickname == 'Alice' + + await adapter._handle_native_event(wecomcs_event()) + assert len(eba_calls) == 1 + assert len(legacy_calls) == 1 + + +@pytest.mark.asyncio +async def test_wecomcs_send_reply_and_platform_api_use_underlying_client(): + adapter = make_adapter() + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'external-1|kf-1', message) + adapter.bot.send_text_msg.assert_awaited_once() + open_kfid, external_userid, msgid, content = adapter.bot.send_text_msg.await_args.args + assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello') + assert msgid.startswith('lb-') + + image = platform_message.MessageChain([platform_message.Image(base64='data:image/png;base64,AAAA')]) + await adapter.send_message('person', 'external-1|kf-1', image) + adapter.bot.send_image_msg.assert_awaited_once() + + source_event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot) + await adapter.reply_message(source_event, message) + open_kfid, external_userid, reply_msgid, content = adapter.bot.send_text_msg.await_args.args + assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello') + assert reply_msgid.startswith('lb-') + + token_status = await adapter.call_platform_api('check_access_token', {}) + customer_info = await adapter.call_platform_api('get_customer_info', {'external_userid': 'external-1'}) + + assert token_status == {'valid': True} + assert customer_info['nickname'] == 'Alice' diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index a2fdddd33..884ee74cf 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -32,6 +32,7 @@ def mock_app(self): app.logger = SimpleNamespace() app.logger.debug = MagicMock() + app.logger.warning = MagicMock() return app @@ -71,6 +72,90 @@ async def test_set_query_var_success(self, mock_app): assert response.code == 0 assert mock_query.variables['test_var'] == 'test_value' + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'key', + [ + '_host_box_scope', + '_host_tool_source_refs', + '_pipeline_bound_plugins', + '_pipeline_bound_mcp_servers', + '_pipeline_bound_skills', + '_pipeline_mcp_resource_attachments', + '_pipeline_mcp_resource_agent_read_enabled', + '_activated_skills', + '_fallback_model_uuids', + '_monitoring_message_id', + '_sandbox_outbound_collected', + '_authorized_models', + '_permission_tools', + '_routed_by_rule', + ], + ) + async def test_set_query_var_rejects_host_reserved_keys(self, mock_app, key): + runtime_handler = make_handler(mock_app) + original_variables = {key: 'host-owned'} + mock_query = SimpleNamespace(variables=original_variables.copy()) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': 'plugin-overwrite', + } + ) + + assert response.code != 0 + assert response.message == f'Query variable {key!r} is reserved for LangBot Host' + assert mock_query.variables == original_variables + mock_app.logger.warning.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'key', + [ + 'business_context', + '_ltm_context', + '_knowledge_base_uuids', + '_skill_authoring_post_response_candidate', + ], + ) + async def test_set_query_var_keeps_plugin_business_variables_writable(self, mock_app, key): + runtime_handler = make_handler(mock_app) + mock_query = SimpleNamespace(variables={}) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': {'plugin': 'value'}, + } + ) + + assert response.code == 0 + assert mock_query.variables[key] == {'plugin': 'value'} + + @pytest.mark.asyncio + @pytest.mark.parametrize('key', ['', None, 7]) + async def test_set_query_var_rejects_invalid_key_shapes(self, mock_app, key): + runtime_handler = make_handler(mock_app) + mock_query = SimpleNamespace(variables={}) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': 'value', + } + ) + + assert response.code != 0 + assert response.message == 'Query variable key must be a non-empty string' + assert mock_query.variables == {} + @pytest.mark.asyncio async def test_get_query_var_success(self, mock_app): """Test get_query_var retrieves variable from query.""" @@ -205,10 +290,3 @@ def test_edition_exists(self): assert hasattr(constants, 'edition') assert constants.edition == 'community' - - def test_required_database_version_exists(self): - """Test database version constant.""" - from langbot.pkg.utils import constants - - assert hasattr(constants, 'required_database_version') - assert isinstance(constants.required_database_version, int) diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 2dbc1f4f9..1c4dca7f2 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -7,8 +7,11 @@ from unittest.mock import AsyncMock, Mock import pytest +from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError + def make_handler(app): """Create a RuntimeConnectionHandler with mocked external connection.""" @@ -27,6 +30,22 @@ def compiled_params(statement): return statement.compile().params +def make_agent_resources( + models: list[dict] | None = None, + tools: list[dict] | None = None, + knowledge_bases: list[dict] | None = None, +): + """Create a minimal AgentRun resources payload for run-scoped action tests.""" + return { + 'models': models or [], + 'tools': tools or [], + 'knowledge_bases': knowledge_bases or [], + 'files': [], + 'storage': {'plugin_storage': False, 'workspace_storage': False}, + 'platform_capabilities': {}, + } + + class TestRagRerankAction: """Tests for RAG rerank action handler.""" @@ -423,3 +442,859 @@ async def test_query_found_returns_success(self, app): assert response.code == 0 assert response.data == {'bot_uuid': 'test-bot-uuid'} + + +class TestAgentRunProxyActions: + """Tests for AgentRunner proxy actions that need host Query semantics.""" + + @pytest.fixture + def app(self): + mock_app = Mock() + mock_app.logger = Mock() + mock_app.query_pool = Mock() + mock_app.query_pool.cached_queries = {} + mock_app.model_mgr = Mock() + mock_app.model_mgr.get_model_by_uuid = AsyncMock() + mock_app.model_mgr.get_rerank_model_by_uuid = AsyncMock() + mock_app.tool_mgr = Mock() + mock_app.tool_mgr.execute_func_call = AsyncMock(return_value={'ok': True}) + mock_app.tool_mgr.get_tool_detail = AsyncMock( + return_value={ + 'name': 'test/search', + 'description': 'Search test data', + 'human_desc': 'Search', + 'parameters': {'type': 'object'}, + } + ) + return mock_app + + @staticmethod + def query(remove_think=True): + return SimpleNamespace( + pipeline_config={'output': {'misc': {'remove-think': remove_think}}}, + variables={}, + prompt=SimpleNamespace(messages=[provider_message.Message(role='system', content='effective prompt')]), + ) + + @pytest.mark.asyncio + async def test_get_prompt_returns_query_effective_prompt(self, app): + """GET_PROMPT returns the preprocessed Query prompt for the active run.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_get_prompt' + query = self.query() + app.query_pool.cached_queries[900] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=900, + plugin_identity='test/runner', + resources=make_agent_resources(), + available_apis={'prompt_get': True}, + ) + + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['prompt'][0]['role'] == 'system' + assert response.data['prompt'][0]['content'] == 'effective prompt' + + @pytest.mark.asyncio + async def test_invoke_llm_restores_query_and_model_options(self, app): + """INVOKE_LLM passes Query, model extra_args and remove-think to provider.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_invoke_llm_options' + query = self.query(remove_think=True) + app.query_pool.cached_queries[901] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=901, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_001'}]), + ) + + provider = SimpleNamespace( + invoke_llm=AsyncMock(return_value=provider_message.Message(role='assistant', content='ok')), + ) + model = SimpleNamespace( + model_entity=SimpleNamespace( + abilities=['func_call'], + extra_args={'temperature': 0.2, 'top_p': 0.8}, + ), + provider=provider, + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + provider.invoke_llm.assert_awaited_once() + kwargs = provider.invoke_llm.await_args.kwargs + assert kwargs['query'] is query + assert kwargs['extra_args'] == { + 'temperature': 0.7, + 'top_p': 0.8, + 'presence_penalty': 0.1, + } + assert kwargs['remove_think'] is True + assert [tool.name for tool in kwargs['funcs']] == ['search'] + + @pytest.mark.asyncio + async def test_invoke_llm_returns_provider_usage(self, app): + """INVOKE_LLM includes optional provider usage in the action response.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.modelmgr import requester as model_requester + + usage = { + 'prompt_tokens': 11, + 'completion_tokens': 7, + 'total_tokens': 18, + 'prompt_tokens_details': {'cached_tokens': 3}, + } + + class UsageProvider: + async def invoke_llm(self, **kwargs): + kwargs['query'].variables[model_requester.LLM_USAGE_QUERY_VARIABLE] = usage + return provider_message.Message(role='assistant', content='ok') + + run_id = 'run_proxy_invoke_llm_usage' + query = self.query() + app.query_pool.cached_queries[905] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=905, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_usage_001'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=UsageProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['message']['content'] == 'ok' + assert response.data['usage'] == usage + assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables + + @pytest.mark.asyncio + async def test_count_tokens_validates_run_authorization_and_calls_provider(self, app): + """COUNT_TOKENS is run-scoped and forwards messages/tools to the model requester.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_count_tokens' + query = self.query() + app.query_pool.cached_queries[906] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=906, + plugin_identity='test/runner', + resources=make_agent_resources( + models=[{'model_id': 'llm_count_001', 'operations': ['count_tokens']}], + ), + ) + + requester = SimpleNamespace(count_tokens=AsyncMock(return_value=37)) + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={'temperature': 0.2}), + provider=SimpleNamespace(requester=requester), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_count_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'temperature': 0.7}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data == {'tokens': 37} + requester.count_tokens.assert_awaited_once() + kwargs = requester.count_tokens.await_args.kwargs + assert kwargs['model'] is model + assert kwargs['messages'][0].content == 'hello' + assert [tool.name for tool in kwargs['funcs']] == ['search'] + assert kwargs['extra_args'] == {'temperature': 0.7} + + @pytest.mark.asyncio + async def test_count_tokens_rejects_model_without_operation(self, app): + """COUNT_TOKENS requires the explicit model operation in the run snapshot.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_count_tokens_denied' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources( + models=[{'model_id': 'llm_count_002', 'operations': ['invoke']}], + ), + ) + + runtime_handler = make_handler(app) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_count_002', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) + finally: + await registry.unregister(run_id) + + assert response.code != 0 + assert 'operation count_tokens' in response.message + app.model_mgr.get_model_by_uuid.assert_not_awaited() + + @pytest.mark.asyncio + async def test_invoke_llm_stream_restores_query_and_options(self, app): + """INVOKE_LLM_STREAM applies the same host context as non-streaming calls.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + class StreamProvider: + def __init__(self): + self.kwargs = None + + async def invoke_llm_stream(self, **kwargs): + self.kwargs = kwargs + yield provider_message.MessageChunk(role='assistant', content='hi') + + run_id = 'run_proxy_invoke_llm_stream_options' + query = self.query(remove_think=False) + app.query_pool.cached_queries[902] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=902, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_001'}]), + ) + + provider = StreamProvider() + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={'max_tokens': 128}), + provider=provider, + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'max_tokens': 256}, + 'remove_think': True, + } + ) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0] + assert provider.kwargs['query'] is query + assert provider.kwargs['extra_args'] == {'max_tokens': 256} + assert provider.kwargs['remove_think'] is True + assert provider.kwargs['funcs'] == [] + + @pytest.mark.asyncio + async def test_invoke_llm_stream_skips_none_chunks(self, app): + """INVOKE_LLM_STREAM tolerates provider heartbeat/no-op chunks.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + class StreamProvider: + async def invoke_llm_stream(self, **kwargs): + yield provider_message.MessageChunk(role='assistant', content='ok') + yield None + yield provider_message.MessageChunk(role='assistant', content=' done', is_final=True) + + run_id = 'run_proxy_invoke_llm_stream_none_chunks' + query = self.query() + app.query_pool.cached_queries[904] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=904, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_002'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=StreamProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_002', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0, 0] + assert [response.data['chunk']['content'] for response in responses] == ['ok', ' done'] + + @pytest.mark.asyncio + async def test_invoke_llm_stream_returns_provider_usage_event(self, app): + """INVOKE_LLM_STREAM emits a final usage-only action response when available.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.modelmgr import requester as model_requester + + usage = { + 'prompt_tokens': 9, + 'completion_tokens': 4, + 'total_tokens': 13, + 'prompt_tokens_details': {'cached_tokens': 2}, + } + + class StreamProvider: + async def invoke_llm_stream(self, **kwargs): + yield provider_message.MessageChunk(role='assistant', content='ok') + kwargs['query'].variables[model_requester.LLM_USAGE_QUERY_VARIABLE] = usage + + run_id = 'run_proxy_invoke_llm_stream_usage' + query = self.query() + app.query_pool.cached_queries[906] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=906, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'llm_stream_usage_001'}]), + ) + + model = SimpleNamespace( + model_entity=SimpleNamespace(abilities=[], extra_args={}), + provider=StreamProvider(), + ) + app.model_mgr.get_model_by_uuid.return_value = model + runtime_handler = make_handler(app) + + responses = [] + try: + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) + async for response in stream: + responses.append(response) + finally: + await registry.unregister(run_id) + + assert [response.code for response in responses] == [0, 0] + assert responses[0].data['chunk']['content'] == 'ok' + assert responses[1].data == {'usage': usage} + assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables + + @pytest.mark.asyncio + async def test_call_tool_passes_current_query(self, app): + """CALL_TOOL passes the current Query back into tool execution.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_query' + query = self.query() + app.query_pool.cached_queries[903] = query + + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=903, + plugin_identity='test/runner', + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'test/search', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ), + ) + + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + 'parameters': {'q': 'langbot'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert getattr(query, '_agent_run_session')['run_id'] == run_id + app.tool_mgr.execute_func_call.assert_awaited_once_with( + name='test/search', + parameters={'q': 'langbot'}, + query=query, + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, + ) + + @pytest.mark.asyncio + async def test_get_tool_detail_passes_frozen_source_ref(self, app): + """GET_TOOL_DETAIL resolves only the implementation frozen for the run.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_get_tool_detail_source' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'test/search', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.GET_TOOL_DETAIL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + app.tool_mgr.get_tool_detail.assert_awaited_once_with( + 'test/search', + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ('action', 'action_payload', 'manager_method'), + [ + pytest.param( + PluginToRuntimeAction.CALL_TOOL, + {'parameters': {'q': 'langbot'}}, + 'execute_func_call', + id='call', + ), + pytest.param( + PluginToRuntimeAction.GET_TOOL_DETAIL, + {}, + 'get_tool_detail', + id='detail', + ), + ], + ) + @pytest.mark.parametrize( + 'tool_resource', + [ + pytest.param( + {'tool_name': 'test/search', 'source_id': 'bound-mcp'}, + id='missing-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp'}, + id='missing-source-id', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 7, 'source_id': 'bound-mcp'}, + id='invalid-source-type', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp', 'source_id': 7}, + id='invalid-source-id-type', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp', 'source_id': None}, + id='unscoped-mcp-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'plugin', 'source_id': None}, + id='unscoped-plugin-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'builtin', 'source_id': 'unexpected'}, + id='builtin-source-id', + ), + ], + ) + async def test_tool_actions_reject_malformed_frozen_source_identity( + self, + app, + action, + action_payload, + manager_method, + tool_resource, + ): + """Run-scoped tool actions never fall back from a malformed authorization snapshot.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = f'run_proxy_malformed_source_{action.value}' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[tool_resource]), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[action.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + **action_payload, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code != 0 + assert response.message == 'Tool test/search has an invalid frozen source identity for this agent run' + getattr(app.tool_mgr, manager_method).assert_not_awaited() + + @pytest.mark.asyncio + async def test_call_tool_returns_error_when_host_denies_execution(self, app): + """CALL_TOOL preserves the existing error response when a loader denies execution.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_denied' + query = self.query() + app.query_pool.cached_queries[907] = query + app.tool_mgr.execute_func_call.side_effect = ToolExecutionDeniedError( + 'langbot_mcp_read_resource', + 'MCP resource agent reads are disabled', + ) + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=907, + plugin_identity='test/runner', + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'langbot_mcp_read_resource', + 'source': 'mcp', + 'source_id': None, + } + ] + ), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'langbot_mcp_read_resource', + 'parameters': {'server_name': 'docs', 'uri': 'file:///README.md'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code != 0 + assert 'Failed to execute tool langbot_mcp_read_resource' in response.message + assert 'MCP resource agent reads are disabled' in response.message + + @pytest.mark.asyncio + async def test_call_tool_falls_back_to_host_execution_query(self, app): + """Pure EBA runs use their Host-only Query when no cached Query exists.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_event_first' + query = self.query() + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]), + execution_query=query, + ) + + runtime_handler = make_handler(app) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'exec', + 'parameters': {'command': 'pwd'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert getattr(query, '_agent_run_session')['run_id'] == run_id + app.tool_mgr.execute_func_call.assert_awaited_once_with( + name='exec', + parameters={'command': 'pwd'}, + query=query, + source_ref={'source': 'builtin', 'source_id': None}, + ) + + @pytest.mark.asyncio + async def test_pure_event_call_tool_exec_uses_native_host_path(self, app): + """A pure EBA run can call authorized native exec through CALL_TOOL.""" + from langbot.pkg.agent.runner.execution_context import build_execution_query + from langbot.pkg.agent.runner.host_models import AgentEventEnvelope + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.toolmgr import ToolManager + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + + event = AgentEventEnvelope( + event_id='event-native-exec', + event_type='message.received', + source='platform', + bot_id='bot-1', + conversation_id='person_user-1', + input=AgentInput(text='run pwd'), + delivery=DeliveryContext( + surface='platform', + reply_target={'target_type': 'person', 'target_id': 'user-1'}, + platform_capabilities={'adapter': 'TestAdapter'}, + ), + ) + query = build_execution_query(event, []) + app.box_service = SimpleNamespace( + execute_tool=AsyncMock( + return_value={ + 'ok': True, + 'session_id': 'lb-box-test', + 'stdout': '/workspace\n', + 'stderr': '', + } + ), + ) + app.monitoring_service = None + app.skill_mgr = None + native_loader = NativeToolLoader(app) + native_loader._backend_available = True + tool_mgr = ToolManager(app) + tool_mgr.native_tool_loader = native_loader + tool_mgr.plugin_tool_loader = Mock() + tool_mgr.mcp_tool_loader = Mock() + tool_mgr.skill_tool_loader = Mock() + app.tool_mgr = tool_mgr + + run_id = 'run_pure_event_native_exec' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]), + execution_query=query, + ) + + runtime_handler = make_handler(app) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'exec', + 'parameters': {'command': 'pwd'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['result']['ok'] is True + assert response.data['result']['stdout'] == '/workspace\n' + app.box_service.execute_tool.assert_awaited_once_with({'command': 'pwd'}, query) + + @pytest.mark.asyncio + async def test_invoke_rerank_uses_authorized_model_and_extra_args(self, app): + """INVOKE_RERANK validates run-scoped model access and merges model extra_args.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_rerank_options' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=904, + plugin_identity='test/runner', + resources=make_agent_resources(models=[{'model_id': 'rerank_001'}]), + ) + + provider = SimpleNamespace( + invoke_rerank=AsyncMock( + return_value=[ + {'index': 0, 'relevance_score': 0.2}, + {'index': 1, 'relevance_score': 0.9}, + ] + ), + ) + rerank_model = SimpleNamespace( + model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}), + provider=provider, + ) + app.model_mgr.get_rerank_model_by_uuid.return_value = rerank_model + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'rerank_model_uuid': 'rerank_001', + 'query': 'hello', + 'documents': ['a', 'b'], + 'top_k': 1, + 'extra_args': {'top_n': 2}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}] + provider.invoke_rerank.assert_awaited_once() + kwargs = provider.invoke_rerank.await_args.kwargs + assert kwargs['extra_args'] == {'top_n': 2, 'return_documents': False} diff --git a/tests/unit_tests/provider/conftest.py b/tests/unit_tests/provider/conftest.py index 13b44fd14..2332915ea 100644 --- a/tests/unit_tests/provider/conftest.py +++ b/tests/unit_tests/provider/conftest.py @@ -7,6 +7,10 @@ from __future__ import annotations +import json +import inspect +from typing import Any + import pytest from unittest.mock import AsyncMock, Mock from types import SimpleNamespace @@ -30,6 +34,67 @@ def __init__(self, ap, config: dict): self._invoke_count = 0 self._last_messages = None self._last_model = None + self._last_funcs = None + self._invoke_payloads = [] + self._last_count_tokens_payload = None + self._count_tokens_payloads = [] + self._scripted_llm_responses = [] + + @staticmethod + def _content_to_text(content) -> str: + if content is None: + return '' + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get('text') + else: + text = getattr(item, 'text', None) + if text: + parts.append(str(text)) + return ''.join(parts) + return str(content) + + def queue_llm_responses(self, *responses: Any) -> None: + """Queue deterministic LLM responses for multi-turn tests.""" + self._scripted_llm_responses.extend(responses) + + async def _coerce_llm_response( + self, + response: Any, + *, + query: Any, + model: requester.RuntimeLLMModel, + messages: list, + funcs: list | None, + extra_args: dict, + remove_think: bool, + ): + """Convert scripted response values into provider Message objects.""" + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + if callable(response): + response = response( + query=query, + model=model, + messages=messages, + funcs=funcs, + extra_args=extra_args, + remove_think=remove_think, + ) + if inspect.isawaitable(response): + response = await response + + if isinstance(response, provider_message.Message): + return response + if isinstance(response, dict): + return provider_message.Message.model_validate(response) + if isinstance(response, str): + return provider_message.Message(role='assistant', content=response) + return response async def invoke_llm( self, @@ -44,10 +109,30 @@ async def invoke_llm( self._invoke_count += 1 self._last_messages = messages self._last_model = model + self._last_funcs = funcs or [] + self._invoke_payloads.append( + { + 'messages': messages, + 'funcs': funcs or [], + 'extra_args': dict(extra_args or {}), + 'remove_think': remove_think, + } + ) # Import the message entity for response import langbot_plugin.api.entities.builtin.provider.message as provider_message + if self._scripted_llm_responses: + return await self._coerce_llm_response( + self._scripted_llm_responses.pop(0), + query=query, + model=model, + messages=messages, + funcs=funcs, + extra_args=extra_args, + remove_think=remove_think, + ) + return provider_message.Message( role='assistant', content=[provider_message.ContentElement(type='text', text='Fake LLM response')], @@ -70,6 +155,39 @@ async def invoke_llm_stream( content=[provider_message.ContentElement(type='text', text='Fake stream chunk')], ) + async def count_tokens( + self, + model: requester.RuntimeLLMModel, + messages: list, + funcs=None, + extra_args={}, + ) -> int: + """Return deterministic token estimates for token-free integration tests.""" + payload: list[dict] = [] + for message in messages: + payload.append( + { + 'role': getattr(message, 'role', ''), + 'content': self._content_to_text(getattr(message, 'content', None)), + 'tool_calls': getattr(message, 'tool_calls', None), + 'tool_call_id': getattr(message, 'tool_call_id', None), + } + ) + + for func in funcs or []: + payload.append( + { + 'name': getattr(func, 'name', ''), + 'description': getattr(func, 'description', ''), + 'parameters': getattr(func, 'parameters', {}), + } + ) + + self._last_count_tokens_payload = payload + self._count_tokens_payloads.append(payload) + text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + return max(1, (len(text) + 3) // 4) + async def invoke_embedding(self, model, input_text: list, extra_args={}): """Return fake embedding vectors.""" return [[0.1, 0.2, 0.3] for _ in input_text] diff --git a/tests/unit_tests/provider/runners/__init__.py b/tests/unit_tests/provider/runners/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py deleted file mode 100644 index 366ef6d87..000000000 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Tests for DifyServiceAPIRunner pure utility methods. - -Tests the helper methods that don't require real Dify API calls. -""" - -from __future__ import annotations - -import pytest - - -class TestDifyExtractTextOutput: - """Tests for _extract_dify_text_output method.""" - - def _create_runner(self): - """Create runner instance.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'chat', - 'api-key': 'test-key', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - runner.dify_client = MagicMock() - - return runner - - def test_extract_none_value(self): - """None returns empty string.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output(None) - - assert result == '' - - def test_extract_string_value(self): - """Plain string is returned.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('plain text') - - assert result == 'plain text' - - def test_extract_dict_with_content(self): - """Dict with 'content' key extracts content.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output({'content': 'extracted content'}) - - assert result == 'extracted content' - - def test_extract_dict_without_content(self): - """Dict without 'content' key is JSON dumped.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output({'key': 'value'}) - - assert 'key' in result - assert 'value' in result - - def test_extract_json_string_with_content(self): - """JSON string with 'content' key extracts content.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('{"content": "json content"}') - - assert result == 'json content' - - def test_extract_json_string_without_content(self): - """JSON string without 'content' key returns original.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output('{"other": "value"}') - - assert '{"other": "value"}' in result - - def test_extract_whitespace_string(self): - """Whitespace string returns empty.""" - runner = self._create_runner() - - result = runner._extract_dify_text_output(' ') - - assert result == '' - - -class TestDifyRunnerConfigValidation: - """Tests for runner config validation.""" - - def test_invalid_app_type_raises(self): - """Invalid app-type raises DifyAPIError.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - from langbot.libs.dify_service_api.v1.errors import DifyAPIError - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'invalid-type', - 'api-key': 'test', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - with pytest.raises(DifyAPIError, match='不支持'): - DifyServiceAPIRunner(mock_app, pipeline_config) - - def test_valid_app_types(self): - """Valid app-types don't raise.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - - for app_type in ['chat', 'agent', 'workflow']: - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': app_type, - 'api-key': 'test', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - # Should not raise - assert runner is not None - - -class TestDifyRunnerInit: - """Tests for runner initialization.""" - - def test_runner_stores_config(self): - """Runner stores pipeline_config.""" - from unittest.mock import MagicMock - - from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner - - mock_app = MagicMock() - pipeline_config = { - 'ai': { - 'dify-service-api': { - 'app-type': 'chat', - 'api-key': 'test-key', - 'base-url': 'https://api.dify.ai', - } - }, - 'output': {'misc': {}}, - } - - runner = DifyServiceAPIRunner(mock_app, pipeline_config) - - assert runner.pipeline_config == pipeline_config - assert runner.ap == mock_app diff --git a/tests/unit_tests/provider/test_fake_requester.py b/tests/unit_tests/provider/test_fake_requester.py new file mode 100644 index 000000000..9b0f85005 --- /dev/null +++ b/tests/unit_tests/provider/test_fake_requester.py @@ -0,0 +1,57 @@ +"""Tests for provider test doubles used by integration paths.""" + +from __future__ import annotations + +import pytest + +from langbot.pkg.entity.persistence import model as persistence_model +from langbot.pkg.provider.modelmgr import requester +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.resource import tool as resource_tool + + +@pytest.mark.asyncio +async def test_fake_requester_counts_messages_and_tools(runtime_provider): + """Fake requester should support token-free AgentRunner context budgeting.""" + runtime_model = requester.RuntimeLLMModel( + model_entity=persistence_model.LLMModel( + uuid='fake-count-model', + name='fake-count-model', + provider_uuid=runtime_provider.provider_entity.uuid, + abilities=['func_call'], + extra_args={}, + ), + provider=runtime_provider, + ) + + async def _placeholder_func(**kwargs): + return kwargs + + messages = [ + provider_message.Message(role='system', content='You are a test assistant.'), + provider_message.Message( + role='user', + content=[ + provider_message.ContentElement(type='text', text='hello'), + provider_message.ContentElement(type='text', text=' world'), + ], + ), + ] + tools = [ + resource_tool.LLMTool( + name='lookup', + human_desc='Lookup', + description='Lookup a value', + parameters={'type': 'object', 'properties': {'query': {'type': 'string'}}}, + func=_placeholder_func, + ) + ] + + requester_inst = runtime_provider.requester + message_tokens = await requester_inst.count_tokens(runtime_model, messages, funcs=[]) + message_and_tool_tokens = await requester_inst.count_tokens(runtime_model, messages, funcs=tools) + + assert message_tokens > 0 + assert message_and_tool_tokens > message_tokens + assert requester_inst._last_count_tokens_payload[-1]['name'] == 'lookup' + assert requester_inst._last_count_tokens_payload[1]['content'] == 'hello world' diff --git a/tests/unit_tests/provider/test_localagent_inbound_attachments.py b/tests/unit_tests/provider/test_localagent_inbound_attachments.py deleted file mode 100644 index bc7352f13..000000000 --- a/tests/unit_tests/provider/test_localagent_inbound_attachments.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Unit tests for LocalAgentRunner._inject_inbound_attachments. - -Covers the user -> sandbox attachment path added for the Box attachment -round-trip: - -* materialized descriptors are stashed on the query and described to the model - via an appended text note (in-sandbox paths + outbox convention); -* non-image file parts (file_base64 / file_url) are stripped from the user - message content because OpenAI-compatible chat models reject them, while - image and text parts are kept for vision models; -* the helper is a no-op when the box service is unavailable or yields nothing, - and never raises into the chat turn on materialization failure. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock - -import pytest - -import langbot_plugin.api.entities.builtin.provider.message as provider_message - -from langbot.pkg.provider.runners.localagent import LocalAgentRunner - - -def _make_runner(box_service) -> LocalAgentRunner: - runner = LocalAgentRunner.__new__(LocalAgentRunner) - runner.ap = SimpleNamespace(logger=Mock(), box_service=box_service) - return runner - - -def _make_query(): - return SimpleNamespace(variables={}, query_id='q-123') - - -def _box_service(attachments): - svc = SimpleNamespace( - available=True, - OUTBOX_MOUNT_DIR='/outbox', - materialize_inbound_attachments=AsyncMock(return_value=attachments), - ) - return svc - - -@pytest.mark.asyncio -async def test_inject_strips_file_parts_and_appends_note(): - box = _box_service([{'type': 'Voice', 'path': '/inbox/q-123/voice.wav', 'size': 176000}]) - runner = _make_runner(box) - query = _make_query() - user_message = provider_message.Message( - role='user', - content=[ - provider_message.ContentElement.from_text('transcribe this'), - provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'), - ], - ) - - await runner._inject_inbound_attachments(query, user_message) - - types = [getattr(ce, 'type', None) for ce in user_message.content] - # file_base64 dropped; text kept; sandbox-path note appended as text - assert 'file_base64' not in types - assert types.count('text') == 2 - note = user_message.content[-1].text - assert '/inbox/q-123/voice.wav' in note - assert '/outbox/q-123' in note - # descriptors stashed for downstream stages - assert query.variables['_sandbox_inbound_attachments'] == box.materialize_inbound_attachments.return_value - - -@pytest.mark.asyncio -async def test_inject_keeps_image_parts(): - box = _box_service([{'type': 'Image', 'path': '/inbox/q-123/pic.png', 'size': 1234}]) - runner = _make_runner(box) - query = _make_query() - user_message = provider_message.Message( - role='user', - content=[ - provider_message.ContentElement.from_text('what is this'), - provider_message.ContentElement.from_image_base64('data:image/png;base64,iVBORw0K'), - ], - ) - - await runner._inject_inbound_attachments(query, user_message) - - types = [getattr(ce, 'type', None) for ce in user_message.content] - assert 'image_base64' in types # vision part preserved - assert types[-1] == 'text' # note appended last - - -@pytest.mark.asyncio -async def test_inject_promotes_string_content_to_list_with_note(): - box = _box_service([{'type': 'File', 'path': '/inbox/q-123/data.csv', 'size': 42}]) - runner = _make_runner(box) - query = _make_query() - user_message = provider_message.Message(role='user', content='clean this csv') - - await runner._inject_inbound_attachments(query, user_message) - - assert isinstance(user_message.content, list) - assert [getattr(ce, 'type', None) for ce in user_message.content] == ['text', 'text'] - assert user_message.content[0].text == 'clean this csv' - assert '/inbox/q-123/data.csv' in user_message.content[1].text - - -@pytest.mark.asyncio -async def test_inject_noop_without_box_service(): - runner = _make_runner(box_service=None) - query = _make_query() - user_message = provider_message.Message(role='user', content='hello') - - await runner._inject_inbound_attachments(query, user_message) - - assert user_message.content == 'hello' - assert '_sandbox_inbound_attachments' not in query.variables - - -@pytest.mark.asyncio -async def test_inject_noop_when_no_attachments(): - box = _box_service([]) - runner = _make_runner(box) - query = _make_query() - user_message = provider_message.Message(role='user', content='hello') - - await runner._inject_inbound_attachments(query, user_message) - - assert user_message.content == 'hello' - assert '_sandbox_inbound_attachments' not in query.variables - - -@pytest.mark.asyncio -async def test_inject_swallows_materialization_error(): - box = SimpleNamespace( - available=True, - OUTBOX_MOUNT_DIR='/outbox', - materialize_inbound_attachments=AsyncMock(side_effect=RuntimeError('disk full')), - ) - runner = _make_runner(box) - query = _make_query() - user_message = provider_message.Message(role='user', content='hello') - - # must not raise - await runner._inject_inbound_attachments(query, user_message) - assert user_message.content == 'hello' - runner.ap.logger.warning.assert_called_once() diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py deleted file mode 100644 index 08b4c540b..000000000 --- a/tests/unit_tests/provider/test_localagent_sandbox_exec.py +++ /dev/null @@ -1,281 +0,0 @@ -from __future__ import annotations - -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock - -import pytest - -import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -import langbot_plugin.api.entities.builtin.provider.message as provider_message -import langbot_plugin.api.entities.builtin.provider.session as provider_session - -from langbot.pkg.provider.runners.localagent import LocalAgentRunner, _StreamAccumulator - - -class RecordingProvider: - def __init__(self): - self.requests: list[dict] = [] - - async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None): - self.requests.append( - { - 'messages': list(messages), - 'funcs': list(funcs), - 'remove_think': remove_think, - } - ) - - if len(self.requests) == 1: - return provider_message.Message( - role='assistant', - content='Let me calculate that exactly.', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall( - name='exec', - arguments=json.dumps( - {'command': ("python - <<'PY'\nnums = [1, 2, 3, 4]\nprint(sum(nums) / len(nums))\nPY")} - ), - ), - ) - ], - ) - - tool_result = json.loads(messages[-1].content) - return provider_message.Message( - role='assistant', - content=f'The average is {tool_result["stdout"]}.', - ) - - -class RecordingStreamProvider: - def __init__(self): - self.stream_requests: list[dict] = [] - - def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None): - self.stream_requests.append( - { - 'messages': list(messages), - 'funcs': list(funcs), - 'remove_think': remove_think, - } - ) - - async def _stream(): - if len(self.stream_requests) == 1: - yield provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall( - name='exec', - arguments=json.dumps({'command': "python -c 'print(1)'"}), - ), - ) - ], - is_final=True, - ) - return - - yield provider_message.MessageChunk( - role='assistant', - content='Tool execution failed.', - is_final=True, - ) - - return _stream() - - -def make_query() -> pipeline_query.Query: - adapter = AsyncMock() - adapter.is_stream_output_supported = AsyncMock(return_value=False) - - return pipeline_query.Query.model_construct( - query_id='avg-query', - launcher_type=provider_session.LauncherTypes.PERSON, - launcher_id=12345, - sender_id=12345, - message_chain=[], - message_event=None, - adapter=adapter, - pipeline_uuid='pipeline-uuid', - bot_uuid='bot-uuid', - pipeline_config={ - 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'}, - }, - 'output': {'misc': {'remove-think': False}}, - }, - prompt=SimpleNamespace(messages=[]), - messages=[], - user_message=provider_message.Message( - role='user', - content='Please calculate the average of 1, 2, 3, and 4.', - ), - use_funcs=[SimpleNamespace(name='exec')], - use_llm_model_uuid='test-model-uuid', - variables={}, - ) - - -def test_stream_accumulator_merges_fragmented_tool_call_arguments(): - accumulator = _StreamAccumulator(msg_sequence=1) - - assert ( - accumulator.add( - provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall(name='exec', arguments='{"command":'), - ) - ], - ) - ) - is None - ) - - emitted = accumulator.add( - provider_message.MessageChunk( - role='assistant', - tool_calls=[ - provider_message.ToolCall( - id='call-1', - type='function', - function=provider_message.FunctionCall(name='exec', arguments='"pwd"}'), - ) - ], - is_final=True, - ) - ) - - assert emitted is not None - final_msg = accumulator.final_message() - assert final_msg.tool_calls[0].function.name == 'exec' - assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}' - - -@pytest.mark.asyncio -async def test_localagent_uses_exec_for_exact_calculation(): - provider = RecordingProvider() - model = SimpleNamespace( - provider=provider, - model_entity=SimpleNamespace( - uuid='test-model-uuid', - name='test-model', - abilities=['func_call'], - extra_args={}, - ), - ) - - tool_manager = SimpleNamespace( - execute_func_call=AsyncMock( - return_value={ - 'session_id': 'avg-query', - 'backend': 'podman', - 'status': 'completed', - 'ok': True, - 'exit_code': 0, - 'stdout': '2.5', - 'stderr': '', - 'duration_ms': 18, - } - ) - ) - - app = SimpleNamespace( - logger=Mock(), - model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), - tool_mgr=tool_manager, - rag_mgr=SimpleNamespace(), - box_service=SimpleNamespace( - get_system_guidance=Mock( - return_value=( - 'When the exec tool is available, use it for exact calculations, statistics, ' - 'structured data parsing, and code execution instead of estimating mentally. ' - 'Unless the user explicitly asks for the script, code, or implementation details, ' - 'do not include the generated script in the final answer. ' - 'A default workspace is mounted at /workspace for file tasks.' - ) - ), - ), - skill_mgr=SimpleNamespace( - get_skills_for_pipeline=AsyncMock(return_value=[]), - detect_skill_activation=AsyncMock(return_value=None), - build_activation_prompt=Mock(return_value=None), - ), - ) - - runner = LocalAgentRunner(app, pipeline_config={}) - query = make_query() - - results = [message async for message in runner.run(query)] - - assert [message.role for message in results] == ['assistant', 'tool', 'assistant'] - assert results[-1].content == 'The average is 2.5.' - - tool_manager.execute_func_call.assert_awaited_once() - tool_name, tool_parameters = tool_manager.execute_func_call.await_args.args[:2] - assert tool_name == 'exec' - assert 'print(sum(nums) / len(nums))' in tool_parameters['command'] - - first_request = provider.requests[0] - assert any( - message.role == 'system' - and 'exec' in str(message.content) - and 'exact calculations' in str(message.content) - and 'Unless the user explicitly asks for the script' in str(message.content) - and '/workspace' in str(message.content) - for message in first_request['messages'] - ) - assert [tool.name for tool in first_request['funcs']] == ['exec'] - - -@pytest.mark.asyncio -async def test_localagent_streaming_tool_error_yields_message_chunks(): - provider = RecordingStreamProvider() - model = SimpleNamespace( - provider=provider, - model_entity=SimpleNamespace( - uuid='test-model-uuid', - name='test-model', - abilities=['func_call'], - extra_args={}, - ), - ) - - adapter = AsyncMock() - adapter.is_stream_output_supported = AsyncMock(return_value=True) - - query = make_query() - query.adapter = adapter - - app = SimpleNamespace( - logger=Mock(), - model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), - tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(side_effect=RuntimeError('boom'))), - rag_mgr=SimpleNamespace(), - box_service=SimpleNamespace( - get_system_guidance=Mock(return_value='sandbox guidance'), - ), - skill_mgr=SimpleNamespace( - get_skills_for_pipeline=AsyncMock(return_value=[]), - detect_skill_activation=AsyncMock(return_value=None), - build_activation_prompt=Mock(return_value=None), - ), - ) - - runner = LocalAgentRunner(app, pipeline_config={}) - - results = [message async for message in runner.run(query)] - - assert all(isinstance(message, provider_message.MessageChunk) for message in results) - assert any(message.role == 'tool' and message.content == 'err: boom' for message in results) diff --git a/tests/unit_tests/provider/test_mcp_remote_transport.py b/tests/unit_tests/provider/test_mcp_remote_transport.py index b2f1d2e14..72e2d0708 100644 --- a/tests/unit_tests/provider/test_mcp_remote_transport.py +++ b/tests/unit_tests/provider/test_mcp_remote_transport.py @@ -15,6 +15,20 @@ from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession +@pytest.fixture(autouse=True) +def _isolate_loopback_transport_from_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep real loopback transport tests independent of workstation proxies.""" + for variable in ( + 'ALL_PROXY', + 'all_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + ): + monkeypatch.delenv(variable, raising=False) + + class _TransportProbe: def __init__(self, streamable_status: int | None) -> None: self.streamable_status = streamable_status diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py index 773efe71c..8cd279c82 100644 --- a/tests/unit_tests/provider/test_mcp_resources.py +++ b/tests/unit_tests/provider/test_mcp_resources.py @@ -8,9 +8,12 @@ import pytest from mcp import types as mcp_types +from langbot.pkg.agent.runner.execution_context import project_mcp_resource_config +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError from langbot.pkg.provider.tools.loaders.mcp import ( MCP_RESOURCE_CONTEXT_QUERY_KEY, MCP_RESOURCE_TRACE_QUERY_KEY, + MCP_READ_RESOURCE_SCHEMA, MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE, MCPLoader, @@ -257,24 +260,43 @@ async def test_mcp_loader_can_hide_synthetic_resource_tools(): @pytest.mark.asyncio -async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled(): +async def test_mcp_loader_get_tool_returns_synthetic_resource_schema(): + loader = MCPLoader(_app()) + loader.sessions = {'docs': _connected_session()} + + tool = await loader.get_tool(MCP_TOOL_READ_RESOURCE) + + assert tool is not None + assert tool.name == MCP_TOOL_READ_RESOURCE + assert tool.parameters == MCP_READ_RESOURCE_SCHEMA + + +@pytest.mark.asyncio +@pytest.mark.parametrize('read_enabled', [False, 0, None, 'false']) +@pytest.mark.parametrize( + ('tool_name', 'parameters'), + [ + (MCP_TOOL_LIST_RESOURCES, {'server_name': 'docs'}), + (MCP_TOOL_READ_RESOURCE, {'server_name': 'docs', 'uri': 'file:///README.md'}), + ], +) +async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled( + read_enabled, + tool_name, + parameters, +): loader = MCPLoader(_app()) session = _connected_session() loader.sessions = {'docs': session} - query = SimpleNamespace( - variables={ - '_pipeline_bound_mcp_servers': ['srv-1'], - '_pipeline_mcp_resource_agent_read_enabled': False, - } - ) - - result = await loader.invoke_tool( - MCP_TOOL_READ_RESOURCE, - {'server_name': 'docs', 'uri': 'file:///README.md'}, + query = SimpleNamespace(variables={'_pipeline_bound_mcp_servers': ['srv-1']}) + project_mcp_resource_config( query, + {'mcp-resource-agent-read-enabled': read_enabled}, ) - assert result[0].text == 'Error: MCP resource agent reads are disabled.' + with pytest.raises(ToolExecutionDeniedError, match='MCP resource agent reads are disabled'): + await loader.invoke_tool(tool_name, parameters, query) + session.session.read_resource.assert_not_called() @@ -321,3 +343,93 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1 docs.session.read_resource.assert_awaited_once() other.session.read_resource.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('enabled_config', 'expected_enabled'), + [ + pytest.param({}, True, id='missing'), + pytest.param({'enabled': True}, True, id='true'), + pytest.param({'enabled': False}, False, id='false'), + pytest.param({'enabled': 0}, False, id='zero'), + pytest.param({'enabled': None}, False, id='none'), + pytest.param({'enabled': 'false'}, False, id='string-false'), + ], +) +async def test_build_resource_context_attachment_enabled_fails_closed(enabled_config, expected_enabled): + loader = MCPLoader(_app()) + session = _connected_session(name='docs', uuid='srv-1') + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='enabled attachment', + ) + ] + ) + loader.sessions = {'docs': session} + attachment = { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + **enabled_config, + } + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_attachments': [attachment], + } + ) + + context = await loader.build_resource_context_for_query(query) + + if expected_enabled: + assert 'enabled attachment' in context + session.session.read_resource.assert_awaited_once() + else: + assert context == '' + session.session.read_resource.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + pytest.param('max_tokens', '100', id='string-tokens'), + pytest.param('max_tokens', 0, id='zero-tokens'), + pytest.param('max_tokens', -1, id='negative-tokens'), + pytest.param('max_tokens', True, id='boolean-tokens'), + pytest.param('max_bytes', '4096', id='string-bytes'), + pytest.param('max_bytes', 0, id='zero-bytes'), + pytest.param('max_bytes', -1, id='negative-bytes'), + pytest.param('max_bytes', True, id='boolean-bytes'), + ], +) +async def test_build_resource_context_invalid_attachment_limits_fail_closed(field_name, invalid_value): + ap = _app() + loader = MCPLoader(ap) + session = _connected_session(name='docs', uuid='srv-1') + loader.sessions = {'docs': session} + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_attachments': [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + field_name: invalid_value, + } + ], + } + ) + + context = await loader.build_resource_context_for_query(query) + + assert context == '' + session.session.read_resource.assert_not_awaited() + ap.logger.warning.assert_called_once() diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py index b4e1b3ca8..dc5a92e03 100644 --- a/tests/unit_tests/provider/test_model_service.py +++ b/tests/unit_tests/provider/test_model_service.py @@ -12,13 +12,39 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session from langbot.pkg.api.http.service.model import _runtime_model_data +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.api.http.service.provider import ModelProviderService from langbot.pkg.entity.persistence import model as persistence_model from langbot.pkg.pipeline.preproc.preproc import PreProcessor from langbot.pkg.provider.modelmgr import requester from langbot.pkg.provider.modelmgr.modelmgr import ModelManager from langbot.pkg.provider.modelmgr.token import TokenManager -from langbot.pkg.provider.runners.localagent import LocalAgentRunner + + +DEFAULT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' + + +class FakeAgentRunnerRegistry: + async def get(self, runner_id, bound_plugins=None): + return AgentRunnerDescriptor( + id=runner_id, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + config_schema=[ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ], + capabilities={'tool_calling': True, 'knowledge_retrieval': True, 'multimodal_input': True}, + permissions={ + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + }, + ) def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed(): @@ -120,6 +146,7 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() ap = SimpleNamespace() ap.logger = Mock() + ap.agent_runner_registry = FakeAgentRunnerRegistry() ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) ap.skill_mgr = None # PreProcessor only uses skill_mgr for the local-agent skill-binding branch @@ -183,11 +210,13 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() ) pipeline_config = { 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': model_uuid, 'fallbacks': []}, - 'prompt': [], - 'knowledge-bases': [], + 'runner': {'id': DEFAULT_RUNNER_ID}, + 'runner_config': { + DEFAULT_RUNNER_ID: { + 'model': {'primary': model_uuid, 'fallbacks': []}, + 'prompt': [], + 'knowledge-bases': [], + }, }, }, 'trigger': {'misc': {'combine-quote-message': False}}, @@ -220,8 +249,3 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline() processed_query = result.new_query assert processed_query.use_llm_model_uuid == model_uuid - - runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config) - candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query) - - assert [model.model_entity.uuid for model in candidates] == [model_uuid] diff --git a/tests/unit_tests/provider/test_provider_specific_fields.py b/tests/unit_tests/provider/test_provider_specific_fields.py index 2520e608e..f9ae9a8fc 100644 --- a/tests/unit_tests/provider/test_provider_specific_fields.py +++ b/tests/unit_tests/provider/test_provider_specific_fields.py @@ -1,11 +1,17 @@ -"""Unit tests for provider_specific_fields round-trip in LiteLLMRequester. +"""Unit tests for LiteLLMRequester message/tool conversion. -This tests the fix for GitHub issue #1899: Gemini requires thought_signature -to be preserved across tool call rounds for function calls to work correctly. +This includes provider_specific_fields round-trip coverage for GitHub issue +#1899 and token counting preflight behavior for AgentRunner context budgeting. """ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from langbot.pkg.provider.modelmgr import requester as model_requester from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester @@ -14,6 +20,84 @@ def _make_requester() -> LiteLLMRequester: return LiteLLMRequester.__new__(LiteLLMRequester) +def _make_configured_requester() -> LiteLLMRequester: + req = LiteLLMRequester.__new__(LiteLLMRequester) + req.requester_cfg = { + 'base_url': '', + 'timeout': 120, + 'custom_llm_provider': 'openai', + 'drop_params': False, + 'num_retries': 0, + 'api_version': '', + } + req.ap = SimpleNamespace( + tool_mgr=SimpleNamespace( + generate_tools_for_openai=AsyncMock( + return_value=[ + { + 'type': 'function', + 'function': { + 'name': 'search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + }, + } + ] + ) + ) + ) + return req + + +def _make_runtime_model() -> model_requester.RuntimeLLMModel: + provider = SimpleNamespace(token_mgr=SimpleNamespace(get_token=Mock(return_value='sk-test'))) + return SimpleNamespace( + model_entity=SimpleNamespace( + name='gpt-4.1', + extra_args={'temperature': 0.2}, + ), + provider=provider, + ) + + +@pytest.mark.asyncio +async def test_count_tokens_uses_litellm_counter_with_request_messages_and_tools(): + """Token preflight uses the same LiteLLM request shape as chat completion.""" + req = _make_configured_requester() + model = _make_runtime_model() + tool = resource_tool.LLMTool( + name='search', + human_desc='Search', + description='Search', + parameters={'type': 'object'}, + func=lambda **kwargs: None, + ) + + with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42) as counter: + tokens = await req.count_tokens( + model=model, + messages=[ + provider_message.Message( + role='user', + content=[ + provider_message.ContentElement(type='text', text='hello'), + provider_message.ContentElement(type='file_url', file_url='https://example.test/a.pdf'), + ], + ) + ], + funcs=[tool], + extra_args={'presence_penalty': 0.1}, + ) + + assert tokens == 42 + counter.assert_called_once() + kwargs = counter.call_args.kwargs + assert kwargs['model'] == 'openai/gpt-4.1' + assert kwargs['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hello'}]}] + assert kwargs['tools'][0]['function']['name'] == 'search' + assert kwargs['tool_choice'] == 'auto' + + def test_convert_messages_preserves_tool_call_provider_specific_fields(): """Tool calls should retain provider_specific_fields through _convert_messages.""" req = _make_requester() diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 71c0da653..7345ac460 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -302,6 +302,59 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l assert result.role == 'assistant' +@pytest.mark.asyncio +async def test_runtime_provider_invoke_llm_stashes_usage(runtime_provider, runtime_llm_model): + """RuntimeProvider preserves requester usage for upstream action handlers.""" + provider = runtime_provider + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + query = pipeline_query.Query.model_construct( + query_id='test-query-usage', + launcher_type='person', + launcher_id=12345, + sender_id=12345, + message_chain=None, + message_event=None, + adapter=None, + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config={'ai': {}, 'output': {}, 'trigger': {}}, + session=None, + prompt=None, + messages=[], + user_message=None, + use_funcs=[], + use_llm_model_uuid=None, + variables={}, + resp_messages=[], + resp_message_chain=None, + current_stage_name=None, + ) + usage = { + 'prompt_tokens': 11, + 'completion_tokens': 7, + 'total_tokens': 18, + 'prompt_tokens_details': {'cached_tokens': 3}, + } + provider.requester.invoke_llm = AsyncMock( + return_value=( + provider_message.Message(role='assistant', content='ok'), + usage, + ) + ) + + result = await provider.invoke_llm( + query, + runtime_llm_model, + [provider_message.Message(role='user', content='Hello')], + ) + + assert result.content == 'ok' + assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage + + @pytest.mark.asyncio async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider, runtime_llm_model): """Test RuntimeProvider.invoke_llm_stream yields chunks from requester.""" @@ -345,6 +398,61 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider assert chunks[0].role == 'assistant' +@pytest.mark.asyncio +async def test_runtime_provider_invoke_llm_stream_stashes_usage(runtime_provider, runtime_llm_model): + """RuntimeProvider transfers captured stream usage to the public query usage key.""" + provider = runtime_provider + + import langbot_plugin.api.entities.builtin.provider.message as provider_message + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + query = pipeline_query.Query.model_construct( + query_id='test-stream-usage', + launcher_type='person', + launcher_id=12345, + sender_id=12345, + message_chain=None, + message_event=None, + adapter=None, + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config={'ai': {}, 'output': {}, 'trigger': {}}, + session=None, + prompt=None, + messages=[], + user_message=None, + use_funcs=[], + use_llm_model_uuid=None, + variables={}, + resp_messages=[], + resp_message_chain=None, + current_stage_name=None, + ) + usage = { + 'prompt_tokens': 13, + 'completion_tokens': 2, + 'total_tokens': 15, + } + + async def fake_stream(**kwargs): + kwargs['query'].variables[requester.LLM_USAGE_QUERY_VARIABLE] = usage + yield provider_message.MessageChunk(role='assistant', content='ok') + + provider.requester.invoke_llm_stream = fake_stream + + chunks = [ + chunk + async for chunk in provider.invoke_llm_stream( + query, + runtime_llm_model, + [provider_message.Message(role='user', content='Hello')], + ) + ] + + assert len(chunks) == 1 + assert query.variables[requester.LLM_USAGE_QUERY_VARIABLE] == usage + + @pytest.mark.asyncio async def test_runtime_provider_invoke_embedding_returns_vectors(runtime_provider, runtime_embedding_model): """Test RuntimeProvider.invoke_embedding returns embedding vectors.""" diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 9db7b945e..bd2838787 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -176,6 +176,65 @@ def test_register_activated_skill_without_skill_manager_returns_false(self): assert register_activated_skill(ap, query, 'primary') is False +class TestPersistActivatedSkill: + """Host-side persistence of activated skills into conversation state (S-01/S-02).""" + + @pytest.mark.asyncio + async def test_persist_writes_conversation_state(self): + from unittest.mock import patch + from langbot.pkg.provider.tools.loaders.skill import ( + persist_activated_skill, + ACTIVATED_SKILLS_KEY, + ACTIVATED_SKILL_NAMES_STATE_KEY, + ) + + ap = _make_ap() + ap.persistence_mgr.get_db_engine = Mock(return_value=Mock()) + + query = SimpleNamespace(variables={ACTIVATED_SKILLS_KEY: {'pdf': {'name': 'pdf'}}}) + query._agent_run_session = { + 'runner_id': 'plugin:test/runner/default', + 'authorization': { + 'state_context': { + 'scope_keys': {'conversation': 'conv-scope-key'}, + 'binding_identity': 'binding-1', + 'conversation_id': 'c1', + }, + }, + } + + store = SimpleNamespace(state_set=AsyncMock(return_value=(True, None))) + with patch( + 'langbot.pkg.agent.runner.persistent_state_store.get_persistent_state_store', + return_value=store, + ): + await persist_activated_skill(ap, query, 'pdf') + + store.state_set.assert_awaited_once() + kwargs = store.state_set.await_args.kwargs + assert kwargs['scope_key'] == 'conv-scope-key' + assert kwargs['state_key'] == ACTIVATED_SKILL_NAMES_STATE_KEY + assert kwargs['value'] == ['pdf'] + assert kwargs['scope'] == 'conversation' + assert kwargs['runner_id'] == 'plugin:test/runner/default' + assert kwargs['binding_identity'] == 'binding-1' + + @pytest.mark.asyncio + async def test_persist_noop_without_run_session(self): + from unittest.mock import patch + from langbot.pkg.provider.tools.loaders.skill import persist_activated_skill + + ap = _make_ap() + query = SimpleNamespace(variables={'_activated_skills': {'pdf': {'name': 'pdf'}}}) + + with patch( + 'langbot.pkg.agent.runner.persistent_state_store.get_persistent_state_store', + ) as mock_factory: + await persist_activated_skill(ap, query, 'pdf') + + mock_factory.assert_not_called() + + class TestSkillPathHelpers: def test_get_visible_skills_filters_by_bound_names(self): from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills @@ -193,12 +252,13 @@ def test_get_visible_skills_filters_by_bound_names(self): assert list(result.keys()) == ['visible'] - def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self): + def test_restore_activated_skills_from_state_filters_by_visibility(self): from langbot.pkg.provider.tools.loaders.skill import ( ACTIVATED_SKILLS_KEY, + ACTIVATED_SKILL_NAMES_STATE_KEY, PIPELINE_BOUND_SKILLS_KEY, get_activated_skill_names, - restore_activated_skills, + restore_activated_skills_from_state, ) ap = _make_ap() @@ -209,8 +269,9 @@ def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self } ) query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']}) + state = {'conversation': {ACTIVATED_SKILL_NAMES_STATE_KEY: ['visible', 'hidden', 'visible', '']}} - restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', '']) + restored = restore_activated_skills_from_state(ap, query, state) assert restored == ['visible'] assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible'] diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py index 0ae33115c..a999f237c 100644 --- a/tests/unit_tests/provider/test_tool_manager.py +++ b/tests/unit_tests/provider/test_tool_manager.py @@ -7,12 +7,15 @@ from __future__ import annotations -import pytest -from unittest.mock import Mock, AsyncMock from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import pytest + +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError def get_toolmgr_module(): @@ -259,6 +262,119 @@ async def test_plugin_loader_checked_first(self, mock_app_with_loaders, sample_q mock_plugin_loader.invoke_tool.assert_called_once() mock_mcp_loader.invoke_tool.assert_not_called() + @pytest.mark.asyncio + async def test_bound_mcp_source_wins_over_unbound_plugin_name_collision(self, mock_app_with_loaders): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_plugin_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + query = Mock(variables={}) + + result = await manager.execute_func_call( + 'shared_tool', + {'value': 1}, + query, + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, + ) + + assert result == 'mcp_result' + mock_plugin_loader.has_tool.assert_not_awaited() + mock_plugin_loader.invoke_tool.assert_not_awaited() + mock_mcp_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='bound-mcp') + mock_mcp_loader.invoke_tool.assert_awaited_once_with( + 'shared_tool', + {'value': 1}, + query, + source_id='bound-mcp', + ) + + @pytest.mark.asyncio + async def test_bound_plugin_source_selects_one_of_two_same_name_plugins(self, mock_app_with_loaders): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_plugin_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + query = Mock(variables={}) + + result = await manager.execute_func_call( + 'shared_tool', + {}, + query, + source_ref={'source': 'plugin', 'source_id': 'authorized/plugin'}, + ) + + assert result == 'plugin_result' + mock_plugin_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='authorized/plugin') + mock_plugin_loader.invoke_tool.assert_awaited_once_with( + 'shared_tool', + {}, + query, + source_id='authorized/plugin', + ) + mock_mcp_loader.has_tool.assert_not_awaited() + + @pytest.mark.asyncio + async def test_disabled_synthetic_mcp_tool_is_monitored_as_error( + self, + mock_app_with_loaders, + sample_query, + ): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_app.monitoring_service = SimpleNamespace(record_tool_call=AsyncMock()) + sample_query.variables = {} + sample_query.launcher_type = None + sample_query.launcher_id = None + sample_query.bot_uuid = 'bot-1' + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.invoke_tool = AsyncMock( + side_effect=ToolExecutionDeniedError( + 'langbot_mcp_read_resource', + 'MCP resource agent reads are disabled', + ) + ) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + + with pytest.raises(ToolExecutionDeniedError): + await manager.execute_func_call( + 'langbot_mcp_read_resource', + {'server_name': 'docs', 'uri': 'file:///README.md'}, + sample_query, + source_ref={'source': 'mcp', 'source_id': None}, + ) + + record = mock_app.monitoring_service.record_tool_call + record.assert_awaited_once() + assert record.await_args.kwargs['status'] == 'error' + assert record.await_args.kwargs['tool_source'] == 'mcp' + assert 'MCP resource agent reads are disabled' in record.await_args.kwargs['error_message'] + + +class TestToolManagerSourceResolution: + @pytest.mark.asyncio + @pytest.mark.parametrize('source', ['mcp', 'plugin']) + async def test_same_name_implementations_in_one_scope_are_hidden(self, source): + toolmgr = get_toolmgr_module() + app = Mock(logger=Mock()) + manager = toolmgr.ToolManager(app) + manager.get_tool_catalog = AsyncMock( + return_value=[ + {'name': 'shared_tool', 'source': source, 'source_id': 'source-a'}, + {'name': 'shared_tool', 'source': source, 'source_id': 'source-b'}, + {'name': 'unique_tool', 'source': 'builtin'}, + ] + ) + + catalog = await manager.get_resolved_tool_catalog() + + assert [item['name'] for item in catalog] == ['unique_tool'] + app.logger.warning.assert_called_once() + class TestToolManagerShutdown: """Tests for shutdown method.""" diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index 2085a9e8c..7d8c0e2d5 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -65,7 +65,8 @@ def make_tool(name: str) -> resource_tool.LLMTool: @pytest.mark.asyncio -async def test_tool_manager_omits_skill_authoring_tools_by_default(): +async def test_tool_manager_includes_skill_tools_by_default(): + """Skill tools are exposed like native tools; the SkillToolLoader self-gates.""" manager = ToolManager(SimpleNamespace()) manager.native_tool_loader = StubLoader([make_tool('exec')]) manager.skill_tool_loader = StubLoader([make_tool('activate')]) @@ -74,20 +75,21 @@ async def test_tool_manager_omits_skill_authoring_tools_by_default(): tools = await manager.get_all_tools() - assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool'] + assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool'] @pytest.mark.asyncio -async def test_tool_manager_includes_skill_authoring_tools_when_requested(): +async def test_tool_manager_omits_skill_tools_when_loader_unavailable(): + """When the SkillToolLoader gate is closed (no sandbox / skill_mgr) it returns no tools.""" manager = ToolManager(SimpleNamespace()) manager.native_tool_loader = StubLoader([make_tool('exec')]) - manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.skill_tool_loader = StubLoader([]) manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) - tools = await manager.get_all_tools(include_skill_authoring=True) + tools = await manager.get_all_tools() - assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool'] + assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool'] @pytest.mark.asyncio diff --git a/tests/unit_tests/provider/test_tool_source_routing.py b/tests/unit_tests/provider/test_tool_source_routing.py new file mode 100644 index 000000000..4365263c4 --- /dev/null +++ b/tests/unit_tests/provider/test_tool_source_routing.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.provider.tools.loaders.mcp import ( + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + MCPLoader, + MCPSessionStatus, +) +from langbot.pkg.provider.tools.loaders.plugin import PluginToolLoader +from langbot_plugin.api.entities.builtin.resource.tool import LLMTool + + +def make_tool(name: str) -> LLMTool: + return LLMTool( + name=name, + human_desc=name, + description=name, + parameters={'type': 'object', 'properties': {}}, + func=lambda parameters: parameters, + ) + + +@pytest.mark.asyncio +async def test_two_mcp_servers_with_same_tool_route_to_authorized_server(): + tool = make_tool('shared_tool') + first = SimpleNamespace( + server_uuid='mcp-a', + get_tools=Mock(return_value=[tool]), + invoke_mcp_tool=AsyncMock(return_value='from-a'), + ) + second = SimpleNamespace( + server_uuid='mcp-b', + get_tools=Mock(return_value=[tool]), + invoke_mcp_tool=AsyncMock(return_value='from-b'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'first': first, 'second': second} + query = SimpleNamespace(variables={}) + + result = await loader.invoke_tool( + 'shared_tool', + {'value': 1}, + query, + source_id='mcp-b', + ) + + assert result == 'from-b' + first.invoke_mcp_tool.assert_not_awaited() + second.invoke_mcp_tool.assert_awaited_once_with( + 'shared_tool', + {'value': 1}, + query=query, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reserved_name', [MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE]) +async def test_reserved_name_routes_to_real_mcp_tool_when_source_id_is_present(reserved_name): + real_tool = make_tool(reserved_name) + session = SimpleNamespace( + server_uuid='srv-real', + get_tools=Mock(return_value=[real_tool]), + invoke_mcp_tool=AsyncMock(return_value='real-result'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'real': session} + query = SimpleNamespace(variables={}) + + assert await loader.has_tool(reserved_name, source_id='srv-real') is True + assert await loader.get_tool(reserved_name, source_id='srv-real') is real_tool + result = await loader.invoke_tool(reserved_name, {}, query, source_id='srv-real') + + assert result == 'real-result' + session.invoke_mcp_tool.assert_awaited_once_with(reserved_name, {}, query=query) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('reserved_name', 'invoke_method'), + [ + (MCP_TOOL_LIST_RESOURCES, '_invoke_mcp_list_resources'), + (MCP_TOOL_READ_RESOURCE, '_invoke_mcp_read_resource'), + ], +) +async def test_reserved_name_uses_host_synthetic_tool_when_source_id_is_none( + reserved_name, + invoke_method, +): + real_tool = make_tool(reserved_name) + session = SimpleNamespace( + server_uuid='srv-real', + server_name='real', + enable=True, + status=MCPSessionStatus.CONNECTED, + session=object(), + get_tools=Mock(return_value=[real_tool]), + has_resource_support=Mock(return_value=True), + invoke_mcp_tool=AsyncMock(return_value='real-result'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'real': session} + synthetic_invoke = AsyncMock(return_value='synthetic-result') + setattr(loader, invoke_method, synthetic_invoke) + query = SimpleNamespace(variables={}) + + assert await loader.has_tool(reserved_name, source_id=None) is True + tool = await loader.get_tool(reserved_name, source_id=None) + result = await loader.invoke_tool(reserved_name, {}, query, source_id=None) + + assert tool is not None + assert tool is not real_tool + assert result == 'synthetic-result' + synthetic_invoke.assert_awaited_once_with({}, query) + session.invoke_mcp_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_two_plugins_with_same_tool_forward_only_authorized_plugin(): + manifest = SimpleNamespace( + owner='authorized/plugin', + metadata=SimpleNamespace( + name='shared_tool', + description=SimpleNamespace(en_US='Shared tool'), + ), + spec={ + 'llm_prompt': 'Shared tool', + 'parameters': {'type': 'object', 'properties': {}}, + }, + ) + connector = SimpleNamespace( + list_tools=AsyncMock(return_value=[manifest]), + call_tool=AsyncMock(return_value='authorized-result'), + ) + loader = PluginToolLoader(SimpleNamespace(plugin_connector=connector, logger=Mock())) + query = SimpleNamespace(session=SimpleNamespace(), query_id=7) + query.session.model_dump = Mock(return_value={}) + + result = await loader.invoke_tool( + 'shared_tool', + {}, + query, + source_id='authorized/plugin', + ) + + assert result == 'authorized-result' + connector.call_tool.assert_awaited_once_with( + 'shared_tool', + {}, + session=query.session, + query_id=7, + bound_plugins=['authorized/plugin'], + ) diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index 8f05277cb..256dc8cb8 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -8,6 +8,10 @@ import pytest +from langbot_plugin.api.entities.builtin.agent_runner.manifest import ( + AgentRunnerCapabilities, + AgentRunnerPermissions, +) from langbot_plugin.api.entities.builtin.pipeline.query import Query from langbot_plugin.api.entities.builtin.platform.entities import Friend from langbot_plugin.api.entities.builtin.platform.events import FriendMessage @@ -17,6 +21,32 @@ from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session +class _FakeRunnerDescriptor: + config_schema = [ + {'name': 'model', 'type': 'model-fallback-selector'}, + {'name': 'prompt', 'type': 'prompt-editor', 'default': []}, + {'name': 'knowledge-bases', 'type': 'knowledge-base-multi-selector', 'default': []}, + ] + permissions = { + 'models': ['invoke', 'stream'], + 'tools': ['detail', 'call'], + 'knowledge_bases': ['list', 'retrieve'], + } + permissions = AgentRunnerPermissions.model_validate(permissions) + capabilities = AgentRunnerCapabilities( + tool_calling=True, + knowledge_retrieval=True, + multimodal_input=True, + skill_authoring=True, + ) + + def supports_tool_calling(self): + return self.capabilities.tool_calling + + def supports_knowledge_retrieval(self): + return self.capabilities.knowledge_retrieval + + def _make_query() -> Query: message_chain = MessageChain([Plain(text='create a skill')]) return Query( @@ -34,11 +64,13 @@ def _make_query() -> Query: pipeline_uuid='pipe-1', pipeline_config={ 'ai': { - 'runner': {'runner': 'local-agent'}, - 'local-agent': { - 'model': {'primary': 'model-1', 'fallbacks': []}, - 'prompt': 'default', - 'knowledge-bases': [], + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': 'model-1', 'fallbacks': []}, + 'prompt': [], + 'knowledge-bases': [], + }, }, }, 'trigger': {'misc': {}}, @@ -57,11 +89,20 @@ def _make_conversation() -> Conversation: ) +async def _passthrough_preproc_event(event, bound_plugins): + return SimpleNamespace( + event=SimpleNamespace( + default_prompt=event.default_prompt, + prompt=event.prompt, + ) + ) + + def _make_app(*, skill_service) -> SimpleNamespace: session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1') conversation = _make_conversation() model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'})) - tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) return SimpleNamespace( sess_mgr=SimpleNamespace( @@ -83,8 +124,8 @@ def _make_app(*, skill_service) -> SimpleNamespace: pipeline_service=SimpleNamespace( get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}}) ), + agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=_FakeRunnerDescriptor())), skill_mgr=SimpleNamespace( - build_skill_aware_prompt_addition=Mock(return_value=''), skills={}, ), skill_service=skill_service, @@ -109,7 +150,7 @@ def _import_preproc_modules(): @pytest.mark.asyncio -async def test_preproc_enables_skill_authoring_tools_when_skill_service_available(): +async def test_preproc_loads_host_tools_for_runner(): preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) @@ -118,16 +159,54 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl result = await stage.process(_make_query(), 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_preproc_puts_host_skill_tools_into_query_scope(): + """AgentRunner resource authorization consumes the tools discovered by preproc.""" + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + { + 'name': 'activate', + 'source': 'skill', + 'description': 'Activate a skill', + 'parameters': {}, + }, + { + 'name': 'register_skill', + 'source': 'skill', + 'description': 'Register a skill', + 'parameters': {}, + }, + ] + ) + query = _make_query() + stage = preproc_module.PreProcessor(app) + + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, include_skill_authoring=True, include_mcp_resource_tools=True, ) + assert [tool.name for tool in query.use_funcs] == ['activate', 'register_skill'] @pytest.mark.asyncio -async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing(): +async def test_preproc_loads_host_tools_regardless_of_skill_service(): + """Skill tooling no longer gates on skill_service at the preproc layer.""" preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=None) @@ -136,10 +215,10 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing result = await stage.process(_make_query(), 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, - include_skill_authoring=False, + include_skill_authoring=True, include_mcp_resource_tools=True, ) @@ -156,7 +235,7 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable result = await stage.process(query, 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, include_skill_authoring=True, @@ -165,30 +244,24 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable @pytest.mark.asyncio -async def test_preproc_injects_skill_index_into_system_prompt(): - """The Tool Call activation pattern still needs the LLM to know which - skills exist. PreProcessor must append the SkillManager's index - addendum to the first system message.""" +async def test_preproc_records_all_visible_skills_without_prompt_injection(): preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) - addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...' - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum) query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None) + app.pipeline_service.get_pipeline.assert_awaited_once_with('pipe-1') + assert query.variables.get('_pipeline_bound_skills') is None head = query.prompt.messages[0] assert head.role == 'system' - assert head.content.endswith(addendum) + assert head.content == 'system prompt' @pytest.mark.asyncio async def test_preproc_respects_pipeline_bound_skills_subset(): - """When ``enable_all_skills`` is false the bound list is passed through - so the addendum only mentions skills allowed for this pipeline.""" preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) @@ -200,31 +273,79 @@ async def test_preproc_respects_pipeline_bound_skills_subset(): } } ) - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this']) assert query.variables.get('_pipeline_bound_skills') == ['only-this'] + assert query.prompt.messages[0].content == 'system prompt' @pytest.mark.asyncio -async def test_preproc_skips_injection_when_addendum_is_empty(): - """No visible skills → system prompt is left untouched (no - ``Available Skills`` block appended).""" +async def test_preproc_does_not_load_skill_preferences_without_skill_mgr(): preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) - app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + app.skill_mgr = None # no skill manager -> skill tooling unavailable query = _make_query() result = await stage_process_capture(preproc_module, app, query) assert result.result_type == entities_module.ResultType.CONTINUE - if query.prompt and query.prompt.messages: - assert 'Available Skills' not in (query.prompt.messages[0].content or '') + app.pipeline_service.get_pipeline.assert_not_awaited() + assert '_pipeline_bound_skills' not in query.variables + assert query.prompt.messages[0].content == 'system prompt' + + +@pytest.mark.asyncio +async def test_preproc_uses_transcript_history_view_when_available(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + conversation = app.sess_mgr.get_conversation.return_value + conversation.messages = [Message(role='user', content='legacy history')] + app.plugin_connector.emit_event = AsyncMock(side_effect=_passthrough_preproc_event) + + transcript_messages = [ + Message(role='user', content='from transcript user'), + Message(role='assistant', content='from transcript assistant'), + ] + + stage = preproc_module.PreProcessor(app) + stage._load_agent_runner_history_messages = AsyncMock(return_value=transcript_messages) + + query = _make_query() + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + assert query.messages == transcript_messages + stage._load_agent_runner_history_messages.assert_awaited_once_with( + 'plugin:langbot-team/LocalAgent/default', + 'conv-1', + bot_id='bot-1', + workspace_id=None, + thread_id=None, + ) + + +@pytest.mark.asyncio +async def test_preproc_falls_back_to_conversation_messages_when_transcript_empty(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + legacy_messages = [Message(role='user', content='legacy history')] + app.sess_mgr.get_conversation.return_value.messages = legacy_messages + app.plugin_connector.emit_event = AsyncMock(side_effect=_passthrough_preproc_event) + + stage = preproc_module.PreProcessor(app) + stage._load_agent_runner_history_messages = AsyncMock(return_value=None) + + query = _make_query() + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + assert query.messages == legacy_messages async def stage_process_capture(preproc_module, app, query): diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py index 6fd7d64f2..800ff8906 100644 --- a/tests/unit_tests/test_skill_service.py +++ b/tests/unit_tests/test_skill_service.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock import pytest +from langbot_plugin.entities.io.errors import ActionCallTimeoutError from langbot.pkg.api.http.service.skill import SkillService @@ -82,6 +83,17 @@ async def test_list_skills_returns_empty_when_box_unavailable(self): service = SkillService(self._ap_with_disabled_box()) assert await service.list_skills() == [] + @pytest.mark.asyncio + async def test_list_skills_returns_empty_when_box_action_times_out(self): + """A transient Box list failure should not break unrelated extension surfaces.""" + box_service = SimpleNamespace( + available=True, + list_skills=AsyncMock(side_effect=ActionCallTimeoutError('Action box_list_skills call timed out')), + ) + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), box_service=box_service)) + + assert await service.list_skills() == [] + @pytest.mark.asyncio async def test_read_skill_file_refused_when_box_unavailable(self): service = SkillService(self._ap_with_disabled_box()) diff --git a/tests/unit_tests/vector/test_mgr.py b/tests/unit_tests/vector/test_mgr.py index 5c8927b54..0b6d017d7 100644 --- a/tests/unit_tests/vector/test_mgr.py +++ b/tests/unit_tests/vector/test_mgr.py @@ -56,7 +56,7 @@ def test_initialize_no_config_defaults_to_chroma(self): # Run initialize synchronously for test import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) # Chroma should be instantiated mock_chroma_class.assert_called_once_with(mock_app) @@ -78,7 +78,7 @@ def test_initialize_chroma_backend(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) mock_app.logger.info.assert_called() @@ -99,7 +99,7 @@ def test_initialize_qdrant_backend(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_qdrant_class.assert_called_once_with(mock_app) @@ -119,7 +119,7 @@ def test_initialize_seekdb_backend(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_seekdb_class.assert_called_once_with(mock_app) @@ -138,7 +138,8 @@ def test_initialize_valkey_search_backend(self): mgr = VectorDBManager(mock_app) import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + + asyncio.run(mgr.initialize()) mock_valkey_class.assert_called_once_with(mock_app) @@ -161,7 +162,7 @@ def test_initialize_milvus_backend_with_uri(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_milvus_class.assert_called_once_with( mock_app, uri='http://localhost:19530', token='root:Milvus', db_name='langbot_db' @@ -183,7 +184,7 @@ def test_initialize_milvus_backend_defaults(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) # Should use default values mock_milvus_class.assert_called_once_with(mock_app, uri='./data/milvus.db', token=None, db_name='default') @@ -204,7 +205,7 @@ def test_initialize_pgvector_with_connection_string(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, connection_string='postgresql://user:pass@host:5432/langbot' @@ -235,7 +236,7 @@ def test_initialize_pgvector_with_individual_params(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret' @@ -257,7 +258,7 @@ def test_initialize_pgvector_defaults(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres' @@ -279,7 +280,7 @@ def test_initialize_unknown_backend_defaults_to_chroma(self): import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) mock_app.logger.warning.assert_called() diff --git a/tests/unit_tests/vector/test_valkey_search_filter.py b/tests/unit_tests/vector/test_valkey_search_filter.py index 7439712dd..1da53fe9a 100644 --- a/tests/unit_tests/vector/test_valkey_search_filter.py +++ b/tests/unit_tests/vector/test_valkey_search_filter.py @@ -357,10 +357,12 @@ def _fake_credentials(**kwargs): cred_calls.append(kwargs) return ('CRED', kwargs) - monkeypatch.setattr(mod, 'GlideClient', _FakeClient) - monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials) - monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw) - monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k)) + # The optional glide import defines none of these names when the + # dependency is absent, which is the normal fast-test environment. + monkeypatch.setattr(mod, 'GlideClient', _FakeClient, raising=False) + monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials, raising=False) + monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw, raising=False) + monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k), raising=False) return backend, created, cred_calls, warnings async def test_username_without_password_fails_closed(self, monkeypatch): diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py index 427df9f19..638a0e8d0 100644 --- a/tests/unit_tests/vector/test_vdb_base.py +++ b/tests/unit_tests/vector/test_vdb_base.py @@ -115,7 +115,7 @@ async def delete_collection(self, collection): # list_by_filter should return empty list and -1 for total import asyncio - result = asyncio.get_event_loop().run_until_complete(db.list_by_filter('test_collection')) + result = asyncio.run(db.list_by_filter('test_collection')) assert result == ([], -1) diff --git a/tests/utils/import_isolation.py b/tests/utils/import_isolation.py index 9f2b3c583..47fce42bc 100644 --- a/tests/utils/import_isolation.py +++ b/tests/utils/import_isolation.py @@ -143,10 +143,6 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]: # Mock core.app - Application class is referenced but not instantiated mock_app = MagicMock() - # Mock provider.runner - has preregistered_runners attribute - mock_runner = MagicMock() - mock_runner.preregistered_runners = [] # Empty by default, tests override - # Mock utils.importutil - prevents auto-import of runners mock_importutil = MagicMock() mock_importutil.import_modules_in_pkg = lambda pkg: None @@ -158,19 +154,11 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]: 'langbot.pkg.pipeline.controller': MagicMock(), 'langbot.pkg.pipeline.pipelinemgr': MagicMock(), 'langbot.pkg.pipeline.process.process': MagicMock(), - 'langbot.pkg.provider.runner': mock_runner, 'langbot.pkg.utils.importutil': mock_importutil, } -# Package attributes that need to be updated alongside sys.modules mocking. -# When Python imports a submodule (e.g., langbot.pkg.provider.runner), it -# automatically sets an attribute on the parent package. The import statement -# `from ....provider import runner` gets this attribute, not sys.modules directly. -# This dict maps mock module names to the parent packages that need attribute updates. -_PACKAGE_ATTRIBUTE_UPDATES: dict[str, tuple[str, str]] = { - 'langbot.pkg.provider.runner': ('langbot.pkg.provider', 'runner'), -} +_PACKAGE_ATTRIBUTE_UPDATES: dict[str, tuple[str, str]] = {} def get_handler_modules_to_clear(handler_name: str) -> list[str]: diff --git a/uv.lock b/uv.lock index 3e4b2a124..3ded3ad4f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.11, <4.0" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -18,11 +21,11 @@ resolution-markers = [ [[package]] name = "aenum" -version = "3.1.16" +version = "3.1.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693, upload-time = "2026-01-12T22:34:38.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" }, + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, ] [[package]] @@ -46,11 +49,11 @@ wheels = [ [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] @@ -217,16 +220,25 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.4" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -240,7 +252,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.77.0" +version = "0.116.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -252,34 +264,34 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/85/6cb5da3cf91de2eeea89726316e8c5c8c31e2d61ee7cb1233d7e95512c31/anthropic-0.77.0.tar.gz", hash = "sha256:ce36efeb80cb1e25430a88440dc0f9aa5c87f10d080ab70a1bdfd5c2c5fbedb4", size = 504575, upload-time = "2026-01-29T18:20:41.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/27/9df785d3f94df9ac72f43ee9e14b8120b37d992b18f4952774ed46145022/anthropic-0.77.0-py3-none-any.whl", hash = "sha256:65cc83a3c82ce622d5c677d0d7706c77d29dc83958c6b10286e12fda6ffb2651", size = 397867, upload-time = "2026-01-29T18:20:39.481Z" }, + { url = "https://files.pythonhosted.org/packages/c7/dd/2a1e81cf1b163acc340afc4ec74ed1d86f5eed1a809fabdeed3e0997b346/anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256", size = 956896, upload-time = "2026-07-02T19:08:08.756Z" }, ] [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] name = "apscheduler" -version = "3.11.2" +version = "3.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" }, ] [[package]] @@ -325,13 +337,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "async-lru" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, ] [[package]] @@ -384,11 +437,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +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/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { 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]] @@ -447,15 +500,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "bcrypt" version = "5.0.0" @@ -528,15 +572,15 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -550,141 +594,169 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.39" +version = "1.43.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/ea/b96c77da49fed28744ee0347374d8223994a2b8570e76e8380a4064a8c4a/boto3-1.42.39.tar.gz", hash = "sha256:d03f82363314759eff7f84a27b9e6428125f89d8119e4588e8c2c1d79892c956", size = 112783, upload-time = "2026-01-30T20:38:31.226Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/e7/976bf3dfe0aa5d7f31bec2f2cf57c79641620c910a39bc843a237aa9592d/boto3-1.43.46.tar.gz", hash = "sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96", size = 112654, upload-time = "2026-07-10T19:32:12.498Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/c4/3493b5c86e32d6dd558b30d16b55503e24a6e6cd7115714bc102b247d26e/boto3-1.42.39-py3-none-any.whl", hash = "sha256:d9d6ce11df309707b490d2f5f785b761cfddfd6d1f665385b78c9d8ed097184b", size = 140606, upload-time = "2026-01-30T20:38:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl", hash = "sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c", size = 140031, upload-time = "2026-07-10T19:32:11.129Z" }, ] [[package]] name = "botocore" -version = "1.42.39" +version = "1.43.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/a6/3a34d1b74effc0f759f5ff4e91c77729d932bc34dd3207905e9ecbba1103/botocore-1.42.39.tar.gz", hash = "sha256:0f00355050821e91a5fe6d932f7bf220f337249b752899e3e4cf6ed54326249e", size = 14914927, upload-time = "2026-01-30T20:38:19.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958, upload-time = "2026-01-30T20:38:14.814Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, ] [[package]] name = "bracex" -version = "2.6" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, ] [[package]] name = "build" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, { name = "packaging" }, { name = "pyproject-hooks" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/21/6ec54248b4d0d51f12f3ca4aa77a128077d747a5db86cb5a2fcd9aedecbd/build-1.5.1.tar.gz", hash = "sha256:94e17f1db803ab22f46049376c44c8437c52090f0dfdf1adc43df56542d644fb", size = 112439, upload-time = "2026-07-09T06:21:59.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f7/2c3f99ff9282f1c1ec9f6298f2c03034658a0901eeacb3b3501cb488574c/build-1.5.1-py3-none-any.whl", hash = "sha256:f1a58fe2e5af5b0238a07b9e70207492c79ddebbdb1ad954fc86d62a56be3e0d", size = 31195, upload-time = "2026-07-09T06:21:58.551Z" }, ] [[package]] name = "cachetools" -version = "6.2.6" +version = "7.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] @@ -698,89 +770,118 @@ wheels = [ [[package]] name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] name = "chromadb" -version = "1.4.1" +version = "1.5.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -791,16 +892,17 @@ dependencies = [ { name = "jsonschema" }, { name = "kubernetes" }, { name = "mmh3" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "onnxruntime" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, { name = "orjson" }, { name = "overrides" }, - { name = "posthog" }, { name = "pybase64" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pypika" }, { name = "pyyaml" }, { name = "rich" }, @@ -811,25 +913,25 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/35/24479ac00e74b86e388854a573a9ebe6d41c51c37e03d00864bb967d861f/chromadb-1.4.1.tar.gz", hash = "sha256:3cceb83e0a7a3c2db0752ebf62e9cfe652da657594c093fe07e74022581a58eb", size = 2226347, upload-time = "2026-01-14T19:18:15.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/d1/5e33b26985f0c7046a0be1cee2158ada1748ee700d2545057fde1468d74d/chromadb-1.5.9.tar.gz", hash = "sha256:5c20e62a455c28bacac927f26116a73fd8e1799e0d908be8e8a4f02197a54731", size = 2595635, upload-time = "2026-05-05T05:54:51.713Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/f0/7c815bb80a2aaa349757ed0c743fa7e85bbe16f612057b25cf1809456a32/chromadb-1.4.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05d98ffe4a9a5549c9a78eee7624277f9d99c53200a01f1176ecb1d31ea3c819", size = 20313209, upload-time = "2026-01-14T19:18:12.111Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4b/c16236d56bf6bf144edbe5a03c431b59ba089bd6f86baefa8ebc288bf8b8/chromadb-1.4.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:38336431c01562cffdb3ef693f22f7a88df5304f942e01ed66ee0bbaf08f35da", size = 19634405, upload-time = "2026-01-14T19:18:08.264Z" }, - { url = "https://files.pythonhosted.org/packages/70/9c/33c6c3036e30632c2b64d333e92af3972e6bef423a8285e0edc5f487d322/chromadb-1.4.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaaf9c7d4ddbbdc74bd7cac45d9729032020cc6e65a2b8f313257e6c949beed", size = 20276410, upload-time = "2026-01-14T19:18:00.226Z" }, - { url = "https://files.pythonhosted.org/packages/29/bc/0c6a6255cd55fe384c1bda6bebb47b5ff9d5c535d993fd3451e4a3fbe42f/chromadb-1.4.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad50fbb5799dcaef5ae7613be583a06b44b637283db066396490863266f48623", size = 21082323, upload-time = "2026-01-14T19:18:04.604Z" }, - { url = "https://files.pythonhosted.org/packages/79/be/5092571f87ddf08022a3d9434d3374d3f5aa20ebad1c75d63107c0c046d6/chromadb-1.4.1-cp39-abi3-win_amd64.whl", hash = "sha256:cedc9941dad1081eb9be89a7f5f66374715d4f99f731f1eb9da900636c501330", size = 21376957, upload-time = "2026-01-14T19:18:16.95Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5b/3cced915244f43ed14b53fe9f63a37f05f865064f4e4fe7d9448d3f2a352/chromadb-1.5.9-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60701011b5e6409647fa40d12c7c5a66b2b0bfcf33a52db2ad53a30a2abc4957", size = 22564540, upload-time = "2026-05-05T05:54:48.906Z" }, + { url = "https://files.pythonhosted.org/packages/34/4c/adcef1f4e82a2ef69ccd3711d55fc289193d54c4c0ff7a0292a3631db46f/chromadb-1.5.9-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:814b9c95617377f6501e5757d63dfddb554a283a7739c87b9fa573850174e6f3", size = 21699698, upload-time = "2026-05-05T05:54:45.078Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/937bc4d2e6f8ab9664ec79931fbbd69efff47e513ec2924b071e4b0ff774/chromadb-1.5.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9192d111bd662241625867962333d99369a00769a50f8b2f58cb388731274d7e", size = 22680924, upload-time = "2026-05-05T05:54:36.25Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ec/0c42039e80b9acc534f67b73b7a42471948042859b3a64867b50a4a77fa3/chromadb-1.5.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc09b3df76e5a5cb386aed2715a2eea152e3949f9e1ba93c7119505377749929", size = 23316203, upload-time = "2026-05-05T05:54:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ce/0f7be6e5d0feafa2cda54b12e6542afeea7dea89d2d411e14da90f8abb96/chromadb-1.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c", size = 23542454, upload-time = "2026-05-05T05:54:54.942Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -841,18 +943,6 @@ 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 = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - [[package]] name = "colorlog" version = "6.6.0" @@ -867,89 +957,86 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, - { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, - { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, - { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, - { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, - { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, - { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, - { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, - { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, - { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, - { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, - { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, - { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, - { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, - { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, - { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, - { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, - { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, - { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, - { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, - { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, - { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, - { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, - { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, - { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, - { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, - { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, - { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, - { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, - { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, - { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, - { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, - { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, - { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, - { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, - { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, - { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, - { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, - { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, - { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, - { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, - { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, - { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] @@ -1018,7 +1105,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder", marker = "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1035,65 +1122,79 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.5.5" +version = "1.5.6" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" }, ] [[package]] name = "cuda-toolkit" -version = "13.0.2" +version = "13.0.3.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, ] [package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] cudart = [ - { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufft", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cufile", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-curand", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cublas", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx", marker = "(python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] name = "dashscope" -version = "1.25.10" +version = "1.26.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "certifi" }, { name = "cryptography" }, + { name = "httpx" }, + { name = "httpx-sse" }, { name = "requests" }, + { name = "rich" }, + { name = "typer" }, { name = "websocket-client" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/50/58/57c7bbdbf6ecb1b17e627bb0186cd94aa7d5a86be7ec5e5c2981c55ed181/dashscope-1.26.3.tar.gz", hash = "sha256:ca11b2eb9da9bfb0fa15d55da9277e65daadb996925f0e2241106dab16415b4a", size = 1422906, upload-time = "2026-07-10T02:32:55.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/70/776c2bf6c6c454ab73d2066a1dc976b912f49e0ca2bb0962e6acd375c3f1/dashscope-1.25.10-py3-none-any.whl", hash = "sha256:b748a5dd371e7b6230322c94ebc3151c9be1f9301148a807d69501b6e828fc1d", size = 1341923, upload-time = "2026-01-29T03:48:45.115Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7c/b8d04426576f6e484d1d5392a57d11a3fa85cae6c11c491ae2cb85386575/dashscope-1.26.3-py3-none-any.whl", hash = "sha256:da9804f694346018a6f21eef753e4dc3f7854cb1df8d4120056ee8617540f630", size = 1525734, upload-time = "2026-07-10T02:32:53.337Z" }, ] [[package]] @@ -1123,24 +1224,24 @@ wheels = [ [[package]] name = "discord-py" -version = "2.6.4" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, ] [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] @@ -1163,11 +1264,11 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -1192,7 +1293,7 @@ wheels = [ [[package]] name = "e2b" -version = "2.21.1" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1207,9 +1308,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458, upload-time = "2026-05-14T17:36:02.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/b3/811fa25263d0745a3571c0b4f55ed7b865d12f618d866ada1f37ed007a3b/e2b-2.31.0.tar.gz", hash = "sha256:0d695e4a73e8a46db0ceca16cac042efffddeee9e0d4fc1e80a7e65427ae4756", size = 184598, upload-time = "2026-07-08T13:37:06.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476, upload-time = "2026-05-14T17:36:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/40/02/18f9440951b4c14433c4a8509f8550ed488acac98d2ef389d6d55041bdc2/e2b-2.31.0-py3-none-any.whl", hash = "sha256:532631b1b5d13d3bc6b7f8a81730f4c54e021b724cc3ec21efd000c84f58a87a", size = 336189, upload-time = "2026-07-08T13:37:04.75Z" }, ] [[package]] @@ -1279,11 +1380,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.29.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, ] [[package]] @@ -1418,11 +1519,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.1.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [[package]] @@ -1449,117 +1550,142 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, - { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, - { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] name = "grpcio" -version = "1.76.0" +version = "1.82.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, ] [[package]] @@ -1586,40 +1712,43 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, ] [[package]] name = "hpack" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, ] [[package]] @@ -1646,38 +1775,45 @@ wheels = [ [[package]] name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +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]] @@ -1711,35 +1847,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.3.5" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "shellingham" }, { name = "tqdm" }, - { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/e9/2658cb9bc4c72a67b7f87650e827266139befaf499095883d30dabc4d49f/huggingface_hub-1.3.5.tar.gz", hash = "sha256:8045aca8ddab35d937138f3c386c6d43a275f53437c5c64cdc9aa8408653b4ed", size = 627456, upload-time = "2026-01-29T10:34:19.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/84/a579b95c46fe8e319f89dc700c087596f665141575f4dcf136aaa97d856f/huggingface_hub-1.3.5-py3-none-any.whl", hash = "sha256:fe332d7f86a8af874768452295c22cd3f37730fb2463cf6cc3295e26036f8ef9", size = 536675, upload-time = "2026-01-29T10:34:17.713Z" }, -] - -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/8f/999e4dda11c6187c78f090eac00895a47e11a0049308f07579bcb7aa3aa2/huggingface_hub-1.23.0.tar.gz", hash = "sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88", size = 919163, upload-time = "2026-07-09T14:49:32.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ce/13b2ba57838b8db1e6bd033c1b21ce0b9f6153b87d4e4939f77074e41eb0/huggingface_hub-1.23.0-py3-none-any.whl", hash = "sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2", size = 770336, upload-time = "2026-07-09T14:49:30.597Z" }, ] [[package]] @@ -1768,11 +1891,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] @@ -1786,23 +1909,23 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.1" +version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, ] [[package]] name = "importlib-resources" -version = "6.5.2" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] [[package]] @@ -1837,87 +1960,88 @@ wheels = [ [[package]] name = "jiter" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" }, - { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" }, - { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" }, - { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" }, - { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" }, - { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, - { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, - { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, - { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" }, - { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" }, - { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" }, - { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" }, - { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" }, - { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" }, - { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" }, - { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, - { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, - { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, - { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, - { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, - { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, - { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, - { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" }, - { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" }, - { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, +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]] @@ -1952,11 +2076,11 @@ wheels = [ [[package]] name = "jsonpointer" -version = "3.0.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] [[package]] @@ -1988,9 +2112,10 @@ wheels = [ [[package]] name = "kubernetes" -version = "35.0.0" +version = "36.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "aiohttp" }, { name = "certifi" }, { name = "durationpy" }, { name = "python-dateutil" }, @@ -2001,14 +2126,14 @@ dependencies = [ { name = "urllib3" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, ] [[package]] name = "langbot" -version = "4.10.5" +version = "4.11.0" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -2124,7 +2249,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.4.13" }, + { name = "langbot-plugin", specifier = "==0.5.0a2" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2189,7 +2314,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.4.13" +version = "0.5.0a2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2210,28 +2335,28 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2f/b4848b565f5321bfae785dae6b783d111288d027f6e8fa41449c35d7467b/langbot_plugin-0.5.0a2.tar.gz", hash = "sha256:168484e5ad00d168adc6abb2548df3a52a08c59b148438a61173290dc65afb1b", size = 315950, upload-time = "2026-06-10T17:06:13.802Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/57d7395746ac08927dbecd198551fe73feb0dd9a5ee9d098d42eda30bf52/langbot_plugin-0.5.0a2-py3-none-any.whl", hash = "sha256:7eaeeabb07416c72cce3666d00b99d77e084e88acd0c0865d3010e140e2c308f", size = 215401, upload-time = "2026-06-10T17:06:15.032Z" }, ] [[package]] name = "langchain" -version = "1.3.10" +version = "1.3.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/e351d85c7828b9b90c5729de66170457c882c754efef0712904cfcd3192d/langchain-1.3.10.tar.gz", hash = "sha256:fd6ac9da86c479e4ff376e772d9e17a9232bd3113e9f2ddcb70cdc4bf7afc119", size = 632522, upload-time = "2026-06-18T19:43:00.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/33/fd716d0273c8495482953bd63461bf02f71f3a8f4a2fe6c0a70a0e6ff799/langchain-1.3.13.tar.gz", hash = "sha256:bcf874680f31e9970f0db2264509df5bc2115d9680e9d651d537eb49bf1a7d8a", size = 642868, upload-time = "2026-07-10T23:06:08.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/f6/a682e68d004a2e23cae6c5c42e3c0d071bc0e7768167bd12277992f096f9/langchain-1.3.10-py3-none-any.whl", hash = "sha256:5da67f21aa56119744ad51b3e46ffac570c88f4fae0876e3b1c6a1c4bc0e344e", size = 133038, upload-time = "2026-06-18T19:42:58.918Z" }, + { url = "https://files.pythonhosted.org/packages/95/c6/dc676c632f3d20c88789b0726c43ad5e039c25338cc3bb7090fc247d1522/langchain-1.3.13-py3-none-any.whl", hash = "sha256:20a8fe4b1dea7db74356f7d2b5455c4970099b9f7f53c2122ea97f115c907fbd", size = 136911, upload-time = "2026-07-10T23:06:07.012Z" }, ] [[package]] name = "langchain-core" -version = "1.4.8" +version = "1.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -2244,9 +2369,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2", size = 967294, upload-time = "2026-07-08T20:06:54.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, + { url = "https://files.pythonhosted.org/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624", size = 558293, upload-time = "2026-07-08T20:06:52.382Z" }, ] [[package]] @@ -2275,7 +2400,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.2.6" +version = "1.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2285,9 +2410,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" }, + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, ] [[package]] @@ -2334,28 +2459,32 @@ wheels = [ [[package]] name = "langsmith" -version = "0.8.18" +version = "0.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, + { name = "distro" }, { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, { name = "uuid-utils" }, { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/8e/49a69c6793bf3fc5af62481e7e9b678fce59d1b384323d3ce87959a653e3/langsmith-0.10.2.tar.gz", hash = "sha256:9aa685383fbdec07a0df51dafc333ab0d4b6b995771172a232c3364714eb17a6", size = 4707343, upload-time = "2026-07-10T13:21:54.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, + { url = "https://files.pythonhosted.org/packages/ee/17/cc8eaf4e82e4bb22009bdde20085ff91719dce643d355fed94d523300130/langsmith-0.10.2-py3-none-any.whl", hash = "sha256:c2a3929055758ac1831582f0939fafc0973cc08432365bbad335c336338ec37c", size = 652545, upload-time = "2026-07-10T13:21:52.13Z" }, ] [[package]] name = "lark-oapi" -version = "1.6.4" +version = "1.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2364,77 +2493,89 @@ dependencies = [ { name = "requests-toolbelt" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/aa/db027c41fdfb4f42471634cfc2a6f69d64d68f58ee555914293d60dbaceb/lark_oapi-1.6.4.tar.gz", hash = "sha256:b2aceccd1a01e55a82927ba1ee187e2eae5392cc97bc00ce0b3f08da3fb9a4ce", size = 2078060, upload-time = "2026-05-12T11:03:07.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/d0/0d4abc5e6dce888d71683d84a8ceb6fa9b798c2766e0f070eaacbdca5770/lark_oapi-1.7.1.tar.gz", hash = "sha256:57dc4616010cb9355dba11be3cf9b571b1a088b13abc22baf233c7e3527dd396", size = 2282040, upload-time = "2026-07-08T06:21:40.23Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/9f/47ec3a6628acdd74229a91abf67a3b574002fe6e587be4265c3bd928bddf/lark_oapi-1.6.4-py3-none-any.whl", hash = "sha256:9013b2793f627612906090c5d960ca7bd1cf8896a66875528221c769e0ceecc0", size = 7142621, upload-time = "2026-05-12T11:03:03.882Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/3650ba9230ade1245fa8d6021c9fe8febd27c9b5fdec78ffa465dbbc962e/lark_oapi-1.7.1-py3-none-any.whl", hash = "sha256:16945576a8c783a10e50911c00e421c1212fde3a76b2c2a6a4879b8606c1af8f", size = 7807753, upload-time = "2026-07-08T06:21:30.818Z" }, ] [[package]] name = "librt" -version = "0.7.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, - { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, - { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, - { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, - { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, - { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, - { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, - { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, - { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, - { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, - { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, - { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, - { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, - { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, - { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, - { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, - { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, - { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] [[package]] name = "line-bot-sdk" -version = "3.22.0" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aenum" }, @@ -2446,26 +2587,26 @@ dependencies = [ { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/c7/e8b07aa532669e56baab8404b213b1902678d78d7339e36dc1ecc3a91b32/line_bot_sdk-3.22.0.tar.gz", hash = "sha256:f686586a5e576449b3f8612d761fc79f726b52d817e60f60b69aac1d59e9f25d", size = 468902, upload-time = "2026-01-21T11:24:14.06Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/74/cf9150e1192ccd8f615d738657a552df4623de822888f26e4201c6e67295/line_bot_sdk-3.25.0.tar.gz", hash = "sha256:c9f0d00494322e1b477fc19b51ffa9076eaccb8de39b067d06c49ee6d463354a", size = 476550, upload-time = "2026-07-07T08:50:57.437Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/9c/b03ee25728f76a1292110ed9a7b330bf0c744f0f5fc3ea9ab7db3e3fc01d/line_bot_sdk-3.22.0-py2.py3-none-any.whl", hash = "sha256:64e202330997e02fd7cfe77b51f9df812aff61dd9d0e7ba840e8ecd96c2dda67", size = 818871, upload-time = "2026-01-21T11:24:12.117Z" }, + { url = "https://files.pythonhosted.org/packages/af/d8/380e68ba0e643e47843a18a07c97fd8b79da3f7604e2656e4cd1088720c0/line_bot_sdk-3.25.0-py2.py3-none-any.whl", hash = "sha256:2950eba64da474ce753b3bc4372f22a285f7ccad23a140ca8490679c0ec56535", size = 838667, upload-time = "2026-07-07T08:50:55.871Z" }, ] [[package]] name = "linkify-it-py" -version = "2.0.3" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uc-micro-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, ] [[package]] name = "litellm" -version = "1.88.1" +version = "1.92.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2481,9 +2622,14 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969, upload-time = "2026-06-09T01:06:25.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/ea/5522be7e0dbcaa7c3fddfd2834f387c6e8eab47c0cc65f2a74657c815af7/litellm-1.92.0.tar.gz", hash = "sha256:773adf5503ee1793289689c899394a83df8122993760d9acd782e32aa798db9d", size = 15098143, upload-time = "2026-07-12T01:15:59.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/9a/8f8909201b4bebaf96498c09226f6baa8540086a4c4188ad57d7dfbd97c1/litellm-1.88.1-py3-none-any.whl", hash = "sha256:369b84e57d9426582ddc35e731956ddb6618cda97cc44e4e4d2dfa75982a6e3a", size = 15276206, upload-time = "2026-06-09T01:06:16.72Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/401dfb16c88f22fcb285eafc074cb995047feb24b98bcf47e9552207837c/litellm-1.92.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f0ec8327aacc7914cc16310a1a3cc14340f1140b0a761403c5a4a98b01684373", size = 19292358, upload-time = "2026-07-12T01:15:43.628Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/f4d671762611a88ff7818e891094984202c7502e2984eed0e440a11332bd/litellm-1.92.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b4b65395c5d7b15fa24e08a13871c0617f6d156c46cee0eb920f3a5954e50adf", size = 19290890, upload-time = "2026-07-12T01:15:46.237Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/8061907b67aa04b7cdf2a41cbc4f8aebfbc722c909a7e4b2aea25def7053/litellm-1.92.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c7b2849591a35f7039dc7386a81a8e68fccb5ac2fecaa5122192c1172556b29", size = 19291180, upload-time = "2026-07-12T01:15:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6c/dc9b0b580ee8af9117abd729d1de25d74fae487a0029ca77049a09f3ad33/litellm-1.92.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f17499155366afd1eaaeb6fd0c7b55cbd2239dcd72f2fe1f8071a969cc402fae", size = 19289559, upload-time = "2026-07-12T01:15:51.376Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/1671376f228443330471416e24ee51bc8364c0ae6155d4ec6217b70a4753/litellm-1.92.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:437a0e403136996430795ead76502103dcf74769b6909e12d3e2511061ea8ff8", size = 19291560, upload-time = "2026-07-12T01:15:54.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/141cf1162eeee762fc839c335e3f78fc309a65f2385023479a20b09e680a/litellm-1.92.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f03047a73154f33c2733899e4bf9b5ed129e2e345caa305895a318452e4a807", size = 19289770, upload-time = "2026-07-12T01:15:57.136Z" }, ] [[package]] @@ -2663,23 +2809,23 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.1" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [package.optional-dependencies] @@ -2782,7 +2928,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2800,21 +2946,21 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] name = "mdit-py-plugins" -version = "0.5.0" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, ] [[package]] @@ -2826,114 +2972,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistletoe" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203, upload-time = "2024-07-14T10:17:35.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304, upload-time = "2024-07-14T10:17:33.243Z" }, -] - [[package]] name = "mmh3" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" }, - { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" }, - { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" }, - { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" }, - { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" }, - { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" }, - { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" }, - { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" }, - { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" }, - { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" }, - { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" }, - { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" }, - { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" }, - { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" }, - { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" }, - { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" }, - { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" }, - { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" }, - { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" }, - { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" }, - { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" }, - { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" }, - { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" }, - { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" }, - { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" }, - { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" }, - { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" }, - { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" }, - { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" }, - { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" }, - { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" }, - { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" }, - { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" }, - { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" }, - { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" }, - { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" }, - { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" }, - { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" }, - { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" }, - { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" }, - { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" }, - { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" }, - { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" }, - { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" }, - { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" }, - { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" }, - { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" }, - { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" }, +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, ] [[package]] name = "moto" -version = "5.2.1" +version = "5.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -2944,9 +3083,9 @@ dependencies = [ { name = "werkzeug" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/63/d944f387582cc53f53febbff2b3fa36a6d2ed7c1feef8990bf646cfa9cba/moto-5.2.2.tar.gz", hash = "sha256:aac8023a429e125e91c91f8f4730a67b54f518cda587352f7e67252fe3168f75", size = 8678761, upload-time = "2026-06-06T18:57:54.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/c1/45/13cff46f4f617a6e97e1d497d75abd913e250bb4c823a4985668c6e593e4/moto-5.2.2-py3-none-any.whl", hash = "sha256:3817f1e39721ca833579b921e53e3b68547ace6a34d848c9486fbb5905808de9", size = 6698689, upload-time = "2026-06-06T18:57:51.435Z" }, ] [[package]] @@ -3077,41 +3216,54 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/2e/1ea8028583fef6561d146b51d738d91268201313ecf69e603e808a740e74/mypy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea6fe1a30f3e7dc574d641497ffead2428046b0f8bc5804d13b4e4392fdc317b", size = 14739569, upload-time = "2026-07-08T01:35:33.202Z" }, + { url = "https://files.pythonhosted.org/packages/ba/67/c0607da57d78358b3b4fbfa90ee8ca5c6bd1dbb997c9648904ad4d8861d8/mypy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6965829a6d847a0925f51149472bbeb7a39332fb4801972fdfd9cedd9f8d43a4", size = 13821442, upload-time = "2026-07-08T01:33:45.991Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/0407007d4ec7a762bac20724af1f0a57a9d860186c39e73105b6b8396fa2/mypy-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a906bfc9c4c5de3ece6dc4726f8076d56ce9be1720baf0c6f84e926c10262a4", size = 14049813, upload-time = "2026-07-08T01:35:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/d718e006c8fed4bece7a1bebeea6dcd05f31433af552fc45a82fef426379/mypy-2.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91df92625cb3452758f27f4965b000fb05ad89b00c282cc3430a7bd6b0e5389e", size = 14978473, upload-time = "2026-07-08T01:36:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/82/03/dae7299c45a84efb1590875f92652a5beb2da98a2cff9a567995e2583fe4/mypy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d42893d15894fd34f395090e2d78315ba7c637d5ee221683e02893f578c2f548", size = 15224649, upload-time = "2026-07-08T01:35:15.988Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/738e4e73d030f20852a81383c74319ca4a9a4fdaadc3a75823588fe2c833/mypy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3abaeec02cdc72e30a147d99eb81852b6b745a2c8d19607e25241d79b1abbce", size = 11049851, upload-time = "2026-07-08T01:34:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/6c58caedb3fa5831a9b179687cd7dc252c66a61dca4800e5ba19c8eff82b/mypy-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c39bdb7ceab252d15011e26d3a254b4aaa3bbf121b551febfa301df9b0c69abe", size = 10060307, upload-time = "2026-07-08T01:33:21.852Z" }, + { url = "https://files.pythonhosted.org/packages/d1/be/fbaba7b0ee89874fb11668416dec9e5585c190b676b0796cff26a9290fe8/mypy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:484a2be712b245ac6e89847141f1f50c612b0a924aa25917e63e6cfcf4da07cd", size = 14928025, upload-time = "2026-07-08T01:37:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/f79a7c5a76671b0f563d4beaa7d99fe90df4500d2c1d2ba1be0432121bcf/mypy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb0a020dc68480d40e484675558ed637140df1ccbf896a81ba68bca85f2b50a0", size = 13793027, upload-time = "2026-07-08T01:34:33.937Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/3db0086bab611d34e26061b86189e6f71de6d22a9b81699a93b006eabcf6/mypy-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc361340732ce7108fa0308812caf02bb6868f16112f1efd35bcad88badf3327", size = 14108322, upload-time = "2026-07-08T01:36:08.316Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/4f1e13979a848de2a0fd385462354b58358b6e8b3d9661663e308f6e3d5d/mypy-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0179a3a0b833f724a65f22613607cf7ea941ab17ec34fa283f8d6dfe21d9fa9", size = 15190198, upload-time = "2026-07-08T01:36:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/7759f6294d9d25d86671957d0974a215a2a24d429526e26a2f603de951c5/mypy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e0899b13da1e4ba44b880550f247402ce90ffecc71c54b220bcbe7ecb34f394", size = 15424222, upload-time = "2026-07-08T01:33:39.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1b/05b212bef4d2234b5f0b551ea53ce0680d8075b2e79861c765f70b590945/mypy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:511320b17467402e2906130e185abffffa3d7648aff1444fc2abb61f4c8a087d", size = 11135191, upload-time = "2026-07-08T01:35:03.019Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/495e7122f6589948b36d3820a046461906756a0eb1b6dedc13ebfec7815e/mypy-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:7589f370b33dcdd95708f5340f13a67c2c49140957f934b42ef63064d343cca0", size = 10132502, upload-time = "2026-07-08T01:34:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/2d7a9ac5646274cd6e77ce3abcc2a9ece760c2b21f4c4b9f301711e07855/mypy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6968f27347ef539c443ddfd6897e79db525ddb8c856aa8fbf14c34f310ca5193", size = 14931618, upload-time = "2026-07-08T01:33:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/1adaa7caaa104f87021b1ac71252d62e646e9b623d77900ac7a0ae252bf3/mypy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3df226d2a0ae2c3b03845af217800a68e2965d4b14914c99b78d3a2c8ae23299", size = 13857718, upload-time = "2026-07-08T01:35:27.555Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/b11586b5aebbb82213e297fc30a6fcf3bed6a9deea3739cd8dd87621f3fd/mypy-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc53553996aca2094216ad9306a6f06c5265d206c1bcb54dd367560bd3557825", size = 14059704, upload-time = "2026-07-08T01:33:57.363Z" }, + { url = "https://files.pythonhosted.org/packages/03/db/071e05ab442596bdf7a845e830d5ef7128a0175281038245b171a6b16873/mypy-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:996abf2f0bebf572556c60720e8dc0cf5292b64060fa68d7f2bc9caa48e01b6f", size = 15128719, upload-time = "2026-07-08T01:33:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/c4b84eafb85ee315da72471523cc1bf7d7c42164085c42333601da7a8817/mypy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ec287c2381898c652bf8ff79448627fe1a9ee76d22005181fac7315a485c7108", size = 15378692, upload-time = "2026-07-08T01:34:10.468Z" }, + { url = "https://files.pythonhosted.org/packages/68/a4/59a0ee94877fdfe2958cec9b6add72a75393063c79cb60ab4026dd5e10c2/mypy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2c967a7685fa93fcf1a778b3ebe76e756b28ba14655f539d7b61ff3da69352a", size = 11150911, upload-time = "2026-07-08T01:35:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/90/e4/6a9144be50180ed43d8c92de9b03dff504daa92b5bcc0353e8960799a23b/mypy-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:d59a4b80351ec92e5f7415fcdd008bd77fcbefc7adf9cbc7ffe4eb9f71617734", size = 10125389, upload-time = "2026-07-08T01:36:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" }, + { url = "https://files.pythonhosted.org/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" }, ] [[package]] @@ -3138,6 +3290,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281, upload-time = "2023-05-07T15:00:25.094Z" }, ] +[[package]] +name = "narwhals" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/66ed1fc6e38a0c0f330627ec5c5d597990d6159b6712b82af0ad2c65f06c/narwhals-2.23.0.tar.gz", hash = "sha256:13e7ff5b4bb4a2f77b907c2e4d8a76e273dfc1323a3c997440a2f9fd26aed408", size = 656209, upload-time = "2026-07-01T11:21:53.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl", hash = "sha256:769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9", size = 458938, upload-time = "2026-07-01T11:21:51.677Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -3158,81 +3319,151 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.2" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, - { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, - { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, - { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, - { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, - { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, - { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, - { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, - { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, - { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, - { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, - { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] [[package]] @@ -3362,11 +3593,11 @@ wheels = [ [[package]] name = "nvidia-nvjitlink" -version = "13.0.88" +version = "13.3.33" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, ] [[package]] @@ -3398,52 +3629,58 @@ wheels = [ [[package]] name = "ollama" -version = "0.6.1" +version = "0.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/72/5f12423b6b39ca8430fbe56f77fcf4ef60f63067c7c4a2e30e200ed9ec16/ollama-0.6.2.tar.gz", hash = "sha256:936d55daa684f474364c098611c933626f8d6c7d67065c5b7ae0c477b508b07f", size = 53145, upload-time = "2026-04-29T21:21:15.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" }, ] [[package]] name = "onnxruntime" -version = "1.23.2" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, { name = "flatbuffers" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "protobuf" }, - { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, - { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, - { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" }, + { url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" }, + { url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" }, + { url = "https://files.pythonhosted.org/packages/3a/37/48ab79c39b58a7c9f6f5aac1fa0ff2b993eb2643393d6ed9e839ddb6f347/onnxruntime-1.27.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0874edc171f470fc4dd2bbb60bc0989612ed1a8b89b365cda016630a93227f13", size = 18433941, upload-time = "2026-06-15T22:42:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/d535ca8a09dbf697f853377c8dc0820dbcaae5f334316b400b953afbcba8/onnxruntime-1.27.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b51c014cf1a4fcd93c29a97eac8071fa27710dae05a4d0380bb60a66d60a62c", size = 16439970, upload-time = "2026-06-15T22:42:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b1/ea9ee80c0bdaa4efb13f29f8c236f3740f6655e8c092a2d119515a5a652c/onnxruntime-1.27.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:445fb702ea5241ba813a3ce2febe2e9408a64f6ad2eb610924322c536165f7cd", size = 18659240, upload-time = "2026-06-15T22:43:13.165Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/1404507d76a21940e8bf46f414e3d1abd94dc888cb89a30f4a540275846f/onnxruntime-1.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:49e416be0d717338b6d041b99911b716d70c397d277056450724f93bdded3fc2", size = 13685306, upload-time = "2026-06-15T22:43:40.416Z" }, + { url = "https://files.pythonhosted.org/packages/10/e5/ca5cf012ccccb806c70e94aadfebca5606acc62b33eb88cec13352d0778f/onnxruntime-1.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:856032937dd3bc7a7c141909c8d7ae4fde3e3f59bddf061ae627b9a051bda95c", size = 13456280, upload-time = "2026-06-15T22:43:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/67/7b/dca330a8397e9d816c976d7aed4e24a4a2d279bb1e551e3d0221d1389b1d/onnxruntime-1.27.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6197a02e3f620c4dc13cff51b80672409fc1ffab3aa2593911b19fd322ff48b", size = 16443274, upload-time = "2026-06-15T22:42:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f6/2bac21f722aa45d876d4a51f26bd0ef30e704068a3cd5021a5a7cd784271/onnxruntime-1.27.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:370d211e1ceeac4cd5f45301655463ac59e27cdc74d9f7aeb2d19ff4b7a76715", size = 18670781, upload-time = "2026-06-15T22:43:17.151Z" }, ] [[package]] name = "openai" -version = "2.41.1" +version = "2.45.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3455,39 +3692,38 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, ] [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -3498,116 +3734,116 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/1d/6336453716ca0a240d4417d19e6d5b77a5e7163e5670ec4f7ec4d3ede7bf/opentelemetry_exporter_otlp_proto_grpc-1.43.0.tar.gz", hash = "sha256:1b3e0627daa9bc21884d4a13946807c255eb558bfe5bdd543dffb6f4c9faee0d", size = 27213, upload-time = "2026-06-24T15:20:00.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/6d/74/2700b5d5c946bf2dba87073fce3dfc198c46bc92ea3d5693f54bc51c90b1/opentelemetry_exporter_otlp_proto_grpc-1.43.0-py3-none-any.whl", hash = "sha256:6a10d1feacffffda19acacbf277b736094b1e2f4dbb98c90ccb2c6e1962e2ec6", size = 19626, upload-time = "2026-06-24T15:19:42.233Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.39.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" }, ] [[package]] name = "orjson" -version = "3.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" }, - { url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" }, - { url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" }, - { url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" }, - { url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" }, - { url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" }, - { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" }, - { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" }, - { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" }, - { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" }, - { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" }, - { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" }, - { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" }, - { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" }, - { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" }, - { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" }, - { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" }, - { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" }, - { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" }, - { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" }, - { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" }, - { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" }, - { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" }, - { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" }, - { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" }, - { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" }, - { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" }, - { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" }, - { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" }, - { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" }, - { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" }, +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -3669,179 +3905,175 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pandas" -version = "3.0.0" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/1e/b184654a856e75e975a6ee95d6577b51c271cd92cb2b020c9378f53e0032/pandas-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850", size = 10313247, upload-time = "2026-01-21T15:50:15.775Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5e/e04a547ad0f0183bf151fd7c7a477468e3b85ff2ad231c566389e6cc9587/pandas-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2", size = 9913131, upload-time = "2026-01-21T15:50:18.611Z" }, - { url = "https://files.pythonhosted.org/packages/a2/93/bb77bfa9fc2aba9f7204db807d5d3fb69832ed2854c60ba91b4c65ba9219/pandas-3.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5", size = 10741925, upload-time = "2026-01-21T15:50:21.058Z" }, - { url = "https://files.pythonhosted.org/packages/62/fb/89319812eb1d714bfc04b7f177895caeba8ab4a37ef6712db75ed786e2e0/pandas-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae", size = 11245979, upload-time = "2026-01-21T15:50:23.413Z" }, - { url = "https://files.pythonhosted.org/packages/a9/63/684120486f541fc88da3862ed31165b3b3e12b6a1c7b93be4597bc84e26c/pandas-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9", size = 11756337, upload-time = "2026-01-21T15:50:25.932Z" }, - { url = "https://files.pythonhosted.org/packages/39/92/7eb0ad232312b59aec61550c3c81ad0743898d10af5df7f80bc5e5065416/pandas-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d", size = 12325517, upload-time = "2026-01-21T15:50:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/51/27/bf9436dd0a4fc3130acec0828951c7ef96a0631969613a9a35744baf27f6/pandas-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd", size = 9881576, upload-time = "2026-01-21T15:50:30.149Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/c618b871fce0159fd107516336e82891b404e3f340821853c2fc28c7830f/pandas-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b", size = 9140807, upload-time = "2026-01-21T15:50:32.308Z" }, - { url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" }, - { url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" }, - { url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" }, - { url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" }, - { url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" }, - { url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" }, - { url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" }, - { url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" }, - { url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" }, - { url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" }, - { url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" }, - { url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" }, - { url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" }, - { url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" }, - { url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" }, - { url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" }, - { url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" }, - { url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" }, - { url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pgvector" -version = "0.4.2" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, ] [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -3855,11 +4087,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -3883,25 +4115,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, ] -[[package]] -name = "posthog" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, -] - [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -3910,9 +4126,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -3926,116 +4142,128 @@ wheels = [ [[package]] name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "protobuf" -version = "6.33.5" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -4255,7 +4483,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -4263,106 +4491,111 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -4404,26 +4637,26 @@ crypto = [ [[package]] name = "pylibseekdb" -version = "1.3.0" +version = "1.3.0.post3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" }, - { url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" }, - { url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" }, - { url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" }, - { url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/fbe09403c8c379e90cca2f5cc117a46915fd1ed8d405a39bca273076b72f/pylibseekdb-1.3.0.post3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:e89ce984e3222f5af7cb276510f208c07dfdf89c3a812b8ee56b86f869685e0e", size = 111357685, upload-time = "2026-07-10T01:34:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/71/db/38f49f9ddd500824c1036e42e4ee766592ea39180e99b2e070d56d82a66e/pylibseekdb-1.3.0.post3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1f1e8ae274281a2fb93505f688525ec50a4c02c680ebbe384313bf65862b3a", size = 111138382, upload-time = "2026-07-10T03:06:44.932Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0f/9a6092594b5940bae9f2c267e4cc9a923b0402349cba172ca2697e98a6d9/pylibseekdb-1.3.0.post3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:514ba51eeff4a982d015c938f605ec9782aa7eedf0040af0e6adc102bd565417", size = 126196252, upload-time = "2026-07-09T13:25:37.552Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/9b3e832cb4bd86c8bc04fc4b837881274afd239d90d34de4ec216e43b8cd/pylibseekdb-1.3.0.post3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:58cbe0c4ed609fc67f9012760c5529858ceebe96fd79a4042cddf52b7ea4813e", size = 111359127, upload-time = "2026-07-10T01:34:45.464Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7d/498704c1d2d3fb5788d561ee575097ec0a844b3c6a020e959d6f4d6fc0b9/pylibseekdb-1.3.0.post3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadcf644e9d1187f04d671816ac0ddb0ae88018fefd53b40eee2f81ee509bb97", size = 111139156, upload-time = "2026-07-10T03:06:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/262eee5c442bce35f718a01b8c3a7dbb6aae4fef60dcdb1579d8fd3d6cee/pylibseekdb-1.3.0.post3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5733828a73f9723e84cf9ca318b228e7a9bf3c66b4684e08701373a438d087c7", size = 126196960, upload-time = "2026-07-09T12:44:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/a144f6a513ac659ea3bf4d65d1f800db8eb63f9c5d0c42c8c1adc5c28bd4/pylibseekdb-1.3.0.post3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ba5e515340988e4d8906ac386497cbab0849c3ecbe5d782fd72093f4d208f94e", size = 111359188, upload-time = "2026-07-10T01:35:06.299Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f9/a1e6179b1d8963610f45d24f2c3d68bd9dc075dc541f5ee9573d7963e570/pylibseekdb-1.3.0.post3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aaee77db55170af5fe32a8fde4041dc3f3bf2648f25a254c2742eaa6599fad5b", size = 111139238, upload-time = "2026-07-10T03:07:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/ed/50/054eacf5b3a20d7c1e1966b4151c96403931b9b2a0c6e1ac95764b3890d0/pylibseekdb-1.3.0.post3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b57df3dabce5c80bf2230db2dc3f55b8eb6807a79b0884c748c0380aa34aa466", size = 126196988, upload-time = "2026-07-09T13:25:45.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/18391d35352b8a0ba34e314bcfc4618695caf582a380d37554eefdc570ca/pylibseekdb-1.3.0.post3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:d80831551c0bc89c0ec65c7dcc8307a3fc21dc8be63b7c7032cd8ff48038e421", size = 111359477, upload-time = "2026-07-10T01:35:23.333Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/fdf9b38800387e8364927e6e3fc0747141ed12981abd8ea4b9537e6d86dc/pylibseekdb-1.3.0.post3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b830bde12333c28d754dcafa07eec0742c70c68b237383861375a312e878a4e", size = 111139896, upload-time = "2026-07-10T03:07:14.242Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2a/5ce7a6ef1c992faa094d9b53807bf221e6e76f9ce4e6c75e0bd41af34e8d/pylibseekdb-1.3.0.post3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:2cd976b01d410b8cb07f190eb69d20b76f97257439f1d421b9bc5939bc227aff", size = 126197339, upload-time = "2026-07-09T13:25:53.556Z" }, ] [[package]] name = "pymilvus" -version = "2.6.8" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -4432,20 +4665,20 @@ dependencies = [ { name = "pandas" }, { name = "protobuf" }, { name = "python-dotenv" }, + { name = "requests" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/68/9b8bac2267af60035d65fb5a4247c5ac8da175d66ec794d84d9cd3486524/pymilvus-2.6.8.tar.gz", hash = "sha256:15232f5f66805bf2f50b30bbad59637b62f5258d9343f7615353ce1221fab6b5", size = 1421303, upload-time = "2026-01-29T07:32:16.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/27/3af2199afaabd48791584fa5da5929f08d1a3c8c37a2ef12c15fc9309111/pymilvus-2.6.8-py3-none-any.whl", hash = "sha256:c4c413ffdef2599064301fd831de6f9839a753abe27c68c6148707629711d069", size = 300995, upload-time = "2026-01-29T07:32:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c1/01647e61f3a82fd881382746b6dde3401d65b88cd4f75bd059901fb2392b/pymilvus-3.0.0-1-py3-none-any.whl", hash = "sha256:57c8e7c87fbbf579f122b4df893949bc78e50bca2988527864891bd544817b05", size = 344817, upload-time = "2026-05-07T14:57:45.235Z" }, ] [[package]] name = "pymysql" -version = "1.1.2" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/bc/1c6a92f385940f727daeecf3bacaf186e03875dff57197801046c583bcf0/pymysql-1.2.0.tar.gz", hash = "sha256:6c7b17ca686988104d7426c27895b455cdeea3e9d3ceb1270f0c3704fead8c33", size = 49021, upload-time = "2026-05-19T08:26:22.302Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bd/2534e130295c8cfd4f0a2e31623baab7502278f1e97bcfe61db75656a77f/pymysql-1.2.0-py3-none-any.whl", hash = "sha256:62169ce6d5510f08e140c5e7990ee884a9764024e4a9a27b2cc11f1099322ae0", size = 45716, upload-time = "2026-05-19T08:26:20.974Z" }, ] [[package]] @@ -4494,11 +4727,11 @@ wheels = [ [[package]] name = "pypika" -version = "0.50.0" +version = "0.51.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/fb/b7d5f29108b07c10c69fc3bb72e12f869d55a360a449749fba5a1f903525/pypika-0.50.0.tar.gz", hash = "sha256:2ff66a153adc8d8877879ff2abd5a3b050a5d2adfdf8659d3402076e385e35b3", size = 81033, upload-time = "2026-01-14T12:34:21.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/5b/419c5bb460cb27b52fcd3bc96830255c3265bc1859f55aafa3ff08fae8bd/pypika-0.50.0-py2.py3-none-any.whl", hash = "sha256:ed11b7e259bc38abbcfde00cfb31f8d00aa42ffa51e437b8f5ac2db12b0fe0f4", size = 60577, upload-time = "2026-01-14T12:34:20.078Z" }, + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, ] [[package]] @@ -4520,12 +4753,162 @@ wheels = [ ] [[package]] -name = "pyreadline3" -version = "3.5.4" +name = "pyromark" +version = "0.9.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/38/07cb1d1571c9b55fd8da8c20e44af9401bbf6707bdea3faafd85fda28a28/pyromark-0.9.13.tar.gz", hash = "sha256:698ad208ea8960e1f65fdbf8d65e5f967bbdc8bc3dd904e3a451dc3a5d073fb2", size = 9040, upload-time = "2026-06-13T02:01:32.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/4d/8f1dd1f7eb41192ba36ae46eb602aee0b584cb10cc083df211e041f0cf37/pyromark-0.9.13-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:25abddcefb7b34b93074a619d35bcc28b291da5d9931ce9ba7014a28287c1eba", size = 345879, upload-time = "2026-06-13T02:04:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/33/03/5a124b114fcced19cc6e2e28cc942d1cc510185638a84b0dc2db385f0c91/pyromark-0.9.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8d80549c0ee768f260f85ce288056d1350c2ab317f4e20feb34567265eea1d4", size = 331993, upload-time = "2026-06-13T02:00:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/d63d38ebeab5aa1936402852fd642e9f708b29190082de406c827c65d969/pyromark-0.9.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52a0b68d12fd9ad9cd08afa4730d5e9cdc5e82c8b1bc8bf02cbb37a4a0e673da", size = 362933, upload-time = "2026-06-13T02:00:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/29/77/8e4dfa8cc4bfc42f82117633559075d9967a5efa17acc10201177e9b9391/pyromark-0.9.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8899df4aa9193e81a677cd04cbb857113fafc91d782483fd956bd063135e3c2", size = 360413, upload-time = "2026-06-13T02:00:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1c/ef20967874c9a25a160d3b3f8c90c041e624da36ec2f0a7058b2427452e9/pyromark-0.9.13-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06e75f26df18468d63342b0564ed382ecefdd5799deb531479a35737bb43dbe5", size = 385866, upload-time = "2026-06-13T02:02:44.664Z" }, + { url = "https://files.pythonhosted.org/packages/0a/be/27b6c082921c3b316e28344e9240b66d50ad6c203084802c5e0892b7c97a/pyromark-0.9.13-cp311-cp311-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:78183778fdd007f3f4ca514905b080c422ec5c114a4409d100d830733fa62792", size = 416450, upload-time = "2026-06-13T02:03:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/62/de/3eec023139526405852515757930116292b79b77ed1f4ed6ef4fd093e7ce/pyromark-0.9.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e916c5078b7a634253f581c27b58ac4b97a32ddbf99e39de9d88dfaf0ed6f13d", size = 409438, upload-time = "2026-06-13T02:03:03.038Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/bf219c3f082059b2de08e82e98dc391aa411e51a01fe2e1aa3d0ab20a340/pyromark-0.9.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b452c781f936e84b5bf1ea715c96c61b6659cffae111533f9c9d8149207bd6e", size = 451798, upload-time = "2026-06-13T02:01:51.644Z" }, + { url = "https://files.pythonhosted.org/packages/a8/88/1a8bf47a03cb06a4131968fe39c4bad24a62a3014144032c79e7b64a0af1/pyromark-0.9.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a6864f5b8cfb220426b3054a242c18fd8d2fb0590a8ae97518f14bc917a64b", size = 370130, upload-time = "2026-06-13T02:03:22.878Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c0/f383dee1b02981d708dcfbfd846e2b3f49936b31da9d0ce1d955ddded15b/pyromark-0.9.13-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:909b67238f964a5006d0b47c2af9876e086a48b1772c6cfd0cb3f01778edea9c", size = 363692, upload-time = "2026-06-13T02:02:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/43b92ef4a57e40d883d777124dbaed7820767b18e1026e8b38c0bd58e8fd/pyromark-0.9.13-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:825b829e7c93ca9dac9b6e102574270a04815e535a1ddf8d197b6f5a3eeaeb01", size = 360282, upload-time = "2026-06-13T02:01:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ff/8821bf195210fe926a971a5544998d8460c0d7a0e1ff4d28bbcf2b59d26b/pyromark-0.9.13-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:dfee571f674a0de3586c8fe2dfbbe541076608cc92c33f9c5d67bba7793afacb", size = 386384, upload-time = "2026-06-13T02:02:01.728Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5f/be2582961285fd22c809e56364c0fdf829a5a3fa59d55a7fe71d55093a66/pyromark-0.9.13-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:2752058c8c151f85a477f891e18fdf3d7a35d793cc18afcc10b18d79470acbf1", size = 409521, upload-time = "2026-06-13T02:00:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/39/f0/b6da338ec6db6d052ceea746a5fb2c4a85c122a918948ac38335802deb8f/pyromark-0.9.13-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:63abf7707ffa79b6ef6fcad10311d6b84debd59238e473fe4e70be377e40053d", size = 451466, upload-time = "2026-06-13T02:02:03.076Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/a12dda45dfc8db081c351e451281e57bf25e97a366555ce304675a40e59f/pyromark-0.9.13-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a95d68c9403a2f4b3f7f98f1f25114f3d82bd0610395f0c8e8afc92017a7262b", size = 370516, upload-time = "2026-06-13T02:02:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/38/78/590ee32a2e588741a6c95b8827885a033223bb521f96a6fb3d0a1fbfbb79/pyromark-0.9.13-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:ec8436664dd3ed600d077b8e7aeeb5ef6ce264e8a7c84f7520bce77da05927ee", size = 372100, upload-time = "2026-06-13T02:01:39Z" }, + { url = "https://files.pythonhosted.org/packages/98/7a/1aa1a6ff6ee2bcb9eaa59e5e62c3a618746560af76cd3428d18f8d046b4b/pyromark-0.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a4052c3fed55b9cfbbb25fc708bd8ea79347ea8cb9fd6672e3901d06c26f25ac", size = 540130, upload-time = "2026-06-13T02:03:27.711Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e0/7c51d575b2e51bb387ed2cd2d9b144423a71ce24ea00c20cdb825b0922e0/pyromark-0.9.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:be0719366e869b98afbee3a7ab51c297cd4251a34847bf6d33052c528311a08f", size = 636749, upload-time = "2026-06-13T02:03:24.559Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/266a0abde36880813e76bf1ae89b77f299eb7064398094be3acb7e9833bb/pyromark-0.9.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3ea416703153af2ef155a9073153c052f644a8a23e44a952c1ab1ff049139ed6", size = 604372, upload-time = "2026-06-13T02:01:10.286Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fe/6cd30f5e4f659205d5a3706dd7b1b0085c0222fec4a7921d819a173eea1a/pyromark-0.9.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:74ff2d67a937264dff321616f08a4cb735cca5bb343ab9032c0d442015059577", size = 540429, upload-time = "2026-06-13T02:00:17.551Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ff/8ea11bade053c51a9b91074cede9ef6b4bd5de4b95eee3b1404830d2f8ad/pyromark-0.9.13-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:adc7282d41ae38235dfd6817a394cf85c386cdef83069c8183217c43a7dda417", size = 545926, upload-time = "2026-06-13T02:02:51.37Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/c9c492b311524eedf937da427bec1c34f95b2c1e0d362766c6006c502ba6/pyromark-0.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d2e189d70834b6db74433b498430702a1f4fcba0c916d290381c7db04db7dbe2", size = 589939, upload-time = "2026-06-13T02:00:29.934Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bb/19ef0c32c6d110d44d3964f8f609d623c9f106c0e07b0af4414bfdcf280e/pyromark-0.9.13-cp311-cp311-win32.whl", hash = "sha256:c968b07eb781b9225402bceed49ad6ece05d29f28534fda6c428be69f016fa14", size = 261364, upload-time = "2026-06-13T02:03:21.042Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/2ed03e249de83ed7cba14e193de69c743722ce84f34e06a99f87dee44cf9/pyromark-0.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:1a8f3183151562d5f6dd7561b737d10c3704c92f43505949a8553aa60663ca56", size = 275472, upload-time = "2026-06-13T02:01:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/0b/17/5346e9d98c268ecd3ee035264906faa8fe85eda371d813488c87a23106b1/pyromark-0.9.13-cp311-cp311-win_arm64.whl", hash = "sha256:e3cf83effc4dd7effab0c4460c4e3e84eeb774323a24fe00dbc471cd897c54a3", size = 263061, upload-time = "2026-06-13T02:03:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/4f/7d8c4f7396481085917416a7cf47f6f9b2d3c5a6ec3d740a88e6f88e8f09/pyromark-0.9.13-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c954af595f6282bcc6d0eda15a1e63d4c3fc7fe4cc6a32015cb3717966e2b25", size = 347107, upload-time = "2026-06-13T02:02:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/c7/13/d335cc2b6e2ba15527912bdbb609f38d1240a6d7b9bfd9b6ab2745b561d9/pyromark-0.9.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c2b44e0551738138257fdb5d8b6729a258a5664e131c2eec5dd35f486e9ac95", size = 329560, upload-time = "2026-06-13T02:02:20.411Z" }, + { url = "https://files.pythonhosted.org/packages/16/a9/36533506da9046887e2e5d267e58789c5be25e6e39cf9c5d7a02624bbbce/pyromark-0.9.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11dbdcf61e5f1f5fa449d4cc577d81a23ff5d2cd3cd09cad84f646f69dc1f361", size = 361938, upload-time = "2026-06-13T02:03:08.022Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/5c6cb0243487ae88b2940da24f8e4c0ac7ba07cfb2830c5bedeeaa929328/pyromark-0.9.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8bbe4e9737f691545ab9227975711d12274b8c6f179f1195535f4cc834fd63", size = 359822, upload-time = "2026-06-13T02:00:59.239Z" }, + { url = "https://files.pythonhosted.org/packages/de/41/d04fa49bb7f8a4553b86ef5fc3bffa74bce598db4506dbdf184513658e86/pyromark-0.9.13-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28b29717ac13d42a97776b027171fc975a8bd53a81b488d8b086d20f3932584e", size = 384748, upload-time = "2026-06-13T02:00:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/2d/89/af425cf97670da595198158d77edc81d2fdaf4c7cad5802dbc8546839b6c/pyromark-0.9.13-cp312-cp312-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:05015e0cf2ab145e8a4ee55a6945ccae5aeccd465b24f657f938137dce492fb9", size = 416113, upload-time = "2026-06-13T02:00:55.526Z" }, + { url = "https://files.pythonhosted.org/packages/17/97/43e2ef81407efe0dacdd489f5939ae9b5fd7b44b2840f4d8b1f60375ea64/pyromark-0.9.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:856e2dae46f42b6ceeac3ccdb63a4605cd7df9b466208c9fdc1657972b246d35", size = 408238, upload-time = "2026-06-13T02:01:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/20c20476780524c44174aefe5cc30e8d35ca59fef1e37797ab45190f5009/pyromark-0.9.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e405c2b0143c095eb8e1224456c54663efa6249a740c77e188273dbf06c98264", size = 447698, upload-time = "2026-06-13T02:00:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/55/bc/3df74f99f6ae3cd286f1d2d4a89b590be791a7492aec44965e2a3a72fbc7/pyromark-0.9.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:888bdd14ad94a52920295aa6e7a2f5ed51a682d1e24341d7119956335f17acf7", size = 369414, upload-time = "2026-06-13T02:01:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/71/7f/0548d73f979d0f6acab63dc2a29868ffd3ba90bfc94d6fe169e61de4bf2a/pyromark-0.9.13-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84b680c0e04096e82dd092351a7be96094dbf448585a37726dfef3e1d98975c8", size = 362768, upload-time = "2026-06-13T02:00:47.572Z" }, + { url = "https://files.pythonhosted.org/packages/36/53/0820849f1d2ef0a3f376c58ddb76b444c321dfe6b88a023dd8ee3fd4cce9/pyromark-0.9.13-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:c7d9eb7001b2bc8bae0970a55e8bb8c5a0e192e8597d67b370dc6b09529a7bc1", size = 359710, upload-time = "2026-06-13T02:03:34.011Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/8a563b875debaa4f282d392383bdb111aa481f44b0e3ee4d14595d18624d/pyromark-0.9.13-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:6b40fbff35386a38b7f04b822965da4713fc418ba59809209841a7a54176380f", size = 385263, upload-time = "2026-06-13T02:02:05.96Z" }, + { url = "https://files.pythonhosted.org/packages/84/e7/25dd6c85bfc3e79a1fdaf6cbc85f1368ccb84eaa3005d9701cb2f12dbe90/pyromark-0.9.13-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:8a1a78979379dd4e109f78db5d21f3b37bb286e72006405136860979a086b369", size = 408413, upload-time = "2026-06-13T02:03:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/757359ff90a757a2b1a53c454166ff1362756e772d52610daa97c1333b02/pyromark-0.9.13-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9873a58a3e606f18c7d861131a053b6284784de5748cadbec8262eed98da0ed2", size = 447345, upload-time = "2026-06-13T02:02:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/ab3243241d5dea86fea37513021299afcad522f9775c6b88ec4f8df8992a/pyromark-0.9.13-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9c86e7e7474f282c1bc06e7edfc2437841ee4e81eb6f8f88146d6e7532b47ab4", size = 369848, upload-time = "2026-06-13T02:01:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e6/82b9707e92087c3d0c0e47e5fe75fd7893f513fc481356a69b3e4886baf5/pyromark-0.9.13-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:bec49ec259ae2dda3c8109b48008bd586fc78013f74b1ff7edeb8de9240023d5", size = 371831, upload-time = "2026-06-13T02:02:39.918Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/a435975a7681c872a8782140fd9e13864bf2a320de093477df3937bcb992/pyromark-0.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2689858ffc0d41b94504e3614a14b7b3d519302f0067763b105c1630cd73087", size = 539434, upload-time = "2026-06-13T02:03:43.662Z" }, + { url = "https://files.pythonhosted.org/packages/b2/34/6a4f33ce7d7182435b5c8373977ee69d359897ff8bc7c13308950b4fcb18/pyromark-0.9.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e34de5d39a2dcdb764c545944eecdc5f65a323a4f2413a86c95296ac9f620607", size = 636140, upload-time = "2026-06-13T02:01:17.907Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9a/21ca7b0611a0499678a85903928bc87567e23ed5bd40cacceae6bbf13db1/pyromark-0.9.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aee5dbc63658712b2fcadd60f4a3067f427a015fe7592fdfecebfa53a239f97b", size = 603202, upload-time = "2026-06-13T02:00:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/2c32f5d77d18a3da293689e77a5ed4d89afe5acf2cb7b1c3774aa6852ebe/pyromark-0.9.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:add9ca166ef18289bef2dc45e8a59767d96869de44e88e0c67db3ce911c0ea68", size = 539453, upload-time = "2026-06-13T02:02:09.858Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/d18a21e9aaa58b79b7e6e38891dfa450021b1e880086059026144c10389b/pyromark-0.9.13-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9af4033b942a7e217978d2a58423957dd92049a018e71771edfb8056eef0d733", size = 545869, upload-time = "2026-06-13T02:01:43.008Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2a/4d1175fd2ca66471035e0906047761fe700fda7257c3a3261d6f210cf856/pyromark-0.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c03ccce2fc6b792c7736a938c15ac681a5cc12077cc069936b4b40394764fbab", size = 589243, upload-time = "2026-06-13T02:03:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1d/1a79fec09f6c948edd747fbeecdac13eed0bd4a64441d065627e1cc605c1/pyromark-0.9.13-cp312-cp312-win32.whl", hash = "sha256:4ae2c64e5d72ca675fea7b542ba4e171dbd5c9f106ecf67157a1f98cb6919089", size = 259387, upload-time = "2026-06-13T02:03:35.668Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/4e1c9cfc94bff9cdbcc88c65cb31677744636ea302b38f71c8b5020833b8/pyromark-0.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:56b850673ddabbea3ad9144d0ae1bf4e541f2bd10794f0f2c325cc9761c1c912", size = 272988, upload-time = "2026-06-13T02:00:04.327Z" }, + { url = "https://files.pythonhosted.org/packages/23/25/a8e3a4675b5fff94ca81b003dc88613e455ce313288adcd02affdf74e959/pyromark-0.9.13-cp312-cp312-win_arm64.whl", hash = "sha256:93bc33109486a8de202e029cc7cc262431959ee811a9ab84d95539b35d1ef84c", size = 259188, upload-time = "2026-06-13T02:01:45.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/59/737ee814443f15cbc601a46993542611579166eaf77ce36308d0e4ad96a8/pyromark-0.9.13-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:23427f94943af6f676d2010422b918bb7001481936821c2183f558681e4ee113", size = 347204, upload-time = "2026-06-13T02:00:07.688Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/f44f00f15ccb298f172e15fc15d4c9b7069f6b48138240a9f0b8be4ffb3f/pyromark-0.9.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0eecb82e0272f2fcb39951cb110e8fded3ba404779cde0674c3e9d1a0c4f4554", size = 329532, upload-time = "2026-06-13T02:03:40.433Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/e53eca95749456fb9c3491b6db8ec397d85baf7361453a7f464a06eeb442/pyromark-0.9.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd2d938224318c37ae88b030e8e4a31da6f404e330cacfb6085cbb0bcf2b8790", size = 361821, upload-time = "2026-06-13T02:02:49.706Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7f/9c26996d86cb7c6f164e13398b83c457421bcec570813a4809be98413d5d/pyromark-0.9.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2c4d93abbb0c8a12ead5be5ac5d2160efb3ca64bcab07991cc9b00c300fdef6", size = 359402, upload-time = "2026-06-13T02:01:44.412Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c9/3928776b2562b527b617812465a2641f56b57539784797878316d942a840/pyromark-0.9.13-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36601de50eb02c7c5d9e05c9fe6c17ef3ca65f6b8ae2ede3cfdfbea20168be66", size = 384362, upload-time = "2026-06-13T02:03:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fc/eca3054bdfc0bbf20eab50f01f5cada066e220b1614c215028926f9bace7/pyromark-0.9.13-cp313-cp313-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:066fc48a5b8d4af0145c5ad345c3cfbec9b459ab68103dcec121fc3d99a8ba7e", size = 415551, upload-time = "2026-06-13T02:02:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/511447f78706666163ed40419fcb60ad3fae68294f3b73357b84bf6ffa64/pyromark-0.9.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ad521c9dc57efa21d00a3078d454564db0bd4f23fab17c9337a92962e3db47", size = 408220, upload-time = "2026-06-13T02:01:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/d8f4ccb1f9bc76e6fccb2639a0ba455db0959e80f40a37987c9c24d14fda/pyromark-0.9.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf19ca20e952220747ed937ec8e2cd27c469c5f836a34baac65e66dbc95bb805", size = 447358, upload-time = "2026-06-13T02:02:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b8e32ca4653f7ee4aa92f8123eedf451285034cdede0ef1549583f647353/pyromark-0.9.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27717aec818ca84577b9bf1f0c124497d05a196201bc409d3403514d7b0d8d9a", size = 369337, upload-time = "2026-06-13T02:00:53.406Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/813a3455d6862b74c3fa268584d97f70ca9617661d6e2319a2f88510b2fc/pyromark-0.9.13-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4a77b882d6d2e9f2a41d4a7291a7e3966dea588fdc55bcfc591e4f26c450b37c", size = 362614, upload-time = "2026-06-13T02:01:41.628Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a2/08b6a90a79790306a93a72f947781a87e662aedd82a07277b9a1de455c95/pyromark-0.9.13-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:fd63057c09f04870d8c6dfc0d025373903270b3d074ffc788da9aeb30a21f3e9", size = 359312, upload-time = "2026-06-13T02:00:43.098Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/210d32b4f2e1f2e1044555add0aeafb0b9510d3746de572d63a8c858f6db/pyromark-0.9.13-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:b4d370584853b6375f03d519c3681e55ac0b8b2728006a939f82c3c9daf3d69f", size = 384844, upload-time = "2026-06-13T02:01:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/e8/94/19042187a2119fc8b442291347cea20c474cb7525fa5317ef1550c0a424b/pyromark-0.9.13-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1647e97a5a155be0ce0c27c5238157f648817fb729b3d0dbe115330da2576aa5", size = 408344, upload-time = "2026-06-13T02:02:41.463Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4e/b9628114b85d9f329336d7eba4c67eb33cea8b2dbfca1b401f48ba668ee0/pyromark-0.9.13-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:2aa38f9e2642bcc577b6054ed273774f67c0d930939bfe1a7454a045ecb1d0de", size = 446999, upload-time = "2026-06-13T02:03:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ac/a91e402583c4cb070f3849003a42892265fdb57faf422f2bfc17f2696904/pyromark-0.9.13-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3a51fe36ae1b4c1eb9412f78cd8a8222eca94c8e29425b9b0d8a8ad4bcb3e7a6", size = 369749, upload-time = "2026-06-13T02:03:26.141Z" }, + { url = "https://files.pythonhosted.org/packages/af/09/104ac2e7b2922fc386f16d6e77430f498cbd3e346f7dc8c57dd688849112/pyromark-0.9.13-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:2c1c84c2400b547c4a4cb338b059b407fcfd91c41965f909609fed50bb367789", size = 371711, upload-time = "2026-06-13T02:00:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6c/92c544b5cc2f7ea0acfacad7be421e3767553709aec93edce53abc672727/pyromark-0.9.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:abc766e2facf40a547089388f0ef8943c575017dad02028e97128f64a1b0f7f7", size = 539402, upload-time = "2026-06-13T02:00:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/da8f4d58cc7b326d3b1e852faab56cec5baa38c4acf636a3394cf3ed5047/pyromark-0.9.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01e94bc60df17f954295b331e4689685dea58eafe0a6baa21df33927a4afae5f", size = 635788, upload-time = "2026-06-13T02:01:58.899Z" }, + { url = "https://files.pythonhosted.org/packages/0f/30/68587d302b4f33cfac34fa283f464d38fb17d76f1f63b9bb901cb8422a95/pyromark-0.9.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ad6ac301b80d04ab316e91fac6a589d96781e45a2d0a4e45eeddd02e5183e63", size = 602863, upload-time = "2026-06-13T02:03:15.974Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a0/cf74784bffa8dd8d784282885598c0bab9760f87236641ebc60f2e378117/pyromark-0.9.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d0914f56654817bbc1e797453132b66e4e812ad862d7a5459a6180d15e51cefb", size = 539394, upload-time = "2026-06-13T02:01:24.378Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/630325fb099ef2e69482a8c468f032d0180dbe40dbda28cfd6e1ada481b8/pyromark-0.9.13-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23abeac16379ad2989a679867c0d22cddb7739fe62f1fc45ddded2a6706f9e3b", size = 545876, upload-time = "2026-06-13T02:00:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/6c0ac5b05b57ae40c9faf9a0ccdd6bfcf99a571920a0d30a059d7c1e64db/pyromark-0.9.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:46ce2456619b06b7bfe6ae97290b12026202648e0a4a1ac4e9189a9bef921ba8", size = 589214, upload-time = "2026-06-13T02:01:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/06/6f/c799683b3edd4f6b4be96936b69dc281276b104c32b314cce259c721a92d/pyromark-0.9.13-cp313-cp313-win32.whl", hash = "sha256:38ac98d9109beb5b11ce4c13dc0eeccb1a3c2c8764c67b0d4ff127b353c94b08", size = 259111, upload-time = "2026-06-13T02:02:04.55Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b0914eab6667689e52083645b52992de3edea2453edceb8297cb90c6b60e/pyromark-0.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:4989189bd02749cfa424cbb64685f49011fa3fca76a8f42ec21836a960a62fc9", size = 273208, upload-time = "2026-06-13T02:01:37.576Z" }, + { url = "https://files.pythonhosted.org/packages/2f/48/2688e68b7433c99c9c0dc80e5fe4fee520886d1dc9f69f16aa613be0b45c/pyromark-0.9.13-cp313-cp313-win_arm64.whl", hash = "sha256:9f04dd9143d4036c22c2be4dfb996b7e58089521f491028fb4284e9d5a41471d", size = 259224, upload-time = "2026-06-13T02:02:36.768Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b7/1c8da64c55ad0232cad4126501cd28a3642eee04c6209efca5b325ef16e1/pyromark-0.9.13-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1d3c41b6d6308d389fbfd9bee9a330dda9268c3449ca5bd525bc6964810d58e5", size = 347437, upload-time = "2026-06-13T02:00:05.367Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/26f4bc33dfbf1a478131b03e23dff208e306cbdb031fe01d6b297052ac21/pyromark-0.9.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe531f8d92c6484452d08bdfc66fb8d8bca80b05f8eddfc2c42891c9e142546a", size = 329630, upload-time = "2026-06-13T02:02:46.463Z" }, + { url = "https://files.pythonhosted.org/packages/07/6b/4aad0b683b86261d4a3a662b42e71f7624dd2cefc84b10249aa581bf167f/pyromark-0.9.13-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3079f1d253765b8965060181aa1e817822507d160f5237fce008fea26959875", size = 362036, upload-time = "2026-06-13T02:00:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/76fa614ff0f6ad7d99d28afcd2e74c6b21c93c698e8569b7823c6204b3f4/pyromark-0.9.13-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9468c0383a1173a04c10bceb26a8c0bcae51e86e963a361948a729314fe021ca", size = 359717, upload-time = "2026-06-13T02:03:12.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/237af7f764b0ebef1d057ee20540ec2a6faddbc1b8a5183dcdfcfc48788f/pyromark-0.9.13-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f19cd1dd8f7b750f06985ae531603c6a8202317680dba48bdef0638c78d8fd80", size = 384772, upload-time = "2026-06-13T02:00:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/df/ff/b308fa0a0850db259344dca420a2395fbe6b949211d49e172f11e318b2cd/pyromark-0.9.13-cp314-cp314-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a473872442852e08a44ea5408adcd1e80dca14df0a08c73c66c52351a5fd639e", size = 416289, upload-time = "2026-06-13T02:00:56.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/4a/2945b2bde40da9d4a1ad948c292029a167617fc1e0786237da8d32afbf7a/pyromark-0.9.13-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d91f2b0a31784559ed7faa395931d633913b0a5f42d62f9181a2d95860c72d72", size = 408555, upload-time = "2026-06-13T02:03:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/db987f142840d923224be3712b4ec424b5932c01621750ac3fbd97a217c0/pyromark-0.9.13-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8615a56ec483605777bfd79ece80ccde1321bbf7b2b95ed8c7a855deba7fd824", size = 447472, upload-time = "2026-06-13T02:01:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/11aaa8d23672aa6ee082fccafaf51a37039947571f71d10fd43692a1628f/pyromark-0.9.13-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ab264c3dc47d34b214ed776b6736aa9163ea45a5edf81314162af1b5a0be4b", size = 369693, upload-time = "2026-06-13T02:03:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/245d02193ca4278719bb933770905c41c308da233e4e41760a9e2ce671d8/pyromark-0.9.13-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d287146af4d28e092f1e8f8fd88148ce58ed76f4130ff7aee4f6c3b15e8c4a35", size = 362739, upload-time = "2026-06-13T02:00:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/241052c75e4453bf78aa14f2d313dde17bbb5b1e21fb9649d5991e195750/pyromark-0.9.13-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:df32934dce2acb1466c84a9acc2915f4f6c2f536860cf0dbd1a78998cd955635", size = 359642, upload-time = "2026-06-13T02:01:40.21Z" }, + { url = "https://files.pythonhosted.org/packages/36/57/9283323b99eb5bc759f74f039f810e712a2adf00b3870b137cacc638b00e/pyromark-0.9.13-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ef0b0c55daf3c945e562881b2af75be18e3b9ec3aed1ec29144326e9e960cfb5", size = 385198, upload-time = "2026-06-13T02:01:03.935Z" }, + { url = "https://files.pythonhosted.org/packages/68/45/37c0a6f0ce92d8aad9d5f8c62ce3e99ee47c8dc5ef5b90409bcf480300f5/pyromark-0.9.13-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:133cd93c97fe1c65ff63d5ac722ea918ec0883515f86d758daea1b1c9aa024a0", size = 408650, upload-time = "2026-06-13T02:01:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8d/cc25266b5c40aa32f216ac459e4f3d89eed31d9a4b45ad7296b860920562/pyromark-0.9.13-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:1a0c839de71d7813654965aa222ba404c40c993a46229eeef20cc27a3fd884f6", size = 447635, upload-time = "2026-06-13T02:02:12.81Z" }, + { url = "https://files.pythonhosted.org/packages/6c/68/546efc43d3df2f31628970b0a515071f651470c3f33d707c6d5dd890fe69/pyromark-0.9.13-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:46a26c93965b17de983a1a169c3935e1f3ae15551c4b17f40ff172f5b2eedece", size = 369977, upload-time = "2026-06-13T02:00:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a6/42ed1dfa32e087b00088215c91f27513da48c6dec1abe6ce95b1dd7a89d8/pyromark-0.9.13-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:9cce837290c65852740bc650bf07f744f45d6f1f2f5447a7051d445b5fa04f83", size = 372058, upload-time = "2026-06-13T02:03:53.488Z" }, + { url = "https://files.pythonhosted.org/packages/32/de/0c6046e98e01c8c0b802e715a395b06a5103a37aff512e1686b13bcfdb2a/pyromark-0.9.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15a9f09accde95cc1b78c4e5f2b54004eb40db46d83d3f6347462d91572f129b", size = 539682, upload-time = "2026-06-13T02:00:24.228Z" }, + { url = "https://files.pythonhosted.org/packages/11/dd/b0503550f2b611c27ed3da06a3b2b0def0f2939c607d6e69e0038e967545/pyromark-0.9.13-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47f98ab69003cba0898fc17a0616409b368ac4312b50e28c98d42b5c28506a97", size = 635962, upload-time = "2026-06-13T02:03:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/b0e1e4161831ab42fdc8e2898f01629c49d882bb9ce9cc597b9c80332816/pyromark-0.9.13-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:387deaf82ff2b5efea2692882495d0c7a579d4d51b568a0a3c3b5b4f33dc14a0", size = 603246, upload-time = "2026-06-13T02:02:57.899Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7b/17f30ea5f15fbe0b298fd413be9822e75ac783f43035a813b99212e1a318/pyromark-0.9.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:31afa7c3915c91c2a743d90daffdfcf007f969ac1acc459b505d589de12290a2", size = 539687, upload-time = "2026-06-13T02:03:09.496Z" }, + { url = "https://files.pythonhosted.org/packages/e3/05/465c02372cd08c9cbc7150bd857e770240516c16a7e6598cc0e7b78bc79f/pyromark-0.9.13-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:622ec0643cb14f5e2660af330984e5408b55f29a9dea2bbda6dd4c6c6b45a0ee", size = 545981, upload-time = "2026-06-13T02:03:58.526Z" }, + { url = "https://files.pythonhosted.org/packages/4f/31/a595d689363b20170a0f42fa64e4ac2b767e8a52ed594edd912696f067bc/pyromark-0.9.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:850d141ed6b90cb5dd4e4d64b1cf5c126bc77d2dd07be674d4b7ec8536fff777", size = 589612, upload-time = "2026-06-13T02:00:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/5e/09/ebad22792a6c8e15c8d598e196a5f1de08f04155181fb00bae5f8c5dc596/pyromark-0.9.13-cp314-cp314-win32.whl", hash = "sha256:5169d3fb0036348357e26a394d071e9e9dea29bdd4d7e4a238108d265d62a294", size = 259636, upload-time = "2026-06-13T02:03:42.11Z" }, + { url = "https://files.pythonhosted.org/packages/25/ca/f032fd9dc9438a872e887bc8a336fce5857a9906d8ab1528d6f567937ec9/pyromark-0.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:2f2dcdd6d73ee43eb04d955561f6dabec4f2113e2270923e96bcc046eab22153", size = 273010, upload-time = "2026-06-13T02:00:45.206Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d9/769a66192dd7bdbb11d17e05a8d8ab785f9af2ae3c4c133e0b3ed2312cca/pyromark-0.9.13-cp314-cp314-win_arm64.whl", hash = "sha256:daa632304574031b4a6da6c20cc099b3e74bdac1409f42aa1529ce265e655e56", size = 259440, upload-time = "2026-06-13T02:01:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bb/6106c448e9596179cabda8c6cc824ed8dad459db3fa5a7864ce3fa2e1264/pyromark-0.9.13-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4171e025a65dcb4d95bf095227d3e3d06c09cfcd079153fcb8abbb30e3abefa", size = 346149, upload-time = "2026-06-13T02:02:23.017Z" }, + { url = "https://files.pythonhosted.org/packages/5e/08/d3640f4cd8e12dd70db0307bd4bea0c37af357cc6a1a19a2d9725b4858f8/pyromark-0.9.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:891ef8d99d05f797a198151e4e59f9a1973e8ac3edf5ca6eb1975161ba5cce5f", size = 328387, upload-time = "2026-06-13T02:03:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/959a4d6457b0d77a7fdfe08290b8d30fa2a72e3beed84bde35d13f9d93c6/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d48ac3eba2fcd29010bca0b033cdb0d2c646c8644311f98f1ebdbcf07693007", size = 360632, upload-time = "2026-06-13T02:02:08.57Z" }, + { url = "https://files.pythonhosted.org/packages/26/3e/d15e327bafcd589342e99fda4d1d8994b928fd1603113c9e76462eab4933/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f14c123f67d6dd1a53b803e088fa9cff33f0f79315c63d18180ceb0bb925465", size = 357516, upload-time = "2026-06-13T02:02:27.342Z" }, + { url = "https://files.pythonhosted.org/packages/24/66/11c9fda9e2ef8de46d8e798e6121e2bc2c20070d49feb91cec7a5183cab8/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:910d311bd34eed7f65e77f5c26d8dc5132f0510e211efda4630ad86435d4152e", size = 383109, upload-time = "2026-06-13T02:01:02.776Z" }, + { url = "https://files.pythonhosted.org/packages/11/7b/ca8b1851299f8f66789455b56cf7f3e3acb9680540047cc15129ebb3f7de/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f68d4edf37585711b651984038375f129beb74e968bd25765490d2d09844cce", size = 414248, upload-time = "2026-06-13T02:02:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/d6229864a19890e9255b9406c12deffe08f8889e873a24fe6feae0dcfdc0/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c67aef3368a268b840d26f3377e8fc91277ff04fdf0fabeb683f16aa93f2cf5", size = 406156, upload-time = "2026-06-13T02:02:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/10/0b/05e84d94fb29fb199ad0359ef1e0a66c5a8eb6224dd1b9122d1c5e230b72/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64544805d773ec1dcc5eb27aaaf6dfb9c35becf0986c6b591735e0d3143d692e", size = 445470, upload-time = "2026-06-13T02:02:52.967Z" }, + { url = "https://files.pythonhosted.org/packages/26/b7/a2a126977df3b91014c8bb7d6b115157cb07f5cd9e5538fac2eee977e88d/pyromark-0.9.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe417c102bd6db7fa242a010b8577e70deac83a7061bfe0dd312ae5589c15258", size = 368458, upload-time = "2026-06-13T02:01:01.573Z" }, + { url = "https://files.pythonhosted.org/packages/41/72/6207e862048922c45281f3dc5a28345607818d25c9f5ff086bc5362f4532/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:1942593ab01c9ec47793f41a23e7397c7ee5f27f19afa182b4e4eb0301c35062", size = 361320, upload-time = "2026-06-13T02:01:15.273Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/1eda76a2cfbc1c5847e5cbadf94cb2cf1e0906f752f2efeb8ec78df375d9/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_armv7l.whl", hash = "sha256:f3d1ea1d809427c464eb93707a3986f17d45a7443467c87ae2172fbb3d121584", size = 357506, upload-time = "2026-06-13T02:00:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/7b5992b5d25b6a584ec3f6109dfacec5f806ccb793dcf6a62ec018977d0e/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:a5bba1de9b7536149ffbbfbf8de1695d474969f6ee01cad2297a7ee65d7efff8", size = 383548, upload-time = "2026-06-13T02:02:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/84/41/ce93e8875513e6942af27c2f466c77f101a114d013cf9eb6bb60d5bd5695/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f7d78a4fa198ebc4e35f6cee819728e219572466cdd4e50e48b2dca67b03eb12", size = 406280, upload-time = "2026-06-13T02:01:48.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1c/02d0276a4eb3223955a6282e2286600a5d669e1b7f0c4fba1394b7599c81/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:87c550ee33d2c2d18669e72826f7493d72683e8754614bf084d04afd141d6e9d", size = 445559, upload-time = "2026-06-13T02:01:36.141Z" }, + { url = "https://files.pythonhosted.org/packages/fe/92/0a117e1451d7dd606d7b8e2f11c89e98db49648498af677b4c25b113d425/pyromark-0.9.13-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4e7b7c304e193e88d07a1a73dc0ec314b50ddb49d392c112a5c94b00f46cfe78", size = 368775, upload-time = "2026-06-13T02:02:15.955Z" }, + { url = "https://files.pythonhosted.org/packages/bb/39/364bc3ed8fa65198cdd11eb6106f13c50dfa5ad214da84bcfc19cc6d2280/pyromark-0.9.13-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d40e67ca347fe30487436c1b877542cf1cb4c25151ea089c08dac29797a86c56", size = 370596, upload-time = "2026-06-13T02:00:34.637Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b9/7fdbde0f686e1a39f2abe845d9faaabea2462cec781076f6ad9a6b414fbc/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fa37a7459bdfe974b325c76584811921d4dc0bff6fe0d9e29105ff3fc3846efb", size = 537825, upload-time = "2026-06-13T02:02:25.881Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3e/c4db2d4807e00e17c2822908fdd9321f7796e397533e10356566b36c1fb9/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fe747a884ffbf29d473ca39233a529eda3d120fdd26bdce5ed0efff23c3cb2eb", size = 634026, upload-time = "2026-06-13T02:01:12.795Z" }, + { url = "https://files.pythonhosted.org/packages/39/3f/9cb933795daf93608919a60219860c46337342a2b6bf25116b15ddd4350b/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:076ba65aaa2deb6aa16dfaf9d67e41a1dbed576847d1e30e99f1dfb31aa4b00a", size = 601409, upload-time = "2026-06-13T02:00:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/723f84b4c5136a964282e8604445f33a986ebea4be3dd7c1473641fc3a3c/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fad077fb48fec838a504a53019533b6130f4745aa919cf3f1b6bf556dcef65e7", size = 537827, upload-time = "2026-06-13T02:00:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/65/bf/14443d38b099ef773262c83cfb2f706ac8ea927f6596a44af1bd5b772b7c/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2b2d622243812c22fdceef303bbcf83cd276e27cfd0804f3cb453b8df741e475", size = 544313, upload-time = "2026-06-13T02:00:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2a/931e48aa458c2545ba6455c64fe378a111a6a1660f6c77c28a5697582b43/pyromark-0.9.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0fa65870cb07a0749c17a4f06abe22d9567e3ac375ae44b9821e05fce9b665db", size = 588359, upload-time = "2026-06-13T02:00:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/90/74620f80b32e65a141a978857b1a68a4d0d0f85b3b91b457ea45c41780b9/pyromark-0.9.13-cp314-cp314t-win32.whl", hash = "sha256:f9726b38ddf551828297e78aa9d5596ff060ac5809cc515944fe88c9a171d87f", size = 259394, upload-time = "2026-06-13T02:03:17.596Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/dbe1885fc2cda0c3e3268dd7e4a6470bc893559b4fb7d6defffd430c729f/pyromark-0.9.13-cp314-cp314t-win_amd64.whl", hash = "sha256:245995a170b590422fefed495b5357075e2f7130b03aa36cf4f1383fdc38ee24", size = 273007, upload-time = "2026-06-13T02:00:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e4/5d56922736d9560c7febd2012faa1005ce4793468afdcb43bb10d89cabae/pyromark-0.9.13-cp314-cp314t-win_arm64.whl", hash = "sha256:83b04e9de01d75a92c52aa86823c6467636112d2a3f83eae963d4e327af21e2b", size = 259002, upload-time = "2026-06-13T02:03:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/98/a72d7e631420552bfdb00713e3bfd0db259fab0741e7f94859823b6f6e53/pyromark-0.9.13-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f7c4db6e6a198c24f36867ff753a8f4249d51fd26f2b184d73fff2aecbe0d7aa", size = 347069, upload-time = "2026-06-13T02:01:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a0/5ccbbb62580e84150387097feadc606d1f694b5a2f7cfbbeb6a0b2606484/pyromark-0.9.13-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2b213de58fc13c8e4f55c5dd33d84b9811dff5fa4e7d3bdf3e96fed5e888b6c4", size = 333374, upload-time = "2026-06-13T02:00:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/83/ba/439efb17fe2173dbf3db6928aac3f9f64236203c6fc1cc2dfa80c6281449/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fba817a8c91d6eb3cdb1040cefda245c059be7467f790c917b9b003eb915186", size = 364183, upload-time = "2026-06-13T02:02:43.096Z" }, + { url = "https://files.pythonhosted.org/packages/89/02/91b4bae301f13843c27607d6d7be1f2b83d0f59c0ec2473307e8aedc5904/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:332ca84b2770fabc1c6899bc5c4c3d35eb83f43783a95537cdbbd44ea0f863e4", size = 361588, upload-time = "2026-06-13T02:01:26.949Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6b/94c37318a5d721706efe177b14039e9664fd8ac1621860ca021a083f4d7f/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7dfc65f95e77ec07dcef8837bc7f53d976aac03e12f7a69f53a73d28e9ca001", size = 387067, upload-time = "2026-06-13T02:02:14.414Z" }, + { url = "https://files.pythonhosted.org/packages/8b/6f/3e0a97a1296349d955785198761076bacda7b31b3b5c43321723ecbc5197/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b3905ee47580945c67e7f8151a872f3564beffe3b95bb0269bd795818eb4bce8", size = 417937, upload-time = "2026-06-13T02:00:58.03Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/635497f577cbd23a8883eed26955cdd76210085cddddf4aee4b7b72cdbfd/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15f2c2af675b2ce2e7ce21e7f8e072fb41d66bf4f99181df0ef795877103d416", size = 410169, upload-time = "2026-06-13T02:00:38.345Z" }, + { url = "https://files.pythonhosted.org/packages/08/08/66c867715aa2fc8164683991f1ad0e430fa6ebc36fb4cc8a41c345e7f994/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ed9539d53942bb0f4ff272552a78cd6819326e798701b5bd9fbe48d0d12f12b", size = 453132, upload-time = "2026-06-13T02:02:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7f/c442a2254b882c5810e40b6edf60b64db986b6d2252e1eba205856151df3/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c64b16fd0f0ade4a40c958c2e20e44366f8766c90249ef193aa03bbbe7b7065", size = 371591, upload-time = "2026-06-13T02:01:57.309Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c0/0a510b0c922927b2e54828d026afc467b9186538729402b7aaffc92c34d3/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a9dc3c575185d32e89211557944c3916bb66859f458db0e3606398287fc32454", size = 364917, upload-time = "2026-06-13T02:01:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f2/f203f610fe383c9ccdd3751548e0abdb5043f55c5478c1ead18122e7ff73/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_armv7l.whl", hash = "sha256:77059e67b4b32389f976d4c5dfed0a44faf0dfe787e3a17460b2b1af6af6b443", size = 361585, upload-time = "2026-06-13T02:01:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/bd44c539c71362264a1cabcefe1db761a64336cd8c4f3999637ed44af0dd/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_i686.whl", hash = "sha256:557719dfcb54bf4544ace7a1bfe7f48648c06d9c5e7739b4646b58908129f65e", size = 387534, upload-time = "2026-06-13T02:01:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/f0/cd/e062c969b2fe85f225f96e04056c7f78c61c5e4d78442814a02341d5c7f0/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_ppc64le.whl", hash = "sha256:eb444954a743f26a03fc45ba6f3b2809ba281a4d281d5437e3d288ebcd576ab8", size = 410357, upload-time = "2026-06-13T02:01:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/dd/76/ffc100c0b6e933c46f6ae5bd82155a61fce2219bc043c3f940a069282550/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_s390x.whl", hash = "sha256:21d9d58f4d5db42facd4ed0aa3206180f03a58818b6f91411e49700b71bf4f5c", size = 452603, upload-time = "2026-06-13T02:00:27.798Z" }, + { url = "https://files.pythonhosted.org/packages/0b/44/4fecfc826832015c8c67065571850d6d14fcdfd2999bd90648c505d4b8c7/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3eddbb172f1f0006aed70b736e702597bf1948f5080e6bdbfda88eaf30d0001a", size = 372056, upload-time = "2026-06-13T02:03:01.427Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/8922d639db0abcbd0bf11859ff4430a4a8bcf2c27e597366ba0a94a8aaf5/pyromark-0.9.13-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a8bfea5e02bca4df9029811dbdb926e3b07d101a2cc240bec5819a8601e92395", size = 373041, upload-time = "2026-06-13T02:03:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/26038c0adeaca575bd2a8849006d77ee7ab64268cbe8571fa1937fd2cccd/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c6a50a0b174500fb394ddce0a49475f27eaaedc5cfdb08aaa05a396f87788279", size = 541227, upload-time = "2026-06-13T02:02:38.349Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/06b6c6e09dcbbdc21bcf389aa8f3d948aba22d3351931f8c40b5a11ed6c8/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:0e040b1acdf2226077c67873fa2eccef8fc8fafcd877283645606ca67e702207", size = 637889, upload-time = "2026-06-13T02:03:51.869Z" }, + { url = "https://files.pythonhosted.org/packages/bb/23/0ed2a3d77e0e3bb40f3958a515e12fac47d4bf59ecdd53291dc1035bb346/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:2c9d73cc5c18572b99e4693278060c8b626d340136cbcc8784f2a2318c369eca", size = 605485, upload-time = "2026-06-13T02:01:33.231Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/6991fa8b0d4c4fa119ba3b408b180d1cd108bdc069412faee3ded5fb4ad5/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_ppc64le.whl", hash = "sha256:e390c27bc095036544440fdd059ac271cb077dc31a4516cafe924511fd97f62a", size = 541039, upload-time = "2026-06-13T02:01:50.007Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/f8520f8d518f6403f973feebc9ff7f3daf099062357fce09c98ac651b7a3/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_riscv64.whl", hash = "sha256:f1050d3ee44ca5ffd405132409e67effc57ec7433d541f3ea2e7659956ce3a87", size = 547162, upload-time = "2026-06-13T02:03:06.356Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cd/773169b11e5185a4f446483d7fb0a02944718742a7efef0802a3ef4b0143/pyromark-0.9.13-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6cd916ec7ebc6afaffce23304deab73bdc709dc9eeee27001d84145f971a1933", size = 591098, upload-time = "2026-06-13T02:00:09.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1e/1b931a94de921cd4d954489561edb935d6aa66af5a016e19d1bef4282f4a/pyromark-0.9.13-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b0b73353856e34916fba9ba94812d1ffe8ebcdf04ba723ee9796a00f62436215", size = 276796, upload-time = "2026-06-13T02:00:14.436Z" }, ] [[package]] @@ -4534,7 +4917,8 @@ version = "1.1.0.post3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", marker = "python_full_version < '3.14'" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "onnxruntime", marker = "python_full_version < '3.14'" }, { name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" }, { name = "pymysql" }, @@ -4549,7 +4933,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -4558,36 +4942,36 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { 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 = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -4602,6 +4986,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, +] + [[package]] name = "python-docx" version = "1.2.0" @@ -4635,43 +5032,46 @@ wheels = [ [[package]] name = "python-socks" -version = "2.8.0" +version = "2.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130, upload-time = "2025-12-09T12:17:05.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/ad/5bbfab3d3a266963cca0c09e23a725bbf5f0535d21c8bd1d5272e4430771/python_socks-2.8.2.tar.gz", hash = "sha256:ffc493951854fa3fc0551e0434a09a7b9f9047f9ad666dce42cb94a52e8a34b6", size = 39656, upload-time = "2026-06-23T05:22:01.994Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075, upload-time = "2025-12-09T12:17:03.269Z" }, + { url = "https://files.pythonhosted.org/packages/3d/18/23b981b1cf58c1e7b7e36fa1392daf12878b6a3c26c5e4248601647a6d09/python_socks-2.8.2-py3-none-any.whl", hash = "sha256:7cf785d0631e0659384a773b3c402bc22cccdc23894ba1d65f8524748ace1193", size = 55501, upload-time = "2026-06-23T05:22:00.658Z" }, ] [[package]] name = "python-telegram-bot" -version = "22.6" +version = "22.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700, upload-time = "2026-06-12T08:10:29.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267, upload-time = "2026-01-24T13:56:58.06Z" }, + { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397, upload-time = "2026-06-12T08:10:27.066Z" }, ] [[package]] name = "pywin32" -version = "311" +version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { 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]] @@ -4731,20 +5131,21 @@ wheels = [ [[package]] name = "qdrant-client" -version = "1.16.2" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "httpx", extra = ["http2"] }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "portalocker" }, { name = "protobuf" }, { name = "pydantic" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112, upload-time = "2025-12-12T10:58:30.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd", size = 398126, upload-time = "2026-05-11T14:12:36.998Z" }, ] [[package]] @@ -4823,106 +5224,106 @@ wheels = [ [[package]] name = "regex" -version = "2026.1.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, - { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, - { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, - { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, - { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, - { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, - { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, - { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, - { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, - { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, - { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, - { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, - { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, - { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, - { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, - { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, - { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, - { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, - { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, - { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, - { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, - { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, - { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, - { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, - { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, - { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, - { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, - { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +version = "2026.7.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/16/bfd13770be1acd1c05506b93fc6be15c759d6417595d1ba334d355efbf26/regex-2026.7.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341", size = 494639, upload-time = "2026-07-10T19:46:52.207Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/0086215709f0f705661f13ba81516287538886ef0d589c545c12b0484669/regex-2026.7.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1", size = 295920, upload-time = "2026-07-10T19:46:53.63Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9e/8e07d0eea46d2cf36bf4d3794634bb0a820f016d31bc349dfef008d96b02/regex-2026.7.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a", size = 290673, upload-time = "2026-07-10T19:46:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/d83c446de21c70ff49d2f1b2ff2196ac79a4ac6373d2cfe496011a250600/regex-2026.7.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17", size = 792378, upload-time = "2026-07-10T19:46:56.116Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0e/d265e0cc6da47aea97e90eb896be2d2e8f92d16add13bac04fa46a0fd972/regex-2026.7.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be", size = 861790, upload-time = "2026-07-10T19:46:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a5/62655f6208d1170a3e9188d6a45d4af0a5ae3b9da8b87d474818ac5ff016/regex-2026.7.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2", size = 906530, upload-time = "2026-07-10T19:46:59.142Z" }, + { url = "https://files.pythonhosted.org/packages/86/b7/d65aa2e9ffb18677cd0afbcf5990da8519a4e50778deb1bca49f043c5174/regex-2026.7.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b", size = 799912, upload-time = "2026-07-10T19:47:00.534Z" }, + { url = "https://files.pythonhosted.org/packages/8f/19/3a5ce23ea2eb1fe36306aef49c79746ce297e4b434aeb981b525c661413a/regex-2026.7.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa", size = 773675, upload-time = "2026-07-10T19:47:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/fe/76/3c0eaa426700dd2ba14f2335f2b700a4e1484202254192ae440b83b8352a/regex-2026.7.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561", size = 781711, upload-time = "2026-07-10T19:47:03.425Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a8/a5a3fad84f9a7f897619f0f8e0a2c64946e9709044a186a8f869fb5c332f/regex-2026.7.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f", size = 854539, upload-time = "2026-07-10T19:47:04.999Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c7/47e9b8c8ee77723b9eda74f517b6b25d2f555cf276c063a9eeea35bd86d5/regex-2026.7.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf", size = 763378, upload-time = "2026-07-10T19:47:06.845Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/e27e42d9d42edf71205c7e6f5b2902bc874ea03c557c80da03b8ed16c9bf/regex-2026.7.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296", size = 844663, upload-time = "2026-07-10T19:47:08.923Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b5/2423acb98362184ad9c8eebabafa15188d6a177daab919add8f2120fc6cd/regex-2026.7.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e", size = 789236, upload-time = "2026-07-10T19:47:10.303Z" }, + { url = "https://files.pythonhosted.org/packages/60/ed/b387e84c8a3d6aa115dfb56865437a3fbaf28f4a6fb3b76cc6cce38ced70/regex-2026.7.10-cp311-cp311-win32.whl", hash = "sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a", size = 266774, upload-time = "2026-07-10T19:47:11.904Z" }, + { url = "https://files.pythonhosted.org/packages/c4/64/f30a163a65ed1f07ad12c53af00a6bd2a7251a5329fba5a08adc6f9e81a3/regex-2026.7.10-cp311-cp311-win_amd64.whl", hash = "sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c", size = 277959, upload-time = "2026-07-10T19:47:13.231Z" }, + { url = "https://files.pythonhosted.org/packages/2b/de/61c8174171134cebb834ca9f8fe2ff8f49d8a3dd43453b48b537d0fbb49b/regex-2026.7.10-cp311-cp311-win_arm64.whl", hash = "sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc", size = 276918, upload-time = "2026-07-10T19:47:14.693Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" }, + { url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" }, + { url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" }, + { url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" }, + { url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" }, + { url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" }, + { url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" }, + { url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" }, + { url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" }, + { url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" }, + { url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" }, + { url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" }, + { url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" }, ] [[package]] @@ -4967,327 +5368,318 @@ wheels = [ [[package]] name = "responses" -version = "0.26.0" +version = "0.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" }, ] [[package]] name = "rich" -version = "14.3.1" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +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.14.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, - { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, - { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, - { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, - { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, - { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, - { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, - { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]] name = "s3transfer" -version = "0.16.0" +version = "0.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/da/4bef7ce7bb989b222aa4785a413896dbec53306dfc59c6ce7d16a7ffbd6a/s3transfer-0.19.1.tar.gz", hash = "sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3", size = 165354, upload-time = "2026-07-10T19:32:04.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/24/23/e84c64ad0e8bc59cd1b2ef98def848deff0ef3456c542afe74d51e9e8c85/s3transfer-0.19.1-py3-none-any.whl", hash = "sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de", size = 90072, upload-time = "2026-07-10T19:32:03.673Z" }, ] [[package]] name = "safetensors" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] [[package]] name = "scikit-learn" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "narwhals", marker = "python_full_version >= '3.14'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "scipy", marker = "python_full_version >= '3.14'" }, { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, - { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, ] [[package]] name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, ] [[package]] name = "sentence-transformers" -version = "5.2.3" +version = "5.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, { name = "scipy", marker = "python_full_version >= '3.14'" }, { name = "torch", marker = "python_full_version >= '3.14'" }, @@ -5295,18 +5687,18 @@ dependencies = [ { name = "transformers", marker = "python_full_version >= '3.14'" }, { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/d2cb00765a6b15c994a7fccf20f9032f16e8193ca49147cb5155166ad744/sentence_transformers-5.6.0.tar.gz", hash = "sha256:0e7164d051e416c1853ade7c274ff52af3f9da0f4be7f0b83d734c27699e1057", size = 453194, upload-time = "2026-06-16T14:01:56.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/9f/dba4b3e18ebbe1eaa29d9f1764fbc7da0cd91937b83f2b7928d15c5d2d36/sentence_transformers-5.2.3-py3-none-any.whl", hash = "sha256:6437c62d4112b615ddebda362dfc16a4308d604c5b68125ed586e3e95d5b2e30", size = 494225, upload-time = "2026-02-17T14:05:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/76/c1/dc1582b79e9a2eb0cddf9559cd9bcdff084f541d6fe881fdd9d98630dba7/sentence_transformers-5.6.0-py3-none-any.whl", hash = "sha256:d2075b5e687a1611005e20ab04a6846994d51adfcf39610aed066af3c0c0b81f", size = 596411, upload-time = "2026-06-16T14:01:55.103Z" }, ] [[package]] name = "setuptools" -version = "80.10.2" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] @@ -5329,11 +5721,11 @@ wheels = [ [[package]] name = "slack-sdk" -version = "3.39.0" +version = "3.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207, upload-time = "2025-11-20T15:27:57.556Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, ] [[package]] @@ -5347,60 +5739,59 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] name = "sqlalchemy" -version = "2.0.46" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, - { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" }, - { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" }, - { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, - { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, - { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, - { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, - { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, - { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, - { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, - { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [package.optional-dependencies] @@ -5410,28 +5801,29 @@ asyncio = [ [[package]] name = "sqlmodel" -version = "0.0.31" +version = "0.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/b8/e7cd6def4a773f25d6e29ffce63ccbfd6cf9488b804ab6fb9b80d334b39d/sqlmodel-0.0.31.tar.gz", hash = "sha256:2d41a8a9ee05e40736e2f9db8ea28cbfe9b5d4e5a18dd139e80605025e0c516c", size = 94952, upload-time = "2025-12-28T12:35:01.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/72/5aa5be921800f6418a949a73c9bb7054890881143e6bc604a93d228a95a3/sqlmodel-0.0.31-py3-none-any.whl", hash = "sha256:6d946d56cac4c2db296ba1541357cee2e795d68174e2043cd138b916794b1513", size = 27093, upload-time = "2025-12-28T12:35:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, ] [[package]] name = "sse-starlette" -version = "3.2.0" +version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, ] [[package]] @@ -5460,7 +5852,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -5482,28 +5874,28 @@ wheels = [ [[package]] name = "telegramify-markdown" -version = "0.5.4" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mistletoe" }, + { name = "pyromark" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/eb/8a3a557eec87c0fcd4c0939232fa5ea407801050370596daa4ca3e51a1db/telegramify_markdown-0.5.4.tar.gz", hash = "sha256:c32bd04e5a1c22519c011ccf7350a01b6d162e6cc9a9d89c83eff964d491007e", size = 40370, upload-time = "2025-12-20T06:43:11.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/c4/2308a3698b0f723cb2c126f130279fd6ed2ebba0c0f1f6b6799b45d7729b/telegramify_markdown-1.2.0.tar.gz", hash = "sha256:e9fe82b56a1d98045b72a98b09134351e9d36c96d1df240d99e953a89da06325", size = 89307, upload-time = "2026-06-14T00:06:43.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/f0/4d07bcada3cddb66bccf061661b733e8512c5580e1bd11fba2aea1488d70/telegramify_markdown-0.5.4-py3-none-any.whl", hash = "sha256:7c806e12b6c7045d7723e064a0ff25afcb16c92c0d95385b61a57b8c53a430d3", size = 33536, upload-time = "2025-12-20T06:43:10.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ee/bc49efc4a773a36afb70a9db6c9ce0f73bec64e47e7097598c90c161b1bf/telegramify_markdown-1.2.0-py3-none-any.whl", hash = "sha256:f9fb021ec5f944cf312b5aa022e0f7e156ea4651e70fa3df82f3c0a5182f6dfb", size = 61968, upload-time = "2026-06-14T00:06:42.247Z" }, ] [[package]] name = "tenacity" -version = "9.1.2" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] name = "textual" -version = "7.5.0" +version = "8.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -5513,9 +5905,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, ] [[package]] @@ -5529,56 +5921,56 @@ wheels = [ [[package]] name = "tiktoken" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] [[package]] @@ -5609,121 +6001,120 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "torch" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-bindings", marker = "python_full_version == '3.14.*' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "filelock", marker = "python_full_version >= '3.14'" }, { name = "fsspec", marker = "python_full_version >= '3.14'" }, { name = "jinja2", marker = "python_full_version >= '3.14'" }, { name = "networkx", marker = "python_full_version >= '3.14'" }, - { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "setuptools", marker = "python_full_version >= '3.14'" }, { name = "sympy", marker = "python_full_version >= '3.14'" }, - { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "triton", marker = "python_full_version == '3.14.*' and sys_platform == 'linux'" }, { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894, upload-time = "2026-06-17T21:06:55.077Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264, upload-time = "2026-06-17T21:08:17.65Z" }, - { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086, upload-time = "2026-06-17T21:08:27.69Z" }, - { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, - { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, - { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, - { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, - { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, - { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, - { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" }, - { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" }, - { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" }, - { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" }, - { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/1e3160e18e12aa3038390efab3ce02b36a9d4d6a527ecdd8520dca2e68d8/torch-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c", size = 427199369, upload-time = "2026-07-08T16:04:51.054Z" }, + { url = "https://files.pythonhosted.org/packages/01/79/1f2d34ad7034ee1c7ffc1cf8bf0f8213af2a81df6ecdb3997ecec107c09d/torch-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7", size = 526574961, upload-time = "2026-07-08T16:04:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/0f2ce40f58aefbdb3392f9acce3c8171940943ae2d661f70558bfa73befb/torch-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330", size = 122015870, upload-time = "2026-07-08T16:05:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" }, + { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, ] [[package]] name = "tqdm" -version = "4.67.2" +version = "4.68.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/89/4b0001b2dab8df0a5ee2787dcbe771de75ded01f18f1f8d53dedeea2882b/tqdm-4.67.2.tar.gz", hash = "sha256:649aac53964b2cb8dec76a14b405a4c0d13612cb8933aae547dd144eacc99653", size = 169514, upload-time = "2026-01-30T23:12:06.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] [[package]] name = "transformers" -version = "5.3.0" +version = "5.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "packaging", marker = "python_full_version >= '3.14'" }, { name = "pyyaml", marker = "python_full_version >= '3.14'" }, { name = "regex", marker = "python_full_version >= '3.14'" }, @@ -5732,9 +6123,9 @@ dependencies = [ { name = "tqdm", marker = "python_full_version >= '3.14'" }, { name = "typer", marker = "python_full_version >= '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/f7/418169401560cec2b61512e6bf37b0cfb4c8e27700cfa868a1de073cb65d/transformers-5.13.1.tar.gz", hash = "sha256:1e2452d6778a7482158df5d5dacf6bf775d5b2fdcfce33caaf7f6b0e5f3e3397", size = 9196891, upload-time = "2026-07-11T09:15:50.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/54eacf96b5c835bbd6ca631aa2740e7705ed63d9e3a8afd2d2cc6d09cae5/transformers-5.13.1-py3-none-any.whl", hash = "sha256:53f0ea8aa397e29244c2377ba981bcaf0c87adcf44fbdd447ef6306522afcacd", size = 11503977, upload-time = "2026-07-11T09:15:46.801Z" }, ] [[package]] @@ -5756,57 +6147,44 @@ wheels = [ [[package]] name = "typer" -version = "0.21.1" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, -] - -[[package]] -name = "typer-slim" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] [[package]] name = "types-aiofiles" -version = "25.1.0.20251011" +version = "25.1.0.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/42/f5b9b90162d2196f016b87228d6bf43f2c2c0c6501bfd5415001b3eb68bb/types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76", size = 14891, upload-time = "2026-05-18T06:05:27.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3d/7a9ed9faafeae3aa3b5bc22fa5b979ff9cf3c83ecbe919b58eae07795b8c/types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9", size = 14377, upload-time = "2026-05-18T06:05:26.871Z" }, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20250915" +version = "6.0.12.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { 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]] @@ -5823,32 +6201,32 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.3" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] name = "tzlocal" -version = "5.3.1" +version = "5.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] [[package]] name = "uc-micro-py" -version = "1.0.3" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] [[package]] @@ -5871,75 +6249,133 @@ wheels = [ [[package]] name = "uuid-utils" -version = "0.14.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" }, - { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" }, - { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" }, - { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" }, - { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" }, - { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" }, - { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" }, - { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430, upload-time = "2026-01-20T20:37:24.998Z" }, - { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106, upload-time = "2026-01-20T20:37:23.896Z" }, - { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423, upload-time = "2026-01-20T20:37:17.828Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659, upload-time = "2026-01-20T20:37:14.286Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029, upload-time = "2026-01-20T20:37:08.277Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298, upload-time = "2026-01-20T20:37:07.271Z" }, - { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217, upload-time = "2026-01-20T20:36:59.687Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/b2/8f03b61f0aa4afc687855c4f00db35f4d3e58c480cd885abc46f6e41308f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f9b093cb3b6c9d6233ef45a05cab064d2aa0a8cb3c5777084c9e20fcb77c2371", size = 563901, upload-time = "2026-07-09T13:48:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cb/88b909ffb9ac11f88d2e6ceabc592ccc660b5830b06dbcbd290ab8981f1f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0bc4c431ccd59c764080ceb43b126043325fe17861b87759d026a0cdd8423bb2", size = 286383, upload-time = "2026-07-09T13:48:10.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/bc5b64e9898867227c535cd0366c571c580a736748e81329437c1773e442/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00d182e31034250690f417b9068b78eab423c10d76766664e82d9860c340479", size = 323244, upload-time = "2026-07-09T13:48:11.477Z" }, + { url = "https://files.pythonhosted.org/packages/13/d9/8a17462ce066fbf89670fb737a3f0c93a77816736d2a4d134787e759d8ea/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:570db214f6d8507587a8faa968a3fe65e957daeb7bc48b27dc7f69bc3ecdd6f1", size = 330466, upload-time = "2026-07-09T13:48:13.092Z" }, + { url = "https://files.pythonhosted.org/packages/43/37/0c65d0db3bae45183419756d938f1791a82c835fd92bf234eb4f008d2e02/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:351462debd866f1f25e4d4f5c7fac89525b52151f0102a1bdfe94a999b046f5f", size = 443806, upload-time = "2026-07-09T13:48:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/7e698466d1f5254620b5ee0d711fdd20a0e9c2acd7040740c37193a8f673/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:622cdde768300591ac79bfcd7bb3468e4b191b1105d5dbfe8d87c39d8f63dd46", size = 324261, upload-time = "2026-07-09T13:48:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/5d/48/3a5b242d7f0b8e3ca77dcd7177f3cf73e0280cee32e2349d9796ca27f183/uuid_utils-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:75d7411e8eb9259764dd60310738540649057cda4509b4af14b36b7f663bfeb0", size = 350657, upload-time = "2026-07-09T13:48:17.273Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/f32ea82a89efed2eafee2f1d925d64687a81e550a9951933fb1b75c95ca6/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019476b6bdc047216ef7414be5babe0fa5ccfde977c0cac4fd6c75ddec66ff7", size = 500613, upload-time = "2026-07-09T13:48:18.459Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5c/c7b73ec4bbe28db162a4841d352c6eda582801e0dd9fe72f6ad5cc584ee4/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:04452640d8b6920c480c16e5afe91ff896d236e0c972830f9247e0898d38c803", size = 606306, upload-time = "2026-07-09T13:48:19.726Z" }, + { url = "https://files.pythonhosted.org/packages/63/95/8a2777204e8691b4961e6aa619001c3e5175aa430ab43da3079142e8d310/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:793229621e1ad6cac55f015cfa9f4eff102accbc3da25d607b91c6b0bec167fb", size = 567231, upload-time = "2026-07-09T13:48:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6f/1d778ca3ed6d2cf35f22088e2de714675416747ab41be510f22c141043a7/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03815cea572c8a693cab5475b9d750cc161470961c7defa27e9286cad62f38f5", size = 529373, upload-time = "2026-07-09T13:48:22.312Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/9ad1ab64b3bed0a0237d1db89dc6f5001d6116a82766753da4ac4496f979/uuid_utils-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c4f845166b09acc65c5213a35551a7f81c17fa010ab467229b5813f79d17fe13", size = 169930, upload-time = "2026-07-09T13:48:23.504Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1a/e01417f52eae6e2cb412260bb332b4ee4b37af2982d9c38cff4b68b2e899/uuid_utils-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:14dc2f46abb1091260c0d203fcbdf4e045042cc07e49183fd3b255904b95eb70", size = 177242, upload-time = "2026-07-09T13:48:24.723Z" }, + { url = "https://files.pythonhosted.org/packages/35/20/396c27f996add19f8ac31e49cc4570824e51a97719087dabf94694d25bc4/uuid_utils-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:29179ffb7b317239b6d6afb100d14c439c728770460718280b9c0a42d2561ec2", size = 177023, upload-time = "2026-07-09T13:48:25.834Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287", size = 556403, upload-time = "2026-07-09T13:48:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968", size = 285608, upload-time = "2026-07-09T13:48:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6", size = 319926, upload-time = "2026-07-09T13:48:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988", size = 327172, upload-time = "2026-07-09T13:48:31.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94", size = 439075, upload-time = "2026-07-09T13:48:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc", size = 320247, upload-time = "2026-07-09T13:48:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a", size = 344738, upload-time = "2026-07-09T13:48:34.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c", size = 496845, upload-time = "2026-07-09T13:48:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a", size = 603233, upload-time = "2026-07-09T13:48:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206", size = 561401, upload-time = "2026-07-09T13:48:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc", size = 525314, upload-time = "2026-07-09T13:48:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d", size = 166831, upload-time = "2026-07-09T13:48:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637", size = 172944, upload-time = "2026-07-09T13:48:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652", size = 172459, upload-time = "2026-07-09T13:48:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63", size = 557259, upload-time = "2026-07-09T13:48:45.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73", size = 286271, upload-time = "2026-07-09T13:48:47.018Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9", size = 320025, upload-time = "2026-07-09T13:48:48.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1", size = 327931, upload-time = "2026-07-09T13:48:49.673Z" }, + { url = "https://files.pythonhosted.org/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098", size = 438537, upload-time = "2026-07-09T13:48:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869", size = 320656, upload-time = "2026-07-09T13:48:52.164Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131", size = 345310, upload-time = "2026-07-09T13:48:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb", size = 496771, upload-time = "2026-07-09T13:48:55.365Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3", size = 603631, upload-time = "2026-07-09T13:48:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64", size = 562008, upload-time = "2026-07-09T13:48:58.241Z" }, + { url = "https://files.pythonhosted.org/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89", size = 525527, upload-time = "2026-07-09T13:48:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e", size = 97965, upload-time = "2026-07-09T13:49:01.217Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c", size = 167316, upload-time = "2026-07-09T13:49:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff", size = 173630, upload-time = "2026-07-09T13:49:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f", size = 173214, upload-time = "2026-07-09T13:49:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/e2fd3fdf356e1b55d2acf1b956b4f3f29ffb215a99c387eba04b1c5fba66/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd", size = 562232, upload-time = "2026-07-09T13:49:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/19/28/65e0980d668a6d44e699f59d1acf43d6b5d4893592c115ce7c680bb4dfa1/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a", size = 287858, upload-time = "2026-07-09T13:49:07.45Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/5e97bcebc90fb6a10f98af3dc1ba552e04183aba59e2edc0b9cf486dd998/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc", size = 321587, upload-time = "2026-07-09T13:49:09.489Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/88b2a2370cc3d455ba0515fb6f5c8f7ac0c0f55a86801b6e56a432f22c17/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d", size = 328964, upload-time = "2026-07-09T13:49:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/181c5da673953dfc0958cb4fb3a4984a9098673ddb05cac68e994bc8511b/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7", size = 442909, upload-time = "2026-07-09T13:49:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/5c5e665af542884a8fd3c61725c38453239e13940326b5b70f3ef8881a97/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4", size = 323076, upload-time = "2026-07-09T13:49:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/7de97de18cbf226c2a4f2104ad15e56ca4491717c81c0b71795c0c585b4e/uuid_utils-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099", size = 347360, upload-time = "2026-07-09T13:49:15.237Z" }, + { url = "https://files.pythonhosted.org/packages/26/a1/9915d5dd59fdd1957ded5d188c0ea0b9db5a1d84d42c8d8828a7b83b366e/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354", size = 499267, upload-time = "2026-07-09T13:49:16.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/88108405262ec850cea0f95733445d6873e5772af3292baabd9ef8457740/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330", size = 604940, upload-time = "2026-07-09T13:49:18.147Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/6dbcd300de47cc443cff2656cd5327a385751213dcb2101cfee7388170b2/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0", size = 564172, upload-time = "2026-07-09T13:49:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ab/94/e8057f2288a415fba8a978bca4b589f5cb6b91a028a5dc07a1775938b33f/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5", size = 528533, upload-time = "2026-07-09T13:49:21.075Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6b/31713148c77e48e62f51aa042a98a54a8be0396912ea5130f83f52ae722d/uuid_utils-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0", size = 99197, upload-time = "2026-07-09T13:49:22.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/ca6f6ac5428312df8ed632f6dd9f9e6aba23090471fcdeae53eab027e8b3/uuid_utils-0.17.0-cp314-cp314-win32.whl", hash = "sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a", size = 169540, upload-time = "2026-07-09T13:49:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cd/7ede0db66411fa09817d79b680f7454ea9bee2d374e1922e4efd065760a3/uuid_utils-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0", size = 175984, upload-time = "2026-07-09T13:49:24.703Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/533b5f80cd4918c0693f4e1b7b90ceb1caa45f4266ae8b528135d7ecca5d/uuid_utils-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae", size = 174749, upload-time = "2026-07-09T13:49:25.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/f400ac39d06fd8be5b099c09e41bb975205926722a3e8d53348817cb7ff9/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0", size = 562610, upload-time = "2026-07-09T13:49:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/c71c8312304c56f6d0bcba87cd402fa79bec35d18ffc8c41954196ca68e5/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b", size = 289473, upload-time = "2026-07-09T13:49:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/522117e2e5184ca1d4f0f85ee833e9e21bd8c6b99eff8a4d1a8e5a194e33/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750", size = 321600, upload-time = "2026-07-09T13:49:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/0d81f9bd346fc717bc561c08fa6457e0328966eb76e536b938fe77d56459/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912", size = 329569, upload-time = "2026-07-09T13:49:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/26e1363f36a94c9e8ec2dd21d5f63088d3e7c723adbb12dcc8fdc77be417/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa", size = 442051, upload-time = "2026-07-09T13:49:33.024Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/2c1ed1b34d7df7fdcc11c28fd26d94d44843b37d9af2435ff9fd8abdbc08/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2", size = 324372, upload-time = "2026-07-09T13:49:34.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/bf/328d3c6bb22c496944a1b3b732207d71aa6964eb604e5e3b9dcb91ed0a00/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354", size = 348548, upload-time = "2026-07-09T13:49:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/76/a07de5cb7b90582fdbbc830fd19be129cbbb9897cfe239fef469d7bd2d09/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6", size = 498985, upload-time = "2026-07-09T13:49:37.142Z" }, + { url = "https://files.pythonhosted.org/packages/f4/62/9966e46ae34fcec6b06119631fb3c09705ea78835035ce3a82d3348eb61a/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68", size = 605183, upload-time = "2026-07-09T13:49:38.648Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4e/bb962ba0fe31e903b199f22cf4c1a6cba35a8987aef526d287277ab8ca8b/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3", size = 565412, upload-time = "2026-07-09T13:49:40.115Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/122adfeeeae8a84ccfd43bce627b104d12a2180a93bffd2c0e1b54dad7a6/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd", size = 529885, upload-time = "2026-07-09T13:49:41.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/257304dded339dc35fc9bf35722ac68fd4fdb930f255b8f7bccdf74ebba9/uuid_utils-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91", size = 169472, upload-time = "2026-07-09T13:49:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/35/c8/e78c06db7e9ce317ce7b8759ff2058333eac75caa8c22b75f0059589c9be/uuid_utils-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab", size = 176271, upload-time = "2026-07-09T13:49:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9", size = 175004, upload-time = "2026-07-09T13:49:45.591Z" }, + { url = "https://files.pythonhosted.org/packages/ee/14/4ae708968b15cac7b68d5b854bfce724b21faa1c7a5147fb96d87f468a45/uuid_utils-0.17.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7b9044ce4acbf392d4b3a503fe377641f4deff82e6c341c36ef27af0dea76cdf", size = 567823, upload-time = "2026-07-09T13:49:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e2/d3af9c3d1dc6efb9ee1cffab30f3f2aacacc3892b21b495d78d34c6696bc/uuid_utils-0.17.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9a91c4814c7150a4d798da691b7804eacd78c4b84fb392a60fa0de21341861eb", size = 288763, upload-time = "2026-07-09T13:49:48.491Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/f1b183e412387529893015a94a8447633c665f6d0392de20e245680e636a/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd4a21baaac9a88486f0dd166c5793feb101a0bb9f006f2c401657fff5a1343", size = 324919, upload-time = "2026-07-09T13:49:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3c/d32c799bdd51f3b08b6ee95f9de921b59c69075a96767f937fab55014813/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32abaafc8e91928b3d9f4d82e42d2094041e38ad6bb964066faadff28e4162f1", size = 332689, upload-time = "2026-07-09T13:49:51.402Z" }, + { url = "https://files.pythonhosted.org/packages/6f/90/b4cd455619ff276dc3c3262a7420ead63aa1e531362f00df4cdb07d90e0a/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd741c73440b328f937dc53b344ecadc46bc4f0cec0333a8f42b55f3468ce7ec", size = 445726, upload-time = "2026-07-09T13:49:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f1/5cc042a37932aa9a66eb8ab4a9a5b31d80261ae4565ff0193d8cc1fb9392/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89a0980d49683c00539c59cd9f46b1908c538e6b5b0a48ad12187bb856d0f391", size = 325610, upload-time = "2026-07-09T13:49:54.191Z" }, + { url = "https://files.pythonhosted.org/packages/5e/72/9e800c41d766484484e97845a7a7f677ba94462df86c97183e0290229d16/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:de1064663aa7c839286488a319d2b3b478ca5ab5b2091ade888ed0eeca11a98a", size = 352672, upload-time = "2026-07-09T13:49:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/86ce2c03a1d9674530f6649e49067f7c69929600127077731de590d12132/uuid_utils-0.17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2db386941cfdecdd0b5a8ceeed5cf7479c83d1730dcf64a48d43cfa018cc3310", size = 178681, upload-time = "2026-07-09T13:49:57.096Z" }, ] [[package]] name = "uv" -version = "0.11.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/f0/6254502aebfdc0a9df6069269a126dd58252ac29d2d6cdf4777cea3e90b5/uv-0.11.19.tar.gz", hash = "sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5", size = 4213580, upload-time = "2026-06-03T22:37:15.976Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/73/be32c2f6ba30fa9d8b3baceb478107cc23722d4aaab87145a332e4985185/uv-0.11.19-py3-none-linux_armv6l.whl", hash = "sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522", size = 23620003, upload-time = "2026-06-03T22:37:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ed/3aefe4a4ca4ac9204c6745670dbe12f4add69194d40f5abd1c7bd45ba9af/uv-0.11.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277", size = 23183211, upload-time = "2026-06-03T22:37:20.699Z" }, - { url = "https://files.pythonhosted.org/packages/5b/eb/5d1469f9e709d56066f292978711fbf1f805b7fb46f901d3c1f260fd9908/uv-0.11.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d", size = 21754003, upload-time = "2026-06-03T22:37:05.027Z" }, - { url = "https://files.pythonhosted.org/packages/7b/93/109b5ee6678f54492f94fdef74149643eaa1f2f4716906a2a10816b31247/uv-0.11.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21", size = 23518832, upload-time = "2026-06-03T22:37:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/08/0c/8c59bbcf78e94ca9994256920efa99d1c4dc9d0b966eb62ebba075585a16/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c", size = 23163128, upload-time = "2026-06-03T22:37:23.226Z" }, - { url = "https://files.pythonhosted.org/packages/89/d6/69caf9e6f11c84b5fb92df190b46fbecb7dc6645ae891c6ed66d7aaaa310/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff", size = 23164395, upload-time = "2026-06-03T22:37:18.11Z" }, - { url = "https://files.pythonhosted.org/packages/d6/83/0c2242b77c51ac33a0ddd8b06790429a0b8b9623974c9594ab2b0070ec47/uv-0.11.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc", size = 24541708, upload-time = "2026-06-03T22:37:08.093Z" }, - { url = "https://files.pythonhosted.org/packages/54/10/b1404fc52c0eddc3655f57a8b76e79dcf8dd02568382272f17e2fa68c4bb/uv-0.11.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f", size = 25575501, upload-time = "2026-06-03T22:37:47.526Z" }, - { url = "https://files.pythonhosted.org/packages/7c/17/4cda5994195ba9ce1f6971d40d5f2ceec58e2a79030d9052b3bf322557b1/uv-0.11.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7", size = 24827200, upload-time = "2026-06-03T22:37:34.039Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/2bd8b51e1d76210fd424ae55ec3f34ded5a10eeff3dd38aeb03c816a0af2/uv-0.11.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90", size = 24872664, upload-time = "2026-06-03T22:37:11.301Z" }, - { url = "https://files.pythonhosted.org/packages/06/b1/44b0764f656bbdd0728118610a63f2feddd9cbe450f974d80c5bb56aad34/uv-0.11.19-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178", size = 23617890, upload-time = "2026-06-03T22:37:44.796Z" }, - { url = "https://files.pythonhosted.org/packages/d2/25/312fa33cd4c34e7618f86cad0c9fdb312d8fef2e7fc61944c1a2f1bf1256/uv-0.11.19-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb", size = 24267220, upload-time = "2026-06-03T22:37:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/8d/25/13856aeff9e14c98ee3e1ceae4d209301cbdeabde93abcd758433601dc82/uv-0.11.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82", size = 24376177, upload-time = "2026-06-03T22:37:50.276Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/590b3ab420e03504cf658d2981e1fcb4af60f3858d42da1d4d8740141dd9/uv-0.11.19-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9", size = 23808336, upload-time = "2026-06-03T22:37:26.086Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8e/40acebd4ea419c870930580623e8367e23d810a0ecb8cc2f44d852a27293/uv-0.11.19-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba", size = 25080747, upload-time = "2026-06-03T22:37:13.886Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d3/4037b2acb2bb73b1a3ee47a1d23864ecc503f5840387afd29f621d4fd2ec/uv-0.11.19-py3-none-win32.whl", hash = "sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347", size = 22459533, upload-time = "2026-06-03T22:37:36.741Z" }, - { url = "https://files.pythonhosted.org/packages/d4/43/f374fad7ad94e4a8c47cf09f00d803c76c6cc7f225668c41f4e2fb5de000/uv-0.11.19-py3-none-win_amd64.whl", hash = "sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363", size = 25143888, upload-time = "2026-06-03T22:37:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/18/98/d2db53ae036528b0a9407529ef175ee200b01f626c9c160978784c8af870/uv-0.11.19-py3-none-win_arm64.whl", hash = "sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee", size = 23601290, upload-time = "2026-06-03T22:37:31.393Z" }, +version = "0.11.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/a2/bfd6755b40682ef7e775ddb9d52823dea6551352f4244106da4bad37cd3c/uv-0.11.28.tar.gz", hash = "sha256:df86cfd135542a833e9f84708b3b8dbaa987a3b9db85b267062db49ab639d242", size = 5985690, upload-time = "2026-07-07T23:12:47.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/507b829e79353fe3dcb2779cde8f64497d9c99f9b08b18b8f55ee3bf1786/uv-0.11.28-py3-none-linux_armv6l.whl", hash = "sha256:ae5bbdb6150adcd625f5fa720b04abf2014247d878d1035f19751bb0e7274543", size = 25893158, upload-time = "2026-07-07T23:11:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/10/54/50c85a663ce723e061523ab4ac8b01b650077584e80950f9c93fd073979f/uv-0.11.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bb11d94cb848ff58af79e0bb5e4037cd324d27dbe2dabb7746db698b724a9a20", size = 25041511, upload-time = "2026-07-07T23:11:58.885Z" }, + { url = "https://files.pythonhosted.org/packages/32/e1/49968cab72f16a7d6c45d095d319f9efbe8ce05f3f15c5f7104493694289/uv-0.11.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e9eb317b1cdb249887df77ac232d8a9448f26858b2399f9f2949c6a7b9bedf88", size = 23570471, upload-time = "2026-07-07T23:12:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4d/c9fe448dcd5cf65a5f054517aa42551b7f0920710a6891d98af321a06b22/uv-0.11.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:041e4b80bebc58d7142ac9394370cacd73185fd8d066d6675d14707d83408f6d", size = 25594677, upload-time = "2026-07-07T23:12:04.597Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/4c0c71075ba66cc594f856cbd98844058fcb53cb4dd8a6fccda8419562bb/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:185416a5316df8c5442b47178349f1f27fc1034468670ac1fb499eae3b25bd68", size = 25427944, upload-time = "2026-07-07T23:12:07.226Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/50fef66f4e26bf771429e1d88f849476d8ed21f0fb0708acaa53dc5772a5/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4a9fe246cb2882532277f5d5e5bd8a59462981462a2f98426f35ecfca82460e", size = 25448458, upload-time = "2026-07-07T23:12:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/d0/93/720f45af65ebda460166dc64f3318acd65f7bd3a8e326fbd21810fd920ee/uv-0.11.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f7ce6f6015a3e857bc6a663514afa62856b669ee5c1bd120e4c58ac2ef5513d", size = 26917218, upload-time = "2026-07-07T23:12:13.802Z" }, + { url = "https://files.pythonhosted.org/packages/cd/27/a9b68a15a5fe8db7103bea514c2adb79e9b1114fc8dc96fb39dbd7a5b898/uv-0.11.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b3d0ea11e83b373a2166b82dd0864f5677fbadf98db64541ab2e59c42968905", size = 27771542, upload-time = "2026-07-07T23:12:16.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fd/208607a7f5f86188775387fe0839ef97cf8d013e8d0e909140b7fdb7d0d1/uv-0.11.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c60294e3be4fa203a04015fc02ac8a31d936e86fde06dcb43c7f8f22661dfff", size = 26972190, upload-time = "2026-07-07T23:12:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/62273ee6c9fbebccd8248c153b44870f81ebf5267c31edf4c095d78537fb/uv-0.11.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fe42df9f42056037473f3876adec1615709b57d3470ed39178ff420f3afb9f", size = 27127688, upload-time = "2026-07-07T23:12:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/b15212904e6f0aa4a3709dc86838c6fa070fe97c7e96b3f10174a26b16e3/uv-0.11.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fab3c31007a611866475824a666f5a721bf0c9335db806355a97fcfba2a6bbb7", size = 25715221, upload-time = "2026-07-07T23:12:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/64/35/b83b7c599474aaf1277c2224c09679640c2320562155c4b6ece1c6f014c1/uv-0.11.28-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:2e91eb8a0b00d5f4427195fc818bcaa4d8bb4fccb79f4e973e74802419ab06ca", size = 26392793, upload-time = "2026-07-07T23:12:27.848Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/8093318206dee51b5cfcabbf110892ff63cfd897a5df002e2d8b61350fe6/uv-0.11.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47e3f12fe6f5c80a01639d8df36efde7bdddfc3bbc52250df623547d8d393105", size = 26522809, upload-time = "2026-07-07T23:12:30.757Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/b26d82e9297c29c201f61698ee56bba956f94953b23089532d026a97d93f/uv-0.11.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:d01c7c665511c047f350e587b8b6557c96b61b2eddafbcd8964f0cc2f5b9afbe", size = 26156793, upload-time = "2026-07-07T23:12:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/163e89424668d6c01499efbe85a854ad38f07834bde3f2b16df159eab1d5/uv-0.11.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3fcfda468448093f4d5961ca8c068b0aeec2d02f7226d58ee8513321a929fe4f", size = 27327614, upload-time = "2026-07-07T23:12:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/db4cb824777d013272ccfa77db07a4d12bf1584899458c1917a4b5a4069d/uv-0.11.28-py3-none-win32.whl", hash = "sha256:692edef9cf1d2dd69bb9d9fc01f281a82610547900ce227a3cb269cdf988b5ce", size = 24665179, upload-time = "2026-07-07T23:12:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/d67b18cddd54c503c7bad2b189a47fd7a1d07ea10b9212624f892b985498/uv-0.11.28-py3-none-win_amd64.whl", hash = "sha256:f4fcf2c8d9f1444b900e6b8dbbb828825fb76eca01acd18aeaa5c90240408cda", size = 27603677, upload-time = "2026-07-07T23:12:41.985Z" }, + { url = "https://files.pythonhosted.org/packages/57/94/dc31a771eac989973219c730552dbcf5bf7ea6652dba4ba89b1bbdc75a80/uv-0.11.28-py3-none-win_arm64.whl", hash = "sha256:e94560995737c50525d586da553521fbafe9ef06641e7d885db4b270f53ee84d", size = 25839294, upload-time = "2026-07-07T23:12:44.893Z" }, ] [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [package.optional-dependencies] standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "httptools" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -5988,49 +6424,50 @@ wheels = [ [[package]] name = "valkey-glide" -version = "2.4.1" +version = "2.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "protobuf" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152, upload-time = "2026-05-28T21:41:02.205Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601, upload-time = "2026-05-28T21:41:04.543Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977, upload-time = "2026-05-28T21:41:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446, upload-time = "2026-05-28T21:41:07.833Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646, upload-time = "2026-05-28T21:41:09.451Z" }, - { url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375, upload-time = "2026-05-28T21:41:11.079Z" }, - { url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469, upload-time = "2026-05-28T21:41:12.733Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744, upload-time = "2026-05-28T21:41:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972, upload-time = "2026-05-28T21:41:16.063Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012, upload-time = "2026-05-28T21:41:17.492Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842, upload-time = "2026-05-28T21:41:18.995Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919, upload-time = "2026-05-28T21:41:20.502Z" }, - { url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906, upload-time = "2026-05-28T21:41:22.135Z" }, - { url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495, upload-time = "2026-05-28T21:41:23.783Z" }, - { url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720, upload-time = "2026-05-28T21:41:25.361Z" }, - { url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318, upload-time = "2026-05-28T21:41:26.996Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155, upload-time = "2026-05-28T21:41:42.399Z" }, - { url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571, upload-time = "2026-05-28T21:41:44.078Z" }, - { url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021, upload-time = "2026-05-28T21:41:45.837Z" }, - { url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093, upload-time = "2026-05-28T21:41:47.617Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/af/df/b62875d6b6e98ba10b7074af80a951bce624d7b6d9f9f840771bb09814da/valkey_glide-2.4.2.tar.gz", hash = "sha256:d63a7483c2db59d8c73666a360246cadaac82b6d71087d75839395ffacf863e0", size = 894164, upload-time = "2026-06-26T19:22:02.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/42/066c7ba551902241d36ec481e76c7ed30f119f89064209e4c51715291ce0/valkey_glide-2.4.2-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:4c2b934b1b555b43856bbad8df2963d535492af48a9ac6136cfa4f30858d3a76", size = 7514637, upload-time = "2026-06-26T19:21:22.516Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/08ae6d6dfb9e0b1949f3b075da226b2f2e88b6c30a4b9c04623ed562d928/valkey_glide-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fedf19be23e27eb42684b8cb26c604e2d86f8a3de1dc567e12c7abba1417e91", size = 6959872, upload-time = "2026-06-26T19:21:24.197Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/4ad0f96de5df4f7458ec7254624465e5efcd1a68c5cd3aa21b57801f48c3/valkey_glide-2.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61a417af43720ff04c77376c38d361da5e6cbd6414f8f56cf9c6089c3d78462a", size = 7261428, upload-time = "2026-06-26T19:21:25.8Z" }, + { url = "https://files.pythonhosted.org/packages/86/46/6dfc5841a85dcfddc2d563fcc2e09305e0895fa8863b8de56382bd1c6367/valkey_glide-2.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a23c4c59f0fd33786ad4b8c16e9b672b2c91e1166f7e84cdd06c417e236cddd9", size = 7706925, upload-time = "2026-06-26T19:21:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/5a/71/ca3309428bd221ea1fdf12f84874be034008df163aa3736f1b7cc29da93b/valkey_glide-2.4.2-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:ba1f7ca94ec120169019656ad71fba19bcef13c18180f89e4320f431aa9fb9df", size = 7518921, upload-time = "2026-06-26T19:21:29.353Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c0/dfcafce4b64adc704dc6ed26ae622996d67b7333970a5dcf252bd06f9a84/valkey_glide-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c51bf6fd5b73978e2d24ea40b36931cfafa96158380b9e7cba15c645c63c4", size = 6962771, upload-time = "2026-06-26T19:21:31.365Z" }, + { url = "https://files.pythonhosted.org/packages/54/ee/369bab4baef737d3a5fe9d73a7a012d3616c679f3cf449a25d14d47325b5/valkey_glide-2.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be07848c279b8bb33b80466d56899ed0df41c98fae090817df88787928ac7774", size = 7266588, upload-time = "2026-06-26T19:21:32.884Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/4942619ab48d7f534598743533ab8299bfa1e9bea7dec66fe79042dab75b/valkey_glide-2.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d264a886940cec2c52f5332dfc431b82342cc23e3ea8e3aefc0e762cb4379ed8", size = 7706618, upload-time = "2026-06-26T19:21:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/43/9d/5721042d5642b924bf15f05441f850e2ca404bdba990d727713def3518cf/valkey_glide-2.4.2-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:4f84e94dfe1e9713701e85e7b80cc1526e8f73b5eac7ea84ed544d5918b1dc97", size = 7519180, upload-time = "2026-06-26T19:21:36.187Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/5fc34087202843bc8aea10bfd751a3138bb5410b5ac15d8f8b694a3bef2d/valkey_glide-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20136ce5cf682747bf39fc2b863cccaa2a1f873c8fbf67828d28d13a3d893b4b", size = 6962978, upload-time = "2026-06-26T19:21:37.729Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/15224ccaba81122ac7b6b5fc77c46a97d175d76b7029c3c97f203e099f6a/valkey_glide-2.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:044375a86e29af4707babdc8d022ae3a7e74772634a8104dd1ab86dda48a4dfe", size = 7266837, upload-time = "2026-06-26T19:21:39.304Z" }, + { url = "https://files.pythonhosted.org/packages/0d/29/ba4246d742893be167629549d232b22d48ecca60baf6565036291440dcb7/valkey_glide-2.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d11e84fd13938c23721f070fd45d8d3f3b940fc31120ba2749a9cc89398f48", size = 7706178, upload-time = "2026-06-26T19:21:40.824Z" }, + { url = "https://files.pythonhosted.org/packages/80/67/1f5d09f1dbea743e57d9f4d661afd4e1397568343919fe90cf7ba9246bd8/valkey_glide-2.4.2-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:a413130a4ea71e915b1fc20d79eb2c53f4f02eab15ef375c5ef81399d3a10445", size = 7516994, upload-time = "2026-06-26T19:21:42.394Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6e/85ba9ea7b41694e97d6ad03ad720aaf12a6c842a237df61319ebe6dc4021/valkey_glide-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:650b9cd31bdc816049f348bddc556af71af88e44d0924211843080e47d4c2ae7", size = 6958287, upload-time = "2026-06-26T19:21:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a1/4877e6c74d518395d63b42f2b368685b4685a7d820c67d2a681122018b2b/valkey_glide-2.4.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:284970736d5c6d7e9af91dbca1f5907c718bade1df11d7a0f42960adc5670e87", size = 7266347, upload-time = "2026-06-26T19:21:46.009Z" }, + { url = "https://files.pythonhosted.org/packages/38/88/e6f67b89e7be67d55d91a0c4107e42aa9ad79b4de55b20bd7c39f3311f82/valkey_glide-2.4.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33742e9bc3f7c5131e2cb4e0b17f8ba5c6c0ee16bd534cf68abe68853cde27f1", size = 7707212, upload-time = "2026-06-26T19:21:47.922Z" }, + { url = "https://files.pythonhosted.org/packages/10/5f/277cbb3eb91bf722bb92ad0a7b9eeecae644567c96c2abc4d519b4ba043d/valkey_glide-2.4.2-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:25cffb432a55a95eb3b379b957efa85afc86da3dd8740ada1880ab08dc8c0323", size = 7511857, upload-time = "2026-06-26T19:21:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b5/a776cc8c43c7812e64578ddfe112d40ef12fba6ec6af96f2907b46bf3c2f/valkey_glide-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:966d7ec5a717e06d1b952e44e3ddeb3cc55de30cdda2d3f4257aded71eea43e2", size = 6975485, upload-time = "2026-06-26T19:21:57.779Z" }, + { url = "https://files.pythonhosted.org/packages/37/a2/84328ffbc26bd3b14a2640e9675d76309f5e2c26a54ef328fd77588f70d4/valkey_glide-2.4.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0d460b2979926b055388bb1f109440c03382641f18503d962cf5e0742b3dc7", size = 7257333, upload-time = "2026-06-26T19:21:59.427Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/32f2779a421434c60105552a1b5ea0503e9769df0c526b0f0c3618fd3966/valkey_glide-2.4.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8de9620379d133c3b6d0f2e4238f42a7f342e43f53ad95aa85cd5117ca7e1c", size = 7712832, upload-time = "2026-06-26T19:22:00.999Z" }, ] [[package]] name = "virtualenv" -version = "20.36.1" +version = "21.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, ] [[package]] @@ -6062,101 +6499,118 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +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 = "wcmatch" -version = "10.1" +version = "10.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/15/dc61746d8c0852f6d711ad09c774b63cf7c8211aa49e30871ac3d342b7e2/wcmatch-10.2.1.tar.gz", hash = "sha256:ecac70a5c70e62ba854b78318d3a1408e8651f8f1c96e5837743b71aa6a4fb92", size = 132497, upload-time = "2026-07-02T17:21:48.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, + { url = "https://files.pythonhosted.org/packages/82/ba/20b48eedeab5316bf9a502bb9eb7e3b1588bd61d0f565822fefa8f06e10b/wcmatch-10.2.1-py3-none-any.whl", hash = "sha256:2d775395b93f233af66690f62cb9d52b084ec159a31cc4084f4069d72f437acd", size = 39763, upload-time = "2026-07-02T17:21:47.134Z" }, ] [[package]] @@ -6212,77 +6666,89 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.6" +version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] [[package]] name = "wrapt" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/31/afb4cf08b9892430ec419a3f0f469fb978cb013f4432e0edb9c2cf06f081/wrapt-2.1.0.tar.gz", hash = "sha256:757ff1de7e1d8db1839846672aaecf4978af433cc57e808255b83980e9651914", size = 80924, upload-time = "2026-01-31T23:25:58.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/0a/de541b2543e33144043cd58da09bda8d837ba42e13ae90baca32b0553023/wrapt-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d877003dbc601e1365bd03f6a980965a20d585f90c056f33e1fc241b63a6f0e7", size = 60558, upload-time = "2026-01-31T23:25:27.784Z" }, - { url = "https://files.pythonhosted.org/packages/84/2e/7e48207420e6ca7e7a05c0e4ebe9464ec9965c8face256f3ef8cc2acd862/wrapt-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:771ec962fe3ccb078177c9b8f3529e204ffcbb11d62d509e0a438e6a83f7ca68", size = 61501, upload-time = "2026-01-31T23:26:46.477Z" }, - { url = "https://files.pythonhosted.org/packages/67/2b/639a4970ecdc7143acb69a1162c76b0f1620218ad502c33e1a88d28f00b1/wrapt-2.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73e742368b52f9cf0921e1d2bcb8a6a44ede2e372e33df6e77caa136a942099f", size = 113954, upload-time = "2026-01-31T23:26:01.493Z" }, - { url = "https://files.pythonhosted.org/packages/81/5d/8d9177c8c0ecaf5313b462be63c5aa9672044b02bfd644dd65c6cb420d2a/wrapt-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e9129d1b582c55ad0dfb9e29e221daa0e02b18c67d8642bc8d08dd7038b3aed", size = 115994, upload-time = "2026-01-31T23:25:57.118Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/c5a514a0ed1dc463f5b6b4e31abbaa3b8df48b9fd391a6e8412608155a29/wrapt-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc9e37bfe67f6ea738851dd606640a87692ff81bcc76df313fb75d08e05e855f", size = 115245, upload-time = "2026-01-31T23:26:11.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/9c/2fc6a31f5758266de2cf9dc6111d3bda7b7dd6cbdcabfd755103bbcda08f/wrapt-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:46583aae3c807aa76f96355c4943031225785ed160c84052612bba0e9d456639", size = 113679, upload-time = "2026-01-31T23:25:19.475Z" }, - { url = "https://files.pythonhosted.org/packages/6c/81/ce52694dc8184f4898c01c8af20e145b348fc7a0e4766a7345c45f0e9ce6/wrapt-2.1.0-cp311-cp311-win32.whl", hash = "sha256:e3958ba70aef2895d8c62c2d31f51ced188f60451212294677b92f4b32c12978", size = 57865, upload-time = "2026-01-31T23:25:50.947Z" }, - { url = "https://files.pythonhosted.org/packages/85/31/0df5d38243c2a538e7bd481e676d286b41f98a729e0d37cfed9f4421ad4d/wrapt-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ff9797e6e0b82b330ef80b0cdba7fcd0ca056d4c7af2ca44e3d05fd47929ede", size = 60227, upload-time = "2026-01-31T23:25:35.954Z" }, - { url = "https://files.pythonhosted.org/packages/a3/79/b587edbab21d6b8a7460234440c784e08344bcdf4fdfd9a6e9125ea14923/wrapt-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:4b0a29509ef7b501abe47b693a3c91d1f21c9a948711f6ce7afa81eb274c7eae", size = 58648, upload-time = "2026-01-31T23:25:32.887Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6f/c731b1fbbcdf9bd202809c6fa354c4237b663dd82a95035a7cbe899cfd25/wrapt-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a64c0fb29c89810973f312a04c067b63523e7303b9a2653820cbf16474c2e5cf", size = 61149, upload-time = "2026-01-31T23:25:29.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/da/7022458a1d99f0c59720a0b0fd4b1966f8df6d41e741aadfe43bc5350547/wrapt-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5509d9150ed01c4149e40020fa68e917d5c4bb77d311e79535565c2a0418afcb", size = 61743, upload-time = "2026-01-31T23:26:14.338Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f4/57cc12c3fc6f4fe6ccfc15567cc1ac8aeb53a9946a675adc3df7a1ee4e6a/wrapt-2.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:52bb58b3207ace156b6134235fd43140994597704fd07d148cbcfb474ee084ea", size = 121331, upload-time = "2026-01-31T23:25:37.294Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a4/a96ea114298f81f02c07313da85fd46a2a57bbe12389d0619ac3371f691c/wrapt-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7112cbf72fc4035afe1e3314a311654c41dd92c2932021ef76f5ca87583917b3", size = 122907, upload-time = "2026-01-31T23:26:49.604Z" }, - { url = "https://files.pythonhosted.org/packages/ac/43/df73362b6e47f92aaff0fc3fc459314025c795f75d61724c83232dee199c/wrapt-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e90656b433808a0ab68e95aaf9f588aea5c8c7a514e180849dfc638ba00ec449", size = 121337, upload-time = "2026-01-31T23:26:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/51/4f/8147e3b9a7887cee4eeb3a3414265ad4649a156832a08063f55aa7842af0/wrapt-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e45f54903da38fc4f6f66397fd550fc0dac6164b4c5e721c1b4eb05664181821", size = 120461, upload-time = "2026-01-31T23:26:43.055Z" }, - { url = "https://files.pythonhosted.org/packages/35/b1/eea720fcca8a05dec848a6d11a47c20f59bdabdcc444ba3be0589350eb7a/wrapt-2.1.0-cp312-cp312-win32.whl", hash = "sha256:6653bf30dbbafd55cb4553195cc60b94920b6711a8835866c0e02aa9f22c5598", size = 58089, upload-time = "2026-01-31T23:26:47.773Z" }, - { url = "https://files.pythonhosted.org/packages/af/79/8a8f3f8c71ee3379191b69e47f32115fa25cdb6d5b581d74c64d5c897fa7/wrapt-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d61238a072501ed071a9f4b9567d10c2eb3d2f1a0258ae79b47160871d8f29c3", size = 60330, upload-time = "2026-01-31T23:26:12.518Z" }, - { url = "https://files.pythonhosted.org/packages/08/4e/e992d05c3d2f7163883a65ead2620ff5fe7b3d44d7c2136ce981e40e453d/wrapt-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:9e971000347f61271725e801ef44fa5d01b52720e59737f0d96280bffb98c5d1", size = 58727, upload-time = "2026-01-31T23:26:53.222Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/b414826a5aaf2fdcfe73c2e649cbeb2e098fef4820d1217554ee64f45666/wrapt-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:875a10a6f3b667f90a39010af26acf684ba831d9b18a86b242899d57c74550fa", size = 61155, upload-time = "2026-01-31T23:26:24.462Z" }, - { url = "https://files.pythonhosted.org/packages/58/9e/8b21ea776bf2a3c858e3377ecde4b348893ec44dc1726baaf583ca22c56e/wrapt-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00f8559ceac0fb45091daad5f15d37f2c22bdc28ed71521d47ff01aad8fff3d", size = 61747, upload-time = "2026-01-31T23:25:53.987Z" }, - { url = "https://files.pythonhosted.org/packages/da/ec/48cd2470ad09557dfe6fccfe9de98698cc0df3786a6d4d97e8edd574d67a/wrapt-2.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce0cf4c79c19904aaf2e822af280d7b3c23ad902f57e31c5a19433bc86e5d36d", size = 121342, upload-time = "2026-01-31T23:26:32.156Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4e/e8447b31be27b6057cdfc904a38632a765c3407fb4d10d11e5c1d0c203d5/wrapt-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dd4f8c2256fcde1a85037a1837afc52e8d32d086fd669ae469455fd9a988d6", size = 122951, upload-time = "2026-01-31T23:25:08.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b6/73a6c9277e844ffe11f3002ad27a84ff5418248def33af9435d24dfe6c5b/wrapt-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:737e1e491473047cb66944b8b8fd23f3f542019afd6cf0569d1356d18a7ea6d5", size = 121373, upload-time = "2026-01-31T23:26:18.322Z" }, - { url = "https://files.pythonhosted.org/packages/85/04/869384435fecf829dc05621ffa02dab0f2f830be5d42fa8d8ac7b0b4c9fa/wrapt-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38de19e30e266c15d542ceb0603e657db4e82c53e7f47fd70674ae5da2b41180", size = 120468, upload-time = "2026-01-31T23:25:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/80/ac/42a5378d9b5b486122ae0572c46ae8d69ab6486b9f13961e6b9706297ff5/wrapt-2.1.0-cp313-cp313-win32.whl", hash = "sha256:bc7d496b6e16bd2f77e37e8969b21a7b58d6954e46c6689986fb67b9078100e5", size = 58095, upload-time = "2026-01-31T23:26:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/86/de/538fcef30f70a1aaadab4cab7d0396037518d7ec2b064557171147ce297f/wrapt-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:57df799e67b011847ef7ac64b05ed4633e56b64e7e7cab5eb83dc9689dbe0acf", size = 60344, upload-time = "2026-01-31T23:25:10.615Z" }, - { url = "https://files.pythonhosted.org/packages/08/13/27884668b21e9f0a625c13ebd6a8d70ad8371250ec8519881858404686bf/wrapt-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:01559d2961c29edc6263849fd9d32b29a20737da67648c7fd752a67bd96208c7", size = 58734, upload-time = "2026-01-31T23:26:00.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a3/e558c5b8f3a097aa1e942e2d75923adebfdfafb5a51ec425d1d062e49ab0/wrapt-2.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:66f588c8b3a44863156cfaccb516f946a64b3b03a6880822ab0b878135ca1f5c", size = 62972, upload-time = "2026-01-31T23:26:08.576Z" }, - { url = "https://files.pythonhosted.org/packages/93/b6/7157e98107099fad846f1e79308cc0954e26b25b01c03f1624ba7f57ec54/wrapt-2.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:355779ff720c11a2a5cffd03332dbce1005cb4747dca65b0fc8cdd5f8bf1037e", size = 63610, upload-time = "2026-01-31T23:26:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/e4/8e/b8992671e4b4d3ce2a53af930588c204bf37b66eb212bd1722f2a5a8cf62/wrapt-2.1.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a0471df3fb4e85a9ff62f7142cdb169e31172467cdb79a713f9b1319c555903", size = 152538, upload-time = "2026-01-31T23:26:27.696Z" }, - { url = "https://files.pythonhosted.org/packages/8c/f6/79f9fd4b3c0a8715e651fff1cc1182a983fd971376d5688a06fa94e31acd/wrapt-2.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bacf063143fa86f15b00a21259a81c95c527a18d504b8c820835366d361c879", size = 158702, upload-time = "2026-01-31T23:25:11.848Z" }, - { url = "https://files.pythonhosted.org/packages/9e/46/f88b52beb813eeb830d9134bc6eaf3e53cde4e3cfa1804e383754d4104fe/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c87cd4f61a3b7cd65113e74006e1cd6352b74807fcc65d440e8342f001f8de5e", size = 155564, upload-time = "2026-01-31T23:25:15.033Z" }, - { url = "https://files.pythonhosted.org/packages/93/31/97145ea71e3e5a1b419af5c410b07b258155dc7cc1a6302791a93e991c83/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2893498fe898719ac8fb6b4fe36ca86892bec1e2480d94e3bd1bc592c00527ad", size = 150165, upload-time = "2026-01-31T23:26:09.848Z" }, - { url = "https://files.pythonhosted.org/packages/10/bd/f33551d5bfbb0ddab81296cffc15570570039a973c0f99bba474be0fadf2/wrapt-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:cbc07f101f5f1e7c23ec06a07e45715f459de992108eeb381b21b76d94dbaf4f", size = 59785, upload-time = "2026-01-31T23:25:52.23Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3a/9a76be7a36442f43841bb6336e262e09a915b2fb5dfc2822ffce1fb903d2/wrapt-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2ccc89cd504fc29c32f0b24046e8edf3ef0fcbc5d5efe8c91b303c099863d2c8", size = 63085, upload-time = "2026-01-31T23:26:05.363Z" }, - { url = "https://files.pythonhosted.org/packages/7a/35/65a13c2df008d189ebca5fec534011c5dd69ab4f47e6923b403321816fbf/wrapt-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:0b660be1c9cdfb4c711baab4ccbd0e9d1b65a0480d38729ec8cdbf3b29cb7f15", size = 60254, upload-time = "2026-01-31T23:25:06.052Z" }, - { url = "https://files.pythonhosted.org/packages/6f/eb/7c9eb1ea9b10ea98d9983a147c877a2ae927acb4a86e2dc4a0b548f05ad1/wrapt-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f7bf95bae7ac5f2bbcb307464b3b0ff70569dd3b036a87b1cf7efb2c76e66e5", size = 61316, upload-time = "2026-01-31T23:25:20.739Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c2/1c3d16d6b644f688913a00e2dc10f59adca817b5b3ee034ce4e9a692ab63/wrapt-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:be2f541a242818829526e5d08c716b6730970ed0dc1b76ba962a546947d0f005", size = 61813, upload-time = "2026-01-31T23:25:49.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/51/b6170084b6b771cc62374d924e328df2e81f687399a835f003497cad1110/wrapt-2.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad3aa174d06a14b4758d5a1678b9adde8b8e657c6695de9a3d4c223f4fcbbcce", size = 120309, upload-time = "2026-01-31T23:25:16.866Z" }, - { url = "https://files.pythonhosted.org/packages/f8/34/467829f0dd79f50878b2e67b67c67c816a6326a27d252d4192ef815b4a09/wrapt-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bffa584240d41bc3127510e07a752f94223d73bb1283ac2e99ac44235762efd2", size = 122690, upload-time = "2026-01-31T23:26:16.914Z" }, - { url = "https://files.pythonhosted.org/packages/df/5b/244c61a65e0bc9d4a18cfa2a2b3b05f8065290284fc60436a7ea5047ee10/wrapt-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b2da9c8f1723994b335dbf9f496fbfabc76bcdd001f73772b8eb2118a714cea", size = 121115, upload-time = "2026-01-31T23:26:44.518Z" }, - { url = "https://files.pythonhosted.org/packages/86/7d/f9b5e103d3caf23a72c04a1baf2b61c4a14d1feb440d3c98c26725b4503a/wrapt-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eabe95ea5fbe1524a53c0f3fc535c99f2aa376ec1451b0b79d943d2240d80e36", size = 119487, upload-time = "2026-01-31T23:25:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/f8/49/b61fdc4680dd5cd6828977341b9fd729e2c623338bfe65647f5c0ff8195e/wrapt-2.1.0-cp314-cp314-win32.whl", hash = "sha256:2cd647097df1df78f027ac7d5d663f05daa1a117b69cf7f476cb299f90557747", size = 58519, upload-time = "2026-01-31T23:25:04.426Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4f/42ab43e496d0d19caed9f69366d0f28f7f08c139297e78b17dab6ecbb6d5/wrapt-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0fc3e388a14ef8101c685dc80b4d2932924a639a03e5c44b5ffabbda2f1f2dc", size = 60767, upload-time = "2026-01-31T23:25:21.954Z" }, - { url = "https://files.pythonhosted.org/packages/ef/15/0337768ac97a8758bc0fc1afdf5f656075a7facf198f62bbe8a22b789277/wrapt-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7c06653908a23a85c4b2455b9d37c085f9756c09058df87b4a2fce2b2f8d58c2", size = 59056, upload-time = "2026-01-31T23:26:25.814Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f1/58f4674d1db44912003a51b34e8d9823a832fbbb39162e9dbe06e5f6424e/wrapt-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c70b4829c6f2f4af4cdaa16442032fcaf882063304160555e4a19b43fd2c6c9d", size = 63061, upload-time = "2026-01-31T23:26:06.601Z" }, - { url = "https://files.pythonhosted.org/packages/02/c1/07f6bf6619285f39cd616314217170c6160da99a46ad6ae4a60044f6ab5a/wrapt-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7fd4c4ee51ebdf245549d54a7c2181a4f39caac97c9dc8a050b5ba814067a29", size = 63620, upload-time = "2026-01-31T23:25:30.326Z" }, - { url = "https://files.pythonhosted.org/packages/46/82/f7df1648762260f60c4e22c066a17d95f20267c94bfe653fab4f08e2c297/wrapt-2.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b158558438874e5fd5cb505b5a635bd08c84857bc937973d9e12e1166cdf3b", size = 152546, upload-time = "2026-01-31T23:25:02.102Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/d953336e09bac13a9ffa9073e167c5dec8aaa4a717a8551bf64cb4683590/wrapt-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2e156fe2d41700b837be9b1d8d80ebab44e9891589bc7c41578ef110184e29", size = 158704, upload-time = "2026-01-31T23:25:43.269Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/2ed57e46b30af2a5a750c85a9dd30d2244ef10e2f8db150560126d8cbd24/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f1e9bac6a6c1ba65e0ac50e32c575266734a07b6c17e718c4babd91e2faa69b", size = 155563, upload-time = "2026-01-31T23:25:39.17Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8c/4f54f7ea5addf208be44459393185aaa193bd2d0b8ecf4683b159fcc5238/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12687e6271df7ae5706bee44cc1f77fecb7805976ec9f14f58381b30ae2aceb5", size = 150189, upload-time = "2026-01-31T23:25:44.654Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cc/e8290a1cd94297fbc1e9fbad06481b5a7c918f2db6645c550f05ee47f359/wrapt-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:38bbe336ee32f67eb99f886bd4f040d91310b7e660061bb03b9083d26e8cf915", size = 60431, upload-time = "2026-01-31T23:25:48.34Z" }, - { url = "https://files.pythonhosted.org/packages/d0/df/af5d244938853e3adb1251ca1397e9fa78d3e92adc808a0af0a8547585d3/wrapt-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0fa64a9a07df7f85b352adc42b43e7f44085fb11191b8f5b9b77219f7aaf7e17", size = 63859, upload-time = "2026-01-31T23:26:23.2Z" }, - { url = "https://files.pythonhosted.org/packages/39/c4/28b6f2804e8bc05d17114dfed03a80bce5b83ca2113fd44eecbef12275d1/wrapt-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:da379cbdf3b7d97ace33a69a391b7a7e2130b1aca94dc447246217994233974c", size = 60446, upload-time = "2026-01-31T23:25:41.001Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/70983b75d4abd6f85cffc6df79c623220ec5a579ceaacabac35c904b7b52/wrapt-2.1.0-py3-none-any.whl", hash = "sha256:e035693a0d25ea5bf5826df3e203dff7d091b0d5442aaefec9ca8f2bab38417f", size = 43886, upload-time = "2026-01-31T23:25:07.22Z" }, +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, ] [[package]] @@ -6308,224 +6774,248 @@ wheels = [ [[package]] name = "xxhash" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, - { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, - { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, - { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, - { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, - { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, - { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, - { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, - { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, - { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, - { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, - { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, - { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, - { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, - { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, - { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, - { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/5a/05eaa129555f85476a3e16ff869e95f81a78bbe4647eef9d0229f515a317/xxhash-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602efcad4a42c184e81d43a2b7e6e4f524d619878f2b6ee2ba469011f47c8147", size = 34699, upload-time = "2026-07-06T10:44:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/59/0df1133958b2228929355e022aab1e958c7b2c43e27bf7f59bc9edfa8a54/xxhash-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:131324f719957b988861714de7d6ddf57b47abec3b0cc691302ffeaba0e05e10", size = 32373, upload-time = "2026-07-06T10:44:11.353Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bf/1cfda5b5e6bf26617812b4a31662ef2220d2ad04e0a55b8ff9eb36e56a5c/xxhash-3.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db77278a6eddadbf44ce5aae2fee5ebb4d061f026b1ce2130d058cd4d7a7b670", size = 220284, upload-time = "2026-07-06T10:44:12.683Z" }, + { url = "https://files.pythonhosted.org/packages/70/93/45dc0ad7913b69e5b08bd039236cf628380e4c9cc76a8a4c6625a328e058/xxhash-3.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c332dd48b8cb050da2bb2a3c96d72b1664168650a250ef9718e423df7989e05", size = 240980, upload-time = "2026-07-06T10:44:14.297Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/f28ba7d17f2c1410ee397982c817ab1bd5b2701070c2d2c373539aad000a/xxhash-3.8.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5cd96f6dcdf4fa657b2d95668d71d58455248f98712ecffaa9c528edf40ccae", size = 264526, upload-time = "2026-07-06T10:44:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/f10651cec2c7981b20d693deae6bdfc438427d92be2db4ccabb6181f0021/xxhash-3.8.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c959f88160b13b4e730b0d75b459b7929fc0d2225c284c9683ac95d6feeeac6a", size = 241369, upload-time = "2026-07-06T10:44:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/136e0cbaf5db51e191423b1c98643593189f02b6cd90837bf64b19113d70/xxhash-3.8.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:027dee4355f3fcc41481650d846cf6cfc895c85a1ab7acd063063821a0df5b4c", size = 473186, upload-time = "2026-07-06T10:44:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3f/6aa808a96bdc43dba9a740dec56c744526ee3c0019e32c75e810fa90ae4d/xxhash-3.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad52a0e4bcc0ba956a953a169d1feec2734a64981d689e4fc8f490f7bf91af60", size = 220092, upload-time = "2026-07-06T10:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/a8675e78a9ced96dab853416162268e10e05b452e95db7888cf69f58ac5f/xxhash-3.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d3dfb1f0ff146da7952867a9414f0c7a29762f8825a84879592612fd6139342", size = 309846, upload-time = "2026-07-06T10:44:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/7fe4d4ef4e69f0033e012396ee2a115886bca7b10b7e45ce398626436bfc/xxhash-3.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4482380b462ca9e59994d072a877ecadd1cf51102daeeab2db696f96ab763723", size = 237659, upload-time = "2026-07-06T10:44:24.135Z" }, + { url = "https://files.pythonhosted.org/packages/38/8f/83e9e31d4ed57fe963b99cb5b13a23e3e0f0dad1885aa0ebd2a7819dd423/xxhash-3.8.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:950ac754d16daea42038f38e7465eb84cda4d08d7343c1c915771b29470f065a", size = 268737, upload-time = "2026-07-06T10:44:25.875Z" }, + { url = "https://files.pythonhosted.org/packages/57/79/7e7de46dbe5d1f49afc96a0bc42e6b8df24eae3d6bad6007b99e42f48430/xxhash-3.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0418ec8b2331b9d4d575fc9284427e8e69449d7172e99e1a86fcdd1f51a0a937", size = 224955, upload-time = "2026-07-06T10:44:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/ec/34/b8540839e958d5ef5c6101af6f16032109e7099698ae8edbc8dcefe4d8f4/xxhash-3.8.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:32a94ad2763e0263d9102037d349002c3d3c401e42770542c3eeb4801f311661", size = 239653, upload-time = "2026-07-06T10:44:29.422Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/a735d05f7f859354acadabe470ff40e2c46672275f96dcf096a761904def/xxhash-3.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:89b11a5cdd441aa463f6d34ca0241602bc09b001a76994b6059828494108c673", size = 300213, upload-time = "2026-07-06T10:44:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/3e1cb020237b68117fc212dc5f9753b87f865b4dfee7c1ce62d0836955b5/xxhash-3.8.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:09a204dd4bb0823daf938cdd0dc8057d5f1e14fe3cbde929424255f23f9de872", size = 442508, upload-time = "2026-07-06T10:44:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/f80090622141cc734b039ce1d15ce3ff6dced375e9680249bf5b9b8c6bf9/xxhash-3.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e710ad822c493fb80a4fbc1e3d0a807b1422cb90adbe64378f98291b7fa48fef", size = 216853, upload-time = "2026-07-06T10:44:34.983Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/60157acecc307b238d3651c2483168e224b48b23a36ae6d6903588341d80/xxhash-3.8.1-cp311-cp311-win32.whl", hash = "sha256:5013be3bea7612852c62a7437f3302c1cfb91ca7e703b194459db0b2b2e0d792", size = 31936, upload-time = "2026-07-06T10:44:36.542Z" }, + { url = "https://files.pythonhosted.org/packages/59/5c/ef70c418d878d187b8da56d4cdc06aea6cf5e456b301e96e51e1d2cc8625/xxhash-3.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:f377012b86c0a23a1df0cf5a1b05aa7187649e472f71c7892e5f2c2815bbe74f", size = 32724, upload-time = "2026-07-06T10:44:38.177Z" }, + { url = "https://files.pythonhosted.org/packages/2c/25/f008db952cec6b2a26445b456eeed2ebebd65e08e848ebe09ed6ac0634e6/xxhash-3.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:836f11d4474d3228e9909d97216faa4f7505df41cfaf3927eb29809de785a78d", size = 29212, upload-time = "2026-07-06T10:44:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/4d8040435aeac814fc69ba63621565fbeb19229a138e2568324a26b2a45c/xxhash-3.8.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:39c9d5b61508b0bb68f29e54546de0ed2a74943c6a18585535a7e37356f1dd12", size = 32687, upload-time = "2026-07-06T10:49:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/6a/975f1f2318c760e5bcec109ed379713ae645d8d856c2a3b9ec5d26857087/xxhash-3.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:83b9130b80b216d56fdf9e87131946b353c9627930c061955a101ea82b09fed9", size = 29879, upload-time = "2026-07-06T10:49:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/08/0b/40a2a55ff52cf635bfdc5eae67a772bec85b4f44c6c737f73f6f528d51d1/xxhash-3.8.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8304be0982130954b7fd3aad18e2c6f8ee40254bc3d2e635991c16d77c91e2bd", size = 43246, upload-time = "2026-07-06T10:49:47.905Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/56ed2b6b200f26fb474f3fd387d95d0601efcd5bb33430c90c68924bdd77/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b512261801b1e5fde7b6ebf2fef7977339c620cbbca88a0040ad9ad134f4d02", size = 38202, upload-time = "2026-07-06T10:49:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/56864d895d1161a9f17502088e9c1fb7c06bde2c2efdde620d22bb7a9c43/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49aa8692507835dcc1e8ad8021f20c74c2dc13d83b5112e87877faa2a0035b20", size = 34448, upload-time = "2026-07-06T10:49:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/6b/57/5c6e0908a47f61dca96d01c8ee6fce01ed1050611eb779083ba8758fed81/xxhash-3.8.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:345b07b78e2bf583d71682aa34ae5b5fab575f7a1cb31e10263ebbc6f89f8c42", size = 32869, upload-time = "2026-07-06T10:49:55.972Z" }, ] [[package]] name = "yarl" -version = "1.22.0" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json index 3c3903d4b..7743c1523 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "highlight.js": "^11.11.1", "i18next": "^25.1.2", "i18next-browser-languagedetector": "^8.1.0", @@ -66,6 +67,7 @@ "lucide-react": "^0.507.0", "postcss": "^8.5.10", "qrcode": "^1.5.4", + "radix-ui": "^1.6.0", "react": "19.2.1", "react-dom": "19.2.1", "react-hook-form": "^7.56.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ab0954fe5..165f3ce70 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -105,6 +105,9 @@ dependencies: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -129,6 +132,9 @@ dependencies: qrcode: specifier: ^1.5.4 version: 1.5.4 + radix-ui: + specifier: ^1.6.0 + version: 1.6.0(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) react: specifier: 19.2.1 version: 19.2.1 @@ -546,10 +552,66 @@ packages: resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} dev: false + /@radix-ui/number@1.1.2: + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + dev: false + /@radix-ui/primitive@1.1.3: resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} dev: false + /@radix-ui/primitive@1.1.4: + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + dev: false + + /@radix-ui/react-accessible-icon@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-accordion@1.2.14(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} peerDependencies: @@ -575,6 +637,50 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -595,6 +701,26 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-aspect-ratio@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} peerDependencies: @@ -619,8 +745,1031 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + /@radix-ui/react-avatar@1.2.0(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-collapsible@1.1.14(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-context-menu@2.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-context@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-context@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-direction@1.1.1(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-direction@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-form@0.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-hover-card@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-id@1.1.1(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-id@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-menu@2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-menubar@1.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-navigation-menu@1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-one-time-password-field@0.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-password-toggle-field@0.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-popover@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + aria-hidden: 1.2.6 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + dev: false + + /@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/rect': 1.1.1 + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/rect': 1.1.2 + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -632,22 +1781,16 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + /@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -659,13 +1802,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -673,8 +1810,8 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + /@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -687,30 +1824,35 @@ packages: optional: true dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + /@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true + '@types/react-dom': + optional: true dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -722,46 +1864,55 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-context@1.1.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + /@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} peerDependencies: '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true + '@types/react-dom': + optional: true dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + /@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} peerDependencies: '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true + '@types/react-dom': + optional: true dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + /@radix-ui/react-progress@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -773,41 +1924,37 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) dev: false - /@radix-ui/react-direction@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + /@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} peerDependencies: '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true + '@types/react-dom': + optional: true dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + /@radix-ui/react-radio-group@1.4.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -819,19 +1966,24 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -844,11 +1996,13 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -856,21 +2010,8 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + /@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -882,17 +2023,23 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + /@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -904,37 +2051,23 @@ packages: '@types/react-dom': optional: true dependencies: + '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-id@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + /@radix-ui/react-scroll-area@1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -946,15 +2079,23 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + /@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -966,6 +2107,7 @@ packages: '@types/react-dom': optional: true dependencies: + '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) @@ -977,11 +2119,13 @@ packages: '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) aria-hidden: 1.2.6 @@ -990,8 +2134,8 @@ packages: react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) dev: false - /@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + /@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1003,19 +2147,26 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) aria-hidden: 1.2.6 @@ -1024,8 +2175,8 @@ packages: react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) dev: false - /@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + /@radix-ui/react-separator@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1037,24 +2188,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/rect': 1.1.1 + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + /@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1066,16 +2208,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + /@radix-ui/react-slider@1.4.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1087,36 +2228,67 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + /@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: '@types/react': '*' - '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true - '@types/react-dom': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-slot@1.2.4(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': optional: true dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + /@radix-ui/react-slot@1.3.0(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + + /@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1128,15 +2300,21 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + /@radix-ui/react-switch@1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1148,16 +2326,21 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + /@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1170,13 +2353,12 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -1184,8 +2366,8 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + /@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1197,23 +2379,22 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + /@radix-ui/react-toast@1.2.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1225,35 +2406,26 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) dev: false - /@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + /@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1265,43 +2437,47 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + /@radix-ui/react-toggle-group@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==} peerDependencies: '@types/react': '*' + '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-slot@1.2.4(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': + '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + /@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1314,20 +2490,16 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + /@radix-ui/react-toggle@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1339,22 +2511,17 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + /@radix-ui/react-toolbar@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1366,21 +2533,21 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + /@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1392,9 +2559,18 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) react: 19.2.1 @@ -1445,6 +2621,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -1460,6 +2649,21 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -1474,6 +2678,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -1488,6 +2706,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} peerDependencies: @@ -1502,6 +2734,19 @@ packages: use-sync-external-store: 1.6.0(react@19.2.1) dev: false + /@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1515,6 +2760,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -1528,6 +2786,19 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-previous@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -1542,6 +2813,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-rect@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/rect': 1.1.2 + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-use-size@1.1.1(@types/react@19.2.10)(react@19.2.1): resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: @@ -1556,6 +2841,20 @@ packages: react: 19.2.1 dev: false + /@radix-ui/react-use-size@1.1.2(@types/react@19.2.10)(react@19.2.1): + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@types/react': 19.2.10 + react: 19.2.1 + dev: false + /@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: @@ -1576,10 +2875,34 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false + /@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /@radix-ui/rect@1.1.1: resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} dev: false + /@radix-ui/rect@1.1.2: + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + dev: false + /@rolldown/binding-android-arm64@1.0.3: resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2503,6 +3826,23 @@ packages: engines: {node: '>=6'} dev: false + /cmdk@1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: false + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5057,6 +6397,80 @@ packages: yargs: 15.4.1 dev: false + /radix-ui@1.6.0(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-accessible-icon': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-accordion': 1.2.14(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-alert-dialog': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-aspect-ratio': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-avatar': 1.2.0(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-checkbox': 1.3.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-context-menu': 2.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-dropdown-menu': 2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-form': 0.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-hover-card': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-menubar': 1.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-navigation-menu': 1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-one-time-password-field': 0.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-password-toggle-field': 0.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popover': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-progress': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-radio-group': 1.4.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-scroll-area': 1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-select': 2.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slider': 1.4.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-switch': 1.3.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-tabs': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toast': 1.2.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toolbar': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-tooltip': 1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.10)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + /react-dom@19.2.1(react@19.2.1): resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} peerDependencies: diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx new file mode 100644 index 000000000..5988f4066 --- /dev/null +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Agent } from '@/app/infra/entities/api'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; +import AgentCreateContent from './components/AgentCreateContent'; +import AgentFormComponent from './components/AgentFormComponent'; + +export default function AgentDetailContent({ id }: { id: string }) { + const isCreateMode = id === 'new'; + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData(); + const [agent, setAgent] = useState(null); + const [loading, setLoading] = useState(!isCreateMode); + const [formDirty, setFormDirty] = useState(false); + const [formSaving, setFormSaving] = useState(false); + + useEffect(() => { + if (isCreateMode) { + setDetailEntityName(t('agents.create')); + return () => setDetailEntityName(null); + } + + const sidebarItem = pipelines.find((p) => p.id === id); + setDetailEntityName(sidebarItem?.name ?? id); + return () => setDetailEntityName(null); + }, [id, isCreateMode, pipelines, setDetailEntityName, t]); + + useEffect(() => { + if (isCreateMode) return; + let cancelled = false; + setLoading(true); + httpClient + .getAgent(id) + .then((resp) => { + if (!cancelled) setAgent(resp.agent); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [id, isCreateMode]); + + if (isCreateMode) { + return ( + { + refreshPipelines(); + navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`); + }} + /> + ); + } + + if (loading || !agent) { + return ( +
+ {t('common.loading')} +
+ ); + } + + if (agent.kind === 'pipeline') { + return ; + } + + return ( +
+
+

{t('agents.editAgent')}

+ +
+ +
+ { + refreshPipelines(); + }} + onDeleted={() => { + refreshPipelines(); + navigate('/home/agents'); + }} + onDirtyChange={setFormDirty} + onSavingChange={setFormSaving} + /> +
+
+ ); +} diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx new file mode 100644 index 000000000..654aaa041 --- /dev/null +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import type React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Bot, Workflow } from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { AgentKind } from '@/app/infra/entities/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import EmojiPicker from '@/components/ui/emoji-picker'; + +export default function AgentCreateContent({ + onCreated, +}: { + onCreated: (agentId: string) => void; +}) { + const { t } = useTranslation(); + const [kind, setKind] = useState('agent'); + const formSchema = z.object({ + name: z.string().min(1, { message: t('agents.nameRequired') }), + description: z.string().optional(), + emoji: z.string().optional(), + }); + type FormValues = z.infer; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + description: '', + emoji: '🤖', + }, + }); + + function handleKindChange(nextKind: AgentKind) { + setKind(nextKind); + if (!form.getValues('emoji')) { + form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖'); + } + } + + function handleSubmit(values: FormValues) { + httpClient + .createAgent({ + kind, + name: values.name, + description: values.description ?? '', + emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'), + }) + .then((resp) => { + toast.success(t('agents.createSuccess')); + onCreated(resp.uuid); + }) + .catch((err) => { + toast.error(t('agents.createError') + err.msg); + }); + } + + const typeOptions: Array<{ + kind: AgentKind; + icon: React.ElementType; + title: string; + description: string; + badge: string; + }> = [ + { + kind: 'agent', + icon: Bot, + title: t('agents.agentType'), + description: t('agents.agentTypeDescription'), + badge: t('agents.allEvents'), + }, + { + kind: 'pipeline', + icon: Workflow, + title: t('agents.pipelineType'), + description: t('agents.pipelineTypeDescription'), + badge: t('agents.messageEventsOnly'), + }, + ]; + + return ( +
+
+

{t('agents.create')}

+ +
+ +
+
+
+ {typeOptions.map((option) => { + const Icon = option.icon; + const selected = kind === option.kind; + return ( + + ); + })} +
+ + + + {t('agents.basicInfo')} + + {t('agents.basicInfoDescription')} + + + +
+ +
+ ( + + + {t('common.name')} + * + + + + + + + )} + /> + ( + + {t('common.icon')} + + + + + + )} + /> +
+ + ( + + {t('common.description')} + + + + + + )} + /> + + +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx new file mode 100644 index 000000000..c17c6e28c --- /dev/null +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -0,0 +1,680 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type React from 'react'; +import { Link } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + Brain, + CircleAlert, + CircleCheck, + FileJson2, + Info, + LoaderCircle, + Power, + RefreshCw, + Trash2, + Unplug, +} from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; +import { + PipelineConfigStage, + PipelineConfigTab, +} from '@/app/infra/entities/pipeline'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import EmojiPicker from '@/components/ui/emoji-picker'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +interface AgentFormComponentProps { + agentId: string; + onFinish: () => void; + onDeleted: () => void; + onDirtyChange?: (dirty: boolean) => void; + onSavingChange?: (saving: boolean) => void; +} + +interface SectionItem { + label: string; + name: 'basic' | 'runner' | 'events'; + icon: React.ElementType; +} + +export default function AgentFormComponent({ + agentId, + onFinish, + onDeleted, + onDirtyChange, + onSavingChange, +}: AgentFormComponentProps) { + const { t } = useTranslation(); + const [activeSection, setActiveSection] = + useState('basic'); + const [runnerConfigSchema, setRunnerConfigSchema] = + useState(null); + const [pluginSystemStatus, setPluginSystemStatus] = + useState(null); + const [pluginStatusLoading, setPluginStatusLoading] = useState(true); + const [pluginStatusError, setPluginStatusError] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const isSavingRef = useRef(false); + + const formSchema = z.object({ + basic: z.object({ + name: z.string().min(1, { message: t('agents.nameRequired') }), + description: z.string().optional(), + emoji: z.string().optional(), + enabled: z.boolean().optional(), + }), + runner: z.record(z.string(), z.any()), + runner_config: z.record(z.string(), z.any()), + supported_event_patterns_text: z.string(), + }); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + basic: { + name: '', + description: '', + emoji: '🤖', + enabled: true, + }, + runner: {}, + runner_config: {}, + supported_event_patterns_text: '*', + }, + }); + + const savedSnapshotRef = useRef(''); + const initializedStagesRef = useRef>(new Set()); + const watchedValues = form.watch(); + const hasUnsavedChanges = (() => { + if (!savedSnapshotRef.current) return false; + return JSON.stringify(watchedValues) !== savedSnapshotRef.current; + })(); + + useEffect(() => { + onDirtyChange?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onDirtyChange]); + + useEffect(() => { + let cancelled = false; + Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) + .then(([metadata, resp]) => { + if (cancelled) return; + setRunnerConfigSchema(metadata.runner_config ?? null); + const agent = resp.agent; + const config = (agent.config ?? {}) as Record; + const loadedValues: FormValues = { + basic: { + name: agent.name ?? '', + description: agent.description ?? '', + emoji: agent.emoji || '🤖', + enabled: agent.enabled ?? true, + }, + runner: (config.runner as Record) ?? {}, + runner_config: + (config.runner_config as Record) ?? {}, + supported_event_patterns_text: ( + agent.supported_event_patterns ?? + agent.capability?.supported_event_patterns ?? ['*'] + ).join('\n'), + }; + form.reset(loadedValues); + savedSnapshotRef.current = JSON.stringify(loadedValues); + initializedStagesRef.current.clear(); + }) + .catch((err) => { + toast.error(t('agents.loadError') + err.msg); + }); + return () => { + cancelled = true; + }; + }, [agentId, form, t]); + + const loadPluginSystemStatus = useCallback(async () => { + setPluginStatusLoading(true); + setPluginStatusError(false); + try { + setPluginSystemStatus(await httpClient.getPluginSystemStatus()); + } catch { + setPluginSystemStatus(null); + setPluginStatusError(true); + } finally { + setPluginStatusLoading(false); + } + }, []); + + useEffect(() => { + void loadPluginSystemStatus(); + }, [loadPluginSystemStatus]); + + const sections: SectionItem[] = [ + { label: t('agents.basicInfo'), name: 'basic', icon: Info }, + { label: t('agents.runnerSettings'), name: 'runner', icon: Brain }, + { label: t('agents.advanced'), name: 'events', icon: FileJson2 }, + ]; + + const currentRunner = (form.watch('runner') as Record)?.id; + const runnerOptions = useMemo(() => { + const runnerStage = runnerConfigSchema?.stages.find( + (stage) => stage.name === 'runner', + ); + return ( + runnerStage?.config.find((item) => item.name === 'id')?.options ?? [] + ); + }, [runnerConfigSchema]); + const selectedRunnerOption = runnerOptions.find( + (option) => option.name === currentRunner, + ); + + function renderRunnerStatusActions(showRetry = true) { + return ( +
+ {showRetry && ( + + )} + +
+ ); + } + + function renderRunnerStatus() { + if (pluginStatusLoading) { + return ( + + + {t('agents.runnerStatusLoading')} + + ); + } + + if (pluginStatusError || !pluginSystemStatus) { + return ( + + + {t('agents.runnerStatusCheckFailed')} + + {t('agents.runnerStatusCheckFailedDescription')} + {renderRunnerStatusActions()} + + + ); + } + + if (!pluginSystemStatus.is_enable) { + return ( + + + {t('plugins.systemDisabled')} + + {t('plugins.systemDisabledDesc')} + {renderRunnerStatusActions(false)} + + + ); + } + + if (!pluginSystemStatus.is_connected) { + return ( + + + {t('plugins.connectionError')} + + {t('plugins.connectionErrorDesc')} + {renderRunnerStatusActions()} + + + ); + } + + if (runnerOptions.length === 0) { + return ( + + + {t('agents.noRunnersAvailable')} + + {t('agents.noRunnersAvailableDescription')} + {renderRunnerStatusActions()} + + + ); + } + + if (!currentRunner || !selectedRunnerOption) { + return ( + + + {t('agents.selectedRunnerUnavailable')} + + {t('agents.selectedRunnerUnavailableDescription', { + runner: currentRunner || t('agents.noRunnerSelected'), + })} + {renderRunnerStatusActions()} + + + ); + } + + return ( + + + {t('agents.runnerReady')} + + {t('agents.runnerReadyDescription', { + runner: extractI18nObject(selectedRunnerOption.label), + })} + + + ); + } + + function updateSnapshotIfInitial(stageKey: string) { + if (!initializedStagesRef.current.has(stageKey)) { + initializedStagesRef.current.add(stageKey); + if (!hasUnsavedChanges) { + savedSnapshotRef.current = JSON.stringify(form.getValues()); + } + } + } + + function handleDynamicFormEmit( + formName: 'runner' | 'runner_config', + stageName: string, + values: object, + ) { + if (formName === 'runner') { + form.setValue('runner', values, { shouldDirty: true }); + updateSnapshotIfInitial(`runner.${stageName}`); + return; + } + + const currentRunnerConfigs = + (form.getValues('runner_config') as Record) || {}; + form.setValue( + 'runner_config', + { + ...currentRunnerConfigs, + [stageName]: values, + }, + { shouldDirty: true }, + ); + updateSnapshotIfInitial(`runner_config.${stageName}`); + } + + function renderDynamicStage(stage: PipelineConfigStage) { + const isRunnerSelector = stage.name === 'runner'; + if (!isRunnerSelector && stage.name !== currentRunner) return null; + + const initialValues = isRunnerSelector + ? (form.watch('runner') as Record) || {} + : ((form.watch('runner_config') as Record) || {})[ + stage.name + ] || {}; + + return ( + + + {extractI18nObject(stage.label)} + {stage.description && ( + + {extractI18nObject(stage.description)} + + )} + + + + handleDynamicFormEmit( + isRunnerSelector ? 'runner' : 'runner_config', + stage.name, + values, + ) + } + /> + + + ); + } + + function normalizeEventPatterns(value: string): string[] { + const patterns = value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + return patterns.length > 0 ? patterns : ['*']; + } + + function handleSubmit(values: FormValues) { + if (isSavingRef.current) return; + const submittedSnapshot = JSON.stringify(values); + const runner = values.runner || {}; + const agent: Partial = { + name: values.basic.name, + description: values.basic.description ?? '', + emoji: values.basic.emoji, + enabled: values.basic.enabled ?? true, + component_ref: (runner.id as string) || null, + supported_event_patterns: normalizeEventPatterns( + values.supported_event_patterns_text, + ), + config: { + runner, + runner_config: values.runner_config ?? {}, + }, + }; + + isSavingRef.current = true; + setIsSaving(true); + onSavingChange?.(true); + httpClient + .updateAgent(agentId, agent) + .then(() => { + savedSnapshotRef.current = submittedSnapshot; + onFinish(); + toast.success(t('agents.saveSuccess')); + }) + .catch((err) => { + toast.error(t('agents.saveError') + err.msg); + }) + .finally(() => { + isSavingRef.current = false; + setIsSaving(false); + onSavingChange?.(false); + }); + } + + function confirmDelete() { + httpClient + .deleteAgent(agentId) + .then(() => { + toast.success(t('agents.deleteSuccess')); + setShowDeleteConfirm(false); + onDeleted(); + }) + .catch((err) => { + toast.error(t('agents.deleteError') + err.msg); + }); + } + + return ( + <> +
+
+ +
+ + +
+ {activeSection === 'basic' && ( +
+ + + {t('agents.basicInfo')} + + {t('agents.basicInfoDescription')} + + + +
+ ( + + + {t('common.name')} + * + + + + + + + )} + /> + ( + + {t('common.icon')} + + + + + + )} + /> +
+ + ( + + {t('common.description')} + + + + + + )} + /> + + ( + +
+ + + {t('agents.enabled')} + + + {t('agents.enabledDescription')} + +
+ + + +
+ )} + /> +
+
+ + + + + {t('agents.dangerZone')} + + + {t('agents.dangerZoneDescription')} + + + +
+
+

+ {t('agents.deleteAgentAction')} +

+

+ {t('agents.deleteAgentHint')} +

+
+ +
+
+
+
+ )} + + {activeSection === 'runner' && ( +
+ {renderRunnerStatus()} + {runnerConfigSchema?.stages.map((stage) => + renderDynamicStage(stage), + )} + {!runnerConfigSchema && ( + + + {t('agents.runnerSettings')} + + {t('agents.noRunnerMetadata')} + + + + )} +
+ )} + + {activeSection === 'events' && ( + + + {t('agents.bindableEvents')} + + {t('agents.bindableEventsDescription')} + + + + ( + + {t('agents.supportedEvents')} + +