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:
jinliyl 2026-09-15 20:05:52 +08:00 • committed by GitHub
parent dab56fc794
commit 5f2c693ddb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 988 additions and 26 deletions

View file

@ -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

View file

@ -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

View file

@ -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`。
## 值的解析

View file

@ -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
View 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
View 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.

View 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 边界,不访问外部服务。

View 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"

View file

@ -1,4 +1,4 @@
"""DingTalk cookbook integration."""
"""DingTalk integration plugin for ReMe."""
from .send import DingTalkMarkdownSendStep
from .wait import DingTalkWaitStep

View file

@ -0,0 +1,3 @@
backends:
dingtalk_markdown_send_step: reme_dingtalk.send:DingTalkMarkdownSendStep
dingtalk_wait_step: reme_dingtalk.wait:DingTalkWaitStep

View file

@ -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)

View file

@ -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")

View file

@ -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):

View file

@ -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
View 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]

View file

@ -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",

View file

@ -1,5 +0,0 @@
"""Optional, end-to-end cookbook workflows."""
from . import dingtalk
__all__ = ["dingtalk"]

View 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"

View file

@ -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)