mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-30 01:52:29 +00:00
feat(plugins): extract DingTalk integration (#551)
* feat(plugins): extract DingTalk integration * feat(config): complete cookbook model setup * docs(config): add cookbook startup guide * fix(config): clarify DingTalk Claude tool access
This commit is contained in:
parent
dab56fc794
commit
5f2c693ddb
19 changed files with 988 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
||||
## 值的解析
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
201
plugins/dingtalk/LICENSE
Normal file
201
plugins/dingtalk/LICENSE
Normal file
|
|
@ -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.
|
||||
124
plugins/dingtalk/README.md
Normal file
124
plugins/dingtalk/README.md
Normal file
|
|
@ -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.
|
||||
120
plugins/dingtalk/README_ZH.md
Normal file
120
plugins/dingtalk/README_ZH.md
Normal file
|
|
@ -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 边界,不访问外部服务。
|
||||
32
plugins/dingtalk/pyproject.toml
Normal file
32
plugins/dingtalk/pyproject.toml
Normal file
|
|
@ -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"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""DingTalk cookbook integration."""
|
||||
"""DingTalk integration plugin for ReMe."""
|
||||
|
||||
from .send import DingTalkMarkdownSendStep
|
||||
from .wait import DingTalkWaitStep
|
||||
3
plugins/dingtalk/src/reme_dingtalk/plugin.yaml
Normal file
3
plugins/dingtalk/src/reme_dingtalk/plugin.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
backends:
|
||||
dingtalk_markdown_send_step: reme_dingtalk.send:DingTalkMarkdownSendStep
|
||||
dingtalk_wait_step: reme_dingtalk.wait:DingTalkWaitStep
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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):
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
161
reme/config/cookbook.yaml
Normal file
161
reme/config/cookbook.yaml
Normal file
|
|
@ -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]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
"""Optional, end-to-end cookbook workflows."""
|
||||
|
||||
from . import dingtalk
|
||||
|
||||
__all__ = ["dingtalk"]
|
||||
201
tests/unit/test_cookbook_config.py
Normal file
201
tests/unit/test_cookbook_config.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue