diff --git a/docs/en/configuration.md b/docs/en/configuration.md index df811cef..88e6f737 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -18,11 +18,17 @@ Configuration is merged in this order, with later values winning: ```bash reme start reme start config=demo +reme start config=cookbook reme start config=/absolute/path/to/app.yaml reme start service.port=8181 workspace_dir=/data/reme ``` `config` accepts a built-in name or a `.yaml`, `.yml`, or `.json` file. Overrides are deep-merged, so changing `service.port` preserves sibling service settings. +The optional `cookbook` variant extends `default` and composes the separately installed Auto Fin, Daily Paper, and +DingTalk plugins. It requires the three DingTalk application credential environment variables before configuration +loading. It also enables `text-embedding-v4` vector retrieval, uses AgentScope with +`${LLM_MODEL_NAME:-qwen3.8-max}` by default, and runs the DingTalk bridge through Claude Code with the same +`LLM_MODEL_NAME` and `LLM_API_KEY`. ## CLI values diff --git a/docs/en/framework.md b/docs/en/framework.md index a497b403..96e7ea21 100644 --- a/docs/en/framework.md +++ b/docs/en/framework.md @@ -58,6 +58,7 @@ reme/ plugin.py # installed plugin contract and entry-point loader config/ default.yaml # default service / jobs / components + cookbook.yaml # Auto Fin + Daily Paper + DingTalk composition config_parser.py # config=, dot notation, and env placeholder parsing components/ component_registry.py # backend registry and application-local copies @@ -77,12 +78,12 @@ reme/ base_step.py # BaseStep, Ref, dispatch_steps common/ # version, help, health_check, status, chat benchmark/ # LongMemEval / BEAM evaluation steps - cookbook/ # built-in cookbook support steps file_io/ # read/write/edit/delete/move/frontmatter/daily index/ # watch/init/update/search/traverse evolve/ # auto_memory, auto_resource, auto_dream, proactive transfer/ # upload/download plugins/ + dingtalk/ # independent DingTalk integration plugin distribution auto-fin/ # independent example plugin distribution daily_paper/ # independent paper-research plugin distribution integrations/ @@ -242,22 +243,27 @@ duplicate `(component_type, backend)` providers fail during assembly instead of The legacy Python `Plugin` descriptor and `reme.configs` entry points remain accepted during migration. Configuration files can use `extends` to inherit another built-in, legacy plugin, or file-based configuration. See the independently -packaged [Auto Fin](../../plugins/auto-fin/README.md) and [Daily Paper](../../plugins/daily_paper/README.md) plugins. +packaged [DingTalk](../../plugins/dingtalk/README.md), [Auto Fin](../../plugins/auto-fin/README.md), and +[Daily Paper](../../plugins/daily_paper/README.md) plugins. Plugin packages are managed locally and remain separate from per-application activation: ```bash reme plugins list +reme plugins install reme-dingtalk reme plugins install reme-auto-fin reme plugins install reme-daily-paper reme plugins show daily-paper reme plugins validate daily-paper reme plugins uninstall daily-paper -reme start plugins='["auto-fin","daily-paper"]' +reme start config=cookbook ``` These management commands use the current Python interpreter's pip and never run through an HTTP or MCP service. +The built-in `cookbook` configuration composes the three plugins, adds DingTalk delivery to the two report pipelines, +and starts the DingTalk Agent bridge as a background Job. Enabling Auto Fin or Daily Paper alone keeps it independent +from DingTalk. ### 4.3 Component.bind diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 4945d88b..58b7920e 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -18,11 +18,15 @@ ReMe 使用 YAML 或 JSON 描述 Service、Job 和 Component。默认配置位 ```bash reme start reme start config=demo +reme start config=cookbook reme start config=/absolute/path/to/app.yaml reme start service.port=8181 workspace_dir=/data/reme ``` `config` 支持内置配置名以及 `.yaml`、`.yml`、`.json` 文件。覆盖采用深度合并,不会因为修改 `service.port` 而丢失 `service` 下的其他字段。 +可选的 `cookbook` 变体继承 `default`,并组合独立安装的 Auto Fin、Daily Paper 与钉钉插件;配置加载前必须提供三个 +钉钉应用凭据环境变量。该配置还会启用 `text-embedding-v4` 向量检索,默认通过 AgentScope 使用 +`${LLM_MODEL_NAME:-qwen3.8-max}`,并让钉钉桥接通过 Claude Code 复用 `LLM_MODEL_NAME` 与 `LLM_API_KEY`。 ## 值的解析 diff --git a/docs/zh/framework.md b/docs/zh/framework.md index d5cf6e27..bb091610 100644 --- a/docs/zh/framework.md +++ b/docs/zh/framework.md @@ -53,6 +53,7 @@ reme/ plugin.py # 已安装插件契约与 entry-point loader config/ default.yaml # 默认 service / jobs / components + cookbook.yaml # Auto Fin + Daily Paper + 钉钉组合配置 config_parser.py # config=、dot notation、env 占位符解析 components/ component_registry.py # backend 注册表及 Application 局部副本 @@ -72,12 +73,12 @@ reme/ base_step.py # BaseStep、Ref、dispatch_steps common/ # version、help、health_check、status、chat benchmark/ # LongMemEval / BEAM 评测步骤 - cookbook/ # 内置 cookbook 支持步骤 file_io/ # read/write/edit/delete/move/frontmatter/daily index/ # watch/init/update/search/traverse evolve/ # auto_memory、auto_resource、auto_dream、proactive transfer/ # upload/download plugins/ + dingtalk/ # 独立发布的钉钉集成插件 auto-fin/ # 独立发布的示例插件 daily_paper/ # 独立发布的论文研究插件 integrations/ @@ -230,23 +231,27 @@ entry-point 名称就是插件标识;使用 不会互相覆盖。 迁移期间仍兼容旧的 Python `Plugin` descriptor 和 `reme.configs` entry point。配置的 `extends` 可以继承内置配置、 -旧插件配置或文件配置。独立打包示例见 [Auto Fin](../../plugins/auto-fin/README_ZH.md) 与 +旧插件配置或文件配置。独立打包示例见[钉钉](../../plugins/dingtalk/README_ZH.md)、 +[Auto Fin](../../plugins/auto-fin/README_ZH.md) 与 [每日论文](../../plugins/daily_paper/README_ZH.md) 插件。 插件包的本地管理与单个应用是否启用插件相互独立: ```bash reme plugins list +reme plugins install reme-dingtalk reme plugins install reme-auto-fin reme plugins install reme-daily-paper reme plugins show daily-paper reme plugins validate daily-paper reme plugins uninstall daily-paper -reme start plugins='["auto-fin","daily-paper"]' +reme start config=cookbook ``` 这些管理命令使用当前 Python 解释器对应的 pip,不通过 HTTP 或 MCP service 执行。 +内置 `cookbook` 配置负责组合三个插件:为两条报告 pipeline 增加钉钉发送,并以 background Job 启动钉钉 Agent bridge。 +单独启用 Auto Fin 或 Daily Paper 时,它们仍与钉钉完全独立。 ### 4.3 Component.bind diff --git a/plugins/dingtalk/LICENSE b/plugins/dingtalk/LICENSE new file mode 100644 index 00000000..65c2c5cf --- /dev/null +++ b/plugins/dingtalk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Alibaba Group + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/dingtalk/README.md b/plugins/dingtalk/README.md new file mode 100644 index 00000000..119651c7 --- /dev/null +++ b/plugins/dingtalk/README.md @@ -0,0 +1,124 @@ +# DingTalk Plugin + +[中文](README_ZH.md) + +DingTalk provides outbound Markdown notifications and a long-running Agent chat bridge for ReMe. This directory is an +independent Python distribution. Its `reme.plugins` entry point exposes a backend-only `plugin.yaml` that registers +`dingtalk_markdown_send_step` and `dingtalk_wait_step`. Applications decide whether and how to use those backends; +enabling the plugin alone neither exposes a sending endpoint nor starts a long-lived connection. + +## Quick start + +Install ReMe and the plugin: + +```bash +python -m pip install "reme-ai[core]>=0.4.1.12" +reme plugins install reme-dingtalk +reme plugins validate dingtalk +``` + +Set the credentials for an internal DingTalk app robot and the target group conversation IDs: + +```dotenv +DINGTALK_APP_KEY=your-app-key +DINGTALK_APP_SECRET=your-app-secret +DINGTALK_ROBOT_CODE=your-robot-code +DINGTALK_CONVERSATION_IDS=cid-group-one,cid-group-two +``` + +The repository's `cookbook` config composes DingTalk with the independently installed Auto Fin and Daily Paper plugins: + +```bash +reme plugins install reme-auto-fin +reme plugins install reme-daily-paper +reme start config=cookbook +``` + +`cookbook.yaml` owns the cross-plugin routing: it adds notification Steps after automatic tagging, shares each pipeline +with its cron Job, and explicitly starts the DingTalk Agent bridge as a background Job. The business plugins remain +usable without DingTalk. + +## Markdown sending Job example + +Add a private one-shot Job to an application config when direct sending is useful: + +```yaml +extends: default +plugins: [dingtalk] + +jobs: + dingtalk_send: + backend: base + enable_serve: false + parameters: + type: object + properties: + markdown_path: + type: string + required: [markdown_path] + steps: + - backend: dingtalk_markdown_send_step + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + conversation_ids: ${DINGTALK_CONVERSATION_IDS:-} + timeout: 15 +``` + +Run it without exposing the Job over HTTP or MCP: + +```bash +reme start config=/path/to/dingtalk.yaml \ + job=dingtalk_send \ + markdown_path=daily/2026-09-15/report.md +``` + +`markdown_path` must resolve to an existing `.md` file inside the configured workspace. Frontmatter is omitted from +the message; the Markdown body is sent serially to each non-empty, comma-separated conversation ID. If no conversation +IDs are configured, delivery succeeds as a no-op. Once recipients are configured, missing credentials, invalid paths, +empty documents, token failures, and failed recipients make the Job fail explicitly. Delivery metadata reports the +configured and successfully sent counts without logging credentials or conversation IDs. + +## Agent bridge + +The bridge maps each sender and conversation to an independent Agent session, supports ReMe's session commands, and +sends the final Agent response as Markdown. Add this background Job to an application config that extends the built-in +default configuration: + +```yaml +extends: default +plugins: [dingtalk] + +jobs: + dingtalk_agent: + backend: background + supervisor: true + close_timeout: 10 + steps: + - backend: dingtalk_wait_step + agent_wrapper: default + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + worker_count: 4 + builtin_tools: false + job_tools: [search, read] +``` + +The same background Job is enabled by `reme/config/cookbook.yaml`. When used independently, start it with +`reme start config=/path/to/dingtalk.yaml`. A background Job is never exposed through HTTP or MCP. +Messages from different session keys may run concurrently, while messages sharing a key are serialized. Session IDs +live only in `ApplicationContext.metadata`, so restarting ReMe starts new DingTalk-to-Agent session mappings. The +configured `agent_wrapper` controls model access and permissions; grant only the built-in and Job tools the bot needs. + +## Development + +From the repository root: + +```bash +reme plugins install ./plugins/dingtalk --editable +reme plugins validate dingtalk +python -m pytest plugins/dingtalk -v +``` + +Tests mock DingTalk HTTP, WebSocket, and Agent boundaries and do not contact external services. diff --git a/plugins/dingtalk/README_ZH.md b/plugins/dingtalk/README_ZH.md new file mode 100644 index 00000000..0df833eb --- /dev/null +++ b/plugins/dingtalk/README_ZH.md @@ -0,0 +1,120 @@ +# 钉钉插件 + +[English](README.md) + +钉钉插件为 ReMe 提供 Markdown 主动通知和长期运行的 Agent 对话桥接。本目录是一个独立 Python distribution; +`reme.plugins` entry point 暴露只包含 backend 的 `plugin.yaml`,注册 `dingtalk_markdown_send_step` 和 +`dingtalk_wait_step`。Application 自行决定是否以及如何使用这些 backend;仅启用插件不会暴露发送 endpoint,也不会 +启动长期连接。 + +## 快速开始 + +安装 ReMe 和插件: + +```bash +python -m pip install "reme-ai[core]>=0.4.1.12" +reme plugins install reme-dingtalk +reme plugins validate dingtalk +``` + +配置企业内部应用机器人的凭据和目标群会话 ID: + +```dotenv +DINGTALK_APP_KEY=your-app-key +DINGTALK_APP_SECRET=your-app-secret +DINGTALK_ROBOT_CODE=your-robot-code +DINGTALK_CONVERSATION_IDS=cid-group-one,cid-group-two +``` + +仓库内置的 `cookbook` 配置会组合钉钉、Auto Fin 和 Daily Paper 三个独立安装的插件: + +```bash +reme plugins install reme-auto-fin +reme plugins install reme-daily-paper +reme start config=cookbook +``` + +`cookbook.yaml` 负责跨插件路由:在自动标签之后追加通知 Step,让手动 Job 与 cron Job 复用同一 pipeline,并显式以 +background Job 启动钉钉 Agent bridge。两个业务插件仍可完全脱离钉钉使用。 + +## Markdown 发送 Job 示例 + +需要直接发送时,可以在 Application 配置中添加一个私有 one-shot Job: + +```yaml +extends: default +plugins: [dingtalk] + +jobs: + dingtalk_send: + backend: base + enable_serve: false + parameters: + type: object + properties: + markdown_path: + type: string + required: [markdown_path] + steps: + - backend: dingtalk_markdown_send_step + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + conversation_ids: ${DINGTALK_CONVERSATION_IDS:-} + timeout: 15 +``` + +无需通过 HTTP 或 MCP 暴露即可运行: + +```bash +reme start config=/path/to/dingtalk.yaml \ + job=dingtalk_send \ + markdown_path=daily/2026-09-15/report.md +``` + +`markdown_path` 必须指向 workspace 内真实存在的 `.md` 文件。消息会去掉 frontmatter,再按配置顺序串行发送到每个 +非空的逗号分隔会话 ID。未配置会话 ID 时,发送会无副作用成功跳过;一旦配置了接收方,凭据缺失、路径无效、正文 +为空、token 获取失败或部分接收方发送失败都会明确令 Job 失败。响应 metadata 会记录配置数与成功数,但日志不会 +输出凭据或会话 ID。 + +## Agent 对话桥接 + +桥接会为每个“发送者 + 会话”维护独立 Agent session,支持 ReMe 的 session 命令,并以 Markdown 返回 Agent 的最终 +回复。可在继承内置默认配置的应用文件中添加以下后台 Job: + +```yaml +extends: default +plugins: [dingtalk] + +jobs: + dingtalk_agent: + backend: background + supervisor: true + close_timeout: 10 + steps: + - backend: dingtalk_wait_step + agent_wrapper: default + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + worker_count: 4 + builtin_tools: false + job_tools: [search, read] +``` + +`reme/config/cookbook.yaml` 已启用同样的后台 Job。独立使用时通过 `reme start config=/path/to/dingtalk.yaml` 启动。 +后台 Job 不会暴露为 HTTP endpoint 或 MCP tool。不同 session key +可以并发处理,同一 key 的消息保持串行。Session ID 只存放在 `ApplicationContext.metadata`,重启 ReMe 后会重新建立 +钉钉到 Agent 的 session 映射。模型访问与权限由配置的 `agent_wrapper` 决定,只应授予机器人必需的内置工具和 Job。 + +## 开发验证 + +在仓库根目录运行: + +```bash +reme plugins install ./plugins/dingtalk --editable +reme plugins validate dingtalk +python -m pytest plugins/dingtalk -v +``` + +测试会 mock 钉钉 HTTP、WebSocket 和 Agent 边界,不访问外部服务。 diff --git a/plugins/dingtalk/pyproject.toml b/plugins/dingtalk/pyproject.toml new file mode 100644 index 00000000..fd70ef8b --- /dev/null +++ b/plugins/dingtalk/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "reme-dingtalk" +version = "0.1.0" +description = "DingTalk notification and agent bridge plugin for ReMe." +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dependencies = [ + "dingtalk-stream>=0.24.3", + "reme-ai>=0.4.1.12", +] + +[project.entry-points."reme.plugins"] +dingtalk = "reme_dingtalk" + +[tool.setuptools] +package-dir = { "" = "src" } +packages = ["reme_dingtalk"] +include-package-data = true + +[tool.setuptools.package-data] +reme_dingtalk = ["*.yaml"] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" +pythonpath = ["src", "../.."] +testpaths = ["tests"] + +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/reme/steps/cookbook/dingtalk/__init__.py b/plugins/dingtalk/src/reme_dingtalk/__init__.py similarity index 75% rename from reme/steps/cookbook/dingtalk/__init__.py rename to plugins/dingtalk/src/reme_dingtalk/__init__.py index 4e6e1b1f..497a59ed 100644 --- a/reme/steps/cookbook/dingtalk/__init__.py +++ b/plugins/dingtalk/src/reme_dingtalk/__init__.py @@ -1,4 +1,4 @@ -"""DingTalk cookbook integration.""" +"""DingTalk integration plugin for ReMe.""" from .send import DingTalkMarkdownSendStep from .wait import DingTalkWaitStep diff --git a/plugins/dingtalk/src/reme_dingtalk/plugin.yaml b/plugins/dingtalk/src/reme_dingtalk/plugin.yaml new file mode 100644 index 00000000..76bff37f --- /dev/null +++ b/plugins/dingtalk/src/reme_dingtalk/plugin.yaml @@ -0,0 +1,3 @@ +backends: + dingtalk_markdown_send_step: reme_dingtalk.send:DingTalkMarkdownSendStep + dingtalk_wait_step: reme_dingtalk.wait:DingTalkWaitStep diff --git a/reme/steps/cookbook/dingtalk/send.py b/plugins/dingtalk/src/reme_dingtalk/send.py similarity index 96% rename from reme/steps/cookbook/dingtalk/send.py rename to plugins/dingtalk/src/reme_dingtalk/send.py index 77e7b78c..a88870c7 100644 --- a/reme/steps/cookbook/dingtalk/send.py +++ b/plugins/dingtalk/src/reme_dingtalk/send.py @@ -7,9 +7,8 @@ import aiofiles import frontmatter import httpx -from ....components import R -from ...base_step import BaseStep -from ...file_io._path import gate_md, resolve_path +from reme.steps.base_step import BaseStep +from reme.steps.file_io._path import gate_md, resolve_path _GROUP_SEND_URL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send" @@ -18,7 +17,6 @@ def _conversation_ids(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] -@R.register("dingtalk_markdown_send_step") class DingTalkMarkdownSendStep(BaseStep): """Send one Markdown document serially to configured DingTalk groups.""" @@ -41,6 +39,7 @@ class DingTalkMarkdownSendStep(BaseStep): self.timeout = timeout async def execute(self): + """Read the configured Markdown file and deliver its body to each group.""" assert self.context is not None recipients = self.conversation_ids self.context.response.metadata["dingtalk_configured_count"] = len(recipients) diff --git a/reme/steps/cookbook/dingtalk/wait.py b/plugins/dingtalk/src/reme_dingtalk/wait.py similarity index 97% rename from reme/steps/cookbook/dingtalk/wait.py rename to plugins/dingtalk/src/reme_dingtalk/wait.py index 37204618..ce49bc3b 100644 --- a/reme/steps/cookbook/dingtalk/wait.py +++ b/plugins/dingtalk/src/reme_dingtalk/wait.py @@ -1,4 +1,4 @@ -"""Long-running DingTalk Stream bridge for the cookbook application.""" +"""Long-running DingTalk Stream bridge for a ReMe Agent.""" import asyncio import hashlib @@ -7,13 +7,12 @@ import time from typing import Any from urllib.parse import quote_plus -from ...base_step import BaseStep -from ....components import R -from ....components.agent_wrapper import handle_session_command +from reme.components.agent_wrapper import handle_session_command +from reme.steps.base_step import BaseStep def _session_key(message: Any) -> str: - """Return the per-sender, per-conversation Claude session key.""" + """Return the per-sender, per-conversation Agent session key.""" parts = ( message.conversation_type, message.conversation_id, @@ -29,7 +28,6 @@ def _session_ref(key: str) -> str: return hashlib.sha256(key.encode("utf-8")).hexdigest()[:12] -@R.register("dingtalk_wait_step") class DingTalkWaitStep(BaseStep): """Receive DingTalk messages and send final Agent responses as Markdown.""" @@ -52,6 +50,7 @@ class DingTalkWaitStep(BaseStep): self.job_tools = list(job_tools or []) async def execute(self): + """Run the callback bridge until its background Job requests shutdown.""" assert self.context is not None if self.context.stop_event is None or self.app_context is None: raise RuntimeError("dingtalk_wait_step requires an ApplicationContext and background stop_event") diff --git a/tests/unit/test_dingtalk_wait.py b/plugins/dingtalk/tests/test_dingtalk.py similarity index 73% rename from tests/unit/test_dingtalk_wait.py rename to plugins/dingtalk/tests/test_dingtalk.py index 73a44b3c..8d7d0e3c 100644 --- a/tests/unit/test_dingtalk_wait.py +++ b/plugins/dingtalk/tests/test_dingtalk.py @@ -3,15 +3,99 @@ # pylint: disable=missing-function-docstring,protected-access import asyncio +import importlib import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock +import frontmatter +import httpx import pytest +import yaml +from reme_dingtalk import DingTalkMarkdownSendStep +from reme_dingtalk import send as dingtalk_send +from reme_dingtalk.wait import DingTalkWaitStep, _session_key from reme.components import ApplicationContext from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper -from reme.steps.cookbook.dingtalk.wait import DingTalkWaitStep, _session_key +from reme.components.runtime_context import RuntimeContext + +PLUGIN_MANIFEST = yaml.safe_load( + (Path(__file__).parents[1] / "src" / "reme_dingtalk" / "plugin.yaml").read_text(encoding="utf-8"), +) + + +def test_plugin_manifest_declares_only_dingtalk_backends(): + assert set(PLUGIN_MANIFEST) == {"backends"} + assert PLUGIN_MANIFEST["backends"] == { + "dingtalk_markdown_send_step": "reme_dingtalk.send:DingTalkMarkdownSendStep", + "dingtalk_wait_step": "reme_dingtalk.wait:DingTalkWaitStep", + } + + +@pytest.mark.asyncio +async def test_markdown_send_delivers_body_to_groups_in_order(tmp_path, monkeypatch): + report = tmp_path / "daily" / "report.md" + report.parent.mkdir() + report.write_text( + frontmatter.dumps(frontmatter.Post("# Report\n\nBody", name="Frontmatter title")), + encoding="utf-8", + ) + payloads = [] + + async def handler(request: httpx.Request) -> httpx.Response: + payloads.append(json.loads(request.content)) + return httpx.Response(200, json={"processQueryKey": f"query-{len(payloads)}"}) + + transport = httpx.MockTransport(handler) + transport_kwargs = {} + + def ipv4_transport(**kwargs): + transport_kwargs.update(kwargs) + return transport + + dingtalk_stream = importlib.import_module("dingtalk_stream") + monkeypatch.setattr( + dingtalk_stream.DingTalkStreamClient, + "get_access_token", + lambda _client: "access-token", + ) + monkeypatch.setattr(dingtalk_send.httpx, "AsyncHTTPTransport", ipv4_transport) + step = DingTalkMarkdownSendStep( + app_context=ApplicationContext(workspace_dir=str(tmp_path)), + app_key="app-key", + app_secret="app-secret", + robot_code="robot-code", + conversation_ids=" group-one,group-two ", + title="Configured title", + ) + step.logger = MagicMock() + + response = await step(RuntimeContext(markdown_path="daily/report.md")) + + assert transport_kwargs == {"local_address": "0.0.0.0"} + assert [payload["openConversationId"] for payload in payloads] == ["group-one", "group-two"] + assert [json.loads(payload["msgParam"]) for payload in payloads] == [ + {"title": "Configured title", "text": "# Report\n\nBody"}, + ] * 2 + assert response.metadata["dingtalk_configured_count"] == 2 + assert response.metadata["dingtalk_sent_count"] == 2 + logs = "\n".join(call.args[0] for call in step.logger.info.call_args_list) + assert all(value not in logs for value in ("app-key", "app-secret", "robot-code", "group-one", "group-two")) + + +@pytest.mark.asyncio +async def test_markdown_send_without_conversations_is_a_noop(tmp_path): + response = await DingTalkMarkdownSendStep( + app_context=ApplicationContext(workspace_dir=str(tmp_path)), + )(RuntimeContext(markdown_path="missing.md")) + + assert response.success is True + assert response.metadata == { + "dingtalk_configured_count": 0, + "dingtalk_sent_count": 0, + } class _AgentWrapper(BaseAgentWrapper): diff --git a/pyproject.toml b/pyproject.toml index 234e5f53..0f103fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ core = [ "reme-ai[as]", "pillow>=10.0.0", "claude-agent-sdk>=0.2.126", - "dingtalk-stream>=0.24.3", "openai-codex>=0.144.4", "pproxy>=2.7.9,<3", "faiss-cpu>=1.13.2", diff --git a/reme/config/cookbook.yaml b/reme/config/cookbook.yaml new file mode 100644 index 00000000..45edcc81 --- /dev/null +++ b/reme/config/cookbook.yaml @@ -0,0 +1,161 @@ +extends: default + +app_name: ReMe Cookbook + +# Quick start +# =========== +# +# This configuration composes three independently installable plugins. Install +# all three packages into the same Python environment as the `reme` command: +# +# reme plugins install reme-dingtalk +# reme plugins install reme-auto-fin +# reme plugins install reme-daily-paper +# +# Create a `.env` file in the directory where `reme start` will run (or export +# the variables in the shell). These DingTalk application credentials are +# required because this configuration starts the background Agent bridge: +# +# DINGTALK_APP_KEY=your-app-key +# DINGTALK_APP_SECRET=your-app-secret +# DINGTALK_ROBOT_CODE=your-robot-code +# +# Configure one or more comma-separated group conversation IDs to enable the +# Auto Fin and Daily Paper notifications. An empty value keeps report generation +# and the Agent bridge enabled but makes proactive group delivery a no-op: +# +# DINGTALK_CONVERSATION_IDS=cid-group-one,cid-group-two +# +# The default AgentScope workflows and the DingTalk Claude Code bridge share the +# same model name and API key. `LLM_BASE_URL` is used only by AgentScope; +# `CLAUDE_CODE_BASE_URL` is used only by Claude Code: +# +# LLM_API_KEY=your-llm-api-key +# LLM_MODEL_NAME=qwen3.8-max +# LLM_BACKEND=openai +# LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +# CLAUDE_CODE_BASE_URL=https://dashscope.aliyuncs.com/apps/anthropic +# +# Vector retrieval is enabled by this configuration. Set the embedding API key; +# the remaining values show the built-in defaults and only need to be exported +# when another compatible embedding service or model is required: +# +# EMBEDDING_API_KEY=your-embedding-api-key +# EMBEDDING_BACKEND=openai +# EMBEDDING_MODEL_NAME=text-embedding-v4 +# EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +# +# Start the composed application after installing the plugins and configuring +# the environment: +# +# reme start config=cookbook +# +# Auto Fin and Daily Paper remain independently usable without DingTalk when +# they are enabled outside this composed configuration. +plugins: + - auto-fin + - daily-paper + - dingtalk + +components: + as_embedding: + default: + backend: ${EMBEDDING_BACKEND:-openai} + model: ${EMBEDDING_MODEL_NAME:-text-embedding-v4} + dimensions: 1024 + credential: + api_key: ${EMBEDDING_API_KEY:-} + base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1} + parameters: {} + + embedding_store: + default: + backend: local + as_embedding: default + + as_llm: + default: + backend: ${LLM_BACKEND:-openai} + model: ${LLM_MODEL_NAME:-qwen3.8-max} + credential: + api_key: ${LLM_API_KEY:-} + base_url: ${LLM_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1} + + agent_wrapper: + claude_code: + backend: claude_code + model: ${LLM_MODEL_NAME:-qwen3.8-max} + api_key: ${LLM_API_KEY:-} + base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic} + permission_mode: bypassPermissions + # Keep Claude Code's native tools; the wrapper disables WebSearch by default. + + file_store: + default: + embedding_store: default + +jobs: + dingtalk_send: + backend: base + enable_serve: false + description: "Send one workspace Markdown file to configured DingTalk groups." + parameters: + type: object + properties: + markdown_path: + type: string + description: "Workspace-relative path to the Markdown file to send." + required: + - markdown_path + steps: + - &dingtalk_send_defaults + backend: dingtalk_markdown_send_step + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + conversation_ids: ${DINGTALK_CONVERSATION_IDS:-} + timeout: 15 + + auto_fin: + steps: &auto_fin_steps + - backend: auto_fin_data_step + - backend: auto_fin_topic_step + - backend: auto_fin_merge_step + job_tools: [search, read] + - backend: auto_tag_step + - <<: *dingtalk_send_defaults + title: ReMe Auto Fin + + auto_fin_cron: + steps: *auto_fin_steps + + daily_paper: + steps: &daily_paper_steps + - backend: daily_paper_collect_step + - backend: daily_paper_rank_step + - backend: daily_paper_select_step + - backend: daily_paper_analyze_step + - backend: daily_paper_digest_step + job_tools: [search, read] + - backend: auto_tag_step + - <<: *dingtalk_send_defaults + input_mapping: + daily_paper_digest_path: markdown_path + title: ReMe Daily Paper + + daily_paper_cron: + steps: *daily_paper_steps + + dingtalk_wait: + backend: background + supervisor: true + close_timeout: 10 + steps: + - backend: dingtalk_wait_step + agent_wrapper: claude_code + app_key: ${DINGTALK_APP_KEY} + app_secret: ${DINGTALK_APP_SECRET} + robot_code: ${DINGTALK_ROBOT_CODE} + worker_count: 4 + # Add ReMe memory search alongside Claude Code's native tools. + job_tools: [search] diff --git a/reme/steps/__init__.py b/reme/steps/__init__.py index 5a5bed71..80d1d8c0 100644 --- a/reme/steps/__init__.py +++ b/reme/steps/__init__.py @@ -1,13 +1,12 @@ """steps""" -from . import benchmark, common, cookbook, evolve, file_io, index, transfer +from . import benchmark, common, evolve, file_io, index, transfer from .base_step import BaseStep __all__ = [ "BaseStep", "benchmark", "common", - "cookbook", "evolve", "file_io", "index", diff --git a/reme/steps/cookbook/__init__.py b/reme/steps/cookbook/__init__.py deleted file mode 100644 index 44d037f2..00000000 --- a/reme/steps/cookbook/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Optional, end-to-end cookbook workflows.""" - -from . import dingtalk - -__all__ = ["dingtalk"] diff --git a/tests/unit/test_cookbook_config.py b/tests/unit/test_cookbook_config.py new file mode 100644 index 00000000..62f00dc5 --- /dev/null +++ b/tests/unit/test_cookbook_config.py @@ -0,0 +1,201 @@ +"""Validate the built-in cross-plugin cookbook application.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from reme.components import ApplicationContext +from reme.components.agent_wrapper import CcAgentWrapper +from reme.config.config_parser import _load_config, deep_merge_config, expand_env_vars +from reme.schema import ApplicationConfig + +REPOSITORY = Path(__file__).resolve().parents[2] + + +def _cookbook(monkeypatch) -> dict: + credentials = { + "DINGTALK_APP_KEY": "app-key", + "DINGTALK_APP_SECRET": "app-secret", + "DINGTALK_ROBOT_CODE": "robot-code", + "DINGTALK_CONVERSATION_IDS": "group-one,group-two", + "LLM_API_KEY": "llm-api-key", + "EMBEDDING_API_KEY": "embedding-api-key", + } + for name in ( + "LLM_BACKEND", + "LLM_MODEL_NAME", + "LLM_BASE_URL", + "CLAUDE_CODE_BASE_URL", + "EMBEDDING_BACKEND", + "EMBEDDING_MODEL_NAME", + "EMBEDDING_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + for name, value in credentials.items(): + monkeypatch.setenv(name, value) + return _load_config("cookbook") + + +def test_cookbook_extends_default_and_enables_composed_plugins(monkeypatch): + """The named variant inherits the normal service and declares plugin load order.""" + config = _cookbook(monkeypatch) + + assert config["service"]["backend"] == "http" + assert config["plugins"] == ["auto-fin", "daily-paper", "dingtalk"] + + +def test_cookbook_requires_dingtalk_application_credentials(monkeypatch): + """A configured background bridge fails fast instead of supervising empty credentials.""" + for name in ("DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"): + monkeypatch.delenv(name, raising=False) + + with pytest.raises(ValueError, match="undefined env var: DINGTALK_APP_KEY"): + _load_config("cookbook") + + +def test_cookbook_enables_embedding_and_separate_agent_backends(monkeypatch): + """The composed application enables vector search and isolates the DingTalk Claude Code bridge.""" + components = _cookbook(monkeypatch)["components"] + + assert components["as_embedding"]["default"] == { + "backend": "openai", + "model": "text-embedding-v4", + "dimensions": 1024, + "credential": { + "api_key": "embedding-api-key", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + }, + "parameters": {}, + } + assert components["embedding_store"]["default"] == { + "backend": "local", + "as_embedding": "default", + } + assert components["file_store"]["default"]["embedding_store"] == "default" + + assert components["as_llm"]["default"]["backend"] == "openai" + assert components["as_llm"]["default"]["model"] == "qwen3.8-max" + assert components["as_llm"]["default"]["credential"] == { + "api_key": "llm-api-key", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + } + assert components["agent_wrapper"]["default"]["backend"] == "agentscope" + assert components["agent_wrapper"]["claude_code"] == { + "backend": "claude_code", + "model": "qwen3.8-max", + "api_key": "llm-api-key", + "base_url": "https://dashscope.aliyuncs.com/apps/anthropic", + "permission_mode": "bypassPermissions", + } + + +def test_cookbook_dingtalk_keeps_claude_tools_and_adds_only_memory_search(monkeypatch, tmp_path): + """The final SDK options retain native tools, disable WebSearch, and add only ReMe search.""" + config = _cookbook(monkeypatch) + wrapper_config = dict(config["components"]["agent_wrapper"]["claude_code"]) + wrapper_config.pop("backend") + wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path))) + search = SimpleNamespace(name="search", description="Search memory", parameters={}) + monkeypatch.setattr(wrapper, "_resolve_job_tools", lambda _names: [search]) + + opts = wrapper._build_options( # pylint: disable=protected-access + "hello", + **wrapper_config, + job_tools=config["jobs"]["dingtalk_wait"]["steps"][0]["job_tools"], + ) + + assert opts.tools is None + assert opts.disallowed_tools == ["WebSearch"] + assert opts.permission_mode == "bypassPermissions" + assert opts.allowed_tools == ["search"] + assert set(opts.mcp_servers) == {wrapper.MCP_SERVER_NAME} + + +def test_cookbook_appends_dingtalk_to_business_pipelines(monkeypatch): + """Both manual and cron pipelines deliver their final tagged report.""" + jobs = _cookbook(monkeypatch)["jobs"] + auto_fin_steps = jobs["auto_fin"]["steps"] + daily_paper_steps = jobs["daily_paper"]["steps"] + + assert [step["backend"] for step in auto_fin_steps] == [ + "auto_fin_data_step", + "auto_fin_topic_step", + "auto_fin_merge_step", + "auto_tag_step", + "dingtalk_markdown_send_step", + ] + assert jobs["auto_fin_cron"]["steps"] == auto_fin_steps + assert auto_fin_steps[-1]["title"] == "ReMe Auto Fin" + + assert [step["backend"] for step in daily_paper_steps] == [ + "daily_paper_collect_step", + "daily_paper_rank_step", + "daily_paper_select_step", + "daily_paper_analyze_step", + "daily_paper_digest_step", + "auto_tag_step", + "dingtalk_markdown_send_step", + ] + assert jobs["daily_paper_cron"]["steps"] == daily_paper_steps + assert daily_paper_steps[-1]["input_mapping"] == { + "daily_paper_digest_path": "markdown_path", + } + assert daily_paper_steps[-1]["title"] == "ReMe Daily Paper" + for step in (auto_fin_steps[-1], daily_paper_steps[-1]): + assert step["app_key"] == "app-key" + assert step["app_secret"] == "app-secret" + assert step["robot_code"] == "robot-code" + assert step["conversation_ids"] == "group-one,group-two" + + +def test_cookbook_owns_safe_send_and_background_bridge_jobs(monkeypatch): + """Cross-plugin orchestration owns the private sender and long-running bridge.""" + jobs = _cookbook(monkeypatch)["jobs"] + + send = jobs["dingtalk_send"] + assert send["backend"] == "base" + assert send["enable_serve"] is False + assert send["parameters"]["required"] == ["markdown_path"] + + wait = jobs["dingtalk_wait"] + assert wait["backend"] == "background" + assert wait["supervisor"] is True + assert wait["close_timeout"] == 10 + assert wait["steps"] == [ + { + "backend": "dingtalk_wait_step", + "agent_wrapper": "claude_code", + "app_key": "app-key", + "app_secret": "app-secret", + "robot_code": "robot-code", + "worker_count": 4, + "job_tools": ["search"], + }, + ] + + +def test_cookbook_overrides_merge_with_pure_plugin_defaults(monkeypatch): + """Step overrides retain the business plugins' parameters and schedules.""" + application = {} + manifests = ( + REPOSITORY / "plugins" / "auto-fin" / "src" / "reme_auto_fin" / "plugin.yaml", + REPOSITORY / "plugins" / "daily_paper" / "src" / "reme_daily_paper" / "plugin.yaml", + REPOSITORY / "plugins" / "dingtalk" / "src" / "reme_dingtalk" / "plugin.yaml", + ) + for path in manifests: + manifest = yaml.safe_load(path.read_text(encoding="utf-8")) + application = deep_merge_config(application, expand_env_vars(manifest.get("application_defaults") or {})) + application = deep_merge_config(application, _cookbook(monkeypatch)) + + config = ApplicationConfig(**application) + + assert config.jobs["auto_fin"].backend == "base" + assert config.jobs["auto_fin"].parameters["properties"]["topics"]["default"] == "黄金,机器人,半导体" + assert config.jobs["auto_fin_cron"].backend == "cron" + assert config.jobs["auto_fin_cron"].model_extra["cron"] == "0 18 * * *" + assert config.jobs["daily_paper"].backend == "base" + assert config.jobs["daily_paper_cron"].backend == "cron" + assert config.jobs["daily_paper_cron"].model_extra["cron"] == "0 8 * * *" + assert config.jobs["dingtalk_wait"].backend == "background" diff --git a/tests/unit/test_package_versions.py b/tests/unit/test_package_versions.py index bb110710..88f46847 100644 --- a/tests/unit/test_package_versions.py +++ b/tests/unit/test_package_versions.py @@ -38,6 +38,9 @@ def test_studio_packages_have_independent_identity() -> None: daily_paper_config = tomllib.loads( (REPOSITORY / "plugins" / "daily_paper" / "pyproject.toml").read_text(encoding="utf-8"), ) + dingtalk_config = tomllib.loads( + (REPOSITORY / "plugins" / "dingtalk" / "pyproject.toml").read_text(encoding="utf-8"), + ) assert studio_config["project"]["name"] == "reme_studio" assert npm_config["name"] == "@agentscope-ai/reme_studio" @@ -58,6 +61,7 @@ def test_studio_packages_have_independent_identity() -> None: assert "qwenpaw" not in main_config["project"]["optional-dependencies"] assert auto_fin_config["project"]["version"] == "0.1.3" assert daily_paper_config["project"]["version"] == "0.1.3" + assert dingtalk_config["project"]["version"] == "0.1.0" assert main_config["tool"]["setuptools"]["packages"]["find"]["include"] == ["reme", "reme.*"] assert "reme_studio*" in main_config["tool"]["setuptools"]["packages"]["find"]["exclude"] @@ -206,6 +210,26 @@ def test_daily_paper_declares_runtime_dependencies() -> None: assert "pypdf" in by_name +def test_dingtalk_package_metadata_is_self_contained() -> None: + """Keep the independently distributed DingTalk license and runtime dependencies complete.""" + plugin_dir = REPOSITORY / "plugins" / "dingtalk" + assert (plugin_dir / "LICENSE").read_text(encoding="utf-8") == (REPOSITORY / "LICENSE").read_text( + encoding="utf-8", + ) + config = tomllib.loads((plugin_dir / "pyproject.toml").read_text(encoding="utf-8")) + requirements = [Requirement(value) for value in config["project"]["dependencies"]] + by_name = {requirement.name: requirement for requirement in requirements} + + assert set(by_name) == {"dingtalk-stream", "reme-ai"} + assert not by_name["reme-ai"].extras + assert Version("0.4.1.11") not in by_name["reme-ai"].specifier + assert Version("0.4.1.12") in by_name["reme-ai"].specifier + + main_config = tomllib.loads((REPOSITORY / "pyproject.toml").read_text(encoding="utf-8")) + core_requirements = {Requirement(value).name for value in main_config["project"]["optional-dependencies"]["core"]} + assert "dingtalk-stream" not in core_requirements + + def test_studio_package_preparation_preserves_static_gitignore(monkeypatch, tmp_path: Path) -> None: """Keep generated static assets ignored after staging the Studio build.""" _, static_dir = _studio_package_fixture(monkeypatch, tmp_path)